feat(ingest): parse video refs and fetch detail by bvid

This commit is contained in:
kairee committed 2026-11-01 13:46:46 +08:00
1 parent e903c49921
commit d2adb65e08
6 files changed
+410

No files matched your search

+48
View File
@@ -27,6 +27,7 @@ from .models import (
FolderInfo,
InfosResult,
LoginState,
VideoDetail,
ViewStat,
)
@@ -463,6 +464,53 @@ class BilibiliClient:
except (TypeError, ValueError) as exc:
raise BilibiliProtocolError(f"view 解析失败: {exc}") from exc
def view_detail(
self, *, aid: int | None = None, bvid: str | None = None
) -> VideoDetail:
"""按 aid 或 bvid 取视频完整详情;两者必须且只能提供一个。"""
if (aid is None) == (bvid is None):
raise ValueError("view_detail 必须且只能提供 aid 或 bvid")
if bvid is not None:
url = f"{BASE}/x/web-interface/view?bvid={urllib.parse.quote(str(bvid))}"
else:
url = f"{BASE}/x/web-interface/view?aid={int(aid)}"
payload = self.get_json(url)
data = payload.get("data")
if not isinstance(data, Mapping):
raise BilibiliProtocolError("view data 不是对象")
try:
return VideoDetail.from_api(data)
except (TypeError, ValueError) as exc:
raise BilibiliProtocolError(f"view 解析失败: {exc}") from exc
def resolve_short_link(self, url: str, *, max_hops: int = 5) -> str:
"""跟随 ``b23.tv`` 短链重定向,返回最终 URL。
只用于把短链还原成含 bvid/aid 的 bilibili 视频链接;不做分页或登录。
"""
current = str(url)
for _ in range(max(1, int(max_hops))):
host = (urllib.parse.urlparse(current).hostname or "").lower()
if not host.endswith("b23.tv"):
# 已经不是短链域名(例如已还原到 bilibili.com/video/BV…)直接返回。
return current
response = self.transport.request(
"GET", current, self._headers(False), data=None, timeout=self.timeout
)
headers = {
str(key).lower(): value for key, value in response.headers.items()
}
location = headers.get("location")
if response.status >= 400:
raise BilibiliHTTPError(
response.status, response.body.decode("utf-8", "replace")[:200]
)
if response.status in {301, 302, 303, 307, 308} and location:
current = urllib.parse.urljoin(current, location)
continue
return current
return current
def relation_stat(self, mid: int) -> FanStat:
url = f"{BASE}/x/relation/stat?vmid={int(mid)}"
payload = self.get_json(url)
+83
View File
@@ -146,6 +146,89 @@ class ViewStat:
)
@dataclass(frozen=True)
class VideoDetail:
"""``x/web-interface/view`` 的完整元数据 + 七项统计。
比 :class:`ViewStat` 多出 UP、时长、分 P 数、封面等字段,供手动入库建库使用。
"""
aid: int
bvid: str | None
title: str
up_mid: int | None
up_name: str | None
pubdate: int
duration: int
pages: int | None
cover: str | None
stat: ViewStat
def metrics(self) -> dict[str, int]:
return self.stat.metrics()
@classmethod
def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail":
stat = ViewStat.from_api(item)
owner = item.get("owner") or {}
if not isinstance(owner, Mapping):
owner = {}
raw_mid = owner.get("mid")
up_mid = (
raw_mid
if isinstance(raw_mid, int) and not isinstance(raw_mid, bool)
else None
)
up_name = owner.get("name")
if not isinstance(up_name, str) or not up_name:
up_name = None
raw_duration = item.get("duration")
duration = (
raw_duration
if isinstance(raw_duration, int)
and not isinstance(raw_duration, bool)
and raw_duration >= 0
else 0
)
pages: int | None = None
raw_pages = item.get("pages")
if isinstance(raw_pages, list) and raw_pages:
pages = len(raw_pages)
else:
raw_videos = item.get("videos")
if (
isinstance(raw_videos, int)
and not isinstance(raw_videos, bool)
and raw_videos > 0
):
pages = raw_videos
raw_pubdate = item.get("pubdate") or item.get("ctime")
pubdate = (
raw_pubdate
if isinstance(raw_pubdate, int) and not isinstance(raw_pubdate, bool)
else 0
)
cover = item.get("pic")
if not isinstance(cover, str) or not cover:
cover = None
return cls(
aid=stat.aid,
bvid=stat.bvid,
title=stat.title,
up_mid=up_mid,
up_name=up_name,
pubdate=pubdate,
duration=duration,
pages=pages,
cover=cover,
stat=stat,
)
@dataclass(frozen=True)
class FanStat:
mid: int
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""从任意文本中提取 B 站视频引用(纯函数,不做网络请求)。
手动入库支持的消息形态:
* 视频 URL:``https://www.bilibili.com/video/BV1x8x166EzY``(含移动站、查询串、
分 P 参数等尾巴);
* 裸 BV 号:``BV1x8x166EzY``;
* 裸 av 号:``av12345``(大小写不敏感);
* 任意包含上述特征的字符串(例如复制了一整段带链接的文本);
* ``b23.tv`` 短链:``https://b23.tv/xxxx``——只提取短链 token,真正的跳转解析
由 ``bilibili.client`` 的 HTTP 边界完成,保持本模块可离线测试。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# BV 号固定为 "BV" + 10 位 base58 字符;后面不能紧跟字母数字,避免截断更长的串。
_BVID_RE = re.compile(r"BV[0-9A-Za-z]{10}(?![0-9A-Za-z])")
# av 号:前面不能是字母数字,避免命中 "have123" 之类;大小写都接受。
_AID_RE = re.compile(r"(?<![0-9A-Za-z])av(\d{1,20})(?![0-9A-Za-z])", re.IGNORECASE)
# b23.tv 短链 token。
_SHORT_RE = re.compile(r"b23\.tv/([0-9A-Za-z]+)")
# 单条消息最多处理的引用数,避免误粘贴一段文本导致突发请求。
DEFAULT_MAX_REFS = 10
@dataclass(frozen=True)
class VideoRef:
"""一条待解析的视频引用。
``kind`` 取值:
* ``bvid``:``value`` 为 BV 号字符串;
* ``aid``:``value`` 为 av 号对应的整数 aid;
* ``short``:``value`` 为 b23.tv 短链 token,需要 HTTP 跳转解析。
"""
kind: str
value: str | int
def key(self) -> tuple[str, str | int]:
return (self.kind, self.value)
def _matches(text: str) -> list[tuple[int, VideoRef]]:
found: list[tuple[int, VideoRef]] = []
for match in _BVID_RE.finditer(text):
found.append((match.start(), VideoRef("bvid", match.group(0))))
for match in _AID_RE.finditer(text):
found.append((match.start(), VideoRef("aid", int(match.group(1)))))
for match in _SHORT_RE.finditer(text):
found.append((match.start(), VideoRef("short", match.group(1))))
found.sort(key=lambda item: item[0])
return found
def extract_video_refs(
text: object, *, limit: int | None = DEFAULT_MAX_REFS
) -> list[VideoRef]:
"""从文本中按出现顺序提取去重后的视频引用。
* 非字符串 / 空串 → 返回空列表;
* 相同 ``(kind, value)`` 只保留首次出现;
* ``limit`` 为 ``None`` 时不限制数量。
"""
if not isinstance(text, str) or not text:
return []
out: list[VideoRef] = []
seen: set[tuple[str, str | int]] = set()
for _, ref in _matches(text):
key = ref.key()
if key in seen:
continue
seen.add(key)
out.append(ref)
if limit is not None and len(out) >= max(0, int(limit)):
break
return out