项目文件夹

文件
2026-07-13 12:29:17 +08:00

136 行
5.0 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""B站视频数据 + 评论抓取(公开接口,无需登录、无需签名)。
与 douyin-session / xhs-explore 不同:B站的视频统计与评论都是**公开数据**——
`view` 接口不需要 wbi 签名,评论走 `x/v2/reply` 老接口(按热度)。因此本 adapter
是纯 httpx,零登录、零浏览器,clone 下来即可用。
接口:
- 视频数据:https://api.bilibili.com/x/web-interface/view?bvid=<BV号>
- 评论: https://api.bilibili.com/x/v2/reply?type=1&oid=<aid>&sort=2 sort=2 热度)
"""
from __future__ import annotations
import re
import sys
import time
import httpx
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
HEADERS = {"User-Agent": UA, "Referer": "https://www.bilibili.com"}
VIEW_API = "https://api.bilibili.com/x/web-interface/view"
REPLY_API = "https://api.bilibili.com/x/v2/reply"
def normalize_bvid(raw: str) -> str:
"""从 BV 号或 B站视频 URL(含 b23.tv 短链跳转后)里提取 BV 号。"""
m = re.search(r"(BV[0-9A-Za-z]{10})", raw or "")
return m.group(1) if m else (raw or "").strip()
def _client() -> httpx.Client:
return httpx.Client(headers=HEADERS, timeout=20, follow_redirects=True)
def fetch_video(client: httpx.Client, bvid: str) -> dict:
"""拉视频信息 + 统计。返回归一化后的 dict。"""
resp = client.get(VIEW_API, params={"bvid": bvid})
resp.raise_for_status()
j = resp.json()
if j.get("code") != 0:
raise RuntimeError(f"view 接口失败 code={j.get('code')} msg={j.get('message')}")
d = j.get("data") or {}
stat = d.get("stat") or {}
return {
"bvid": d.get("bvid") or bvid,
"aid": d.get("aid"),
"title": d.get("title") or "",
"desc": d.get("desc") or "",
"owner": (d.get("owner") or {}).get("name") or "",
"pubdate": d.get("pubdate") or 0,
"duration_s": d.get("duration") or 0,
"play_count": stat.get("view") or 0,
"like_count": stat.get("like") or 0,
"coin_count": stat.get("coin") or 0,
"favorite_count": stat.get("favorite") or 0,
"share_count": stat.get("share") or 0,
"comment_count": stat.get("reply") or 0,
"danmaku_count": stat.get("danmaku") or 0,
"raw": d,
}
def fetch_comments(client: httpx.Client, aid: int, max_count: int = 50) -> list[dict]:
"""按热度(sort=2)翻页抓评论,最多 max_count 条。"""
out: list[dict] = []
seen: set[str] = set()
pn = 1
for _ in range(60): # 翻页上限保护
if len(out) >= max_count:
break
try:
j = client.get(
REPLY_API,
params={"type": 1, "oid": aid, "sort": 2, "pn": pn, "ps": 20},
).json()
except Exception as exc:
print(f"[警告] 评论请求异常(停止):{exc}")
break
if j.get("code") != 0:
print(f"[警告] 评论接口 code={j.get('code')} msg={j.get('message')}(停止)")
break
reps = (j.get("data") or {}).get("replies") or []
if not reps:
break
for c in reps:
nc = _normalize_comment(c)
if nc["cid"] in seen:
continue
seen.add(nc["cid"])
out.append(nc)
pn += 1
time.sleep(0.4)
out.sort(key=lambda x: x["digg_count"], reverse=True)
return out[:max_count]
def _normalize_comment(c: dict) -> dict:
member = c.get("member") or {}
content = c.get("content") or {}
loc = (c.get("reply_control") or {}).get("location") or ""
return {
"cid": str(c.get("rpid") or ""),
"text": content.get("message") or "",
"digg_count": c.get("like") or 0,
"reply_comment_total": c.get("rcount") or 0,
"create_time": c.get("ctime") or 0,
"user_name": member.get("uname") or "",
"ip_label": loc.replace("IP属地:", "").replace("IP属地:", "").strip() if loc else "",
}
def fetch_all(bvid: str, max_comments: int = 50) -> dict:
"""一次拉完视频数据 + 热门评论。"""
bvid = normalize_bvid(bvid)
with _client() as client:
print(f" → 拉取视频数据 {bvid}")
video = fetch_video(client, bvid)
print(f" ✓ {video['title'][:40]}(播放 {video['play_count']}")
print(" → 拉取热门评论")
if video.get("aid") is None:
print("[警告] 视频数据缺少 aid 字段,无法拉取评论")
comments = []
else:
comments = fetch_comments(client, video["aid"], max_count=max_comments)
print(f" ✓ {len(comments)} 条评论")
return {"video": video, "comments": comments}
if __name__ == "__main__":
# 与 douyin-session / xhs-explore 的接口保持一致;B站公开数据无需登录。
if len(sys.argv) > 1 and sys.argv[1] == "login":
print("[B站] 视频数据与评论均为公开接口,无需登录,直接 /cheat-retro 即可。")