From b85d7bc1ff0a0e7b97c0cdbab23526eb2a95721c Mon Sep 17 00:00:00 2001 From: pumpkinperson996 Date: Thu, 9 Jul 2026 15:03:07 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=84=E8=8C=83=E5=8C=96=E5=90=AB=20B?= =?UTF-8?q?V=20=E5=8F=B7=E7=9A=84=E4=BB=BB=E6=84=8F=20B=20=E7=AB=99?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=EF=BC=88=E7=A8=8D=E5=90=8E=E5=86=8D=E7=9C=8B?= =?UTF-8?q?/=E6=94=B6=E8=97=8F=E5=A4=B9/=E8=BF=BD=E8=B8=AA=E5=8F=82?= =?UTF-8?q?=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从"稍后再看"(/list/watchlater/?bvid=BV...)、收藏夹播放页等场景 复制的链接会被判为无效链接,尽管其中包含完整 BV 号;校验通过的 链接也会带着追踪参数原样传给 yt-dlp。 在请求入口新增 normalize_video_url():提取 BV 号重建标准 /video/BVxxx 链接,保留分 P 参数(?p=N),丢弃其余查询参数。 b23.tv 短链与其他平台行为不变,无 BV 号链接仍按原逻辑拒绝。 Co-Authored-By: Claude Fable 5 --- backend/app/routers/note.py | 13 +++- backend/app/utils/url_parser.py | 23 +++++++ backend/app/validators/video_url_validator.py | 11 ++- backend/tests/test_url_normalize.py | 67 +++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_url_normalize.py 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"