Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions backend/app/routers/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions backend/app/utils/url_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
解析哔哩哔哩短链接以获取真实视频链接
Expand Down
11 changes: 10 additions & 1 deletion backend/app/validators/video_url_validator.py
Original file line number Diff line number Diff line change
@@ -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\-]+",
Expand Down Expand Up @@ -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)):
Expand Down
67 changes: 67 additions & 0 deletions backend/tests/test_url_normalize.py
Original file line number Diff line number Diff line change
@@ -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"