bilibili-page-reader
$
npx mdskill add Misaka-Mikoto-Tech/agent-skills/bilibili-page-readerExtracts Bilibili video content via subtitles, danmaku, or ASR fallback.
- Retrieves subtitles, danmaku, and comments from Bilibili videos.
- Uses kimi-webbridge for browser access and Node.js for network calls.
- Prefers official subtitles; falls back to audio transcription if missing.
- Returns structured text with timestamps for downstream processing.
SKILL.md
.github/skills/bilibili-page-readerView on GitHub ↗
---
name: bilibili-page-reader
description: "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."
---
# Bilibili Page Reader
## Core Rules
- **Browser access** via `kimi-webbridge` for page state, login-only data, Bilibili Evolved (BE) providers.
- **Do not click BE download buttons.** Call providers directly via evaluate.
- **Always use a named session.** A session-less evaluate may land on a different tab and silently return wrong data.
- **Network calls outside the browser** (audio download, playurl API) use Node.js — PowerShell's `curl.exe` and Python's requests both get blocked by Bilibili CDN TLS fingerprinting on this platform.
---
## Workflow Overview
```
┌─ 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 timestamps
```
---
## Phase 1: Subtitles (preferred — via BE)
Use the one-shot evaluate below. It returns both subtitles and danmaku in a single call.
### Setup
```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.
### One-shot evaluate
```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
}
});
})()
```
### Response shape
```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.
---
## Phase 2: Audio Transcription Fallback (when no subtitles exist)
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.
### Why this approach
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 |
### Step 1: Get audio stream URL via Node.js
```bash
node -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.
### Step 2: Download audio (use backup URL — most reliable)
```bash
node -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); });
"
```
### Step 3: Convert to standard audio (stream copy, no re-encode)
```bash
ffmpeg -i output.m4s -c copy output.m4a -y
```
### Step 4: ASR transcription via FunASR
```bash
set 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++
```
### Step 5: Cleanup — remove intermediate audio files
```bash
del 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:**
- Loads `paraformer-zh` (Chinese ASR, 220M params) + `fsmn-vad` + `ct-punc` + `cam++`
- Outputs SRT with per-sentence timestamps via `res[0]["sentence_info"]`
- CPU perf: ~15x realtime (21 min audio ≈ 90 sec)
- Handles ModelScope cache/credential path setup automatically
**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.
### The `audio2srt.py` script
Located next to this `SKILL.md` file. Copy it into the current workspace or call it by its skill-directory path. It handles:
- Model loading with ModelScope credential path override (sandbox-safe)
- Multiple output formats: SRT, VTT, JSON
- Parsing `sentence_info`, `timestamp`, and raw text fallback
- Cleaning SenseVoice special tags (`<|zh|>`, `<|HAPPY|>`, etc.)
- Segment deduplication and time-sorting
**After transcription completes, delete `*.m4s` and `*.m4a`** — only the `.srt` is the final output.
---
## Danmaku Fallback: Protobuf API (when BE's `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.
---
## Comments
```text
https://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.
---
## Data Structures
### 投稿字幕 (from BE `downloadSubtitles`)
```js
[
{ "from": 0.3, "to": 5.3, "sid": 1, "location": 2, "content": "文字内容", "music": 0 },
...
]
```
### Danmaku
```js
{ "progress": 40000, "mode": 1, "fontsize": 25, "color": 16777215,
"content": "弹幕内容", ... }
```
`progress` is in milliseconds.
### DASH audio stream (from playurl API)
- `fnval=4048` → DASH response with `dash.audio[]`
- Audio IDs: 30216 (~65kbps), 30232 (~74kbps), 30280 (~128kbps AAC)
- Codec: `mp4a.40.2` (AAC-LC, 48kHz, stereo)
- Format: `.m4s` = fragmented MP4, valid for ffmpeg stream copy
### ASR output (`sentence_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).
---
## Gotchas
| 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. |