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.
npx skills add https://github.com/Misaka-Mikoto-Tech/agent-skills --skill bilibili-page-reader
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 timestamps
Use the one-shot evaluate below. It returns both subtitles and danmaku in a single call.
~/.kimi-webbridge/bin/kimi-webbridge status
{"action":"navigate","args":{"url":"https://www.bilibili.com/video/BV.../","newTab":true},"session":"bilibili"}
Wait 2–3 seconds for BE to fully initialize.
(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
}
});
})()
{
"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 |
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.
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); });
"
ffmpeg -i output.m4s -c copy output.m4a -y
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++
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:
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)(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.
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.
downloadSubtitles)[
{ "from": 0.3, "to": 5.3, "sid": 1, "location": 2, "content": "文字内容", "music": 0 },
...
]
{ "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)[
{ "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. |
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
Improves the quality of images, especially screenshots, by enhancing resolution, sharpness, and clarity. Perfect for preparing images for presentations, documentation, or social media posts.
Downloads videos from YouTube and other platforms for offline viewing, editing, or archival. Handles various formats and quality options.
Lightweight WSI tile extraction and preprocessing. Use for basic slide processing tissue detection, tile extraction, stain normalization for H&E images. Best for simple pipelines, dataset preparation, quick tile-based analysis. For advanced spatial proteomics, multiplexed imaging, or deep learning pipelines use pathml.
Microscopy data management platform. Access images via Python, retrieve datasets, analyze pixels, manage ROIs/annotations, batch processing, for high-content screening and microscopy workflows.
Python library for working with DICOM (Digital Imaging and Communications in Medicine) files. Use this skill when reading, writing, or modifying medical imaging data in DICOM format, extracting pixel data from medical images (CT, MRI, X-ray, ultrasound), anonymizing DICOM files, working with DICOM metadata and tags, converting DICOM images to other formats, handling compressed DICOM data, or processing medical imaging datasets. Applies to tasks involving medical image analysis, PACS systems, radiology workflows, and healthcare imaging applications.
This skill should be used when working with pre-trained transformer models for natural language processing, computer vision, audio, or multimodal tasks. Use for text generation, classification, question answering, translation, summarization, image classification, object detection, speech recognition, and fine-tuning models on custom datasets.
Take misaka-mikoto-tech/bilibili-page-reader from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.