Merge pull request #421 from pumpkinperson996/fix/normalize-bilibili-url

fix: 规范化含 BV 号的任意 B 站链接(稍后再看/收藏夹/追踪参数)
This commit is contained in:
Jianwu Huang
2026-08-25 13:57:02 +08:00
committed by GitHub
4 changed files with 111 additions and 3 deletions
+11 -2
View File
@@ -7,7 +7,7 @@ from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File 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 dataclasses import asdict
from app.db.video_task_dao import get_task_by_video from app.db.video_task_dao import get_task_by_video
@@ -17,7 +17,7 @@ from app.exceptions.note import NoteError
from app.services.note import NoteGenerator, logger from app.services.note import NoteGenerator, logger
from app.services.task_serial_executor import task_serial_executor from app.services.task_serial_executor import task_serial_executor
from app.utils.response import ResponseWrapper as R 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 app.validators.video_url_validator import is_supported_video_url
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -55,6 +55,15 @@ class VideoRequest(BaseModel):
# {"language": "zh", "full_text": "...", "segments": [{"start","end","text"}, ...]} # {"language": "zh", "full_text": "...", "segments": [{"start","end","text"}, ...]}
prefetched_transcript: Optional[dict] = None 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") @field_validator("video_url")
def validate_supported_url(cls, v): def validate_supported_url(cls, v):
url = str(v) url = str(v)
+23
View File
@@ -35,6 +35,29 @@ def extract_video_id(url: str, platform: str) -> Optional[str]:
return None 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]: def resolve_bilibili_short_url(short_url: str) -> Optional[str]:
""" """
解析哔哩哔哩短链接以获取真实视频链接 解析哔哩哔哩短链接以获取真实视频链接
+10 -1
View File
@@ -1,7 +1,9 @@
from pydantic import AnyUrl, validator, BaseModel, field_validator from pydantic import AnyUrl, validator, BaseModel, field_validator, model_validator
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
from app.utils.url_parser import normalize_video_url
SUPPORTED_PLATFORMS = { SUPPORTED_PLATFORMS = {
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+", "bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
"youtube": r"(https?://)?(www\.)?(youtube\.com/(watch\?v=|shorts/)|youtu\.be/)[\w\-]+", "youtube": r"(https?://)?(www\.)?(youtube\.com/(watch\?v=|shorts/)|youtu\.be/)[\w\-]+",
@@ -31,6 +33,13 @@ class VideoRequest(BaseModel):
url: AnyUrl url: AnyUrl
platform: str 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") @field_validator("url")
def validate_video_url(cls, v): def validate_video_url(cls, v):
if not is_supported_video_url(str(v)): if not is_supported_video_url(str(v)):
+67
View File
@@ -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"