Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Get content from Bilibili videos: official subtitles, danmaku (density/peaks/sample), comments. When no subtitles exist (no 投稿字幕), fall back to downloading audio + ASR transcription via FunASR.
.claude/skills/misaka-mikoto-tech-bilibili-page-reader/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 336% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 210% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 252% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 160% | 0% |
kimi-webbridge for page state, login-only data, Bilibili Evolved (BE) providers.curl.exe and Python's requests both get blocked by Bilibili CDN TLS fingerprinting on this platform. ┌─ BE downloadSubtitles provider ──→ 投稿字幕 (timestamps)
│
BiliBili video ───┼─ BE downloadDanmaku provider ────→ danmaku density/peaks/sample
│
└─ No subtitles? ──→ Audio transcription fallback
1. Get audio stream URL (Node.js → playurl API)
2. Download .m4s audio
3. ffmpeg → .m4a
4. FunASR paraformer-zh → SRT with timestampsUse the one-shot evaluate below. It returns both subtitles and danmaku in a single call.
bash~/.kimi-webbridge/bin/kimi-webbridge status
json{"action":"navigate","args":{"url":"https://www.bilibili.com/video/BV.../","newTab":true},"session":"bilibili"}
Wait 2–3 seconds for BE to fully initialize.
js(async () => { const pa = window.bilibiliEvolved.pluginApis; // ── Identifiers ── const s = window.__INITIAL_STATE__ || {}; const vd = s.videoData || {}; const bvid = vd.bvid || s.bvid || location.pathname.match(/BV[\w]+/)?.[0]; const aid = vd.aid || s.aid; const pages = vd.pages || []; const p = parseInt(new URLSearchParams(location.search).get('p') || '1') - 1; const cid = pages[p]?.cid || vd.cid || s.cid || pages[0]?.cid; const title = vd.title || document.title; // ── Register providers ── pa.registerData('downloadVideo.assets', []); // ── Poll for providers (downloadDanmaku loads async, ~1-2s) ── function getProviders() { const g = pa.getData('downloadVideo.assets'); return Array.isArray(g[0]) ? g.flat() : g; } const deadline = Date.now() + 5000; let providers = getProviders(); while (!providers.find(p => p.name === 'downloadDanmaku') && Date.now() < deadline) { await new Promise(r => setTimeout(r, 300)); providers = getProviders(); } // ── Subtitles: try 投稿字幕 first ── let subResult = { count: 0, text: '', source: 'none' }; const subProvider = providers.find(p => p.name === 'downloadSubtitles'); if (subProvider) { try { const subAssets = await subProvider.getAssets([{ input: {} }], { type: 'json', enabled: true }); const subRaw = subAssets[0].data; let subText; if (subRaw instanceof Blob) { const buf = await subRaw.arrayBuffer(); subText = new TextDecoder('utf-8').decode(buf); } else { subText = String(subRaw); } const subtitles = JSON.parse(subText); const subLines = subtitles.map(s => { const totalSec = Math.floor(s.from); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const sec = String(totalSec % 60).padStart(2, '0'); if (h > 0) { return '[' + h + ':' + String(m).padStart(2, '0') + ':' + sec + '] ' + s.content; } return '[' + m + ':' + sec + '] ' + s.content; }); subResult = { count: subtitles.length, text: subLines.join('\n'), source: '投稿字幕' }; } catch(e) { subResult = { count: 0, text: '', source: '投稿字幕_error' }; } } // ── Danmaku: analyze in-page, summary only ── const dmk = providers.find(p => p.name === 'downloadDanmaku'); const dmkAssets = await dmk.getAssets( [{ input: { aid: String(aid), cid: String(cid) } }], { type: 'json', enabled: true } ); const dmkRaw = dmkAssets[0].data; let dmkText; if (dmkRaw instanceof Blob) { const buf = await dmkRaw.arrayBuffer(); dmkText = new TextDecoder('utf-8').decode(buf); } else { dmkText = String(dmkRaw); } const danmaku = JSON.parse(dmkText); // Time density: 30s buckets const bucketSize = 30; const buckets = {}; for (const d of danmaku) { const b = Math.floor(d.progress / 1000 / bucketSize) * bucketSize; buckets[b] = (buckets[b] || 0) + 1; } const density = Object.entries(buckets) .map(([t, c]) => [Number(t), c]) .sort((a, b) => a[0] - b[0]); // Top 5 peak moments const peaks = density.slice().sort((a, b) => b[1] - a[1]).slice(0, 5); // Stratified sample: up to 40 entries across full timeline const sampleCount = Math.min(40, danmaku.length); const step = Math.max(1, Math.floor(danmaku.length / sampleCount)); const danmakuSample = []; for (let i = 0; i < danmaku.length && danmakuSample.length < sampleCount; i += step) { danmakuSample.push({ t: Math.floor(danmaku[i].progress / 1000), c: danmaku[i].content }); } const totalDuration = danmaku.length > 0 ? Math.max(...danmaku.map(d => d.progress)) : 0; // ── Return ── return JSON.stringify({ ok: true, bvid, aid: Number(aid), cid: Number(cid), p: p + 1, title, sub: subResult, dmk: { count: danmaku.length, timeSpanSec: Math.floor(totalDuration / 1000), density, peakMoments: peaks.map(pk => ({ timeSec: pk[0], count: pk[1] })), sample: danmakuSample } }); })()
json{ "sub": { "count": 243, "text": "[0:00] 大家好\n[0:01] 这个视频...", "source": "投稿字幕" }, "dmk": { "count": 247, "density": [[0,11], [30,2], ...], "peakMoments": [{"timeSec": 1170, "count": 16}, ...], "sample": [{"t": 0, "c": "辛苦惹!"}, ...] } }
If sub.count === 0, no 投稿字幕 was available. Proceed to Phase 2 below.
Use when Phase 1 returns sub.count === 0. This replaces the old "AI subtitle API" fallback which is unreliable — the /x/player/v2 API often returns stale/empty subtitle data.
The Bilibili CDN uses TLS fingerprinting that blocks curl and Python requests. Reliable paths:
| Method | Works? | Notes | |--------|--------|-------| | curl.exe with browser headers | ❌ Exit code 35 | SSL blocked | | Python requests / urllib | ❌ Blocked | Same reason | | Node.js https.get() | ✅ | Use User-Agent + Referer headers | | Browser fetch (in-page) | ✅ but evaluate timeout <1s | Only for fast API calls | | Node.js to download audio | ✅ | Backup URL is most stable |
bashnode -e " const https = require('https'); const url = 'https://api.bilibili.com/x/player/playurl' + '?bvid=BVxxxxxxxxx&cid=xxxxxxxxx&qn=0&fnval=4048&fourk=1&platform=web'; https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://www.bilibili.com/' } }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { const d = JSON.parse(data); if (d.code === 0 && d.data && d.data.dash && d.data.dash.audio) { const audio = d.data.dash.audio; const best = audio.reduce((a,b) => a.bandwidth > b.bandwidth ? a : b); console.log(best.baseUrl + '|' + (best.backupUrl ? best.backupUrl[0] || '' : '')); } else { console.log('ERROR: no dash audio data'); } }); }).on('error', (e) => { console.log('ERROR: ' + e.message); }); "
The fnval=4048 flag requests DASH format with separate audio/video streams.
bashnode -e " const https = require('https'); const fs = require('fs'); const url = 'BACKUP_URL_FROM_STEP_1'; const file = fs.createWriteStream('output.m4s'); https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 ...', 'Referer': 'https://www.bilibili.com/' }, timeout: 60000 }, (res) => { if (res.statusCode !== 200) { console.log('HTTP ' + res.statusCode); return; } res.pipe(file); res.on('end', () => { console.log('OK size=' + file.bytesWritten); }); }).on('error', (e) => { console.log('ERR: ' + e.message); }); "
bashffmpeg -i output.m4s -c copy output.m4a -y
bashset MODELSCOPE_CACHE=.\cache\modelscope set MODELSCOPE_CREDENTIAL_PATH=.\cache\modelscope_cred python audio2srt.py output.m4a --srt --model paraformer-zh --punc-model ct-punc --spk-model cam++
bashdel output.m4s output.m4a
The only artifacts worth keeping are the .srt subtitle file. The raw .m4s, the converted .m4a, and any .json debug output are all intermediate and should be cleaned up after the SRT is confirmed valid.
What the script does:
paraformer-zh (Chinese ASR, 220M params) + fsmn-vad + ct-punc + cam++res[0]["sentence_info"]If cam++ is too slow to download, omit --spk-model and group res[0]["timestamp"] (per-character ms array) by punctuation boundaries manually.
If no sentence_info in the output: the spk_model triggers sentence segmentation. Without it, you only get raw text + per-character timestamps.
audio2srt.py scriptLocated next to this SKILL.md file. Copy it into the current workspace or call it by its skill-directory path. It handles:
sentence_info, timestamp, and raw text fallback<|zh|>, <|HAPPY|>, etc.)After transcription completes, delete *.m4s and *.m4a — only the .srt is the final output.
downloadDanmaku is absent)js(async () => { const be = window.bilibiliEvolved; const proto = await be.runtimeLibrary.protobufLibrary; const schema = { nested: { DmSegMobileReply: { fields: { elems: { rule: 'repeated', type: 'DanmakuElem', id: 1 } }, }, DanmakuElem: { fields: { id: { type: 'int64', id: 1 }, progress: { type: 'int32', id: 2 }, mode: { type: 'int32', id: 3 }, fontsize: { type: 'int32', id: 4 }, color: { type: 'uint32', id: 5 }, midHash: { type: 'string', id: 6 }, content: { type: 'string', id: 7 }, ctime: { type: 'int64', id: 8 }, pool: { type: 'int32', id: 11 }, idStr: { type: 'string', id: 12 }, attr: { type: 'int32', id: 13 }, animation: { type: 'string', id: 22 }, }, }, }, }; const root = proto.Root.fromJSON(schema); const DmSegMobileReply = root.lookupType('DmSegMobileReply'); const raw = await new Promise((resolve, reject) => { be.monkeyApis.GM_xmlhttpRequest({ method: 'GET', url: `https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&pid=${aid}&segment_index=1`, headers: { 'Referer': 'https://www.bilibili.com/' }, responseType: 'arraybuffer', onload: r => resolve(r.response), onerror: r => reject(r.responseType) }); }); const decoded = DmSegMobileReply.decode(new Uint8Array(raw)); return JSON.stringify(decoded.elems || []); })()
If segment 1 has data and segment 2 is empty, treat segment 1 as complete. Apply the same density/peak/sample logic from Phase 1.
texthttps://api.bilibili.com/x/v2/reply/main?jsonp=jsonp&type=1&oid={aid}&mode={mode}&ps=20&next=0
Modes: 2 = newest, 3 = popular, 0 = popular (server-dependent). Use DOM to confirm render order.
downloadSubtitles)js[ { "from": 0.3, "to": 5.3, "sid": 1, "location": 2, "content": "文字内容", "music": 0 }, ... ]
js{ "progress": 40000, "mode": 1, "fontsize": 25, "color": 16777215, "content": "弹幕内容", ... }
progress is in milliseconds.
fnval=4048 → DASH response with dash.audio[]mp4a.40.2 (AAC-LC, 48kHz, stereo).m4s = fragmented MP4, valid for ffmpeg stream copysentence_info)js[ { "start": 110, "end": 2190, "text": "话说大家有没有见过那种反驳型人格", "spk": 0 }, { "start": 2330, "end": 3750, "text": "就是不管听到你说什么,", "spk": 0 }, ... ]
start/end in milliseconds. spk is the speaker ID (from cam++ diarization).
| Issue | What to do | |-------|------------| | downloadDanmaku not in providers | Polling loop handles this. If absent after 5s, fall back to Protobuf API. | | Session-less evaluate | Always use "session":"bilibili" or similar. | | registerData() without [] | Always pass []. Provider callbacks crash without data. | | Blob.text() hangs | Use arrayBuffer() + TextDecoder('utf-8').decode() instead. | | CDN blocks curl.exe | Use Node.js https.get() with browser UA + Referer. | | playurl API returns no dash | Don't use platform=html5. Use fnval=4048&platform=web. | | Audio URL returns 403 | URLs expire after ~5 min (deadline param). Fetch + download immediately in one pass. | | ModelScope cache permission denied | Set MODELSCOPE_CACHE + MODELSCOPE_CREDENTIAL_PATH to writable workspace dirs. The audio2srt.py script handles this. | | No sentence_info in output | Add --spk-model cam++ to trigger sentence segmentation. | | Nested array from getData | Always flatten: Array.isArray(g[0]) ? g.flat() : g. | | curl.exe downloading | Use node -e with https.get() instead. |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 26,496 | 5,700 | -78% | 1 | 1 | 0% | 5,060 | 5,051 | -0% | 0 | 0 | — |
case-02 | fail→fail | 6,873 | 5,346 | -22% | 1 | 1 | 0% | 1,107 | 5,313 | +380% | 0 | 0 | — |
case-03 | fail→fail | 17,377 | 5,439 | -69% | 1 | 1 | 0% | 2,851 | 5,324 | +87% | 0 | 0 | — |
case-04 | pass→pass | 21,523 | 25,188 | +17% | 1 | 1 | 0% | 3,933 | 9,942 | +153% | 0 | 0 | — |
case-05 | pass→pass | 17,671 | 19,003 | +8% | 1 | 1 | 0% | 3,252 | 8,779 | +170% | 0 | 0 | — |
case-06 | pass→pass | 13,137 | 15,367 | +17% | 1 | 1 | 0% | 2,495 | 7,892 | +216% | 0 | 0 | — |
case-07 | fail→pass | 33,727 | 10,242 | -70% | 1 | 1 | 0% | 1,541 | 6,714 | +336% | 0 | 0 | — |
case-08 | fail→pass | 11,095 | 6,244 | -44% | 1 | 1 | 0% | 1,869 | 5,789 | +210% | 0 | 0 | — |
case-09 | pass→pass | 14,082 | 7,214 | -49% | 1 | 1 | 0% | 2,158 | 5,952 | +176% | 0 | 0 | — |
case-10 | fail→pass | 9,641 | 2,758 | -71% | 1 | 1 | 0% | 1,490 | 5,245 | +252% | 0 | 0 | — |
case-11 | fail→pass | 14,006 | 3,793 | -73% | 1 | 1 | 0% | 2,265 | 5,497 | +143% | 0 | 0 | — |
case-12 | fail→pass | 12,957 | 5,187 | -60% | 1 | 1 | 0% | 2,233 | 5,798 | +160% | 0 | 0 | — |
case-13 | pass→pass | 11,199 | 2,893 | -74% | 1 | 1 | 0% | 1,795 | 5,250 | +192% | 0 | 0 | — |
case-14 | fail→pass | 13,039 | 2,993 | -77% | 1 | 1 | 0% | 2,142 | 5,297 | +147% | 0 | 0 | — |
case-15 | pass→pass | 8,158 | 3,464 | -58% | 1 | 1 | 0% | 1,381 | 5,326 | +286% | 0 | 0 | — |
case-16 | fail→pass | 11,991 | 2,615 | -78% | 1 | 1 | 0% | 1,886 | 5,087 | +170% | 0 | 0 | — |
case-17 | fail→fail | 13,208 | 5,940 | -55% | 1 | 1 | 0% | 2,201 | 5,881 | +167% | 0 | 0 | — |
case-18 | pass→pass | 9,101 | 4,307 | -53% | 1 | 1 | 0% | 1,561 | 5,518 | +253% | 0 | 0 | — |
case-19 | pass→pass | 15,686 | 2,361 | -85% | 1 | 1 | 0% | 2,278 | 5,104 | +124% | 0 | 0 | — |
case-20 | pass→pass | 11,325 | 3,438 | -70% | 1 | 1 | 0% | 1,957 | 5,397 | +176% | 0 | 0 | — |
case-21 | fail→pass | 10,294 | 2,187 | -79% | 1 | 1 | 0% | 1,579 | 5,093 | +223% | 0 | 0 | — |
case-22 | fail→pass | 12,356 | 2,318 | -81% | 1 | 1 | 0% | 1,722 | 5,172 | +200% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 18 counted toward the lift figure. The other 4 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +41 percentage points is the difference between those two pass rates over the 18 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.