first commit

This commit is contained in:
Jefferyhcool
2025-04-13 17:44:54 +08:00
commit 0e0b8da317
112 changed files with 4397 additions and 0 deletions

23
backend/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 通用
ENV=production
API_BASE_URL=http://127.0.0.1:8000
SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots
STATIC=/static # 外部访问路径URL 前缀)
OUT_DIR=./static/screenshots # 本地输出目录
IMAGE_BASE_URL=/static/screenshots # 图片访问 URL
DATA_DIR=data
# 后端专用
# AI 相关配置
OPENAI_API_KEY = ""
OPENAI_API_BASE_URL = ""
OPENAI_MODEL = ""
DEEP_SEEK_API_KEY = ""
DEEP_SEEK_API_BASE_URL = ""
DEEP_SEEK_MODEL = ""
QWEN_API_KEY = ""
QWEN_API_BASE_URL = ""
QWEN_MODEL = ""

20
backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
RUN rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y ffmpeg && \
rm -rf /var/lib/apt/lists/*
# 确保 PATH 中包含 ffmpeg 路径(可选)
ENV PATH="/usr/bin:${PATH}"
WORKDIR /app
COPY ./backend /app
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
CMD ["python", "main.py"]

8
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from fastapi import FastAPI
from .routers import note
def create_app() -> FastAPI:
app = FastAPI(title="BiliNote")
app.include_router(note.router, prefix="/api")
return app

View File

View File

@@ -0,0 +1,4 @@
import sqlite3
def get_connection():
return sqlite3.connect("note_tasks.db")

View File

@@ -0,0 +1,52 @@
from .sqlite_client import get_connection
def init_video_task_table():
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS video_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL,
platform TEXT NOT NULL,
task_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def insert_video_task(video_id: str, platform: str, task_id: str):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO video_tasks (video_id, platform, task_id)
VALUES (?, ?, ?)
""", (video_id, platform, task_id))
conn.commit()
conn.close()
def get_task_by_video(video_id: str, platform: str):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT task_id FROM video_tasks
WHERE video_id = ? AND platform = ?
ORDER BY created_at DESC
LIMIT 1
""", (video_id, platform))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
def delete_task_by_video(video_id: str, platform: str):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
DELETE FROM video_tasks
WHERE video_id = ? AND platform = ?
""", (video_id, platform))
conn.commit()
conn.close()

View File

View File

@@ -0,0 +1,13 @@
import time
import functools
def timeit(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
duration = end - start
print(f"⏱️ {func.__name__} executed in {duration:.4f} seconds")
return result
return wrapper

View File

View File

@@ -0,0 +1,38 @@
import enum
from abc import ABC, abstractmethod
from typing import Optional, Union
from app.enmus.note_enums import DownloadQuality
from app.models.notes_model import AudioDownloadResult
from os import getenv
QUALITY_MAP = {
"fast": "32",
"medium": "64",
"slow": "128"
}
class Downloader(ABC):
def __init__(self):
#TODO 需要修改为可配置
self.quality = QUALITY_MAP.get('fast')
self.cache_data=getenv('DATA_DIR')
@abstractmethod
def download(self, video_url: str, output_dir: str = None,
quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult:
'''
:param need_video:
:param video_url: 资源链接
:param output_dir: 输出路径 默认根目录data
:param quality: 音频质量 fast | medium | slow
:return:返回一个 AudioDownloadResult 类
'''
pass
@staticmethod
def download_video(self, video_url: str,
output_dir: Union[str, None] = None) -> str:
pass

View File

@@ -0,0 +1,104 @@
import os
from abc import ABC
from typing import Union, Optional
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
class BilibiliDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir=self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
}
],
'noplaylist': True,
'quiet': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,
title=title,
duration=duration,
cover_url=cover_url,
platform="bilibili",
video_id=video_id,
raw_info=info,
video_path=None # ❗音频下载不包含视频路径
)
def download_video(
self,
video_url: str,
output_dir: Union[str, None] = None,
) -> str:
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件未找到: {video_path}")
return video_path
def delete_video(self, video_path: str) -> str:
"""
删除视频文件
"""
if os.path.exists(video_path):
os.remove(video_path)
return f"视频文件已删除: {video_path}"
else:
return f"视频文件未找到: {video_path}"

View File

@@ -0,0 +1 @@
# def download():

View File

@@ -0,0 +1,90 @@
import os
from abc import ABC
from typing import Union, Optional
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
class DouyinDownloader(Downloader, ABC):
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
}
],
'noplaylist': True,
'quiet': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,
title=title,
duration=duration,
cover_url=cover_url,
platform="douyin",
video_id=video_id,
raw_info={'tags':info.get('tags')}, #全部返回会报错
video_path=None # ❗音频下载不包含视频路径
)
def download_video(
self,
video_url: str,
output_dir: Union[str, None] = None,
) -> str:
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'worst[ext=mp4]/worst',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件未找到: {video_path}")
return video_path

View File

@@ -0,0 +1,95 @@
import os
from abc import ABC
from typing import Union, Optional
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
class YoutubeDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir=self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
}
],
'noplaylist': True,
'quiet': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,
title=title,
duration=duration,
cover_url=cover_url,
platform="youtube",
video_id=video_id,
raw_info={'tags':info.get('tags')}, #全部返回会报错
video_path=None # ❗音频下载不包含视频路径
)
def download_video(
self,
video_url: str,
output_dir: Union[str, None] = None,
) -> str:
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'worst[ext=mp4]/worst',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件未找到: {video_path}")
return video_path

View File

@@ -0,0 +1,7 @@
import enum
class DownloadQuality(str, enum.Enum):
fast = "fast"
medium = "medium"
slow = "slow"

View File

13
backend/app/gpt/base.py Normal file
View File

@@ -0,0 +1,13 @@
from abc import ABC,abstractmethod
from app.models.gpt_model import GPTSource
class GPT(ABC):
def summarize(self, source:GPTSource )->str:
'''
:param source:
:return:
'''
pass

View File

@@ -0,0 +1,59 @@
from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
from app.models.transcriber_model import TranscriptSegment
from datetime import timedelta
class DeepSeekGPT(GPT):
def __init__(self):
from os import getenv
self.api_key = getenv("DEEP_SEEK_API_KEY")
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
self.model=getenv('DEEP_SEEK_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.screenshot = False
def _format_time(self, seconds: float) -> str:
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
return "\n".join(
f"{self._format_time(seg.start)} - {seg.text.strip()}"
for seg in segments
)
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
return [
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
for seg in segments
]
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
content = BASE_PROMPT.format(
video_title=title,
segment_text=self._build_segment_text(segments),
tags=tags
)
if self.screenshot:
print(":需要截图")
content += SCREENSHOT
print(content)
return [{"role": "user", "content": content + AI_SUM}]
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
source.segment = self.ensure_segments_type(source.segment)
messages = self.create_messages(source.segment, source.title,source.tags)
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()

View File

@@ -0,0 +1,65 @@
from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
from app.models.transcriber_model import TranscriptSegment
from datetime import timedelta
class OpenaiGPT(GPT):
def __init__(self):
from os import getenv
self.api_key = getenv("OPENAI_API_KEY")
self.base_url = getenv("OPENAI_API_BASE_URL")
self.model=getenv('OPENAI_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.screenshot = False
self.link=False
def _format_time(self, seconds: float) -> str:
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
return "\n".join(
f"{self._format_time(seg.start)} - {seg.text.strip()}"
for seg in segments
)
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
return [
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
for seg in segments
]
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
content = BASE_PROMPT.format(
video_title=title,
segment_text=self._build_segment_text(segments),
tags=tags
)
if self.screenshot:
print(":需要截图")
content += SCREENSHOT
if self.link:
print(":需要链接")
content += LINK
print(content)
return [{"role": "user", "content": content + AI_SUM}]
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
self.link = source.link
source.segment = self.ensure_segments_type(source.segment)
messages = self.create_messages(source.segment, source.title,source.tags)
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()

56
backend/app/gpt/prompt.py Normal file
View File

@@ -0,0 +1,56 @@
BASE_PROMPT = '''
You are a professional note-taking assistant who excels at summarizing video transcripts into clear, structured, and information-rich notes.
🎯 Language Requirement:
- The notes must be written in **Chinese**.
- Proper nouns, technical terms, brand names, and personal names should remain in **English** where appropriate.
📌 Video Title:
{video_title}
📎 Video Tags:
{tags}
📝 Your Task:
Based on the segmented transcript below, generate structured notes in standard **Markdown format**, and follow these principles:
1. **Complete information**: Record as much relevant detail as possible to ensure comprehensive coverage.
2. **Clear structure**: Organize content with logical sectioning. Use appropriate heading levels (`##`, `###`) to summarize key points in each section.
3. **Concise wording**: Use accurate, clear, and professional Chinese expressions.
4. **Remove irrelevant content**: Omit advertisements, filler words, casual greetings, and off-topic remarks.
5. **Keep critical details**: Preserve important facts, examples, conclusions, and recommendations.
6. **Readable layout**: Use bullet points where needed, and keep paragraphs reasonably short to enhance readability.
7. **Table of Contents**: Generate a table of contents at the top based on the `##` level headings.
⚠️ Output Instructions:
- Only return the final **Markdown content**.
- Do **not** wrap the output in code blocks like ```` ```markdown ```` or ```` ``` ````.
🎬 Transcript Segments (Format: Start Time - Text):
---
{segment_text}
---
'''
LINK='''
9. **Add time markers**: THIS IS IMPORTANT For every main heading (`##`), append the starting time of that segment using the format ,start with *Content ,eg: `*Content-[mm:ss]`.
'''
AI_SUM='''
🧠 Final Touch:
At the end of the notes, add a professional **AI Summary** in Chinese a brief conclusion summarizing the whole video.
'''
SCREENSHOT='''
8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section:
- Format: `*Screenshot-[mm:ss]`
- Only use it when truly helpful.
'''

View File

@@ -0,0 +1,59 @@
from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
from app.models.transcriber_model import TranscriptSegment
from datetime import timedelta
class QwenGPT(GPT):
def __init__(self):
from os import getenv
self.api_key = getenv("QWEN_API_KEY")
self.base_url = getenv("QWEN_API_BASE_URL")
self.model=getenv('QWEN_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.screenshot = False
def _format_time(self, seconds: float) -> str:
return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
return "\n".join(
f"{self._format_time(seg.start)} - {seg.text.strip()}"
for seg in segments
)
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
return [
TranscriptSegment(**seg) if isinstance(seg, dict) else seg
for seg in segments
]
def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str):
content = BASE_PROMPT.format(
video_title=title,
segment_text=self._build_segment_text(segments),
tags=tags
)
if self.screenshot:
print(":需要截图")
content += SCREENSHOT
print(content)
return [{"role": "user", "content": content + AI_SUM}]
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
source.segment = self.ensure_segments_type(source.segment)
messages = self.create_messages(source.segment, source.title,source.tags)
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()

0
backend/app/gpt/tools.py Normal file
View File

4
backend/app/gpt/utils.py Normal file
View File

@@ -0,0 +1,4 @@
import codecs
def fix_markdown(markdown: str) -> str:
return codecs.decode(markdown, 'unicode_escape')

View File

View File

@@ -0,0 +1,15 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class AudioDownloadResult:
file_path: str # 本地音频路径
title: str # 视频标题
duration: float # 视频时长(秒)
cover_url: Optional[str] # 视频封面图
platform: str # 平台,如 "bilibili"
video_id: str # 唯一视频ID
raw_info: dict # yt-dlp 的原始 info 字典
video_path: Optional[str] = None # ✅ 新增字段:可选视频文件路径

View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
from typing import List, Union, Optional
from app.models.transcriber_model import TranscriptSegment
@dataclass
class GPTSource:
segment: Union[List[TranscriptSegment], List]
title: str
tags:str
screenshot: Optional[bool] = False
link: Optional[bool] = False

View File

@@ -0,0 +1,12 @@
from dataclasses import dataclass
from typing import Optional
from app.models.audio_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult
@dataclass
class NoteResult:
markdown: str # GPT 总结的 Markdown 内容
transcript: TranscriptResult # Whisper 转写结果
audio_meta: AudioDownloadResult # 音频下载的元信息title、duration、封面等

View File

@@ -0,0 +1,16 @@
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class TranscriptSegment:
start: float # 开始时间(秒)
end: float # 结束时间(秒)
text: str # 该段文字
@dataclass
class TranscriptResult:
language: Optional[str] # 检测语言(如 "zh"、"en"
full_text: str # 完整合并后的文本(用于摘要)
segments: List[TranscriptSegment] # 分段结构,适合前端显示时间轴字幕等
raw: Optional[dict] = None # 原始响应数据,便于调试或平台特性处理

View File

View File

139
backend/app/routers/note.py Normal file
View File

@@ -0,0 +1,139 @@
# app/routers/note.py
import json
import os
import uuid
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, validator
from dataclasses import asdict
from app.db.video_task_dao import get_task_by_video
from app.enmus.note_enums import DownloadQuality
from app.services.note import NoteGenerator
from app.utils.response import ResponseWrapper as R
from app.utils.url_parser import extract_video_id
from app.validators.video_url_validator import is_supported_video_url
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
# from app.services.downloader import download_raw_audio
# from app.services.whisperer import transcribe_audio
router = APIRouter()
class RecordRequest(BaseModel):
video_id: str
platform: str
class VideoRequest(BaseModel):
video_url: str
platform: str
quality: DownloadQuality
screenshot: Optional[bool] = False
link: Optional[bool] = False
@validator("video_url")
def validate_supported_url(cls, v):
url = str(v)
# 支持平台校验
if not is_supported_video_url(url):
raise ValueError("暂不支持该视频平台或链接格式无效")
return v
NOTE_OUTPUT_DIR = "note_results"
def save_note_to_file(task_id: str, note):
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
with open(os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json"), "w", encoding="utf-8") as f:
json.dump(asdict(note), f, ensure_ascii=False, indent=2)
def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, link: bool = False,screenshot: bool = False):
try:
note = NoteGenerator().generate(
video_url=video_url,
platform=platform,
quality=quality,
task_id=task_id,
link=link,
screenshot=screenshot
)
print('Note 结果',note)
save_note_to_file(task_id, note)
except Exception as e:
save_note_to_file(task_id, {"error": str(e)})
@router.post('/delete_task')
def delete_task(data:RecordRequest):
try:
NoteGenerator().delete_note(video_id=data.video_id,platform=data.platform)
return R.success(msg='删除成功')
except Exception as e:
return R.error(msg=e)
@router.post("/generate_note")
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
try:
video_id = extract_video_id(data.video_url, data.platform)
if not video_id:
raise HTTPException(status_code=400, detail="无法提取视频 ID")
existing = get_task_by_video(video_id, data.platform)
if existing:
return R.error(
msg='笔记已生成,请勿重复发起',
)
task_id = str(uuid.uuid4())
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality,data.link ,data.screenshot)
return R.success({"task_id": task_id})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/task_status/{task_id}")
def get_task_status(task_id: str):
path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json")
if not os.path.exists(path):
return R.success({"status": "PENDING"})
with open(path, "r", encoding="utf-8") as f:
content = json.load(f)
if "error" in content:
return R.error(content["error"], code=500)
content['id'] = task_id
return R.success({
"status": "SUCCESS",
"result": content
})
@router.get("/image_proxy")
async def image_proxy(request: Request, url: str):
headers = {
"Referer": "https://www.bilibili.com/", # 模拟B站来源
"User-Agent": request.headers.get("User-Agent", ""),
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, headers=headers)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
content_type = resp.headers.get("Content-Type", "image/jpeg")
return StreamingResponse(resp.aiter_bytes(), media_type=content_type)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@@ -0,0 +1,184 @@
import os
from typing import Union
from pydantic import HttpUrl
from app.db.video_task_dao import insert_video_task, delete_task_by_video
from app.downloaders.base import Downloader
from app.downloaders.bilibili_downloader import BilibiliDownloader
from app.downloaders.douyin_downloader import DouyinDownloader
from app.downloaders.youtube_downloader import YoutubeDownloader
from app.gpt.base import GPT
from app.gpt.deepseek_gpt import DeepSeekGPT
from app.gpt.openai_gpt import OpenaiGPT
from app.gpt.qwen_gpt import QwenGPT
from app.models.gpt_model import GPTSource
from app.models.notes_model import NoteResult
from app.models.notes_model import AudioDownloadResult
from app.enmus.note_enums import DownloadQuality
from app.models.transcriber_model import TranscriptResult
from app.transcriber.base import Transcriber
from app.transcriber.transcriber_provider import get_transcriber
from app.transcriber.whisper import WhisperTranscriber
import re
from app.utils.note_helper import replace_content_markers
from app.utils.video_helper import generate_screenshot
# from app.services.whisperer import transcribe_audio
# from app.services.gpt import summarize_text
from dotenv import load_dotenv
load_dotenv()
BACKEND_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
output_dir = os.getenv('OUT_DIR')
image_base_url = os.getenv('IMAGE_BASE_URL')
print(output_dir)
class NoteGenerator:
def __init__(self):
self.model_size: str = 'base'
self.device: Union[str, None] = None
self.transcriber_type = 'fast-whisper'
self.transcriber = self.get_transcriber()
# TODO 需要更换为可调节
self.provider = os.getenv('MODEl_PROVIDER','openai')
self.video_path = None
def get_gpt(self) -> GPT:
if self.provider == 'openai':
return OpenaiGPT()
elif self.provider == 'deepSeek':
return DeepSeekGPT()
elif self.provider == 'qwen':
return QwenGPT()
else:
raise ValueError(f"不支持的AI提供商{self.provider}")
def get_downloader(self, platform: str) -> Downloader:
if platform == "bilibili":
return BilibiliDownloader()
elif platform == "youtube":
return YoutubeDownloader()
elif platform == 'douyin':
return DouyinDownloader()
else:
raise ValueError(f"不支持的平台:{platform}")
def get_transcriber(self) -> Transcriber:
'''
:param transcriber: 选择的转义器
:return:
'''
if self.transcriber_type == 'fast-whisper':
return get_transcriber()
else:
raise ValueError(f"不支持的转义器:{self.transcriber}")
def save_meta(self, video_id, platform, task_id):
insert_video_task(video_id=video_id, platform=platform, task_id=task_id)
def insert_screenshots_into_markdown(self, markdown: str, video_path: str, image_base_url: str,
output_dir: str) -> str:
"""
扫描 markdown 中的 *Screenshot-xx:xx生成截图并插入 markdown 图片
:param markdown:
:param image_base_url: 最终返回给前端的路径前缀(如 /static/screenshots
"""
matches = self.extract_screenshot_timestamps(markdown)
new_markdown = markdown
for idx, (marker, ts) in enumerate(matches):
image_path = generate_screenshot(video_path, output_dir, ts, idx)
image_relative_path = os.path.join(image_base_url, os.path.basename(image_path)).replace("\\", "/")
image_url = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}"
replacement = f"![]({image_url})"
new_markdown = new_markdown.replace(marker, replacement, 1)
return new_markdown
@staticmethod
def delete_note(video_id: str, platform: str):
return delete_task_by_video(video_id, platform)
import re
def extract_screenshot_timestamps(self, markdown: str) -> list[tuple[str, int]]:
"""
从 Markdown 中提取 Screenshot 时间标记(如 *Screenshot-03:39 或 Screenshot-[03:39]
并返回匹配文本和对应时间戳(秒)
"""
pattern = r"(?:\*Screenshot-(\d{2}):(\d{2})|Screenshot-\[(\d{2}):(\d{2})\])"
matches = list(re.finditer(pattern, markdown))
results = []
for match in matches:
mm = match.group(1) or match.group(3)
ss = match.group(2) or match.group(4)
total_seconds = int(mm) * 60 + int(ss)
results.append((match.group(0), total_seconds))
return results
def generate(
self,
video_url: Union[str, HttpUrl],
platform: str,
quality: DownloadQuality = DownloadQuality.medium,
task_id: Union[str, None] = None,
link: bool = False,
screenshot: bool = False,
path: Union[str, None] = None
) -> NoteResult:
# 1. 选择下载器
downloader = self.get_downloader(platform)
gpt = self.get_gpt()
if screenshot:
video_path = downloader.download_video(video_url)
self.video_path = video_path
print(video_path)
# 2. 下载音频
audio: AudioDownloadResult = downloader.download(
video_url=video_url,
quality=quality,
output_dir=path,
need_video=screenshot
)
# 3. Whisper 转写
transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path)
# 4. GPT 总结
source = GPTSource(
title=audio.title,
segment=transcript.segments,
tags=audio.raw_info.get('tags'),
screenshot=screenshot,
link=link
)
markdown: str = gpt.summarize(source)
print("markdown结果", markdown)
markdown = replace_content_markers(markdown=markdown, video_id=audio.video_id, platform=platform)
if self.video_path:
markdown = self.insert_screenshots_into_markdown(markdown, self.video_path, image_base_url, output_dir)
self.save_meta(video_id=audio.video_id, platform=platform, task_id=task_id)
# 5. 返回结构体
return NoteResult(
markdown=markdown,
transcript=transcript,
audio_meta=audio
)
if __name__ == '__main__':
note = NoteGenerator()
print(note.audio_meta)

View File

View File

@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from app.models.transcriber_model import TranscriptResult
class Transcriber(ABC):
@abstractmethod
def transcript(self,file_path:str)->TranscriptResult:
'''
:param file_path:音频路径
:return: 返回一个 TranscriptResult 类
'''
pass

View File

@@ -0,0 +1,11 @@
from app.transcriber.whisper import WhisperTranscriber
print('实例化transcriber')
# TODO:后面需要加入逻辑选择
_transcriber = None
def get_transcriber(model_size="base", device="cuda"):
global _transcriber
if _transcriber is None:
print('加载_transcriber')
_transcriber = WhisperTranscriber(model_size=model_size, device=device)
return _transcriber

View File

@@ -0,0 +1,92 @@
from faster_whisper import WhisperModel
from app.decorators.timeit import timeit
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
from app.transcriber.base import Transcriber
from app.utils.env_checker import is_cuda_available, is_torch_installed
from app.utils.path_helper import get_model_dir
'''
Size of the model to use (tiny, tiny.en, base, base.en, small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo
'''
class WhisperTranscriber(Transcriber):
# TODO:修改为可配置
def __init__(
self,
model_size: str = "base",
device: str = 'cpu',
compute_type: str = None,
cpu_threads: int = 1,
):
if device == 'cpu' or device is None:
self.device = 'cpu'
else:
self.device = "cuda" if self.is_cuda() else "cpu"
if device == 'cuda' and self.device == 'cpu':
print('没有 cuda 使用 cpu进行计算')
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
model_path = get_model_dir("whisper")
self.model = WhisperModel(
model_size,
device=self.device,
# compute_type="int8", # 或 "float16"
cpu_threads=cpu_threads,
download_root=model_path
)
@staticmethod
def is_torch_installed() -> bool:
try:
import torch
return True
except ImportError:
return False
@staticmethod
def is_cuda() -> bool:
try:
if is_cuda_available():
print("✅ CUDA 可用,使用 GPU")
return True
elif is_torch_installed():
print("⚠️ 只装了 torch但没有 CUDA用 CPU")
return False
else:
print("❌ 还没有安装 torch请先安装")
return False
except ImportError:
return False
@timeit
def transcript(self, file_path: str) -> TranscriptResult:
segments_raw, info = self.model.transcribe(file_path)
segments = []
full_text = ""
for seg in segments_raw:
text = seg.text.strip()
full_text += text + " "
segments.append(TranscriptSegment(
start=seg.start,
end=seg.end,
text=text
))
return TranscriptResult(
language=info.language,
full_text=full_text.strip(),
segments=segments,
raw=info
)
if __name__ == '__main__':
print(WhisperTranscriber(cpu_threads=8).transcript(
'''D:\\data_backup_from_ssd\\02_个人项目\\11_BiliNote\\backend\\data\\BV1vcZ5YQE9X.mp3'''))

View File

@@ -0,0 +1,12 @@
def is_cuda_available() -> bool:
try:
import torch
return torch.cuda.is_available()
except ImportError:
return False
def is_torch_installed() -> bool:
try:
import torch
return True
except ImportError:
return False

View File

@@ -0,0 +1,32 @@
import re
import re
import re
def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilibili') -> str:
"""
替换 *Content-04:16*、Content-04:16 或 Content-[04:16] 为超链接,跳转到对应平台视频的时间位置
"""
# 匹配三种形式:*Content-04:16*、Content-04:16、Content-[04:16]
pattern = r"(?:\*?)Content-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2}))"
def replacer(match):
mm = match.group(1) or match.group(3)
ss = match.group(2) or match.group(4)
total_seconds = int(mm) * 60 + int(ss)
if platform == 'bilibili':
url = f"https://www.bilibili.com/video/{video_id}?t={total_seconds}"
elif platform == 'youtube':
url = f"https://www.youtube.com/watch?v={video_id}&t={total_seconds}s"
elif platform == 'douyin':
url = f"https://www.douyin.com/video/{video_id}"
return f"[原片 @ {mm}:{ss}]({url})"
else:
return f"({mm}:{ss})"
return f"[原片 @ {mm}:{ss}]({url})"
return re.sub(pattern, replacer, markdown)

View File

@@ -0,0 +1,18 @@
import os
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
def get_data_dir():
data_path = os.path.join(PROJECT_ROOT, "data")
os.makedirs(data_path, exist_ok=True)
return data_path
def get_model_dir(subdir: str = "whisper") -> str:
base = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../models"))
path = os.path.join(base, subdir)
os.makedirs(path, exist_ok=True)
return path
if __name__ == '__main__':
print(get_data_dir())

View File

@@ -0,0 +1,18 @@
from app.utils.status_code import StatusCode
class ResponseWrapper:
@staticmethod
def success(data=None, msg="success", code=StatusCode.SUCCESS):
return {
"code": int(code),
"msg": msg,
"data": data
}
@staticmethod
def error(msg="error", code=StatusCode.FAIL, data=None):
return {
"code": int(code),
"msg": msg,
"data": data
}

View File

@@ -0,0 +1,12 @@
from enum import IntEnum
class StatusCode(IntEnum):
SUCCESS = 0
FAIL = 1
DOWNLOAD_ERROR = 1001
TRANSCRIBE_ERROR = 1002
GENERATE_ERROR = 1003
INVALID_URL = 2001
PARAM_ERROR = 2002

View File

@@ -0,0 +1,28 @@
import re
from typing import Optional
def extract_video_id(url: str, platform: str) -> Optional[str]:
"""
从视频链接中提取视频 ID
:param url: 视频链接
:param platform: 平台名bilibili / youtube / douyin
:return: 提取到的视频 ID 或 None
"""
if platform == "bilibili":
# 匹配 BV号如 BV1vc411b7Wa
match = re.search(r"BV([0-9A-Za-z]+)", url)
return f"BV{match.group(1)}" if match else None
elif platform == "youtube":
# 匹配 v=xxxxx 或 youtu.be/xxxxxID 长度通常为 11
match = re.search(r"(?:v=|youtu\.be/)([0-9A-Za-z_-]{11})", url)
return match.group(1) if match else None
elif platform == "douyin":
# 匹配 douyin.com/video/1234567890123456789
match = re.search(r"/video/(\d+)", url)
return match.group(1) if match else None
return None

View File

@@ -0,0 +1,26 @@
import subprocess
import os
import uuid
def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str:
"""
使用 ffmpeg 生成截图,返回生成图片路径
"""
os.makedirs(output_dir, exist_ok=True)
ids=str(uuid.uuid4())
output_path = os.path.join(output_dir, f"screenshot_{str(index)+ids}.jpg")
command = [
"ffmpeg",
"-ss", str(timestamp),
"-i", video_path,
"-frames:v", "1",
"-q:v", "2", # 图像质量
output_path,
"-y" # 覆盖
]
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return output_path

View File

View File

@@ -0,0 +1,24 @@
from pydantic import AnyUrl, validator, BaseModel
import re
SUPPORTED_PLATFORMS = {
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
"youtube": r"(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w\-]+",
"douyin": r"(https?://)?(www\.)?douyin\.com/video/\d+",
}
def is_supported_video_url(url: str) -> bool:
return any(re.match(pattern, url) for pattern in SUPPORTED_PLATFORMS.values())
class VideoRequest(BaseModel):
url: AnyUrl
platform: str
@validator("url")
def validate_video_url(cls, v):
if not is_supported_video_url(str(v)):
raise ValueError("暂不支持该视频平台或链接格式无效")
return v

34
backend/ffmpeg_helper.py Normal file
View File

@@ -0,0 +1,34 @@
import os
import subprocess
from dotenv import load_dotenv
load_dotenv()
def check_ffmpeg_exists() -> bool:
"""
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。
"""
ffmpeg_bin_path = os.getenv("FFMPEG_BIN_PATH")
print(f"FFMPEG_BIN_PATH: {ffmpeg_bin_path}")
if ffmpeg_bin_path and os.path.isdir(ffmpeg_bin_path):
os.environ["PATH"] = ffmpeg_bin_path + os.pathsep + os.environ.get("PATH", "")
try:
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return True
except (FileNotFoundError, OSError, subprocess.CalledProcessError):
return False
def ensure_ffmpeg_or_raise():
"""
校验 ffmpeg 是否可用,否则抛出异常并提示安装方式。
"""
if not check_ffmpeg_exists():
raise EnvironmentError(
"❌ 未检测到 ffmpeg请先安装后再使用本功能。\n"
"👉 下载地址https://ffmpeg.org/download.html\n"
"🪟 Windows 推荐https://www.gyan.dev/ffmpeg/builds/\n"
"💡 如果你已安装,请将其路径写入 `.env` 文件,例如:\n"
"FFMPEG_BIN_PATH=/your/custom/ffmpeg/bin"
)

44
backend/main.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import uvicorn
from starlette.staticfiles import StaticFiles
from dotenv import load_dotenv
from app import create_app
from app.db.video_task_dao import init_video_task_table
from app.transcriber.transcriber_provider import get_transcriber
from ffmpeg_helper import ensure_ffmpeg_or_raise
load_dotenv()
# 读取 .env 中的路径
static_path = os.getenv('STATIC', '/static')
out_dir = os.getenv('OUT_DIR', './static/screenshots')
# 自动创建本地目录static 和 static/screenshots
static_dir = "static"
if not os.path.exists(static_dir):
os.makedirs(static_dir)
if not os.path.exists(out_dir):
os.makedirs(out_dir)
app = create_app()
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
@app.on_event("startup")
def check_env():
ensure_ffmpeg_or_raise()
@app.on_event("startup")
async def load_model_on_startup():
get_transcriber()
@app.on_event("startup")
def startup():
init_video_task_table()
if __name__ == "__main__":
port = int(os.getenv("BACKEND_PORT", 8000))
host = os.getenv("BACKEND_HOST", "0.0.0.0")
uvicorn.run("main:app", host=host, port=port, reload=True)

BIN
backend/requirements.txt Normal file

Binary file not shown.