#!/usr/bin/env python3 """Bilibili videos and articles into folders: text, author, counts, parts, subtitles, video, comments. Run `python3 bilibili.py ... --out `. Standard library only; needs share_common.py beside it, and MY_API_KEY in the environment for the videos. Videos (BV...): the public view API for metadata and parts, the player API for public subtitles (most need a login now, so mostly none), xiazaitool per part for the video (0.005 CNY a part), the WBI-signed reply API for comments with --comments. Articles (/opus/, /dynamic/, t.bilibili.com): the public page's __INITIAL_STATE__ for the article, written to article.md with local images. b23.tv short links are followed once. Everything but the videos is free. Folder layout and shared flags: see share_common.py. """ from __future__ import annotations import argparse import hashlib import json import re import sys import time import urllib.error import urllib.parse import urllib.request from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from share_common import TZ, USER_AGENT, Post, cli, host_in, http_json, https, iso, last_segment, media_only # noqa: E402 BILIBILI = "https://api.bilibili.com" BILI_REFERER = "https://www.bilibili.com/" WBI_MIXIN = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52, ] DOMAINS = ("bilibili.com", "b23.tv") def bvid_of(url: str) -> str: match = re.search(r"(BV[0-9A-Za-z]{10})", url) return match.group(1) if match else "" def opus_of(url: str) -> str: match = re.search(r"(?:/opus/|/dynamic/|t\.bilibili\.com/)(\d{10,})", url) return match.group(1) if match else "" def safe(value: str) -> str: return re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_") or "x" def srt_time(seconds: float) -> str: ms = max(0, int(round(float(seconds or 0) * 1000))) return f"{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}" def select_parts(spec: str, total: int) -> set[int]: """'all', or page numbers and ranges such as 1,3-5.""" if spec == "all": return set(range(1, total + 1)) wanted: set[int] = set() for chunk in filter(None, (c.strip() for c in spec.split(","))): lo, _, hi = chunk.partition("-") wanted.update(range(int(lo), int(hi or lo) + 1)) return wanted class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): # noqa: ANN002, ANN003 return None def follow_short_link(url: str) -> str: """b23.tv links carry no BV or opus id; read the Location of the first redirect.""" opener = urllib.request.build_opener(NoRedirect) try: opener.open(urllib.request.Request(url, headers={"User-Agent": USER_AGENT}), timeout=20) except urllib.error.HTTPError as exc: if exc.code in (301, 302, 303, 307, 308) and exc.headers.get("Location"): return exc.headers["Location"] return url def bili_get(post: Post, path: str, params: dict, raw_name: str) -> dict: obj = http_json(f"{BILIBILI}{path}?{urllib.parse.urlencode(params)}", {"Referer": BILI_REFERER}) post.save_raw(raw_name, obj) if obj.get("code") != 0: raise RuntimeError(f"Bilibili {path} code={obj.get('code')}: {obj.get('message')}") return obj.get("data") or {} def fetch_bilibili(post: Post, args: argparse.Namespace) -> None: url = post.url if "b23.tv" in (urllib.parse.urlparse(url).hostname or ""): url = follow_short_link(url) post.meta["resolved_url"] = url if bvid := bvid_of(url): fetch_bilibili_video(post, bvid, args) elif opus := opus_of(url): fetch_bilibili_opus(post, opus, args) else: media_only(post, args, BILI_REFERER) def fetch_bilibili_video(post: Post, bvid: str, args: argparse.Namespace) -> None: data = bili_get(post, "/x/web-interface/view", {"bvid": bvid}, "bilibili_view") owner = data.get("owner") or {} stat = data.get("stat") or {} post.meta.update( id=bvid, kind="video", title=data.get("title") or "", desc=data.get("desc") or "", author={"name": owner.get("name"), "id": owner.get("mid")}, stats={ "like": stat.get("like"), "collect": stat.get("favorite"), "comment": stat.get("reply"), "share": stat.get("share"), "view": stat.get("view"), "coin": stat.get("coin"), "danmaku": stat.get("danmaku"), }, published=iso(data.get("pubdate")), duration=data.get("duration"), ) if data.get("pic"): post.meta["files"]["cover"] = post.download(https(data["pic"]), "cover.jpg", BILI_REFERER) pages = data.get("pages") or [] wanted = select_parts(args.parts, len(pages)) for page in pages: n = int(page.get("page") or 1) if n not in wanted: continue part = {"page": n, "cid": page.get("cid"), "title": page.get("part") or "", "duration": page.get("duration"), "video": None, "subtitles": []} post.meta["parts"].append(part) part["subtitles"] = bilibili_subtitles(post, bvid, part) if not args.no_video: part_url = f"https://www.bilibili.com/video/{bvid}/" + (f"?p={n}" if n > 1 else "") post.video_via_xiazaitool(part, part_url, BILI_REFERER) skipped = len(pages) - len(post.meta["parts"]) if skipped: post.warn(f"{skipped} of {len(pages)} parts skipped by --parts {args.parts}") if args.comments: post.meta["files"]["comments"] = bilibili_comments(post, str(data["aid"]), 1) def bilibili_subtitles(post: Post, bvid: str, part: dict) -> list[dict]: n = part["page"] data = bili_get(post, "/x/player/v2", {"bvid": bvid, "cid": part["cid"]}, f"bilibili_player_p{n:02d}") tracks = (data.get("subtitle") or {}).get("subtitles") or [] if not tracks: why = "subtitles are only given to logged-in users (need_login_subtitle)" if data.get("need_login_subtitle") else "no subtitles" post.warn(f"P{n}: {why}; transcribe the video instead") out = [] for track in tracks: lang = str(track.get("lan") or "sub") if not track.get("subtitle_url"): # ai-zh listed with an empty URL: the AI subtitle exists but is only given to logged-in users. post.warn(f"P{n}: subtitle {lang} needs login (empty subtitle_url); transcribe the video instead") continue payload = http_json(https(track["subtitle_url"]), {"Referer": BILI_REFERER}) body = payload.get("body") or [] stem = f"subtitles/p{n:02d}_{safe(lang)}" folder = post.dir / "subtitles" folder.mkdir(parents=True, exist_ok=True) (post.dir / f"{stem}.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") lines = [str(item.get("content") or "").strip() for item in body] (post.dir / f"{stem}.txt").write_text("\n".join(line for line in lines if line) + "\n", encoding="utf-8") srt = [] for i, item in enumerate(body, 1): if text := str(item.get("content") or "").strip(): srt += [str(i), f"{srt_time(item.get('from'))} --> {srt_time(item.get('to'))}", text, ""] (post.dir / f"{stem}.srt").write_text("\n".join(srt), encoding="utf-8") out.append({"lang": lang, "lang_doc": track.get("lan_doc"), "srt": f"{stem}.srt", "txt": f"{stem}.txt", "json": f"{stem}.json", "chars": sum(map(len, lines))}) return out def wbi_key(post: Post) -> str: obj = http_json(f"{BILIBILI}/x/web-interface/nav", {"Referer": BILI_REFERER}) # code -101 when logged out, keys still there wbi = (obj.get("data") or {}).get("wbi_img") or {} key = "".join(Path(urllib.parse.urlparse(wbi.get(k, "")).path).stem for k in ("img_url", "sub_url")) return "".join(key[i] for i in WBI_MIXIN if i < len(key))[:32] def wbi_sign(params: dict, mixin: str) -> dict: signed = {**params, "wts": int(time.time())} cleaned = {k: "".join(ch for ch in str(v) if ch not in "!'()*") for k, v in signed.items()} signed["w_rid"] = hashlib.md5((urllib.parse.urlencode(sorted(cleaned.items())) + mixin).encode()).hexdigest() return signed def compact_reply(reply: dict) -> dict: content = reply.get("content") or {} member = reply.get("member") or {} return { "rpid": str(reply.get("rpid_str") or reply.get("rpid") or ""), "user": member.get("uname") or "", "mid": str(member.get("mid") or reply.get("mid") or ""), "time": iso(reply.get("ctime")), "like": int(reply.get("like") or 0), "message": str(content.get("message") or "").replace("\r\n", "\n"), "pictures": [p.get("img_src") for p in content.get("pictures") or [] if p.get("img_src")], "reply_count": int(reply.get("rcount") or 0), "up_liked": bool((reply.get("up_action") or {}).get("like")), "children": [], } def bilibili_comments(post: Post, oid: str, kind: int) -> str: """Top-level comments through the WBI main API (the plain one returns only a few), then their replies.""" mixin = wbi_key(post) roots: dict[str, dict] = {} total = None cursor_next = 0 for page in range(40): params = wbi_sign({"type": kind, "oid": oid, "mode": 3, "next": cursor_next, "ps": 20, "web_location": 1315875}, mixin) data = bili_get(post, "/x/v2/reply/wbi/main", params, f"bilibili_comments_{page:02d}") cursor = data.get("cursor") or {} total = cursor.get("all_count", total) top = data.get("top") or {} pinned = [top.get(k) for k in ("admin", "upper") if isinstance(top.get(k), dict) and top[k].get("rpid")] for reply in pinned + (data.get("top_replies") or []) + (data.get("replies") or []): item = compact_reply(reply) roots.setdefault(item["rpid"], item) if cursor.get("is_end") or cursor.get("next") in (None, cursor_next): break cursor_next = cursor["next"] time.sleep(0.25) for item in roots.values(): pn = 1 while item["reply_count"] and len(item["children"]) < item["reply_count"] and pn <= 20: params = {"type": kind, "oid": oid, "root": item["rpid"], "pn": pn, "ps": 20} try: data = http_json(f"{BILIBILI}/x/v2/reply/reply?{urllib.parse.urlencode(params)}", {"Referer": BILI_REFERER}).get("data") or {} except RuntimeError as exc: item["children_error"] = str(exc) break replies = data.get("replies") or [] if not replies: break item["children"] += [compact_reply(r) for r in replies] pn += 1 time.sleep(0.2) result = { "oid": oid, "type": kind, "total_reported": total, "fetched": len(roots) + sum(len(i["children"]) for i in roots.values()), "fetched_at": datetime.now(TZ).isoformat(timespec="seconds"), "items": list(roots.values()), } (post.dir / "comments.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") return "comments.json" def initial_state(html: str) -> dict: """Cut the JSON object after window.__INITIAL_STATE__= by matching braces outside strings.""" marker = "window.__INITIAL_STATE__=" start = html.find(marker) if start < 0: raise RuntimeError("no window.__INITIAL_STATE__ in the opus page") start = html.index("{", start) depth, in_string, escaped = 0, False, False for i in range(start, len(html)): ch = html[i] if in_string: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == '"': in_string = False elif ch == '"': in_string = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return json.loads(html[start : i + 1]) raise RuntimeError("the opus page's initial state JSON never closes") def nodes_text(nodes: list | None) -> str: out = [] for node in nodes or []: if isinstance(node.get("word"), dict): out.append(str(node["word"].get("words") or "")) elif isinstance(node.get("rich"), dict): out.append(str(node["rich"].get("text") or node["rich"].get("orig_text") or "")) elif isinstance(node.get("formula"), dict): out.append(str(node["formula"].get("latex") or "")) return "".join(out) def render_list(items: list, ordered: bool, level: int = 0) -> list[str]: lines = [] for idx, item in enumerate(items, 1): marker = f"{int(item.get('order') or idx)}." if ordered else "-" text = " ".join(nodes_text((p.get("text") or {}).get("nodes")).strip() for p in item.get("children") or [] if p.get("text")) if text: lines.append(f"{' ' * level}{marker} {text}") for p in item.get("children") or []: if isinstance(p.get("list"), dict): lines += render_list(p["list"].get("children") or [], ordered, level + 1) return lines def render_paragraph(para: dict, images: list[str]) -> str: kind = para.get("para_type") if kind == 1: return nodes_text((para.get("text") or {}).get("nodes")).strip() if kind == 8: heading = para.get("heading") or {} text = nodes_text(heading.get("nodes")).strip() return f"{'#' * max(2, min(6, int(heading.get('level') or 2)))} {text}" if text else "" if kind == 7: code = para.get("code") or {} return f"```{code.get('lang') or ''}\n{str(code.get('content') or '').rstrip()}\n```" if code.get("content") else "" if kind == 2: lines = [] for pic in (para.get("pic") or {}).get("pics") or []: if url := https(str(pic.get("url") or "")): images.append(url) lines.append(f"![{pic.get('comment') or f'图片 {len(images)}'}]({{IMG{len(images)}}})") return "\n".join(lines) if kind == 5: lst = para.get("list") or {} return "\n".join(render_list(lst.get("children") or [], int(lst.get("style") or 0) == 1)) if kind == 6: card = (para.get("link_card") or {}).get("card") or {} for key in ("eva3_video", "eva3_opus", "common", "ugc", "opus"): if isinstance(card.get(key), dict): info = card[key].get("info") if isinstance(card[key].get("info"), dict) else card[key] title = info.get("title") or info.get("desc") or key link = https(str(info.get("jump_url") or info.get("url") or "")) return f"[{title}]({link})" if link else str(title) return "" def fetch_bilibili_opus(post: Post, opus: str, args: argparse.Namespace) -> None: """The public page has the whole article; the polymer API often answers -352 to anonymous calls.""" req = urllib.request.Request(f"https://www.bilibili.com/opus/{opus}", headers={"User-Agent": USER_AGENT, "Referer": BILI_REFERER}) with urllib.request.urlopen(req, timeout=30) as resp: state = initial_state(resp.read().decode("utf-8", errors="replace")) post.save_raw("bilibili_opus_state", state) detail = state.get("detail") or {} modules = {m.get("module_type"): m for m in detail.get("modules") or []} author = (modules.get("MODULE_TYPE_AUTHOR") or {}).get("module_author") or {} stat = (modules.get("MODULE_TYPE_STAT") or {}).get("module_stat") or {} title = ((modules.get("MODULE_TYPE_TITLE") or {}).get("module_title") or {}).get("text") or "" paragraphs = ((modules.get("MODULE_TYPE_CONTENT") or {}).get("module_content") or {}).get("paragraphs") or [] images: list[str] = [] blocks = [b for b in (render_paragraph(p, images) for p in paragraphs) if b] count = lambda key: int(((stat.get(key) or {}).get("count") if isinstance(stat.get(key), dict) else stat.get(key)) or 0) # noqa: E731 post.meta.update( id=opus, kind="article", title=title or f"opus {opus}", desc="\n\n".join(b for b in blocks if not b.startswith("![")), author={"name": author.get("name"), "id": str(author.get("mid") or "")}, stats={"like": count("like"), "collect": count("favorite"), "comment": count("comment"), "share": count("forward"), "coin": count("coin")}, published=iso(author.get("pub_ts")), ) markdown = "\n\n".join([f"# {post.meta['title']}", *blocks]) + "\n" for i, url in enumerate(images, 1): ext = Path(urllib.parse.urlparse(url).path).suffix.lower() name = f"images/{i:02d}{ext if ext in ('.jpg', '.jpeg', '.png', '.webp', '.gif') else '.jpg'}" post.meta["files"]["images"].append(post.download(url, name, BILI_REFERER)) markdown = markdown.replace(f"{{IMG{i}}}", name) (post.dir / "article.md").write_text(markdown, encoding="utf-8") post.meta["files"]["article"] = "article.md" if args.comments: basic = detail.get("basic") or {} # An opus is commented under its own type and id, not type=1 with the opus id. post.meta["files"]["comments"] = bilibili_comments(post, str(basic.get("comment_id_str") or ""), int(basic.get("comment_type") or 0)) def add_args(parser) -> None: parser.add_argument("--parts", default="all", help="parts to fetch: all (default), or e.g. 1,3-5") parser.add_argument("--comments", action="store_true", help="fetch comments and their replies, for every link of this run") if __name__ == "__main__": raise SystemExit(cli(__doc__, "bilibili", lambda u: host_in(u, DOMAINS), lambda u: bvid_of(u) or opus_of(u) or last_segment(u), fetch_bilibili, add_args))