
Bilibili Source
- 170 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
Fetches login-free Bilibili video metadata and stats (views, likes, tags, danmaku) for citable data.
About
Fetches real, verifiable Bilibili video metadata and engagement stats via a single API call. A developer uses it when ingesting a Bilibili source or citing accurate view/like counts instead of estimating.
- One JSON object with title, UP stats, tags, and live metrics
- Accepts BVID, av numbers, b23.tv links, or full URLs
Bilibili Source by the numbers
- 170 all-time installs (skills.sh)
- Ranked #621 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill bilibili-sourceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 170 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
What it does
Fetches login-free Bilibili video metadata and stats (views, likes, tags, danmaku) for citable data.
Files
bilibili-source
Fetch real, verifiable data for a Bilibili video so you can cite it instead of guessing. Engagement numbers are the backbone of any honest "why did this do well" analysis, and hand-typed or estimated numbers are the fastest way a knowledge base rots. This skill makes the numbers cheap to fetch — so there is no excuse to invent them.
Quick start
scripts/bili-fetch.sh BV1xxxxxxxxxReturns one JSON object with everything from a single view/detail API call:
{
"bvid": "BV1xxxxxxxxx",
"aid": 1234567890,
"fetched_at": "2026-06-07T13:54:17Z",
"url": "https://www.bilibili.com/video/BV1xxxxxxxxx",
"title": "<video title>",
"up": { "name": "<UP name>", "mid": 12345678, "fans": 45600 },
"pubdate": "2026-01-10T00:50:47Z",
"tname": "<partition, may be empty>",
"tags": ["<tag>", "<tag>"],
"videos": 1,
"duration_s": 372,
"stat": { "view": 48000, "like": 1200, "coin": 180, "favorite": 950,
"share": 64, "reply": 210, "danmaku": 130 },
"pages": [ { "cid": 12345678, "page": 1, "part": "<part title>", "duration": 372 } ]
}bili-fetch.sh accepts any form a user might paste — BVID, `av` number, `b23.tv` short link, or full URL — and normalizes it. For multi-part videos it returns every part's cid in pages[] (you need the per-part cid to fetch that part's danmaku or subtitles).
Scripts
| Script | What it does | Login |
|---|---|---|
scripts/bili-fetch.sh <ref> | Core: full metadata + live stats (run this first) | No |
scripts/bili-danmaku.sh <ref> [P] | Danmaku (bullet-comment) full text for a part | No |
scripts/bili-subs.sh <ref> [browser] | Subtitle/transcript track | Yes |
scripts/bili-selftest.sh | Health-check every capability against the live API | No |
All three execute (don't read them as reference). bili-danmaku.sh reuses bili-fetch.sh to resolve the part's cid, so they must stay siblings in scripts/.
Danmaku are time-synced comments overlaid on the video — a Bilibili-specific signal of where and how viewers reacted, qualitatively richer than a flat reply count:
scripts/bili-danmaku.sh BV1xxxxxxxxx # P1; add a part number for multi-part videosRules that keep the data honest
- Live metrics → always cite `fetched_at`. The same video re-fetched minutes later drifts (a view count can tick up by a few within a single session). That is not an error — it is proof the data is live. A bare "12,000 views" with no timestamp is meaningless and silently goes stale.
- NO FABRICATION. If a number can't be fetched, write "未获取/未核实" — never estimate. The whole point of the skill is that the number is cheap to fetch.
- The scripts already handle the network quirks so you don't reinvent them: they strip the local proxy (Bilibili is a domestic CN service that a
127.0.0.1proxy breaks), send a browser User-Agent + Referer (avoids the occasional HTTP 412), and retry with backoff. If you call the API by hand, do the same — see references/bilibili_api.md. - CJK post-processing trap. When you later grep/sort the fetched Chinese text or filenames,
sort/commmishandle CJK collation and report false "missing"/"broken" results. Verify withfind -nameorgrep -F, notcomm.
Subtitles require login (no bypass)
Stats and danmaku are login-free. Subtitles are not. Verified across many videos (new and old) plus anonymous cookies: the public player API returns an empty subtitle list for anonymous requests, and yt-dlp reports "Subtitles are only available when logged in." There is no login-free path — do not try to bypass it.
bili-subs.sh therefore needs the user's Bilibili session via browser cookies. Because it reads their logged-in session, ask the user before running it:
scripts/bili-subs.sh BV1xxxxxxxxx chrome # or firefox / safari / edgeThe ai-zh track is Bilibili's AI-generated subtitle — treat it as a draft transcript (same-sound/segmentation errors), mark it as AI-ASR in whatever you produce, and don't claim it is a human-checked verbatim. If a video has no subtitle track, there is nothing to fetch — don't invent one. A SESSDATA-env API alternative is documented in the reference.
Going deeper
For the full endpoint catalog (UP fan history, video tags, real-time viewer count, danmaku archive, the SESSDATA subtitle path), the WBI request-signing algorithm needed for space/wbi/* endpoints, and every gotcha with a tested command, see [references/bilibili_api.md](references/bilibili_api.md).
Verified status
- Stats / metadata / danmaku (
view/detail,relation/stat,dm/list.so,online/total): verified login-free, 2026-06-07. Metrics re-fetched repeatedly and matched independently; danmaku count matchedstat.danmaku. - Subtitles: confirmed login-gated, 2026-06-07 (empty for anonymous across all videos tested). Needs
yt-dlpfor the cookie path.
Maintenance
This skill wraps a third-party API that drifts over time — fields get renamed, endpoints add WBI signing, anti-bot tightens. Before trusting it after a gap, or whenever output looks wrong, run the health-check:
scripts/bili-selftest.shIt hits every capability (and the login-gate invariant) against a stable public fixture and prints one PASS/FAIL row per capability, so drift surfaces as a clear FAIL pointing at what broke — not a silent wrong answer. When a row fails, the endpoint paths, field names, and WBI signing needed to fix it are in references/bilibili_api.md; update the "Verified" dates above once you re-confirm.
Security scan passed
Scanned at: 2026-06-08T00:39:32.168530
Tool: gitleaks + pattern-based validation
Content hash: e8f66e65cb05e73f87f2bf57af3c9bc2c81ba2c560698e35c199f99d5786cb54
{
"skill_name": "bilibili-source",
"evals": [
{
"id": 1,
"name": "cite-real-stats-from-url",
"prompt": "I'm writing up a short analysis of this B站 video: https://www.bilibili.com/video/BV17x411w7KC — I need the actual play count, likes and favorites to cite, not rough numbers. Can you get them?",
"expected_behavior": [
"Runs scripts/bili-fetch.sh with the URL instead of guessing or estimating numbers",
"Reports view/like/favorite from the returned stat block",
"Includes the fetched_at timestamp alongside the metrics (because they are live snapshots)",
"Does not hand-type or round numbers as if authoritative",
"Mentions the UP name and other metadata returned in the same call where relevant"
],
"files": []
},
{
"id": 2,
"name": "danmaku-audience-reactions",
"prompt": "这个 B站 视频 av170001 弹幕里大家都在说什么?我想了解观众的真实反应",
"expected_behavior": [
"Recognizes danmaku (bullet comments) as the relevant signal and uses scripts/bili-danmaku.sh",
"Handles the av number input (does not require a BVID)",
"Returns actual danmaku text, not just the danmaku count",
"Does not fabricate comments if the fetch returns few or none"
],
"files": []
},
{
"id": 3,
"name": "subtitles-need-login",
"prompt": "Grab the subtitle transcript from this bilibili video BV17x411w7KC so I can summarize what it's about.",
"expected_behavior": [
"Knows Bilibili subtitles are login-gated and there is no anonymous path",
"Asks the user before reading their browser cookies / using their Bilibili login",
"Uses scripts/bili-subs.sh (or the documented SESSDATA path) rather than claiming a login-free method",
"Treats the ai-zh track as AI-generated draft, not a verified verbatim transcript",
"Does not invent a transcript if no subtitle track exists"
],
"files": []
},
{
"id": 4,
"name": "multipart-and-shortlink",
"prompt": "朋友发我一个 b23.tv 短链(类似 https://b23.tv/xxxxxxx),说是个多P的合集视频,帮我看看有几个分P、每个分P的 cid 是多少,UP 有多少粉丝",
"expected_behavior": [
"Passes the b23.tv short link straight to scripts/bili-fetch.sh (which expands it)",
"Reports videos (part count) and the per-part cids from pages[], not just the top-level cid",
"Reports the UP follower count (up.fans) from the same call",
"Does not claim it cannot handle short links or multi-part videos"
],
"files": []
}
]
}
Bilibili API reference
Endpoints, fields, and gotchas behind bilibili-source. Every command below was tested 2026-06-07 (curl 8.7 / jq 1.7 / yt-dlp 2026.03). Prefix every request with the proxy-strip + headers shown in Request basics.
Contents
- Request basics — proxy, headers, retries
- Input forms — BVID / av / b23.tv / URL
- Core endpoint: view/detail — everything in one call
- Other login-free endpoints — UP stats, tags, viewers, danmaku
- Multi-part videos
- Danmaku decompression
- Subtitles (login required) — yt-dlp and SESSDATA paths
- WBI signing — only for
space/wbi/* - Gotchas
Request basics
NP() { env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY "$@"; }
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36"
HDR=(-H "User-Agent: $UA" -H "Referer: https://www.bilibili.com")- Proxy: Bilibili is a domestic CN service. A local forward proxy (e.g.
127.0.0.1:1082) makes requests hang or fail — strip proxy env per request. - Headers: UA + Referer avoid the occasional
HTTP 412. (As of the test date a bare request often still succeeds, but the headers are a near-zero-cost defense against IP/time-windowed risk control — keep them.) - Retries: non-zero
codesuch as-412/-799is transient rate-limiting; back off and retry 2–3×. For batches of many videos, add a small sleep between calls. Single-video fetches did not trip any limit across 35 rapid calls.
Input forms
| Input | How to resolve |
|---|---|
BV + 10 chars | Use directly: ?bvid=BV.... Anchor the regex to BV[0-9A-Za-z]{10} — an unanchored BV[0-9A-Za-z]+ over-captures trailing chars. |
av<number> / bare aid | ?aid=<number>. The API accepts aid and returns bvid, so it doubles as an av→BV converter. |
b23.tv/xxxx short link | One curl -sI (no -L); read the Location: header for the canonical URL, then extract BV/av. |
Core endpoint: view/detail
GET https://api.bilibili.com/x/web-interface/view/detail?bvid=<BV> (or ?aid=<n>) — returns everything bilibili-source needs in one call, including the partition (tname) and UP follower count that the plain view endpoint often leaves empty/absent.
NP curl -fsSL "${HDR[@]}" "https://api.bilibili.com/x/web-interface/view/detail?bvid=BV1xxxxxxxxx" \
| jq '.data | {title:.View.title, up:.View.owner.name, fans:.Card.card.fans,
tname:.View.tname, tags:[.Tags[].tag_name], videos:.View.videos,
stat:.View.stat, pages:[.View.pages[]|{cid,page,part,duration}]}'Key paths: data.View (title, aid, bvid, pubdate, duration, videos, owner{mid,name}, tname, pages[], stat{view,like,coin,favorite,share,reply,danmaku}); data.Card.card.fans (UP followers); data.Tags[].tag_name; data.Related[] (up to ~40 related videos).
Other login-free endpoints
| Data | Endpoint | Notes |
|---|---|---|
| UP follower/following | x/relation/stat?vmid=<mid> | data.follower, data.following |
| UP card | x/web-interface/card?mid=<mid> | data.card.fans, name, sign |
| Video tags | x/tag/archive/tags?bvid=<BV> | array of tag_name |
| Real-time viewers | x/player/online/total?bvid=<BV>&cid=<cid> | data.total ("1.7万+"), data.count (int) |
| Danmaku (current pool) | x/v1/dm/list.so?oid=<cid> | raw-deflate XML — see below |
| Player meta | x/player/wbi/v2?bvid=<BV>&cid=<cid> | subtitle list here is empty when anonymous |
tname from view/detail can be empty for some videos; the tags array is the reliable content-classification signal.
Multi-part videos
data.View.videos = part count; data.View.pages[] lists each part as {cid, page, part, duration}. The top-level data.View.cid equals part 1 only — for danmaku/subtitles of later parts you must use that part's own cid from pages[]. bili-fetch.sh emits the full pages[].
Danmaku decompression
x/v1/dm/list.so?oid=<cid> returns headerless raw DEFLATE (not gzip). Decompress with zlib window bits -15, then each comment is <d p="...">text</d>:
NP curl -fsSL "${HDR[@]}" "https://api.bilibili.com/x/v1/dm/list.so?oid=<cid>" \
| python3 -c "import sys,zlib; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read(),-15))" \
| grep -oE '<d [^>]*>[^<]*</d>' | sed -E 's/<d [^>]*>//; s|</d>||'list.so returns the current rolling pool (up to a few thousand). For the full historical archive use the protobuf segment endpoint x/v2/dm/web/seg.so?type=1&oid=<cid>&segment_index=<n> (6-minute segments; needs a protobuf decoder — out of scope for the bundled scripts).
Subtitles (login required)
There is no anonymous path (verified: player/wbi/v2 returns an empty subtitle list for every anonymous request tested, new videos included). Two authenticated options:
1. yt-dlp + browser cookies (what bili-subs.sh uses):
yt-dlp --skip-download --write-subs --sub-langs "ai-zh" --cookies-from-browser chrome \
--add-header "Referer:https://www.bilibili.com" "https://www.bilibili.com/video/<BV>"2. SESSDATA cookie + API (documented; verify on first use with a real login — the empty-list behavior above was only confirmable while logged out):
NP curl -fsSL "${HDR[@]}" -b "SESSDATA=<your_sessdata>" \
"https://api.bilibili.com/x/player/wbi/v2?bvid=<BV>&cid=<cid>" \
| jq '.data.subtitle.subtitles[] | {lan, url:.subtitle_url}'
# then download the .subtitle_url JSON (json3 format: body[].content)ai-zh is AI-generated — same-sound/segmentation errors; mark output as AI-ASR, never as verbatim.
WBI signing
Needed only for space/wbi/* endpoints (e.g. listing a UP's videos via space/wbi/arc/search). None of the endpoints used by the bundled scripts require it. The algorithm, verified end-to-end while logged out:
1. GET x/web-interface/nav (works anonymously) → data.wbi_img.img_url and sub_url; the filename stems are img_key and sub_key. 2. mixin_key = concatenate img_key + sub_key, then reorder by a fixed 64-index table and take the first 32 chars. 3. Add wts=<unix-seconds> to your params, sort keys, URL-encode (drop !'()*), then w_rid = md5(sorted_query + mixin_key). Send params + wts + w_rid.
Gotcha: space/wbi/* also needs an anonymous `buvid3` cookie (get it login-free from x/frontend/finger/spi → data.b_3), or it still returns -352 even with a valid signature.
Gotchas
- `code != 0` is the real error channel, not just HTTP status. Always check
.code == 0; surface.message. - Metrics are live snapshots — emit a fetch timestamp with every stat.
- `-352` risk-control usually means missing WBI signature or
buvid3, not a bad request. - CJK collation —
sort/commgive false negatives on Chinese strings; verify membership withgrep -F/find -name. - No login-free subtitles — settle it once: the empty array from
player/wbi/v2is the ceiling.
#!/usr/bin/env bash
# bili-danmaku.sh — Fetch a Bilibili video's danmaku (bullet comments) as plain text. Login-free.
#
# Danmaku are time-synced comments overlaid on the video — a Bilibili-specific signal of
# WHERE and HOW viewers reacted (spikes of "前方高能", agreement, jokes). Unlike a flat
# reply count, the danmaku text itself is qualitative audience data.
#
# Output: one danmaku per line to stdout; a count line to stderr.
#
# Usage:
# bili-danmaku.sh <BVID | av | b23.tv | URL> [P-number] # resolves the part's cid (default P1)
# bili-danmaku.sh --cid <CID> # if you already have a cid
#
# Deps: curl, jq, python3 (raw-deflate decompression). Resolution reuses bili-fetch.sh.
#
# Note: x/v1/dm/list.so returns the current rolling pool (up to a few thousand). For the
# full historical archive use the protobuf seg.so endpoint — see references/bilibili_api.md.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
bili_curl() {
env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
curl -fsSL --max-time 20 --retry 3 --retry-delay 1 --retry-all-errors \
-H "User-Agent: $UA" -H "Referer: https://www.bilibili.com" "$@"
}
# Resolve cid: either given directly, or pulled from the requested part via bili-fetch.sh.
if [ "${1:-}" = "--cid" ]; then
cid="${2:-}"; [ -z "$cid" ] && { echo "ERROR: --cid needs a value" >&2; exit 2; }
else
ref="${1:-}"; [ -z "$ref" ] && { echo "usage: bili-danmaku.sh <BVID|av|URL> [P] | --cid <CID>" >&2; exit 2; }
p="${2:-1}"
cid=$("$here/bili-fetch.sh" "$ref" | jq -r ".pages[$((p-1))].cid // empty")
[ -z "$cid" ] && { echo "ERROR: could not resolve cid for part $p of: $ref" >&2; exit 1; }
fi
# list.so is headerless raw DEFLATE (zlib window bits -15), not gzip.
xml=$(bili_curl "https://api.bilibili.com/x/v1/dm/list.so?oid=$cid" \
| python3 -c "import sys,zlib; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read(), -15))") \
|| { echo "ERROR: fetch/decompress failed for cid=$cid" >&2; exit 1; }
# Each comment is <d p="...">text</d>; emit just the text, one per line.
texts=$(printf '%s' "$xml" \
| grep -oE '<d [^>]*>[^<]*</d>' \
| sed -E 's/<d [^>]*>//; s|</d>||')
n=$(printf '%s\n' "$texts" | grep -c . || true)
printf '%s\n' "$texts"
echo "[$n danmaku for cid=$cid]" >&2
#!/usr/bin/env bash
# bili-fetch.sh — Fetch comprehensive, login-free metadata + LIVE stats for a Bilibili video.
#
# One call to web-interface/view/detail returns: title, UP (name/mid/follower count),
# publish date, partition, tags, per-part cids, and the full stat block
# (view/like/coin/favorite/share/reply/danmaku). Output is ONE JSON object to stdout.
#
# Engagement metrics are LIVE snapshots — they drift minute to minute — so the JSON
# carries `fetched_at`. Always cite a metric WITH that timestamp; a bare count goes
# stale silently. If a number can't be fetched, write "未获取/未核实" — never estimate.
#
# Accepts any form a user might paste:
# bili-fetch.sh BV1xxxxxxxxx # BVID
# bili-fetch.sh av170001 # av number
# bili-fetch.sh "https://b23.tv/xxxxxxx" # short link (auto-expanded)
# bili-fetch.sh "https://www.bilibili.com/video/BV1xxxxxxxxx"
#
# Deps: curl, jq. No login required.
set -euo pipefail
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
# Bilibili is a domestic CN service: a local forward proxy (e.g. 127.0.0.1:1082)
# breaks the call, so strip inherited proxy env for the request only. A browser
# User-Agent + Referer avoids the occasional HTTP 412 anti-bot response. Retry a
# few times with backoff to ride out transient -412/-799/network blips.
bili_curl() {
env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
curl -fsSL --max-time 20 --retry 3 --retry-delay 1 --retry-all-errors \
-H "User-Agent: $UA" -H "Referer: https://www.bilibili.com" "$@"
}
usage() { echo "usage: bili-fetch.sh <BVID | av-number | b23.tv-link | bilibili-video-URL>" >&2; exit 2; }
input="${1:-}"; [ -z "$input" ] && usage
# 1) Expand b23.tv short links to their canonical URL (single 302 hop).
if printf '%s' "$input" | grep -qi 'b23\.tv'; then
loc=$(env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
curl -sI --max-time 15 -H "User-Agent: $UA" "$input" \
| tr -d '\r' | awk 'tolower($1)=="location:"{print $2; exit}')
[ -n "$loc" ] && input="$loc"
fi
# 2) Resolve to an API query param. BVID is a fixed BV + 10 chars — anchor the length
# so a longer surrounding string can't be over-captured. Fall back to an av/aid number.
bvid=$(printf '%s' "$input" | grep -oE 'BV[0-9A-Za-z]{10}' | head -1 || true)
if [ -n "$bvid" ]; then
q="bvid=$bvid"
else
aid=$(printf '%s' "$input" | grep -oiE 'av[0-9]+|[0-9]{6,}' | grep -oE '[0-9]+' | head -1 || true)
[ -z "$aid" ] && { echo "ERROR: no BVID or av-number found in: $input" >&2; usage; }
q="aid=$aid"
fi
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
json=$(bili_curl "https://api.bilibili.com/x/web-interface/view/detail?$q") \
|| { echo "ERROR: request failed ($q) — proxy or network?" >&2; exit 1; }
code=$(printf '%s' "$json" | jq -r '.code')
if [ "$code" != "0" ]; then
echo "ERROR: bilibili API code=$code msg=$(printf '%s' "$json" | jq -r '.message // "?"')" >&2
exit 1
fi
# Multi-part videos: every part has its own cid (subtitles/danmaku are fetched per cid),
# so emit the full pages[] — not just the top-level cid, which is only part 1.
printf '%s' "$json" | jq --arg ts "$ts" '
.data as $d | $d.View as $v | {
bvid: $v.bvid,
aid: $v.aid,
fetched_at: $ts,
url: ("https://www.bilibili.com/video/" + $v.bvid),
title: $v.title,
up: { name: $v.owner.name, mid: $v.owner.mid, fans: $d.Card.card.fans },
pubdate: ($v.pubdate | todate),
tname: $v.tname,
tags: [ $d.Tags[]?.tag_name ],
videos: $v.videos,
duration_s: $v.duration,
stat: { view: $v.stat.view, like: $v.stat.like, coin: $v.stat.coin,
favorite: $v.stat.favorite, share: $v.stat.share,
reply: $v.stat.reply, danmaku: $v.stat.danmaku },
pages: [ $v.pages[] | { cid, page, part, duration } ]
}'
#!/usr/bin/env bash
# bili-selftest.sh — Health-check for bilibili-source against the LIVE Bilibili API.
#
# Why this exists: this skill wraps a third-party API that changes over time (fields get
# renamed, endpoints add WBI signing, anti-bot tightens). Without a self-test, drift shows
# up as a silent wrong answer in production. Run this after Bilibili changes something, or
# periodically, and API drift surfaces as one clear FAIL row pointing at what broke.
#
# It asserts SHAPE (fields exist, right types) and documented INVARIANTS — never exact
# values, since engagement numbers drift by design.
#
# Usage: bili-selftest.sh
# Deps: curl, jq, python3 (same as the scripts under test).
# Exit: 0 = all green; 1 = drift detected (see failing rows + references/bilibili_api.md).
set -uo pipefail # deliberately NOT -e: run every check and report, don't abort on first fail
here="$(cd "$(dirname "$0")" && pwd)"
FIXTURE="av170001" # AZIS classic — old, stable, public, multi-part (10 P). A neutral fixture.
pass=0; fail=0
ok() { printf " ✅ %s\n" "$1"; pass=$((pass+1)); }
bad() { printf " ❌ %s\n" "$1"; fail=$((fail+1)); }
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
np_curl() {
env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
curl -fsSL --max-time 20 -H "User-Agent: $UA" -H "Referer: https://www.bilibili.com" "$@"
}
echo "bilibili-source self-test (fixture: $FIXTURE)"
# 1) Core fetch — av input normalizes; view/detail returns the documented shape.
J=$("$here/bili-fetch.sh" "$FIXTURE" 2>/dev/null || true)
CID=""
if [ -z "$J" ]; then
bad "fetch: returned nothing (endpoint or input-normalization broke)"
else
echo "$J" | jq -e '.bvid | startswith("BV")' >/dev/null 2>&1 && ok "fetch: av → BVID resolved" || bad "fetch: bvid missing (av-resolution or view/detail broke)"
echo "$J" | jq -e '.stat.view | type=="number"' >/dev/null 2>&1 && ok "fetch: stat.view is a number" || bad "fetch: stat.view missing/renamed"
echo "$J" | jq -e '.up.fans | type=="number"' >/dev/null 2>&1 && ok "fetch: up.fans present (Card.card.fans path)" || bad "fetch: up.fans missing (view/detail Card path drifted)"
echo "$J" | jq -e '(.tags|type)=="array"' >/dev/null 2>&1 && ok "fetch: tags is an array" || bad "fetch: tags missing/renamed"
echo "$J" | jq -e '(.pages|length) == .videos' >/dev/null 2>&1 && ok "fetch: pages[] complete (length == videos)" || bad "fetch: pages[] count != videos (multi-P parsing drifted)"
echo "$J" | jq -e '(.pages|length) > 1' >/dev/null 2>&1 && ok "fetch: multi-part fixture returned >1 part" || bad "fetch: fixture no longer multi-part (pick a new FIXTURE)"
CID=$(echo "$J" | jq -r '.pages[0].cid // empty')
fi
# 2) Full-URL input still normalizes.
echo "$("$here/bili-fetch.sh" "https://www.bilibili.com/video/BV17x411w7KC" 2>/dev/null)" \
| jq -e '.bvid=="BV17x411w7KC"' >/dev/null 2>&1 && ok "fetch: full-URL input normalized" || bad "fetch: URL normalization broke"
# 3) Danmaku — list.so decompresses and yields a line count.
if [ -n "$CID" ]; then
DM=$("$here/bili-danmaku.sh" --cid "$CID" 2>/dev/null | grep -c . || true)
case "$DM" in
''|*[!0-9]*) bad "danmaku: decompression/parse broke (non-numeric result)";;
*) ok "danmaku: list.so decompressed ($DM lines)";;
esac
else
bad "danmaku: skipped — no cid from fetch"
fi
# 4) Login-gate invariant — anonymous subtitle list must stay EMPTY (the documented ceiling).
# A non-empty anonymous list means Bilibili opened subtitles up: update SKILL.md if so.
if [ -n "$CID" ]; then
SUBS=$(np_curl "https://api.bilibili.com/x/player/wbi/v2?aid=170001&cid=$CID" 2>/dev/null \
| jq -r '.data.subtitle.subtitles | length' 2>/dev/null || echo ERR)
case "$SUBS" in
0) ok "subtitles: still login-gated (anonymous list empty, as documented)";;
ERR) bad "subtitles: player/wbi/v2 call failed (endpoint drifted)";;
*) bad "subtitles: anonymous list NON-empty ($SUBS) — login-gate changed, update docs";;
esac
else
bad "subtitles: skipped — no cid from fetch"
fi
echo ""
printf "Result: %d passed, %d failed\n" "$pass" "$fail"
if [ "$fail" -eq 0 ]; then
echo "✅ All capabilities healthy."; exit 0
else
echo "❌ Drift detected — see failing rows; consult references/bilibili_api.md."; exit 1
fi
#!/usr/bin/env bash
# bili-subs.sh — Download a Bilibili video's subtitle/transcript track. REQUIRES LOGIN.
#
# Important, verified 2026-06: Bilibili subtitles are gated behind login. The public
# player API (player/wbi/v2) returns an EMPTY subtitle list for anonymous requests across
# every video tested (new and old), and yt-dlp reports "Subtitles are only available when
# logged in." There is NO login-free path — do not try to bypass it. So this script needs
# the user's Bilibili session, supplied as browser cookies.
#
# Because it reads the user's logged-in session, ASK THE USER before running it.
#
# Usage:
# bili-subs.sh <BVID | av | URL> [browser] # browser: chrome (default), firefox, safari, edge
#
# Output: subtitle file(s) written to the current directory as <id>.<lang>.<ext> (json3/srt).
# Deps: yt-dlp. (Alternative SESSDATA-based API path documented in references/bilibili_api.md.)
set -euo pipefail
ref="${1:-}"
browser="${2:-chrome}"
[ -z "$ref" ] && { echo "usage: bili-subs.sh <BVID|av|URL> [browser]" >&2; exit 2; }
command -v yt-dlp >/dev/null 2>&1 || { echo "ERROR: yt-dlp not installed (brew install yt-dlp / pipx install yt-dlp)" >&2; exit 3; }
# Normalize to a watch URL (yt-dlp accepts BV/av URLs; bare IDs need wrapping).
case "$ref" in
http*) url="$ref" ;;
BV*|av*|AV*) url="https://www.bilibili.com/video/$ref" ;;
*) url="https://www.bilibili.com/video/$ref" ;;
esac
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
echo "Pulling subtitles via yt-dlp using your '$browser' cookies (Bilibili login required)…" >&2
if env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
yt-dlp --skip-download --write-subs --sub-langs "ai-zh,zh-Hans,zh-CN,zh" \
--user-agent "$UA" --add-header "Referer:https://www.bilibili.com" \
--cookies-from-browser "$browser" \
-o "%(id)s.%(ext)s" "$url"; then
echo "Done. If no subtitle file appeared, this video simply has no subtitle track." >&2
else
cat >&2 <<'EOF'
ERROR: subtitle download failed. Most likely causes:
- Not logged into bilibili.com in the chosen browser (subtitles are login-gated).
- Browser cookie DB locked — close the browser and retry, or pass a different browser.
- The video has no subtitle track at all (then there is nothing to fetch — do not invent one).
Alternative: export SESSDATA and use the player/wbi/v2 API path (see references/bilibili_api.md).
EOF
exit 1
fi