diff --git a/backend/app/routers/note.py b/backend/app/routers/note.py index 80033c87..f433e46f 100644 --- a/backend/app/routers/note.py +++ b/backend/app/routers/note.py @@ -7,7 +7,7 @@ from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File -from pydantic import BaseModel, validator, field_validator +from pydantic import BaseModel, validator, field_validator, model_validator from dataclasses import asdict from app.db.video_task_dao import get_task_by_video @@ -17,7 +17,7 @@ from app.services.note import NoteGenerator, logger from app.services.task_serial_executor import task_serial_executor from app.utils.response import ResponseWrapper as R -from app.utils.url_parser import extract_video_id +from app.utils.url_parser import extract_video_id, normalize_video_url from app.validators.video_url_validator import is_supported_video_url from fastapi import APIRouter, Request, HTTPException from fastapi.responses import StreamingResponse @@ -55,6 +55,15 @@ class VideoRequest(BaseModel): # {"language": "zh", "full_text": "...", "segments": [{"start","end","text"}, ...]} prefetched_transcript: Optional[dict] = None + @model_validator(mode="before") + @classmethod + def normalize_url(cls, data): + # 稍后再看/收藏夹/带追踪参数的 B 站链接先规范化成标准 /video/BVxxx 形式, + # 后续校验和 yt-dlp 下载拿到的都是干净链接 + if isinstance(data, dict) and data.get("platform") == "bilibili" and data.get("video_url"): + data["video_url"] = normalize_video_url(str(data["video_url"])) + return data + @field_validator("video_url") def validate_supported_url(cls, v): url = str(v) diff --git a/backend/app/utils/url_parser.py b/backend/app/utils/url_parser.py index 8722fe01..b40bb298 100644 --- a/backend/app/utils/url_parser.py +++ b/backend/app/utils/url_parser.py @@ -35,6 +35,29 @@ def extract_video_id(url: str, platform: str) -> Optional[str]: return None +def normalize_video_url(url: str) -> str: + """ + 将任意包含 BV 号的 B 站链接规范化为标准视频链接。 + + 支持稍后再看(/list/watchlater/?bvid=BV...)、收藏夹播放页(/list/mlXXX?bvid=BV...)、 + 带追踪参数的分享链接等。保留分 P 参数,丢弃其余查询参数。 + + b23.tv 短链与无 BV 号的链接原样返回(后者交由校验器拒绝)。 + """ + if "b23.tv" in url: + return url + + match = re.search(r"BV([0-9A-Za-z]+)", url) + if not match: + return url + + normalized = f"https://www.bilibili.com/video/BV{match.group(1)}" + p = extract_bilibili_p_number(url) + if p: + normalized += f"?p={p}" + return normalized + + def resolve_bilibili_short_url(short_url: str) -> Optional[str]: """ 解析哔哩哔哩短链接以获取真实视频链接 diff --git a/backend/app/validators/video_url_validator.py b/backend/app/validators/video_url_validator.py index cf0a240d..28adf93c 100644 --- a/backend/app/validators/video_url_validator.py +++ b/backend/app/validators/video_url_validator.py @@ -1,7 +1,9 @@ -from pydantic import AnyUrl, validator, BaseModel, field_validator +from pydantic import AnyUrl, validator, BaseModel, field_validator, model_validator import re from urllib.parse import urlparse +from app.utils.url_parser import normalize_video_url + SUPPORTED_PLATFORMS = { "bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+", "youtube": r"(https?://)?(www\.)?(youtube\.com/(watch\?v=|shorts/)|youtu\.be/)[\w\-]+", @@ -31,6 +33,13 @@ class VideoRequest(BaseModel): url: AnyUrl platform: str + @model_validator(mode="before") + @classmethod + def normalize_url(cls, data): + if isinstance(data, dict) and data.get("platform") == "bilibili" and data.get("url"): + data["url"] = normalize_video_url(str(data["url"])) + return data + @field_validator("url") def validate_video_url(cls, v): if not is_supported_video_url(str(v)): diff --git a/backend/tests/test_url_normalize.py b/backend/tests/test_url_normalize.py new file mode 100644 index 00000000..6e92489f --- /dev/null +++ b/backend/tests/test_url_normalize.py @@ -0,0 +1,67 @@ +import pytest + +from app.utils.url_parser import normalize_video_url +from app.validators.video_url_validator import VideoRequest + + +def test_watchlater_url(): + url = ("https://www.bilibili.com/list/watchlater/?bvid=BV1CPXpBYEui" + "&oid=116294762371214&spm_id_from=333.881.0.0&vd_source=abc") + assert normalize_video_url(url) == "https://www.bilibili.com/video/BV1CPXpBYEui" + + +def test_favlist_url(): + url = "https://www.bilibili.com/list/ml123456?bvid=BV1xx411c7mD&oid=987" + assert normalize_video_url(url) == "https://www.bilibili.com/video/BV1xx411c7mD" + + +def test_tracking_params_stripped(): + url = "https://www.bilibili.com/video/BV1xx411c7mD/?spm_id_from=333.881&vd_source=abc" + assert normalize_video_url(url) == "https://www.bilibili.com/video/BV1xx411c7mD" + + +def test_p_number_preserved(): + url = "https://www.bilibili.com/video/BV1xx411c7mD?p=36&spm_id_from=333.881" + assert normalize_video_url(url) == "https://www.bilibili.com/video/BV1xx411c7mD?p=36" + + +def test_no_bv_returned_unchanged(): + url = "https://www.bilibili.com/anime/timeline" + assert normalize_video_url(url) == url + + +def test_b23_short_url_unchanged(): + url = "https://b23.tv/abc123" + assert normalize_video_url(url) == url + + +def test_video_request_accepts_watchlater(): + req = VideoRequest( + url="https://www.bilibili.com/list/watchlater/?bvid=BV1CPXpBYEui&oid=116294762371214", + platform="bilibili", + ) + assert str(req.url) == "https://www.bilibili.com/video/BV1CPXpBYEui" + + +def test_video_request_rejects_no_bv(): + with pytest.raises(ValueError): + VideoRequest(url="https://www.bilibili.com/anime/timeline", platform="bilibili") + + +def test_note_router_request_accepts_watchlater(): + # note.py 里的 VideoRequest 才是 /generate_note 实际使用的请求模型 + from app.routers.note import VideoRequest as NoteVideoRequest + + req = NoteVideoRequest( + video_url="https://www.bilibili.com/list/watchlater/?bvid=BV1CPXpBYEui&oid=116294762371214&spm_id_from=333.881.0.0", + platform="bilibili", + quality="fast", + model_name="test-model", + provider_id="test-provider", + ) + assert req.video_url == "https://www.bilibili.com/video/BV1CPXpBYEui" + + +def test_video_request_youtube_unaffected(): + req = VideoRequest(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ", platform="youtube") + assert str(req.url) == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"