
Bbc Skill
- 1 installs
- 15 repo stars
- Updated May 5, 2026
- agents365-ai/bbc-skill
bbc-skill is a Claude Code skill that downloads all comments on a Bilibili video and outputs JSONL plus a summary for audience and sentiment analysis.
About
A Claude Code skill that collects comments from a Bilibili video and outputs JSONL plus a summary file for further analysis. It is aimed at UP-hosts analyzing their own audience feedback, sentiment, keywords, and IP distribution. A creator uses it to export and study reactions on their Bilibili videos. It is read-only and requires a logged-in Bilibili cookie.
- Downloads all comments (top-level, nested, and pinned) for a Bilibili video
- Produces JSONL plus summary.json for downstream sentiment and keyword analysis
- Read-only, stdlib-only Python; never posts, edits, or deletes
Bbc Skill by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,803 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
bbc-skill capabilities & compatibility
Free; requires a logged-in Bilibili account cookie, no API key.
- Capabilities
- comment scraping · data export · audience analysis
- Use cases
- web scraping · data analysis
- Pricing
- Free
What bbc-skill says it does
Download all comments (top-level + nested + pinned) for a Bilibili video and
Python 3.9+** (stdlib only — zero pip install).
npx skills add https://github.com/agents365-ai/bbc-skill --skill bbc-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 15 |
| Last updated | May 5, 2026 |
| Repository | agents365-ai/bbc-skill ↗ |
What it does
Collect a Bilibili video's comments into JSONL for downstream sentiment and audience analysis.
Who is it for?
UP-hosts collecting and analyzing comments on their own Bilibili videos.
Skip if: Posting, deleting, or editing comments, downloading videos, danmaku, or private messages.
When should I use this skill?
The user asks to collect, download, export, or analyze comments on a Bilibili video (BV number, URL, or UID).
What you get
A JSONL comment dump and summary.json ready for sentiment, keyword, and audience-trend analysis.
- JSONL comment export
- summary.json
By the numbers
- Collects 3 comment types (top-level, nested, pinned)
- Python stdlib only, zero pip install
Files
bbc-skill — Bilibili Comment Collector
Download all comments (top-level + nested + pinned) for a Bilibili video and produce data that Claude Code can analyze downstream.
Update check
Throttle to one check per 24 hours per installation; never mutate the skill directory without explicit user consent.
1. If <this-skill-dir>/.last_update exists and is less than 24 hours old, skip this step entirely.
2. Otherwise, fetch the latest tag from upstream:
git -C <this-skill-dir> ls-remote --tags origin 'v*' 2>/dev/null \
| awk '{print $2}' | sed 's|refs/tags/||' \
| sort -V | tail -13. Compare with this skill's metadata.version from the frontmatter. If the upstream tag is strictly newer (semver), tell the user one line and ask:
"A newer version of this skill is available: vX.Y.Z → vA.B.C. Want me to git pull?"If they say yes, run git -C <this-skill-dir> pull --ff-only. Refresh .last_update either way so the prompt doesn't repeat for 24 hours.
4. If upstream is the same or older, refresh .last_update silently and continue.
5. On any failure (offline, not a git checkout — e.g. ClawHub-installed copy, read-only path, no permission), swallow the error silently and continue with the user's task. Do not mention the failure.
When to use
Trigger this skill when the user:
- Asks to get / fetch / download / export / collect / analyze comments of a
specific Bilibili video (BV 号, URL, or video page).
- Asks to analyze **audience feedback / sentiment / keywords / top comments /
IP distribution** of their own Bilibili videos.
- Provides a Bilibili URL like
https://www.bilibili.com/video/BVxxxxxxxxxx/. - Mentions their UP主 UID and wants batch analysis across their videos.
Do not use for: posting / deleting comments, downloading videos, barrage (弹幕), live stream data, or private messages.
Prerequisites
1. Python 3.9+ (stdlib only — zero pip install). 2. Bilibili cookie. The user must be logged in to bilibili.com. The recommended path:
- Install the Chrome/Edge extension
**Get cookies.txt LOCALLY** (open-source, fully local, no upload).
- On a logged-in bilibili.com tab, click Export → save
www.bilibili.com_cookies.txt.
- Pass via
--cookie-fileor set$BBC_COOKIE_FILE.
Alternatives:
$BBC_SESSDATAenv var with just the SESSDATA value.- Browser auto-detection (Firefox / Chrome / Edge on macOS) via
--browser auto. Works best for Firefox; Chrome/Edge needs a logged-in profile with cookies flushed to disk.
Auth delegation (Principle 7): the skill never runs OAuth flows. The human is expected to log in via browser; the agent only consumes the resulting cookie.
Quick start
Before any fetch, verify the cookie works:
python3 -m bbc cookie-checkSuccess envelope (stdout):
{"ok":true,"data":{"mid":441831884,"uname":"探索未至之境","vip":false}}Fetch all comments for a single video:
python3 -m bbc fetch BV1NjA7zjEAUOr pass a URL:
python3 -m bbc fetch "https://www.bilibili.com/video/BV1NjA7zjEAU/"Output (default ./bilibili-comments/<BV>/):
comments.jsonl— one comment per line, flattenedsummary.json— video metadata + statistics + top-Nraw/— archived API responses.bbc-state.json— resume state
Commands
| Command | Purpose |
|---|---|
| `bbc fetch <BV\ | URL>` |
bbc fetch-user <UID> | Batch fetch all videos of a UP主 |
bbc summarize <dir> | Rebuild summary.json from existing comments.jsonl |
bbc cookie-check | Validate cookie; print logged-in user |
bbc schema [cmd] | Return JSON schema for commands (for agent discovery) |
Call bbc <cmd> --help or bbc schema <cmd> for full parameter details — do not guess flag names.
Agent contract
Stdout vs stderr
- stdout: stable JSON envelope
{"ok":true,"data":...}or
{"ok":false,"error":...}. JSON is the default when stdout is not a TTY. Pass --format table for human-readable tables.
- stderr: human log lines + NDJSON progress events for long tasks.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime / API error |
| 2 | Auth error (cookie invalid / missing) |
| 3 | Validation error (bad BV number, bad flag) |
| 4 | Network error (timeout / retries exhausted) |
Error envelope
{
"ok": false,
"error": {
"code": "auth_expired",
"message": "SESSDATA 已过期,请重新登录 B 站",
"retryable": true,
"retry_after_auth": true
}
}Error codes: validation_error, auth_required, auth_expired, not_found, rate_limited, api_error, network_error. See bbc schema for the full contract.
Dry-run
Every fetch command supports --dry-run to preview the planned request without making network calls:
python3 -m bbc fetch BV1NjA7zjEAU --dry-runIdempotency
Re-running the same fetch command on the same output directory resumes from .bbc-state.json (skips already-fetched pages). Pass --force to refetch.
Analysis workflow (for the agent)
After fetch completes:
1. Read `summary.json` first (< 10 KB) to establish global context: video metadata, total counts, time distribution, top-N. 2. For thematic analysis, Grep or head/tail on comments.jsonl — each line is a flat JSON object, never load the whole file unless small. 3. Typical analyses:
- Sentiment distribution → scan
messageby batch - Top fans → group by
mid, count entries, aggregatelike - UP 主互动 → filter
is_up_reply=true - Audience geography →
ip_locationhistogram - Feedback timeline → bucket
ctime_isoby day/week
The summary.json schema is documented in references/agent-contract.md. Run the skill against any video to produce a real sample locally.
Safety tier
All commands are read-only (tier: open). No mutation, no deletion, no message sending. Dry-run available for all fetch commands.
References
references/api-endpoints.md— Bilibili API fields usedreferences/cookie-extraction.md— per-browser cookie decryptionreferences/agent-contract.md— full envelope + schema contract
Limitations
all_countreturned by the API includes pinned comments. Completeness
check: top_level + nested + pinned == declared_all_count.
- Very old comments (>2 years) may return thin data if the user was deleted.
- Anti-bot: aggressive
--maxvalues or repeated runs may trigger HTTP 412.
The client sleeps 1s between requests and backs off on 412.
# Python
__pycache__/
*.pyc
*.pyo
# Cookie files (NEVER commit!)
*cookies*.txt
cookie.json
cookie.txt
# Runtime outputs
bilibili-comments/
/tmp/
# Editor
.DS_Store
.vscode/
.idea/
# Local-only notes (not for public repo)
CLAUDE.md
# Spike / exploratory research scripts (kept locally only)
spike/
# Local Claude Code settings
.claude/
# Auto-update timestamp
.last_update
# Example outputs (contain commenter PII — user ids, IP locations, etc.)
examples/
# Internal design doc (local reference only)
DESIGN.md
# OS
Thumbs.db
interface:
display_name: "Bilibili Comment Collector"
short_description: "Fetch all comments (top-level + nested + pinned) of Bilibili videos for UP主 self-analysis; zero dependencies, agent-native JSON CLI"
brand_color: "#00A1D6"
policy:
allow_implicit_invocation: true
capabilities:
- Fetch all comments for a single Bilibili video by BV number or URL
- Batch-fetch comments for every video of a UP主 (sequential, one video at a time)
- Include top-level comments, nested replies (楼中楼), and pinned comments
- Emit structured JSONL for comments + summary.json with video metadata
- Agent-native: stable JSON envelope on stdout, NDJSON progress on stderr
- Distinct exit codes (0 OK / 2 auth / 3 validation / 4 network)
- Dry-run preview for every fetch command (no network calls)
- Resume-safe: re-runs skip already-fetched pages
- Incremental mode via --since for monitoring UP的 new comments
- Randomised 5-10s cooldown between videos in batch mode to avoid risk control
- Read-only: never posts, edits, or deletes anything
prerequisites:
- Python 3.9+ (stdlib only — zero pip install)
- Bilibili cookie (SESSDATA, bili_jct) via browser export — recommend
"Get cookies.txt LOCALLY" Chrome/Edge extension
- Optional — openssl + security CLI (macOS) for automatic Chrome cookie decryption
safety:
tier: open
destructive_commands: []
notes:
- All commands are read-only
- Cookie values are never written to stdout; only cookie names appear in cookie-check output
- No shell-out to user-supplied strings
- Output is confined to the --output directory chosen by the user
commands:
- name: fetch
summary: Fetch all comments for one video (BV or URL).
tier: open
- name: fetch-user
summary: Sequentially fetch comments for every video of a UP主.
tier: open
- name: summarize
summary: Rebuild summary.json from an existing comments.jsonl directory.
tier: open
- name: cookie-check
summary: Validate cookie and print logged-in user info.
tier: open
- name: schema
summary: Return JSON schema for a command (agent-discovery).
tier: open
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bbc-skill — Bilibili Comment Collector for AI Agents</title>
<meta name="description" content="Agent-native CLI that fetches all comments from a Bilibili video for UP主 self-analysis. Zero dependencies, JSON envelope, sequential batch mode, Claude Code / Codex / OpenClaw support.">
<meta name="theme-color" content="#0d1117">
<link rel="canonical" href="https://agents365-ai.github.io/bbc-skill/">
<link rel="alternate" hreflang="zh" href="https://agents365-ai.github.io/bbc-skill/zh.html">
<link rel="alternate" hreflang="en" href="https://agents365-ai.github.io/bbc-skill/">
<meta property="og:title" content="bbc-skill — Bilibili Comment Collector for AI Agents">
<meta property="og:description" content="Fetch every comment on your Bilibili videos and hand them to Claude Code / Codex for sentiment, keyword, and audience analysis.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://agents365-ai.github.io/bbc-skill/">
<meta name="twitter:card" content="summary_large_image">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[data-theme="dark"] {
--bg: #0d1117; --bg-card: #161b22; --bg-code: #1c2128;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #FB7299; --accent-hover: #ff8ea8;
--green: #3fb950; --red: #f85149; --amber: #d29922;
}
[data-theme="light"] {
--bg: #ffffff; --bg-card: #f6f8fa; --bg-code: #f0f3f6;
--border: #d0d7de; --text: #1f2328; --text-muted: #656d76;
--accent: #d94d77; --accent-hover: #b53865;
--green: #1a7f37; --red: #cf222e; --amber: #9a6700;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.6; overflow-x: hidden;
transition: background 0.3s, color 0.3s;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); text-decoration: underline; }
code, pre {
font-family: 'SF Mono', 'Fira Code', 'Menlo', monospace;
}
nav {
position: sticky; top: 0; z-index: 100;
background: var(--bg); border-bottom: 1px solid var(--border);
padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;
transition: background 0.3s;
}
nav .logo { font-weight: 700; font-size: 16px; color: var(--text); }
nav .logo span { color: var(--accent); }
nav .nav-right { display: flex; align-items: center; gap: 16px; }
.lang-switch {
font-size: 14px; font-weight: 500; color: var(--text-muted);
padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: all 0.2s;
}
.lang-switch:hover { color: var(--text); border-color: var(--text-muted); text-decoration: none; }
.theme-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px;
transition: all 0.2s; line-height: 1;
}
.theme-btn:hover { color: var(--text); border-color: var(--text-muted); }
.container { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
section { padding: 72px 0; }
section + section { border-top: 1px solid var(--border); }
.hero { text-align: center; padding: 96px 0 72px; border-bottom: 1px solid var(--border); }
.hero-badge {
display: inline-block; font-size: 13px; color: var(--accent);
border: 1px solid var(--accent); border-radius: 20px;
padding: 4px 14px; margin-bottom: 20px; letter-spacing: 0.5px;
}
.hero h1 { font-size: 48px; font-weight: 800; margin-bottom: 16px; letter-spacing: -1px; }
.hero h1 span { color: var(--accent); }
.hero p.tagline { font-size: 20px; color: var(--text-muted); max-width: 680px; margin: 0 auto 32px; }
.hero-install {
display: inline-flex; align-items: center; gap: 12px;
background: var(--bg-code); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 20px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px;
margin-bottom: 24px; max-width: 90%;
}
.hero-install code { color: var(--green); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.copy-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 13px; transition: all 0.2s;
flex-shrink: 0;
}
.copy-btn:hover { color: var(--text); border-color: var(--text-muted); }
.hero-links { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 24px;
border-radius: 8px; font-size: 15px; font-weight: 600; transition: all 0.2s;
}
.btn-primary { background: var(--accent); color: #fff; }
[data-theme="light"] .btn-primary { color: #fff; }
.btn-primary:hover { background: var(--accent-hover); text-decoration: none; }
.btn-outline { border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--text-muted); text-decoration: none; }
.section-title { font-size: 32px; font-weight: 700; margin-bottom: 8px; text-align: center; }
.section-sub { color: var(--text-muted); font-size: 17px; text-align: center; margin-bottom: 48px; max-width: 720px; margin-left: auto; margin-right: auto; }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
.feature-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 28px; transition: background 0.3s;
}
.feature-icon { font-size: 28px; margin-bottom: 12px; }
.feature-card h3 { font-size: 18px; margin-bottom: 8px; }
.feature-card p { color: var(--text-muted); font-size: 14px; }
.steps { display: grid; gap: 28px; max-width: 820px; margin: 0 auto; }
.step {
display: grid; grid-template-columns: 48px 1fr; gap: 20px; align-items: start;
}
.step-num {
width: 48px; height: 48px; border-radius: 50%; background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 20px;
}
.step h3 { font-size: 18px; margin-bottom: 8px; }
.step p { color: var(--text-muted); margin-bottom: 12px; font-size: 15px; }
.step pre {
background: var(--bg-code); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 18px; font-size: 13px; color: var(--green);
overflow-x: auto; line-height: 1.5;
}
.compare-table { width: 100%; border-collapse: collapse; font-size: 15px; }
.compare-table th, .compare-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
.compare-table th { color: var(--text-muted); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; }
.compare-table tr:hover { background: var(--bg-card); }
.check { color: var(--green); font-weight: 700; }
.cross { color: var(--red); }
.ethics {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-code) 100%);
border: 1px solid var(--amber); border-radius: 12px; padding: 32px;
max-width: 820px; margin: 0 auto;
}
.ethics h3 { font-size: 20px; margin-bottom: 16px; color: var(--amber); }
.ethics ul { margin-left: 20px; color: var(--text-muted); font-size: 15px; }
.ethics ul li { margin-bottom: 8px; }
.ethics ul li strong { color: var(--text); }
.output-preview {
background: var(--bg-code); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; overflow-x: auto;
font-size: 13px; color: var(--text); line-height: 1.6;
}
.output-preview .key { color: var(--accent); }
.output-preview .str { color: var(--green); }
.output-preview .num { color: #58a6ff; }
footer {
border-top: 1px solid var(--border); padding: 32px 0;
text-align: center; color: var(--text-muted); font-size: 14px;
}
footer a { color: var(--text-muted); }
footer a:hover { color: var(--text); }
@media (max-width: 640px) {
.hero h1 { font-size: 32px; }
.hero p.tagline { font-size: 17px; }
section { padding: 48px 0; }
.features-grid { grid-template-columns: 1fr; }
.hero-install { flex-direction: column; font-size: 12px; }
}
.skip-link {
position: absolute; top: -40px; left: 0; background: var(--accent); color: #fff;
padding: 8px 16px; z-index: 200; font-size: 14px; transition: top 0.2s;
}
.skip-link:focus { top: 0; }
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav role="navigation" aria-label="Main">
<div class="logo">bbc-<span>skill</span></div>
<div class="nav-right">
<a href="zh.html" class="lang-switch">中文</a>
<button class="theme-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle dark/light theme">🌙</button>
</div>
</nav>
<main id="main-content">
<div class="hero">
<div class="container">
<div class="hero-badge">v1.0.0 — Agent-Native CLI</div>
<h1>bbc-<span>skill</span></h1>
<p class="tagline">Fetch every comment on your Bilibili videos and hand them to Claude Code / Codex for sentiment, keyword, and audience analysis. Zero dependencies. Built for AI agents.</p>
<div class="hero-install">
<code id="install-cmd">git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill</code>
<button class="copy-btn" onclick="copyInstall()">Copy</button>
</div>
<div class="hero-links">
<a href="https://github.com/Agents365-ai/bbc-skill" class="btn btn-primary">GitHub</a>
<a href="#quick-start" class="btn btn-outline">Quick Start</a>
<a href="zh.html" class="btn btn-outline">中文</a>
</div>
</div>
</div>
<section>
<div class="container">
<h2 class="section-title">Why This Skill</h2>
<p class="section-sub">The only Bilibili comment tool designed for AI agents: structured JSON output, self-describing schema, delegated auth, and a strict sequential batch mode.</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🐍</div>
<h3>Zero Dependencies</h3>
<p>Pure Python 3.9+ standard library. No <code>pip install</code>. All cookie decryption goes through system binaries (<code>security</code>, <code>openssl</code>).</p>
</div>
<div class="feature-card">
<div class="feature-icon">💬</div>
<h3>Complete Comments</h3>
<p>Top-level + nested (楼中楼) + pinned (置顶). Full video metadata: views, likes, coins, favorites, tags, owner info.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🤖</div>
<h3>Agent-Native CLI</h3>
<p>Stable JSON envelope on stdout, NDJSON progress on stderr, distinct exit codes, dry-run preview, schema introspection.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🧑‍🎤</div>
<h3>Sequential Batch Mode</h3>
<p>Fetch every video of a UP主 one-by-one. Never parallel. 5-10s randomised cooldown between videos. Resume-safe.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔐</div>
<h3>Delegated Auth</h3>
<p>Human logs in via browser, exports cookie via the open-source <a href="https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc">Get cookies.txt LOCALLY</a> extension. Agent never touches OAuth.</p>
</div>
<div class="feature-card">
<div class="feature-icon">♻️</div>
<h3>Resumable & Incremental</h3>
<p>Re-running the same <code>fetch</code> skips completed pages. <code>--since</code> pulls only new comments for ongoing monitoring.</p>
</div>
</div>
</div>
</section>
<section id="quick-start">
<div class="container">
<h2 class="section-title">Quick Start</h2>
<p class="section-sub">From zero to analyzed comments in three steps.</p>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div>
<h3>Install the "Get cookies.txt LOCALLY" Chrome extension</h3>
<p>Fully offline, open-source, uploads nothing. On a logged-in bilibili.com tab, click Export and save <code>www.bilibili.com_cookies.txt</code>.</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div>
<h3>Install bbc-skill</h3>
<p>Pick the path that matches your agent (see full table below).</p>
<pre>git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill</pre>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div>
<h3>Fetch and analyze</h3>
<p>Verify the cookie, fetch the video, then ask Claude Code to analyze the output.</p>
<pre>bbc cookie-check --cookie-file ~/Downloads/www.bilibili.com_cookies.txt
bbc fetch BV1NjA7zjEAU --cookie-file ~/Downloads/www.bilibili.com_cookies.txt
# Then ask Claude Code: "Read bilibili-comments/BV1NjA7zjEAU/summary.json
# and tell me the audience sentiment and top feedback themes."</pre>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">Multi-Platform Support</h2>
<p class="section-sub">Same <code>SKILL.md</code> + <code>agents/openai.yaml</code> sidecar, six platforms.</p>
<div class="table-wrapper">
<table class="compare-table">
<thead>
<tr><th>Platform</th><th>Status</th><th>Install path</th></tr>
</thead>
<tbody>
<tr><td>Claude Code</td><td class="check">✓ Native</td><td><code>~/.claude/skills/bbc-skill/</code></td></tr>
<tr><td>OpenAI Codex</td><td class="check">✓ Native</td><td><code>~/.agents/skills/bbc-skill/</code></td></tr>
<tr><td>OpenClaw / ClawHub</td><td class="check">✓ Native</td><td><code>~/.openclaw/skills/bbc-skill/</code></td></tr>
<tr><td>Hermes Agent</td><td class="check">✓ Native</td><td><code>~/.hermes/skills/data/bbc-skill/</code></td></tr>
<tr><td>Opencode</td><td class="check">✓ Native</td><td><code>~/.config/opencode/skills/bbc-skill/</code></td></tr>
<tr><td>SkillsMP</td><td class="check">✓ Indexed</td><td><code>skills install bbc-skill</code></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">Output</h2>
<p class="section-sub">Flat JSONL for comments + rich summary.json for Claude to read first.</p>
<pre class="output-preview">bilibili-comments/BV1NjA7zjEAU/
<span class="key">├── comments.jsonl</span> <span class="str"># one comment per line, 59 lines</span>
<span class="key">├── summary.json</span> <span class="str"># video meta + stats + Top-N (~13KB)</span>
<span class="key">├── raw/</span> <span class="str"># archived API responses</span>
<span class="key">└── .bbc-state.json</span> <span class="str"># resume / incremental state</span>
<span class="str">// summary.json preview</span>
{
<span class="key">"video"</span>: {
<span class="key">"title"</span>: <span class="str">"会魔法吗?3步搞定Claude Code…"</span>,
<span class="key">"stat"</span>: { <span class="key">"view"</span>: <span class="num">7287</span>, <span class="key">"like"</span>: <span class="num">97</span>, <span class="key">"coin"</span>: <span class="num">70</span>, <span class="key">"reply"</span>: <span class="num">59</span> }
},
<span class="key">"counts"</span>: { <span class="key">"total"</span>: <span class="num">59</span>, <span class="key">"top_level"</span>: <span class="num">44</span>, <span class="key">"nested"</span>: <span class="num">14</span>, <span class="key">"pinned"</span>: <span class="num">1</span>,
<span class="key">"completeness"</span>: <span class="num">1.0</span>, <span class="key">"unique_users"</span>: <span class="num">46</span> },
<span class="key">"time_distribution"</span>: { <span class="key">"earliest_iso"</span>: <span class="str">"…"</span>, <span class="key">"by_day"</span>: [...] },
<span class="key">"top_liked"</span>: [...],
<span class="key">"top_replied"</span>: [...],
<span class="key">"ip_distribution"</span>: { <span class="str">"浙江"</span>: <span class="num">5</span>, <span class="str">"广东"</span>: <span class="num">4</span>, ... }
}</pre>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">⚠️ Responsible Use</h2>
<p class="section-sub">This tool is for personal, low-volume, legal use. Please read before running.</p>
<div class="ethics">
<h3>✅ OK</h3>
<ul>
<li><strong>Analyze your own videos</strong> — the primary use case.</li>
<li><strong>Assist another creator</strong> with their explicit permission.</li>
<li><strong>Respect the built-in throttling</strong>: 1s per request, 5-10s random cooldown between videos in batch mode. Don't patch these out.</li>
</ul>
<h3>❌ NOT OK</h3>
<ul>
<li><strong>Mass-scraping strangers' videos</strong> or entire categories of UP主.</li>
<li><strong>Commercial resale / public redistribution</strong> of the scraped data.</li>
<li><strong>Bypassing rate limits</strong>, spoofing User-Agents, or using proxy pools to evade Bilibili's anti-bot systems.</li>
<li><strong>High-frequency automation</strong> — e.g. scheduled daily scans of the same channel.</li>
<li><strong>Harassment, doxxing, or coordinated attacks</strong> using the scraped user IDs / IP locations.</li>
</ul>
<p style="margin-top: 20px; color: var(--text-muted); font-size: 14px;">
For organization or commercial use, switch to the
<a href="https://openhome.bilibili.com/">Bilibili Open Platform</a>
official APIs. Apply data-minimization: fetch, analyze, delete.
This project is not affiliated with bilibili.com; account risk
control, bans, and legal consequences are the user's responsibility.
</p>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<p>Built by <a href="https://github.com/Agents365-ai">Agents365-ai</a>. MIT License. Not affiliated with Bilibili.</p>
<p style="margin-top: 8px;">
<a href="https://github.com/Agents365-ai/bbc-skill">GitHub</a> ·
<a href="https://github.com/Agents365-ai/bbc-skill/blob/main/README.md">README</a> ·
<a href="https://github.com/Agents365-ai/bbc-skill/issues">Issues</a>
</p>
</div>
</footer>
<script>
function toggleTheme() {
const root = document.documentElement;
const cur = root.getAttribute('data-theme');
const next = cur === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('bbc-theme', next); } catch (e) {}
}
(function () {
try {
const saved = localStorage.getItem('bbc-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
} catch (e) {}
})();
function copyInstall() {
const text = document.getElementById('install-cmd').textContent;
navigator.clipboard.writeText(text).then(function () {
const btn = document.querySelector('.copy-btn');
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bbc-skill — 哔哩哔哩评论采集 for AI Agents</title>
<meta name="description" content="Agent 原生 CLI:一键拉取 B 站视频全部评论,交给 Claude Code / Codex 做情感、关键词、受众分析。零依赖、JSON envelope、串行批量模式,支持 Claude Code / Codex / OpenClaw。">
<meta name="theme-color" content="#0d1117">
<link rel="canonical" href="https://agents365-ai.github.io/bbc-skill/zh.html">
<link rel="alternate" hreflang="en" href="https://agents365-ai.github.io/bbc-skill/">
<link rel="alternate" hreflang="zh" href="https://agents365-ai.github.io/bbc-skill/zh.html">
<meta property="og:title" content="bbc-skill — 哔哩哔哩评论采集 for AI Agents">
<meta property="og:description" content="一键拉取 B 站视频全部评论,交给 AI agent 做情感 / 关键词 / 受众分析。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://agents365-ai.github.io/bbc-skill/zh.html">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[data-theme="dark"] {
--bg: #0d1117; --bg-card: #161b22; --bg-code: #1c2128;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #FB7299; --accent-hover: #ff8ea8;
--green: #3fb950; --red: #f85149; --amber: #d29922;
}
[data-theme="light"] {
--bg: #ffffff; --bg-card: #f6f8fa; --bg-code: #f0f3f6;
--border: #d0d7de; --text: #1f2328; --text-muted: #656d76;
--accent: #d94d77; --accent-hover: #b53865;
--green: #1a7f37; --red: #cf222e; --amber: #9a6700;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); line-height: 1.7; overflow-x: hidden;
transition: background 0.3s, color 0.3s;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); text-decoration: underline; }
code, pre {
font-family: 'SF Mono', 'Fira Code', 'Menlo', monospace;
}
nav {
position: sticky; top: 0; z-index: 100;
background: var(--bg); border-bottom: 1px solid var(--border);
padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;
transition: background 0.3s;
}
nav .logo { font-weight: 700; font-size: 16px; color: var(--text); }
nav .logo span { color: var(--accent); }
nav .nav-right { display: flex; align-items: center; gap: 16px; }
.lang-switch {
font-size: 14px; font-weight: 500; color: var(--text-muted);
padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: all 0.2s;
}
.lang-switch:hover { color: var(--text); border-color: var(--text-muted); text-decoration: none; }
.theme-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px;
transition: all 0.2s; line-height: 1;
}
.theme-btn:hover { color: var(--text); border-color: var(--text-muted); }
.container { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
section { padding: 72px 0; }
section + section { border-top: 1px solid var(--border); }
.hero { text-align: center; padding: 96px 0 72px; border-bottom: 1px solid var(--border); }
.hero-badge {
display: inline-block; font-size: 13px; color: var(--accent);
border: 1px solid var(--accent); border-radius: 20px;
padding: 4px 14px; margin-bottom: 20px; letter-spacing: 0.5px;
}
.hero h1 { font-size: 48px; font-weight: 800; margin-bottom: 16px; letter-spacing: -1px; }
.hero h1 span { color: var(--accent); }
.hero p.tagline { font-size: 19px; color: var(--text-muted); max-width: 680px; margin: 0 auto 32px; }
.hero-install {
display: inline-flex; align-items: center; gap: 12px;
background: var(--bg-code); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 20px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px;
margin-bottom: 24px; max-width: 90%;
}
.hero-install code { color: var(--green); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.copy-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 13px; transition: all 0.2s;
flex-shrink: 0;
}
.copy-btn:hover { color: var(--text); border-color: var(--text-muted); }
.hero-links { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 24px;
border-radius: 8px; font-size: 15px; font-weight: 600; transition: all 0.2s;
}
.btn-primary { background: var(--accent); color: #fff; }
[data-theme="light"] .btn-primary { color: #fff; }
.btn-primary:hover { background: var(--accent-hover); text-decoration: none; }
.btn-outline { border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--text-muted); text-decoration: none; }
.section-title { font-size: 32px; font-weight: 700; margin-bottom: 8px; text-align: center; }
.section-sub { color: var(--text-muted); font-size: 17px; text-align: center; margin-bottom: 48px; max-width: 720px; margin-left: auto; margin-right: auto; }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
.feature-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 28px; transition: background 0.3s;
}
.feature-icon { font-size: 28px; margin-bottom: 12px; }
.feature-card h3 { font-size: 18px; margin-bottom: 8px; }
.feature-card p { color: var(--text-muted); font-size: 14px; line-height: 1.7; }
.steps { display: grid; gap: 28px; max-width: 820px; margin: 0 auto; }
.step { display: grid; grid-template-columns: 48px 1fr; gap: 20px; align-items: start; }
.step-num {
width: 48px; height: 48px; border-radius: 50%; background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 20px;
}
.step h3 { font-size: 18px; margin-bottom: 8px; }
.step p { color: var(--text-muted); margin-bottom: 12px; font-size: 15px; }
.step pre {
background: var(--bg-code); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 18px; font-size: 13px; color: var(--green);
overflow-x: auto; line-height: 1.5;
}
.compare-table { width: 100%; border-collapse: collapse; font-size: 15px; }
.compare-table th, .compare-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
.compare-table th { color: var(--text-muted); font-weight: 600; font-size: 13px; letter-spacing: 0.5px; }
.compare-table tr:hover { background: var(--bg-card); }
.check { color: var(--green); font-weight: 700; }
.cross { color: var(--red); }
.ethics {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-code) 100%);
border: 1px solid var(--amber); border-radius: 12px; padding: 32px;
max-width: 820px; margin: 0 auto;
}
.ethics h3 { font-size: 20px; margin-bottom: 16px; color: var(--amber); }
.ethics ul { margin-left: 20px; color: var(--text-muted); font-size: 15px; }
.ethics ul li { margin-bottom: 8px; }
.ethics ul li strong { color: var(--text); }
.output-preview {
background: var(--bg-code); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; overflow-x: auto;
font-size: 13px; color: var(--text); line-height: 1.6;
}
.output-preview .key { color: var(--accent); }
.output-preview .str { color: var(--green); }
.output-preview .num { color: #58a6ff; }
footer {
border-top: 1px solid var(--border); padding: 32px 0;
text-align: center; color: var(--text-muted); font-size: 14px;
}
footer a { color: var(--text-muted); }
footer a:hover { color: var(--text); }
@media (max-width: 640px) {
.hero h1 { font-size: 32px; }
.hero p.tagline { font-size: 17px; }
section { padding: 48px 0; }
.features-grid { grid-template-columns: 1fr; }
.hero-install { flex-direction: column; font-size: 12px; }
}
.skip-link {
position: absolute; top: -40px; left: 0; background: var(--accent); color: #fff;
padding: 8px 16px; z-index: 200; font-size: 14px; transition: top 0.2s;
}
.skip-link:focus { top: 0; }
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">跳到主内容</a>
<nav role="navigation" aria-label="主导航">
<div class="logo">bbc-<span>skill</span></div>
<div class="nav-right">
<a href="index.html" class="lang-switch">EN</a>
<button class="theme-btn" onclick="toggleTheme()" title="切换主题" aria-label="切换明暗主题">🌙</button>
</div>
</nav>
<main id="main-content">
<div class="hero">
<div class="container">
<div class="hero-badge">v1.0.0 — Agent 原生 CLI</div>
<h1>bbc-<span>skill</span></h1>
<p class="tagline">一键拉取 B 站视频全部评论,交给 Claude Code / Codex 做情感、关键词、受众分析。零依赖,专为 AI agent 设计。</p>
<div class="hero-install">
<code id="install-cmd">git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill</code>
<button class="copy-btn" onclick="copyInstall()">复制</button>
</div>
<div class="hero-links">
<a href="https://github.com/Agents365-ai/bbc-skill" class="btn btn-primary">GitHub</a>
<a href="#quick-start" class="btn btn-outline">快速上手</a>
<a href="index.html" class="btn btn-outline">English</a>
</div>
</div>
</div>
<section>
<div class="container">
<h2 class="section-title">为什么用这个 skill</h2>
<p class="section-sub">唯一为 AI agent 设计的 B 站评论工具:结构化 JSON 输出、自描述 schema、委托式认证、严格串行批量模式。</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🐍</div>
<h3>零依赖</h3>
<p>纯 Python 3.9+ 标准库,不需要 <code>pip install</code>。Cookie 解密走系统自带的 <code>security</code> / <code>openssl</code>。</p>
</div>
<div class="feature-card">
<div class="feature-icon">💬</div>
<h3>完整评论</h3>
<p>顶级评论 + 楼中楼 + 置顶;视频元数据一起拉:播放、点赞、投币、收藏、标签、UP 主信息。</p>
</div>
<div class="feature-card">
<div class="feature-icon">🤖</div>
<h3>Agent 原生 CLI</h3>
<p>stdout 稳定 JSON envelope、stderr NDJSON 进度、分类 exit code、dry-run 预览、schema 自描述。</p>
</div>
<div class="feature-card">
<div class="feature-icon">🧑‍🎤</div>
<h3>串行批量模式</h3>
<p>按 UID 拉 UP 主所有视频评论 —— 一个一个来,从不并行。视频之间 5-10s 随机休眠,断点续跑。</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔐</div>
<h3>认证委托</h3>
<p>人在浏览器登录,用开源插件 <a href="https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc">Get cookies.txt LOCALLY</a> 导出 cookie 即可,agent 永远不碰 OAuth。</p>
</div>
<div class="feature-card">
<div class="feature-icon">♻️</div>
<h3>断点续传与增量</h3>
<p>重跑同一 <code>fetch</code> 自动跳过已拉页。<code>--since</code> 只拉新评论,方便长期监控。</p>
</div>
</div>
</div>
</section>
<section id="quick-start">
<div class="container">
<h2 class="section-title">快速上手</h2>
<p class="section-sub">三步从零到分析完成。</p>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div>
<h3>装 Chrome 插件「Get cookies.txt LOCALLY」</h3>
<p>完全本地、开源、不上传任何数据。在已登录 bilibili.com 的标签页点 Export,保存 <code>www.bilibili.com_cookies.txt</code>。</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div>
<h3>安装 bbc-skill</h3>
<p>根据你用的 agent 选路径(完整列表见下方)。</p>
<pre>git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill</pre>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div>
<h3>拉评论 → 交给 Claude 分析</h3>
<p>验证 cookie → 拉取 → 让 Claude Code 读分析。</p>
<pre>bbc cookie-check --cookie-file ~/Downloads/www.bilibili.com_cookies.txt
bbc fetch BV1NjA7zjEAU --cookie-file ~/Downloads/www.bilibili.com_cookies.txt
# 然后跟 Claude 说:「读 bilibili-comments/BV1NjA7zjEAU/summary.json,
# 告诉我受众情绪和高频反馈主题」</pre>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">多平台支持</h2>
<p class="section-sub">同一份 <code>SKILL.md</code> + <code>agents/openai.yaml</code> sidecar,兼容 6 个平台。</p>
<div class="table-wrapper">
<table class="compare-table">
<thead>
<tr><th>平台</th><th>状态</th><th>安装路径</th></tr>
</thead>
<tbody>
<tr><td>Claude Code</td><td class="check">✓ 原生支持</td><td><code>~/.claude/skills/bbc-skill/</code></td></tr>
<tr><td>OpenAI Codex</td><td class="check">✓ 原生支持</td><td><code>~/.agents/skills/bbc-skill/</code></td></tr>
<tr><td>OpenClaw / ClawHub</td><td class="check">✓ 原生支持</td><td><code>~/.openclaw/skills/bbc-skill/</code></td></tr>
<tr><td>Hermes Agent</td><td class="check">✓ 原生支持</td><td><code>~/.hermes/skills/data/bbc-skill/</code></td></tr>
<tr><td>Opencode</td><td class="check">✓ 原生支持</td><td><code>~/.config/opencode/skills/bbc-skill/</code></td></tr>
<tr><td>SkillsMP</td><td class="check">✓ 已索引</td><td><code>skills install bbc-skill</code></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">输出格式</h2>
<p class="section-sub">JSONL 扁平评论 + 丰富的 summary.json,Claude 先读后者建立全局认知再按需采样前者。</p>
<pre class="output-preview">bilibili-comments/BV1NjA7zjEAU/
<span class="key">├── comments.jsonl</span> <span class="str"># 每行一条评论,共 59 行</span>
<span class="key">├── summary.json</span> <span class="str"># 视频元数据 + 统计 + Top-N(~13KB)</span>
<span class="key">├── raw/</span> <span class="str"># 原始 API 响应归档</span>
<span class="key">└── .bbc-state.json</span> <span class="str"># 断点 & 增量状态</span>
<span class="str">// summary.json 预览</span>
{
<span class="key">"video"</span>: {
<span class="key">"title"</span>: <span class="str">"会魔法吗?3步搞定Claude Code…"</span>,
<span class="key">"stat"</span>: { <span class="key">"view"</span>: <span class="num">7287</span>, <span class="key">"like"</span>: <span class="num">97</span>, <span class="key">"coin"</span>: <span class="num">70</span>, <span class="key">"reply"</span>: <span class="num">59</span> }
},
<span class="key">"counts"</span>: { <span class="key">"total"</span>: <span class="num">59</span>, <span class="key">"top_level"</span>: <span class="num">44</span>, <span class="key">"nested"</span>: <span class="num">14</span>, <span class="key">"pinned"</span>: <span class="num">1</span>,
<span class="key">"completeness"</span>: <span class="num">1.0</span>, <span class="key">"unique_users"</span>: <span class="num">46</span> },
<span class="key">"time_distribution"</span>: { <span class="key">"earliest_iso"</span>: <span class="str">"…"</span>, <span class="key">"by_day"</span>: [...] },
<span class="key">"top_liked"</span>: [...],
<span class="key">"top_replied"</span>: [...],
<span class="key">"ip_distribution"</span>: { <span class="str">"浙江"</span>: <span class="num">5</span>, <span class="str">"广东"</span>: <span class="num">4</span>, ... }
}</pre>
</div>
</section>
<section>
<div class="container">
<h2 class="section-title">⚠️ 合理使用声明</h2>
<p class="section-sub">本工具仅限个人少量合法使用。不接受请不要跑。</p>
<div class="ethics">
<h3>✅ 可以</h3>
<ul>
<li><strong>分析你自己的视频</strong> —— 这是主要使用场景。</li>
<li><strong>协助其他 UP 主</strong>,在对方明确授权下使用。</li>
<li><strong>遵守内置节流</strong>:每请求 1s、批量模式视频间 5-10s 随机休眠。不要改源码去掉这些。</li>
</ul>
<h3>❌ 不可以</h3>
<ul>
<li><strong>大规模爬取陌生 UP 主的视频评论</strong>或整个分区。</li>
<li><strong>商业转卖或公开二次发布</strong>爬下来的数据。</li>
<li><strong>绕过速率限制</strong>、伪造 UA、使用代理池规避 B 站风控。</li>
<li><strong>高频自动化任务</strong> —— 例如每天定时全量重扫同一 UP。</li>
<li><strong>骚扰、人肉、定向引战</strong> —— 用爬到的 UID / IP 属地做这种事。</li>
</ul>
<p style="margin-top: 20px; color: var(--text-muted); font-size: 14px;">
机构 / 商业用途请走
<a href="https://openhome.bilibili.com/">B 站开放平台</a>
官方 API。贯彻数据最小化原则:拉完 → 分析 → 删除。
本项目与 bilibili.com 无任何关联;使用本工具造成的账号风控、封禁、法律后果由使用者自行承担。
</p>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<p>由 <a href="https://github.com/Agents365-ai">Agents365-ai</a> 构建 · MIT License · 与哔哩哔哩无任何关联</p>
<p style="margin-top: 8px;">
<a href="https://github.com/Agents365-ai/bbc-skill">GitHub</a> ·
<a href="https://github.com/Agents365-ai/bbc-skill/blob/main/README_CN.md">README</a> ·
<a href="https://github.com/Agents365-ai/bbc-skill/issues">提 Issue</a>
</p>
</div>
</footer>
<script>
function toggleTheme() {
const root = document.documentElement;
const cur = root.getAttribute('data-theme');
const next = cur === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('bbc-theme', next); } catch (e) {}
}
(function () {
try {
const saved = localStorage.getItem('bbc-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
} catch (e) {}
})();
function copyInstall() {
const text = document.getElementById('install-cmd').textContent;
navigator.clipboard.writeText(text).then(function () {
const btn = document.querySelector('.copy-btn');
const orig = btn.textContent;
btn.textContent = '已复制!';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}
</script>
</body>
</html>
bbc-skill · 哔哩哔哩评论采集
English README · Online Docs
UP 主专属:一键拉取自己视频的全部评论,交给 Claude Code / Codex / Gemini / 任何 agent 做情感 / 关键词 / 受众分析。
- 🐍 零依赖 — 只用 Python 3.9+ 标准库,不需要
pip install - 💬 完整评论 — 顶级评论 + 楼中楼 + 置顶评论,一条不落
- 📊 视频元数据 — 标题、播放、点赞、投币、收藏、标签一起拉
- 🤖 Agent-native CLI — stdout 稳定 JSON envelope,stderr NDJSON 进度,exit code 分类、dry-run、schema 自描述
- 🧑🎤 批量模式 — 一个命令拉 UP 主全部视频评论,串行处理(一个视频一个视频来,视频之间随机休眠 5-10s)
- 🔐 认证委托 — 人登录 / agent 使用;纯 cookie 文件,不做浏览器自动化
- ♻️ 断点续传 — 重跑同一 BV 自动跳过已拉页;
--since支持增量 - 📁 分析友好输出 —
comments.jsonl+summary.json+raw/归档
多平台支持
遵循 Agent Skills 规范,兼容主流 AI coding agent:
| 平台 | 状态 | 说明 |
|---|---|---|
| Claude Code | ✅ 原生支持 | 标准 SKILL.md 格式 |
| OpenAI Codex | ✅ 原生支持 | agents/openai.yaml sidecar |
| OpenClaw / ClawHub | ✅ 原生支持 | metadata.openclaw 命名空间 |
| Hermes Agent | ✅ 原生支持 | metadata.hermes 命名空间 |
| Opencode | ✅ 原生支持 | 复用 ~/.claude/skills/ |
| SkillsMP | ✅ 索引中 | GitHub topics 配置齐全 |
⚠️ 合理使用声明
请阅读并遵守以下准则,否则请不要使用本工具。
- ✅ 仅限个人、少量、合法使用:分析你自己视频的评论,或获得 UP 主明确授权后协助分析。
- ✅ 保持节制:批量模式已内置 5-10s 随机间隔和 1s 每请求节流;请不要修改源码绕过这些限制。
- ❌ 禁止滥用场景:
- 大规模爬取陌生 UP 的视频评论
- 构建二级数据产品对外出售 / 公开发布
- 绕过速率限制、伪造 User-Agent、使用代理池规避风控
- 高频自动化任务(每日多次全量扫描同一 UP 主的所有视频)
- 把采集到的数据用于骚扰、网络暴力、人肉、定向引战
- 📜 遵守 B 站用户协议 与 robots;商业场景请走 B 站开放平台 官方 API。
- 🔒 数据最小化原则:拉完就分析;不要无限期留存、也不要把含用户个人信息(UID、IP 属地)的 raw 数据分享出去。
- 🎯 读完即删的工作流更健康:本工具设计就是为了「一次分析一批视频」,不是为了搞长期监控。
本项目作者与 bilibili.com 无任何关联。使用本工具造成的任何账号风控、封禁、法律后果由使用者自行承担。如果你不确定某个使用场景是否合规,请不要跑。
---
安装
Claude Code
# 全局安装
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill
# 项目级安装
git clone https://github.com/Agents365-ai/bbc-skill.git .claude/skills/bbc-skillOpenAI Codex
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.agents/skills/bbc-skill
# 项目级
git clone https://github.com/Agents365-ai/bbc-skill.git .agents/skills/bbc-skillOpenClaw / ClawHub
# 通过 ClawHub 包管理器
clawhub install bbc-skill
# 手动安装
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.openclaw/skills/bbc-skillOpencode
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.config/opencode/skills/bbc-skill
# 或直接复用已有的 ~/.claude/skills/bbc-skillHermes Agent
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.hermes/skills/data/bbc-skillSkillsMP
skills install bbc-skill直接命令行用(不走 skill)
git clone https://github.com/Agents365-ai/bbc-skill.git && cd bbc-skill
./scripts/bbc --help
# 或加入 PATH
export PATH="$PWD/scripts:$PATH"安装路径一览
| 平台 | 全局路径 | 项目级路径 |
|---|---|---|
| Claude Code | ~/.claude/skills/bbc-skill/ | .claude/skills/bbc-skill/ |
| OpenAI Codex | ~/.agents/skills/bbc-skill/ | .agents/skills/bbc-skill/ |
| OpenClaw / ClawHub | ~/.openclaw/skills/bbc-skill/ | skills/bbc-skill/ |
| Opencode | ~/.config/opencode/skills/bbc-skill/ | .opencode/skills/bbc-skill/ |
| Hermes | ~/.hermes/skills/data/bbc-skill/ | 通过 external_dirs 配置 |
| SkillsMP | N/A(CLI 安装) | N/A |
---
一分钟上手
第 1 步 · 导出 B 站 cookie
为什么要 cookie:B 站评论 API 对未登录请求会限速且缺字段。UP 主做自己视频分析需要完整数据,所以必须带 cookie。
推荐方式:Chrome 插件 **Get cookies.txt LOCALLY**(开源、完全本地、不上传任何数据)。
1. Chrome 商店安装 Get cookies.txt LOCALLY 2. 打开 https://www.bilibili.com,确认自己已登录(右上角有头像) 3. 点插件图标 → Export → 下载 www.bilibili.com_cookies.txt 4. 把文件放到你方便的位置,例如 ~/Downloads/bilibili_cookies.txt
其他导出方式:
- Firefox:安装 cookies.txt 插件,操作类似
- Edge:同 Chrome 插件(Edge 兼容 Chrome 扩展)
- 命令行手动:浏览器 F12 → Application → Cookies → 复制
SESSDATA值,然后export BBC_SESSDATA="值"
不要分享 SESSDATA —— 泄露等于账号被盗。
第 2 步 · 验证 cookie 能用
./scripts/bbc cookie-check --cookie-file ~/Downloads/bilibili_cookies.txt期望输出:
{"ok": true, "data": {"mid": 441831884, "uname": "探索未至之境", "vip": true, "level": 5, ...}}失败?检查:
- 确认 bilibili.com 当前已登录(cookie 导出时处于登录态)
SESSDATA不要手动改动- 两周未登录可能过期,重新登录一次再导出
第 3 步 · 拉你的视频评论
./scripts/bbc fetch BV1NjA7zjEAU \
--cookie-file ~/Downloads/bilibili_cookies.txt也可以直接传 URL:
./scripts/bbc fetch "https://www.bilibili.com/video/BV1NjA7zjEAU/"输出在 ./bilibili-comments/BV1NjA7zjEAU/:
bilibili-comments/BV1NjA7zjEAU/
├── comments.jsonl # 主数据,每行一条评论
├── summary.json # 视频元数据 + 统计 + Top-N
├── raw/ # 原始 API 响应归档
└── .bbc-state.json # 断点 & 增量标记---
环境变量(免反复传参)
export BBC_COOKIE_FILE="$HOME/Downloads/bilibili_cookies.txt"
./scripts/bbc fetch BV1NjA7zjEAU # 自动读取或者直接传 SESSDATA:
export BBC_SESSDATA="从 F12 复制的值"
./scripts/bbc fetch BV1NjA7zjEAU---
给 Claude Code 用的分析工作流
拉完之后告诉 Claude:
读取 ./bilibili-comments/BV1NjA7zjEAU/summary.json,先给我看整体情况:视频基础数据、评论分布、Top 20 热评。然后我会告诉你接下来分析什么。Claude 会按这个路径做:
1. 先读 `summary.json`(几 KB)建立全局认知:视频标题、播放量、评论数、时间分布、IP 分布、Top-N 热评 / 回复数 2. 按需采样 `comments.jsonl` —— 每行一条 JSON,可以 Grep 关键词、head/tail 看最新最早、按 like 排序取头部 3. 典型分析方向:
- 情感倾向:正面 / 负面 / 中性占比
- 高频词:除了停用词以外的主题词
- UP 主互动:
is_up_reply=true的评论,看你回了哪些、哪些漏回 - 地域分布:
ip_location直方图 - 反馈演变:按
ctime_iso分周 / 月,看发布后一周 vs 长尾 - 铁粉识别:按
mid聚合,同一用户评论多次的名单 - 差评筛查:
like高且含 "垃圾/太水/不行" 类词的评论
---
命令参考
bbc fetch <BV|URL>
--max N 每个视频顶级评论上限(默认全拉)
--since <日期> 只拉这个时间后的新评论(ISO 格式,如 2026-04-01)
--output <dir> 输出目录(默认 ./bilibili-comments/<BV>/)
--cookie-file <path> cookie 文件路径
--browser <name> auto / firefox / chrome / edge / safari
--format json|table stdout 格式
--dry-run 预览请求计划,不发网络
--force 忽略断点,重头抓bbc fetch-user <UID> (即将开放)
批量拉 UP 主所有视频的评论。
bbc summarize <dir>
从已有 comments.jsonl 重建 summary.json(当你手动修改了原始数据时有用)。
bbc cookie-check
验证 cookie 可用性,打印登录用户信息。
bbc schema [command]
返回命令的 JSON schema(参数类型、exit code 映射、错误码)。供 agent 自描述用。
Exit codes
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 运行时 / B站 API 错误 |
| 2 | 认证错误(cookie 无效 / 过期) |
| 3 | 参数校验错误(BV 格式错等) |
| 4 | 网络错误(超时、重试耗尽) |
---
输出格式说明
comments.jsonl 单条记录
{
"rpid": 296636680849,
"bvid": "BV1NjA7zjEAU",
"parent": 0,
"root": 0,
"mid": 71171081,
"uname": "蓝忘今宵-_-YS",
"user_level": 4,
"vip": false,
"ctime": 1776521119,
"ctime_iso": "2026-04-18T06:25:19+00:00",
"message": "已关注 求指教",
"like": 1,
"rcount": 0,
"ip_location": "河北",
"is_up_reply": false,
"top_type": 0,
"mentioned_users": [],
"jump_urls": []
}parent=0→ 顶级评论;否则指向父评论 rpidtop_type:0=普通, 1=UP 置顶, 2=热评置顶is_up_reply:是否是 UP 主本人回复
summary.json 字段一览
video:标题、简介、播放量、点赞、投币、收藏、标签、封面 URL、UP 主counts:总数、顶级数、楼中楼数、置顶数、唯一用户数、UP 回复数、完整度time_distribution:最早 / 最晚评论时间、按天分布top_liked:Top N 点赞评论top_replied:Top N 被回复评论ip_distribution:IP 属地分布
详细 schema 见 references/agent-contract.md。
---
限制 & 注意事项
- 只读 — 本工具不发布 / 修改 / 删除任何评论,安全分层为
open - 速率 — 每请求间隔 1s(顶级)/ 0.5s(楼中楼),5000 条约 10-15 分钟
- 反风控 — 连续多次抓取可能触发 HTTP 412,已内建指数退避重试 3 次
- 完整度 — summary 中
completeness显示实拉 / 接口声称总数;<1.0 说明有被删评论或接口不一致 - 不支持匿名 — UP 主分析必须带 cookie;未登录请求返回字段不完整
---
相关文档
- SKILL.md — 给 Claude 的触发 & 使用指引
- references/api-endpoints.md — 所用 B 站接口字段
- references/agent-contract.md — envelope / exit code / schema 契约
---
贡献
欢迎提 issue、PR、建议。无论是新的分析场景、更稳的反风控默认值、其他平台支持、文档改进 —— 任何贡献都欢迎。提 Issue 或直接发 PR。
---
License
MIT
---
Support
如果这个 skill 帮到了你,可以请作者喝杯咖啡 ☕
<table> <tr> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/wechat-pay.png" width="180" alt="微信支付"> <br> <b>WeChat Pay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/alipay.png" width="180" alt="支付宝"> <br> <b>Alipay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/buymeacoffee.png" width="180" alt="Buy Me a Coffee"> <br> <b>Buy Me a Coffee</b> </td> </tr> </table>
---
作者
Agents365-ai — 为 AI coding agent 制作开源 skill。
- B 站:https://space.bilibili.com/441831884
- GitHub:https://github.com/Agents365-ai
- 其他 skill:drawio-skill · asta-skill · paper-fetch · 更多 →
bbc-skill · Bilibili Comment Collector
中文文档 · Online Docs
Built for Bilibili UP主 (content creators): fetch every comment on your own
videos and feed them to Claude Code / Codex / Gemini / any agent for
sentiment / keyword / audience analysis.
- 🐍 Zero dependencies — Python 3.9+ standard library only, no
pip install - 💬 Complete — top-level + nested + pinned comments, nothing skipped
- 📊 Video metadata — title, view/like/coin/favorite counts, tags included
- 🤖 Agent-native CLI — stable stdout JSON envelope, NDJSON stderr
progress, distinct exit codes, dry-run, schema introspection
- 🧑🎤 Batch mode — fetch every video of a UP主 sequentially (one at a time,
5-10s randomised cooldown between videos)
- 🔐 Delegated auth — human logs in once, agent just consumes the cookie;
no browser automation
- ♻️ Resumable — re-running the same BV skips completed pages;
--since
for incremental monitoring
- 📁 Analysis-friendly —
comments.jsonl+summary.json+raw/archive
Multi-Platform Support
Follows the Agent Skills spec. Works with every major AI coding agent:
| Platform | Status | Details |
|---|---|---|
| Claude Code | ✅ Full support | Native SKILL.md format |
| OpenAI Codex | ✅ Full support | agents/openai.yaml sidecar file |
| OpenClaw / ClawHub | ✅ Full support | metadata.openclaw namespace |
| Hermes Agent | ✅ Full support | metadata.hermes namespace |
| Opencode | ✅ Full support | Reads ~/.claude/skills/ automatically |
| SkillsMP | ✅ Indexed | GitHub topics configured |
⚠️ Responsible Use
Please read and accept these guidelines before using this tool.
- ✅ Personal, low-volume, legal use only: analyze comments on your own
videos, or assist another creator with their explicit authorization.
- ✅ Respect the built-in throttling: 1s per request, 5-10s random
cooldown between videos in batch mode. Do not patch these out.
- ❌ Do NOT use for:
- Mass-scraping strangers' videos
- Building derivative data products for resale or public redistribution
- Bypassing rate limits, spoofing User-Agents, using proxy pools to evade
anti-bot systems
- High-frequency automation (e.g. daily full re-scans of the same channel)
- Harassment, doxxing, coordinated attacks, or targeting specific users
- 📜 Comply with Bilibili's ToS and robots.txt.
For commercial/organization use, switch to the official Bilibili Open Platform APIs.
- 🔒 Data minimization: fetch → analyze → delete. Do not retain raw data
long-term, and do not share files containing user IDs or IP locations.
- 🎯 The tool is designed for one-shot analyses, not long-term
surveillance.
This project is not affiliated with bilibili.com. Any account-level risk
control, bans, or legal consequences are the user's responsibility. When
in doubt about whether a specific use case is allowed — don't run it.
---
Install
Claude Code
# Global install (available in all projects)
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.claude/skills/bbc-skill
# Project-level install
git clone https://github.com/Agents365-ai/bbc-skill.git .claude/skills/bbc-skillOpenAI Codex
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.agents/skills/bbc-skill
# Project-level
git clone https://github.com/Agents365-ai/bbc-skill.git .agents/skills/bbc-skillOpenClaw / ClawHub
# Via ClawHub
clawhub install bbc-skill
# Manual
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.openclaw/skills/bbc-skillOpencode
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.config/opencode/skills/bbc-skill
# Or reuse an existing ~/.claude/skills/bbc-skill — Opencode reads that path tooHermes Agent
git clone https://github.com/Agents365-ai/bbc-skill.git ~/.hermes/skills/data/bbc-skillSkillsMP
skills install bbc-skillStandalone CLI (no skill)
git clone https://github.com/Agents365-ai/bbc-skill.git && cd bbc-skill
./scripts/bbc --help
# Or add to PATH
export PATH="$PWD/scripts:$PATH"Installation paths summary
| Platform | Global path | Project path |
|---|---|---|
| Claude Code | ~/.claude/skills/bbc-skill/ | .claude/skills/bbc-skill/ |
| OpenAI Codex | ~/.agents/skills/bbc-skill/ | .agents/skills/bbc-skill/ |
| OpenClaw / ClawHub | ~/.openclaw/skills/bbc-skill/ | skills/bbc-skill/ |
| Opencode | ~/.config/opencode/skills/bbc-skill/ | .opencode/skills/bbc-skill/ |
| Hermes Agent | ~/.hermes/skills/data/bbc-skill/ | Via external_dirs config |
| SkillsMP | N/A (installed via CLI) | N/A |
---
Quick start
Step 1 · Export your Bilibili cookie
Bilibili's comment API rate-limits and returns thin data for unauthenticated requests. For full UP主 analysis you must authenticate with a cookie.
Recommended: the open-source Chrome extension **Get cookies.txt LOCALLY** — runs entirely locally, uploads nothing.
1. Install Get cookies.txt LOCALLY from the Chrome Web Store. 2. Visit https://www.bilibili.com and confirm you are logged in (avatar visible top-right). 3. Click the extension icon → Export → download www.bilibili.com_cookies.txt. 4. Save it somewhere convenient, e.g. ~/Downloads/bilibili_cookies.txt.
Other options:
- Firefox: cookies.txt
add-on.
- Edge: the same Chrome extension works.
- Manual: DevTools F12 → Application → Cookies → copy the
SESSDATAvalue,
then export BBC_SESSDATA="<value>".
Do not share `SESSDATA` — it authorizes full account access.
Step 2 · Verify the cookie works
./scripts/bbc cookie-check --cookie-file ~/Downloads/bilibili_cookies.txtExpected:
{"ok": true, "data": {"mid": 441831884, "uname": "探索未至之境", "vip": true, "level": 5, ...}}If it fails: confirm you are currently logged in at bilibili.com, re-export the cookie, and retry.
Step 3 · Fetch comments
./scripts/bbc fetch BV1NjA7zjEAU \
--cookie-file ~/Downloads/bilibili_cookies.txtURLs are accepted too:
./scripts/bbc fetch "https://www.bilibili.com/video/BV1NjA7zjEAU/"Output lives in ./bilibili-comments/BV1NjA7zjEAU/:
bilibili-comments/BV1NjA7zjEAU/
├── comments.jsonl # flat JSONL — one comment per line
├── summary.json # video meta + aggregated stats + top-N
├── raw/ # archived API responses
└── .bbc-state.json # resume / incremental state---
Environment variables
export BBC_COOKIE_FILE="$HOME/Downloads/bilibili_cookies.txt"
./scripts/bbc fetch BV1NjA7zjEAUOr pass SESSDATA directly:
export BBC_SESSDATA="<value from DevTools>"
./scripts/bbc fetch BV1NjA7zjEAU---
Analysis workflow with Claude Code
After fetch completes, ask Claude something like:
Read ./bilibili-comments/BV1NjA7zjEAU/summary.json first — give me theoverall picture: video stats, comment distribution, top 20 liked. Then
I'll direct what to analyze next.
Claude follows this path:
1. Read `summary.json` first (a few KB) — video title, stats, time distribution, IP distribution, top-N comments. 2. Sample `comments.jsonl` on demand — each line is a flat JSON record; Grep for keywords, head/tail for chronology, sort by like for hot-comment analysis. 3. Typical analyses:
- Sentiment: positive / negative / neutral ratio
- Keyword frequency (excluding stopwords)
- UP interaction audit: filter
is_up_reply=true, see which threads you
replied to vs. missed
- Geographic breakdown from
ip_location - Feedback evolution: bucket
ctime_isoby week/month - Super-fan detection: group by
mid, rank by comment count - Negative-review triage: high
like+ negative keywords
---
Commands
bbc fetch <BV|URL>
--max N Cap top-level comments (default: all)
--since <date> Only fetch comments newer than this (ISO, e.g. 2026-04-01)
--output <dir> Output directory (default ./bilibili-comments/<BV>/)
--cookie-file <path> Netscape cookie file
--browser <name> auto / firefox / chrome / edge / safari
--format json|table stdout format
--dry-run Preview request plan, no network calls
--force Ignore resume state, refetch everythingbbc fetch-user <UID> (coming soon)
Batch fetch across a UP主's entire video catalog.
bbc summarize <dir>
Rebuild summary.json from an existing comments.jsonl.
bbc cookie-check
Validate the cookie and print the logged-in user.
bbc schema [command]
Return JSON schema for a command (param types, exit codes, error codes).
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime / API error |
| 2 | Auth error (cookie invalid / missing) |
| 3 | Validation error (bad parameter) |
| 4 | Network error (timeout / retries exhausted) |
---
Output schemas
comments.jsonl record
{
"rpid": 296636680849,
"bvid": "BV1NjA7zjEAU",
"parent": 0,
"root": 0,
"mid": 71171081,
"uname": "user nickname",
"user_level": 4,
"vip": false,
"ctime": 1776521119,
"ctime_iso": "2026-04-18T06:25:19+00:00",
"message": "...",
"like": 1,
"rcount": 0,
"ip_location": "河北",
"is_up_reply": false,
"top_type": 0,
"mentioned_users": [],
"jump_urls": []
}parent=0→ top-level; otherwiserpidof the parent commenttop_type: 0=normal, 1=UP pinned, 2=editor pinnedis_up_reply: true if the comment was authored by the video owner
summary.json fields
video: title, description, stats, tags, cover URL, ownercounts: total, top-level, nested, pinned, unique users, UP replies,
completeness ratio
time_distribution: earliest/latest timestamps, daily histogramtop_liked: top-N comments by like counttop_replied: top-N top-level comments by reply countip_distribution: histogram of IP provinces
See references/agent-contract.md for the full schema.
---
Limits & caveats
- Read-only — never posts, edits, or deletes. Safety tier:
open. - Rate — 1s between top-level requests, 0.5s for nested. ~5000 comments
takes 10-15 minutes.
- Anti-bot — HTTP 412 triggers exponential backoff (3 retries).
- Completeness — the
completenessfield insummary.jsoncompares
fetched vs. declared counts; values below 1.0 indicate deleted comments or API inconsistency.
- Anonymous not supported — UP主 analysis requires a valid cookie.
---
References
- SKILL.md — skill trigger + usage guide for Claude
- references/api-endpoints.md — Bilibili
API fields used
- references/agent-contract.md — envelope /
exit code / schema contract
---
Contributing
Suggestions, bug reports, and pull requests are all welcome. If you have ideas — new analysis workflows, better anti-bot defaults, additional platform support, documentation fixes — feel free to open an issue or submit a PR directly.
This skill is community-friendly: every contribution, no matter how small, helps make it better for everyone.
---
License
MIT
---
Support
If this skill helps you, consider supporting the author:
<table> <tr> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/wechat-pay.png" width="180" alt="WeChat Pay"> <br> <b>WeChat Pay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/alipay.png" width="180" alt="Alipay"> <br> <b>Alipay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/buymeacoffee.png" width="180" alt="Buy Me a Coffee"> <br> <b>Buy Me a Coffee</b> </td> </tr> </table>
---
Author
Agents365-ai — building open-source skills for AI coding agents.
- Bilibili: https://space.bilibili.com/441831884
- GitHub: https://github.com/Agents365-ai
- Skills: drawio-skill · asta-skill · paper-fetch · more →
Agent contract
bbc-skill is designed per the agent-native CLI principles: one CLI must serve humans, AI agents, and orchestrators simultaneously.
Channels
| Channel | Content |
|---|---|
stdout | JSON envelope (one command → one final envelope). Table rendering when stdout is a TTY. |
stderr | Human log lines + NDJSON progress events for long tasks. |
| exit code | Numeric class of outcome (see below). |
Stdout format auto-detection
- If stdout is not a TTY (piped / captured / redirected): JSON (one line)
- If stdout is a TTY: human-readable table / indented JSON
- Override with
--format jsonor--format table
Agents redirecting output to a file or capturing via stdout=PIPE will automatically receive parseable JSON without any flag.
Envelope (success)
{
"ok": true,
"data": { ... },
"meta": {
"request_id": "req_xxx",
"latency_ms": 12345,
"schema_version": "1.0.0"
}
}For dry-run commands a top-level dry_run: true is also set.
Envelope (failure)
{
"ok": false,
"error": {
"code": "auth_expired",
"message": "Cookie 已过期,请重新登录 Bilibili。",
"retryable": true,
"retry_after_auth": true
},
"meta": { ... }
}Error codes
code | Exit | Retryable | Meaning |
|---|---|---|---|
validation_error | 3 | no | Bad BV number, bad flag, bad date format |
auth_required | 2 | yes (after login) | No cookie available |
auth_expired | 2 | yes (after login) | Cookie rejected by B站 |
not_found | 1 | no | Video deleted / comment area closed |
rate_limited | 1 | yes (after backoff) | HTTP 412 / code=-352 / -412 / -509 |
api_error | 1 | no | B站 returned non-zero code |
network_error | 4 | yes | Timeout / DNS / retries exhausted |
internal_error | 1 | no | Bug in the skill — please file an issue |
Retry protocol
retryable: false→ do not retry; treat as terminal.retryable: truewithoutretry_after_auth→ safe to retry immediately
after backoff.
retryable: truewithretry_after_auth: true→ prompt the human to
re-authenticate (re-export cookie), then retry.
NDJSON progress events (stderr)
Emitted when BBC_PROGRESS=1 or when stderr is a TTY. One JSON object per line. Schema:
{"event": "start", "command": "fetch", "request_id": "...", "elapsed_ms": 0, "bvid": "..."}
{"event": "progress", "command": "fetch", "request_id": "...", "elapsed_ms": 2294, "phase": "meta", "title": "..."}
{"event": "progress", "command": "fetch", "request_id": "...", "elapsed_ms": 3437, "phase": "top_level", "page": 2, "done": 20, "cumulative": 39, "declared_total": 59}
{"event": "progress", "command": "fetch", "request_id": "...", "elapsed_ms": 6186, "phase": "nested", "root_rpid": 299313655152, "page": 1, "got": 1, "cumulative_subs": 1}
{"event": "complete", "command": "fetch", "request_id": "...", "elapsed_ms": 12237, "counts": {"total": 59, "top_level": 44, "nested": 14, "pinned": 1}}Phases
meta— video metadata fetched (BV→aid resolved)top_level— a top-level comment page was fetchednested— nested replies for a top-level comment were fetchedwarn— non-fatal warnings (e.g. decryption fallback)
Suppression: BBC_PROGRESS=0 disables progress events.
Schema introspection
$ bbc schema
{"ok": true, "data": {"schema_version": "1.0.0", "commands": {...}, ...}}
$ bbc schema fetch
{"ok": true, "data": {"command": "fetch", "params": {...}, "envelope": {...}, ...}}The schema output is the source of truth for:
- Parameter types and defaults
- Exit code mapping
- Error codes and their retryability
An agent can discover the CLI surface without reading this README.
Idempotency
All fetch commands are idempotent within an output directory:
- Re-running with the same
--outputreuses.bbc-state.jsonand resumes - Pass
--forceto discard state and refetch - Same
--since <date>on repeated runs → incremental monitor mode
There is no --idempotency-key flag because:
- The command is read-only (no mutation on the server side)
- The output directory + resume state serves the same purpose for local
correctness
Auth delegation
The skill never runs a browser OAuth flow. The human is responsible for:
- Logging into bilibili.com in a browser
- Exporting the cookie file (see
cookie-extraction.md)
The agent consumes the cookie via --cookie-file / $BBC_COOKIE_FILE / $BBC_SESSDATA / auto-detect. It never invokes an auth retrieval path.
If cookie load fails and stdin is not a TTY, bbc cookie-check and bbc fetch return auth_required immediately — they never block on an interactive prompt.
Safety tier
All commands are tier = open (read-only). Specifically:
- No commands mutate Bilibili state (no post/edit/delete)
- No commands write outside the user-chosen
--outputdirectory - No subprocess shells out to user-supplied strings
- Cookie values are never logged to stdout/stderr; only cookie names may
appear in cookie-check output
Versioning
schema_version in every envelope signals breaking-change boundaries. Minor version bumps add fields; major version bumps may rename or remove fields. Agents that cache schema should compare versions on every run.
Bilibili API endpoints used
All endpoints are HTTP GET with cookie auth. No official SDK — these are reverse-engineered from the web client. They are stable in practice but undocumented by Bilibili.
1. GET /x/web-interface/view
Convert bvid → aid, plus full video metadata.
Params
bvid(required)
Key response fields (data)
bvid,aid,cid— three ID formstitle,desc,dynamic— textual contentpubdate,ctime— Unix timestamps (seconds)duration— secondstname,tid— category name and idpic— cover image URLowner.mid,owner.name,owner.face— UP主 infostat.view,stat.like,stat.coin,stat.favorite,stat.reply,
stat.danmaku, stat.share, stat.his_rank, stat.now_rank
staff[]— collaborative authors (empty if solo)
2. GET /x/web-interface/nav
Validate cookie / fetch logged-in user info.
Key response fields (data)
isLogin— boolmid,unamelevel_info.current_levelvipStatus— 0/1
Used by bbc cookie-check.
3. GET /x/tag/archive/tags
Fetch tags for a given BV.
Params
bvid(required)
Response
data[]withtag_name,tag_id
4. GET /x/v2/reply/main — top-level comments
Paginated top-level comments.
Params
type=1— video (type=11 for article, etc. — not used here)oid=<aid>— Note: must useaid, notbvidmode=3— sort by time desc (mode=2 is hot)next=<cursor>— pagination cursor (0 for first page)ps=20— page size
Key response fields (data)
replies[]— top-level commentstop_replies[]— pinned comments (only on first page)upper.top— UP主 pinned comment (alternate location)cursor.next— next page cursorcursor.is_end— true when donecursor.all_count— total declared comment count
Top-level reply fields used
| Path | Meaning |
|---|---|
rpid, rpid_str | Unique comment id (use str for safe JSON) |
oid, oid_str | Video aid |
mid, mid_str | Commenter UID |
parent | Parent rpid (0 for top-level) |
root | Root rpid (0 for top-level) |
ctime | Unix seconds |
like | Like count |
rcount | Nested reply count |
member.uname | Display name |
member.level_info.current_level | User level 0-6 |
member.sex | "男" / "女" / "保密" |
member.vip.vipStatus | 0/1 |
content.message | Comment text |
content.members[] | @-mentioned users |
content.jump_url | Dict of URLs detected in comment |
reply_control.location | "IP属地:河北" — strip prefix |
replies[] | Inline preview of 1-3 nested replies (not exhaustive) |
5. GET /x/v2/reply/reply — nested replies
Full nested replies for a given top-level comment.
Params
type=1oid=<aid>root=<top-level rpid>pn=1— page number (1-based)ps=20
Response
data.replies[]— nested replies (same schema as top-level, with
parent/root populated)
Call this for every top-level comment where rcount > 0. Stop when the returned page has < 20 replies.
6. GET /x/space/wbi/arc/search — UP主 video list
List a user's videos (for fetch-user batch mode).
Params
mid=<uid>pn=1,ps=50order=pubdate— sort by publish date
Caveat: this endpoint requires WBI signing (MD5 of sorted params + mixin key derived from /x/web-interface/nav). The current fetcher is single-video only; fetch-user ships in a later release.
Error codes observed
code | Meaning | Action |
|---|---|---|
0 | OK | Proceed |
-101 | Account not logged in | Re-auth (auth_expired) |
-111 | CSRF token invalid | Re-auth |
-352 | Risk control triggered | Retry with backoff |
-412 | Rate limited (request) | Retry with backoff |
-509 | Overloaded | Retry with backoff |
62002 | Comment area closed | not_found |
-404, 62004 | Resource not found | not_found |
Pagination details
- `/x/v2/reply/main` uses cursor-based pagination.
cursor.nexton
the current response is the next param for the next request. Start with next=0. Stop when cursor.is_end=true or replies=[].
- `/x/v2/reply/reply` uses page-number pagination (
pn=1, 2, ...).
Stop when returned replies has fewer than ps entries.
Completeness invariant
The first-page cursor.all_count equals:
top_level_count + nested_count + pinned_countUse this as a self-check after fetching everything — see the completeness field in summary.json. A value below 1.0 usually means comments were deleted between pages or a nested page returned inconsistent rcount.
Cookie extraction per platform
The skill needs a valid SESSDATA cookie and (ideally) bili_jct, DedeUserID, buvid3. Priority at load time:
1. --cookie-file <path> (CLI flag) 2. $BBC_COOKIE_FILE env var 3. $BBC_SESSDATA env var (direct value) 4. ~/.config/bbc-skill/cookie.json (cached) 5. Auto-detect from installed browsers
Recommended: browser extension export
Fastest, works identically across OSes.
Chrome / Edge
- Install from Chrome Web Store
- Visit https://www.bilibili.com (stay logged in)
- Click extension icon → Export → save as
www.bilibili.com_cookies.txt - Pass to the CLI via
--cookie-fileor$BBC_COOKIE_FILE
Firefox
cookies.txt add-on, same workflow.
Safari
Safari does not have a first-party cookies.txt extension. Either:
- Use Firefox / Chrome for B站 login, export from there, or
- Use the built-in auto-detect (
--browser safari) which parses
~/Library/Cookies/Cookies.binarycookies
Auto-detection (fallback)
Set --browser auto (default). The skill probes, in order:
1. Firefox — cookies.sqlite via stdlib sqlite3, unencrypted 2. Chrome (macOS) — SQLite + Keychain AES-128-CBC decryption via security and openssl CLI (both system-built-in) 3. Edge (macOS) — same as Chrome, different Keychain service
Windows and Linux Chrome paths are stubbed for this release — use the extension export instead.
Chrome / Edge on macOS — how decryption works
1. Cookie DB: ~/Library/Application Support/Google/Chrome/<profile>/Cookies (or Profile 1/Cookies, etc.) 2. Encrypted value format: v10 or v11 prefix + AES-128-CBC ciphertext 3. Key derivation:
- Fetch password via
security find-generic-password -w -s "Chrome Safe Storage" -a Chrome - PBKDF2-SHA1, salt=
saltysalt, iter=1003, dklen=16
4. Decryption: openssl enc -d -aes-128-cbc -K <hex> -iv <hex> with IV = 16 spaces (b' ' * 16)
All tools (security, openssl) are macOS system binaries, so no pip install required.
Firefox
On all OSes Firefox stores cookies in cookies.sqlite with values in plaintext. The skill copies the DB to a temp file (avoids WAL locks), queries via stdlib sqlite3, and returns the row.
Safari
Safari cookies live at ~/Library/Cookies/Cookies.binarycookies, a custom binary plist format. Parser not implemented in this release; recommend exporting via extension from Chrome/Firefox instead.
Storing cookies long-term
Two safe-ish options:
Option A — local file, permission 600
cp ~/Downloads/bilibili_cookies.txt ~/.config/bbc-skill/cookie.txt
chmod 600 ~/.config/bbc-skill/cookie.txt
export BBC_COOKIE_FILE=~/.config/bbc-skill/cookie.txtOption B — env var (shell profile)
# in ~/.zshrc or ~/.bashrc
export BBC_SESSDATA="your_sessdata_value"Environment variables are slightly more agent-friendly because they don't require a filesystem round-trip, but they're visible to other processes run from the same shell.
Rotating / revoking
SESSDATA is rotated by:
- Clicking "退出登录" in any Bilibili session (invalidates all sessions)
- Waiting for natural expiry (~2 weeks of inactivity)
After rotation, re-export from the browser and update your cookie file or env var.
What NOT to do
- Do not share
SESSDATAin bug reports, screenshots, or public
repositories — it authorizes full account access including posting, deleting content, changing account settings.
- Do not commit the cookie file to version control.
- Do not transmit the cookie to third parties or external services;
this skill only calls api.bilibili.com directly.
#!/usr/bin/env bash
# bbc shim — dispatches to python3 -m bbc with src/ on PYTHONPATH.
# Works whether invoked from the skill directory or via symlink.
set -e
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
export PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m bbc "$@"
__version__ = "1.0.3"
SCHEMA_VERSION = "1.0.0"
import sys
from bbc.cli import main
if __name__ == "__main__":
sys.exit(main())
"""HTTP client: stdlib-only, cookie auth, rate-limit, retry."""
import json
import random
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
# Retryable B站 internal codes
RETRYABLE_CODES = {-352, -412, -509}
# Auth-invalid codes
AUTH_BAD_CODES = {-101, -111}
NOT_FOUND_CODES = {-404, 62002, 62004}
class ApiError(Exception):
def __init__(self, code: str, message: str, *, retryable: bool = False, raw: Any = None):
super().__init__(message)
self.code = code
self.message = message
self.retryable = retryable
self.raw = raw
class Client:
def __init__(
self,
cookies: dict[str, str],
*,
min_interval_sec: float = 1.0,
referer: str = "https://www.bilibili.com/",
):
self.cookies = cookies
self.min_interval = min_interval_sec
self.referer = referer
self._last_request = 0.0
def _cookie_header(self) -> str:
return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
def _throttle(self) -> None:
elapsed = time.monotonic() - self._last_request
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self._last_request = time.monotonic()
def get_json(
self,
url: str,
*,
max_retries: int = 3,
referer: str | None = None,
) -> dict:
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Cookie": self._cookie_header(),
"Referer": referer or self.referer,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
)
attempt = 0
backoff = 2.0
while True:
attempt += 1
self._throttle()
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8")
data = json.loads(raw)
except urllib.error.HTTPError as e:
if e.code == 412 and attempt <= max_retries:
time.sleep(backoff + random.random())
backoff *= 2
continue
if 500 <= e.code < 600 and attempt <= max_retries:
time.sleep(backoff)
backoff *= 2
continue
raise ApiError(
"network_error" if e.code >= 500 else "api_error",
f"HTTP {e.code}: {e.reason}",
retryable=(e.code == 412 or 500 <= e.code < 600),
)
except (urllib.error.URLError, TimeoutError, OSError) as e:
if attempt <= max_retries:
time.sleep(backoff)
backoff *= 2
continue
raise ApiError("network_error", f"{type(e).__name__}: {e}", retryable=True)
except json.JSONDecodeError as e:
raise ApiError("api_error", f"invalid JSON response: {e}")
code = data.get("code")
if code == 0:
return data
if code in AUTH_BAD_CODES:
raise ApiError("auth_expired", data.get("message") or "cookie rejected", raw=data)
if code in NOT_FOUND_CODES:
raise ApiError("not_found", data.get("message") or "resource not found", raw=data)
if code in RETRYABLE_CODES and attempt <= max_retries:
time.sleep(backoff)
backoff *= 2
continue
if code in RETRYABLE_CODES:
raise ApiError("rate_limited", data.get("message") or "rate limited", retryable=True, raw=data)
raise ApiError("api_error", f"B站 code={code}: {data.get('message')}", raw=data)
# ---- Endpoint helpers ----
def bv_to_view(client: Client, bvid: str) -> dict:
url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
return client.get_json(url)["data"]
def get_nav(client: Client) -> dict:
return client.get_json("https://api.bilibili.com/x/web-interface/nav")["data"]
def get_tags(client: Client, bvid: str) -> list[dict]:
url = f"https://api.bilibili.com/x/tag/archive/tags?bvid={bvid}"
try:
return client.get_json(url).get("data") or []
except ApiError:
return []
def get_main_page(client: Client, aid: int, next_cursor: int, bvid: str, ps: int = 20) -> dict:
url = (
"https://api.bilibili.com/x/v2/reply/main"
f"?type=1&oid={aid}&mode=3&next={next_cursor}&ps={ps}"
)
return client.get_json(url, referer=f"https://www.bilibili.com/video/{bvid}/")
def get_sub_page(client: Client, aid: int, root_rpid: int, pn: int, bvid: str, ps: int = 20) -> dict:
url = (
"https://api.bilibili.com/x/v2/reply/reply"
f"?type=1&oid={aid}&root={root_rpid}&ps={ps}&pn={pn}"
)
return client.get_json(url, referer=f"https://www.bilibili.com/video/{bvid}/")
def list_user_videos(
client: Client,
uid: int,
*,
img_key: str,
sub_key: str,
pn: int = 1,
ps: int = 30,
) -> dict:
from bbc import wbi
params = {
"mid": uid,
"pn": pn,
"ps": ps,
"order": "pubdate",
"platform": "web",
"web_location": "1550101",
}
url = wbi.signed_url(
"https://api.bilibili.com/x/space/wbi/arc/search", params, img_key, sub_key
)
return client.get_json(url, referer=f"https://space.bilibili.com/{uid}")
"""argparse dispatcher for bbc."""
import argparse
import json
import sys
from pathlib import Path
from bbc import SCHEMA_VERSION, __version__, api, cookie, envelope, fetch, fetch_user, progress, schema, summarize
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="bbc",
description="Fetch Bilibili video comments for UP主 self-analysis.",
)
p.add_argument("--version", action="version", version=f"bbc {__version__}")
sub = p.add_subparsers(dest="command", required=True)
# fetch
f = sub.add_parser("fetch", help="Fetch all comments for one video (BV or URL)")
f.add_argument("target", help="BV number or Bilibili video URL")
f.add_argument("--max", dest="max_top", type=int, default=None, help="Max top-level comments")
f.add_argument("--since", dest="since", default=None, help="ISO date/time; fetch newer only")
f.add_argument("--output", "-o", default=None, help="Output directory")
f.add_argument("--cookie-file", default=None, help="Netscape cookie file")
f.add_argument("--browser", default="auto", choices=["auto", "firefox", "chrome", "edge", "safari"])
f.add_argument("--format", dest="fmt", default=None, choices=["json", "table"])
f.add_argument("--dry-run", action="store_true")
f.add_argument("--force", action="store_true")
# fetch-user (stub implementation for now)
fu = sub.add_parser("fetch-user", help="Batch fetch all videos of a UP主 by UID")
fu.add_argument("uid", type=int)
fu.add_argument("--output", "-o", default=None)
fu.add_argument("--video-limit", type=int, default=None)
fu.add_argument("--max", dest="max_top", type=int, default=None)
fu.add_argument("--cookie-file", default=None)
fu.add_argument("--browser", default="auto", choices=["auto", "firefox", "chrome", "edge", "safari"])
fu.add_argument("--format", dest="fmt", default=None, choices=["json", "table"])
fu.add_argument("--dry-run", action="store_true")
# summarize
s = sub.add_parser("summarize", help="Rebuild summary.json from existing comments.jsonl")
s.add_argument("directory")
s.add_argument("--format", dest="fmt", default=None, choices=["json", "table"])
# cookie-check
cc = sub.add_parser("cookie-check", help="Validate cookie and print logged-in user")
cc.add_argument("--cookie-file", default=None)
cc.add_argument("--browser", default="auto", choices=["auto", "firefox", "chrome", "edge", "safari"])
cc.add_argument("--format", dest="fmt", default=None, choices=["json", "table"])
# schema
sc = sub.add_parser("schema", help="Return JSON schema for a command (or all)")
sc.add_argument("cmd", nargs="?", default=None)
sc.add_argument("--format", dest="fmt", default=None, choices=["json", "table"])
return p
def _resolve_cookies(args) -> tuple[dict, str]:
return cookie.load(
cookie_file=getattr(args, "cookie_file", None),
browser=getattr(args, "browser", "auto"),
)
def _cmd_schema(args) -> dict:
return envelope.success(
schema.describe(args.cmd),
request_id=envelope.new_request_id(),
elapsed_ms=0,
)
def _cmd_cookie_check(args) -> dict:
req_id = envelope.new_request_id()
clock = envelope.Clock()
try:
cookies, source = _resolve_cookies(args)
except cookie.CookieNotFound as e:
return envelope.failure(
"auth_required", str(e),
request_id=req_id, elapsed_ms=clock.ms(), retryable=True,
)
client = api.Client(cookies)
try:
nav = api.get_nav(client)
except api.ApiError as e:
return envelope.failure(
e.code, e.message, request_id=req_id, elapsed_ms=clock.ms(), retryable=e.retryable
)
is_login = bool(nav.get("isLogin"))
if not is_login:
return envelope.failure(
"auth_expired", "cookie does not represent a logged-in session",
request_id=req_id, elapsed_ms=clock.ms(), retryable=True,
)
return envelope.success(
{
"mid": nav.get("mid"),
"uname": nav.get("uname"),
"vip": bool((nav.get("vipStatus") or 0)),
"level": (nav.get("level_info") or {}).get("current_level"),
"source": source,
"cookie_names": sorted(cookies.keys()),
},
request_id=req_id, elapsed_ms=clock.ms(),
)
def _cmd_fetch(args) -> dict:
req_id = envelope.new_request_id()
clock = envelope.Clock()
# dry-run: no cookie load required (just validate target)
if args.dry_run:
try:
data = fetch.run_dry_run(args.target, args.output)
except api.ApiError as e:
return envelope.failure(
e.code, e.message, request_id=req_id, elapsed_ms=clock.ms(), retryable=e.retryable
)
return envelope.success(
data, request_id=req_id, elapsed_ms=clock.ms(),
extra_top={"dry_run": True},
)
try:
cookies, source = _resolve_cookies(args)
except cookie.CookieNotFound as e:
return envelope.failure(
"auth_required", str(e),
request_id=req_id, elapsed_ms=clock.ms(), retryable=True,
)
try:
since_ts = fetch.parse_since(args.since)
except ValueError as e:
return envelope.failure(
"validation_error", str(e), request_id=req_id, elapsed_ms=clock.ms(), field="since"
)
prog = progress.Progress("fetch", req_id)
try:
data = fetch.run_fetch(
target=args.target,
cookies=cookies,
output=args.output,
max_top=args.max_top,
since_ts=since_ts,
force=args.force,
progress=prog,
)
except api.ApiError as e:
return envelope.failure(
e.code, e.message, request_id=req_id, elapsed_ms=clock.ms(), retryable=e.retryable
)
except Exception as e:
return envelope.failure(
"internal_error", f"{type(e).__name__}: {e}",
request_id=req_id, elapsed_ms=clock.ms(),
)
data["cookie_source"] = source
return envelope.success(data, request_id=req_id, elapsed_ms=clock.ms())
def _cmd_fetch_user(args) -> dict:
req_id = envelope.new_request_id()
clock = envelope.Clock()
if args.dry_run:
data = fetch_user.run_dry_run(args.uid, args.output, args.video_limit)
return envelope.success(
data, request_id=req_id, elapsed_ms=clock.ms(),
extra_top={"dry_run": True},
)
try:
cookies, source = _resolve_cookies(args)
except cookie.CookieNotFound as e:
return envelope.failure(
"auth_required", str(e),
request_id=req_id, elapsed_ms=clock.ms(), retryable=True,
)
prog = progress.Progress("fetch-user", req_id)
try:
data = fetch_user.run_fetch_user(
uid=args.uid,
cookies=cookies,
output=args.output,
video_limit=args.video_limit,
max_top=args.max_top,
progress=prog,
)
except api.ApiError as e:
return envelope.failure(
e.code, e.message, request_id=req_id, elapsed_ms=clock.ms(), retryable=e.retryable
)
except Exception as e:
return envelope.failure(
"internal_error", f"{type(e).__name__}: {e}",
request_id=req_id, elapsed_ms=clock.ms(),
)
data["cookie_source"] = source
return envelope.success(data, request_id=req_id, elapsed_ms=clock.ms())
def _cmd_summarize(args) -> dict:
req_id = envelope.new_request_id()
clock = envelope.Clock()
d = Path(args.directory).expanduser().resolve()
jsonl = d / "comments.jsonl"
if not jsonl.exists():
return envelope.failure(
"validation_error", f"no comments.jsonl in {d}",
request_id=req_id, elapsed_ms=clock.ms(), field="directory",
)
raw_view = d / "raw" / "view.json"
raw_tags = d / "raw" / "tags.json"
view = json.loads(raw_view.read_text(encoding="utf-8")) if raw_view.exists() else {}
tags = json.loads(raw_tags.read_text(encoding="utf-8")) if raw_tags.exists() else []
video_meta = summarize.video_meta_from_view(view, tags)
summary = summarize.build_summary(
jsonl_path=jsonl,
video_meta=video_meta,
fetch_range={"mode": "resumed", "max": None, "since": None, "resumed": True},
declared_all_count=None,
)
out = d / "summary.json"
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
return envelope.success(
{"summary_path": str(out), "counts": summary["counts"]},
request_id=req_id, elapsed_ms=clock.ms(),
)
DISPATCH = {
"schema": _cmd_schema,
"cookie-check": _cmd_cookie_check,
"fetch": _cmd_fetch,
"fetch-user": _cmd_fetch_user,
"summarize": _cmd_summarize,
}
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
handler = DISPATCH.get(args.command)
if handler is None:
env = envelope.failure(
"validation_error", f"unknown command: {args.command}",
request_id=envelope.new_request_id(), elapsed_ms=0,
)
else:
env = handler(args)
fmt = envelope.effective_format(getattr(args, "fmt", None))
envelope.emit(env, fmt)
return envelope.exit_for(env)
if __name__ == "__main__":
sys.exit(main())
"""Cookie auto-detect orchestrator.
Priority:
1. Explicit cookie file (arg or $BBC_COOKIE_FILE)
2. $BBC_SESSDATA env (direct value)
3. Cached config ~/.config/bbc-skill/cookie.json
4. Auto-detect browsers (by OS)
Returns a dict of {name: value} or raises CookieNotFound.
"""
import json
import os
import sys
from pathlib import Path
from . import chrome_macos, firefox, netscape
class CookieNotFound(Exception):
pass
CONFIG_PATH = Path.home() / ".config/bbc-skill/cookie.json"
def _from_env_sessdata() -> dict[str, str] | None:
val = os.environ.get("BBC_SESSDATA", "").strip()
if val:
return {"SESSDATA": val}
return None
def _from_config_file() -> dict[str, str] | None:
if not CONFIG_PATH.exists():
return None
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("SESSDATA"):
return {k: v for k, v in data.items() if isinstance(v, str)}
except (OSError, json.JSONDecodeError):
pass
return None
def _auto_detect(browser_hint: str = "auto") -> tuple[dict[str, str] | None, str | None]:
"""Returns (cookies, source_label)."""
candidates: list[tuple[str, callable]] = []
if browser_hint in ("auto", "firefox"):
candidates.append(("firefox", firefox.extract))
if browser_hint in ("auto", "chrome"):
if sys.platform == "darwin":
candidates.append(("chrome (macOS)", lambda: chrome_macos.extract("chrome")))
if browser_hint in ("auto", "edge"):
if sys.platform == "darwin":
candidates.append(("edge (macOS)", lambda: chrome_macos.extract("edge")))
for label, fn in candidates:
try:
cookies = fn()
except Exception:
cookies = None
if cookies and cookies.get("SESSDATA"):
return cookies, label
return None, None
def load(
*,
cookie_file: str | None = None,
browser: str = "auto",
) -> tuple[dict[str, str], str]:
"""Load cookies. Returns (cookies, source).
Raises CookieNotFound if none found.
"""
# 1. Explicit cookie file
if cookie_file:
path = Path(cookie_file).expanduser()
if not path.exists():
raise CookieNotFound(f"cookie file not found: {path}")
cookies = netscape.parse(path)
if not cookies.get("SESSDATA"):
raise CookieNotFound(f"no SESSDATA in cookie file: {path}")
return cookies, f"file:{path}"
# 2. $BBC_COOKIE_FILE
env_file = os.environ.get("BBC_COOKIE_FILE", "").strip()
if env_file:
path = Path(env_file).expanduser()
if path.exists():
cookies = netscape.parse(path)
if cookies.get("SESSDATA"):
return cookies, f"env:BBC_COOKIE_FILE={path}"
# 3. $BBC_SESSDATA direct
env_cookies = _from_env_sessdata()
if env_cookies:
return env_cookies, "env:BBC_SESSDATA"
# 4. Cached config file
cached = _from_config_file()
if cached:
return cached, f"config:{CONFIG_PATH}"
# 5. Auto-detect browser
cookies, source = _auto_detect(browser)
if cookies:
return cookies, f"browser:{source}"
raise CookieNotFound(
"No cookie found. Provide --cookie-file, set $BBC_SESSDATA, "
"or log into bilibili.com in Chrome/Firefox/Edge (supported) and retry."
)
def save_to_config(cookies: dict[str, str]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(cookies, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(CONFIG_PATH, 0o600)
except OSError:
pass
"""Chrome / Edge cookie extraction on macOS.
Uses:
- sqlite3 (stdlib) to read the Cookies DB
- `security find-generic-password` to fetch the AES password from Keychain
- hashlib.pbkdf2_hmac (stdlib) to derive the AES-128 key
- `openssl enc -aes-128-cbc` (system binary) to decrypt cookie values
Zero pip install. macOS-only.
"""
import base64
import binascii
import glob
import hashlib
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
SALT = b"saltysalt"
ITERATIONS = 1003
KEY_LEN = 16
IV = b" " * 16 # 16 spaces
CHROME_SERVICES = {
"chrome": ("Chrome", "Chrome"),
"edge": ("Microsoft Edge", "Microsoft Edge"),
"chromium": ("Chromium", "Chromium"),
}
def _profile_paths(browser: str) -> list[Path]:
home = Path.home()
if browser == "chrome":
base = home / "Library/Application Support/Google/Chrome"
elif browser == "edge":
base = home / "Library/Application Support/Microsoft Edge"
elif browser == "chromium":
base = home / "Library/Application Support/Chromium"
else:
return []
if not base.exists():
return []
results = []
for prof in ["Default"] + sorted(glob.glob(str(base / "Profile *"))):
p = base / prof if isinstance(prof, str) and "/" not in prof else Path(prof)
# Chrome >= ~96 moved Cookies under Network/
for candidate in [p / "Network/Cookies", p / "Cookies"]:
if candidate.exists():
results.append(candidate)
break
return results
def _fetch_key(browser: str) -> bytes | None:
if sys.platform != "darwin":
return None
svc_name, account = CHROME_SERVICES.get(browser, (None, None))
if not svc_name:
return None
try:
out = subprocess.run(
[
"security",
"find-generic-password",
"-w",
"-s",
f"{svc_name} Safe Storage",
"-a",
account,
],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode != 0:
return None
password = out.stdout.strip().encode()
if not password:
return None
return hashlib.pbkdf2_hmac("sha1", password, SALT, ITERATIONS, KEY_LEN)
except (OSError, subprocess.TimeoutExpired):
return None
def _decrypt_value(encrypted: bytes, key: bytes) -> str | None:
if not encrypted:
return None
# Chrome prefixes with version tag: v10 or v11
if encrypted[:3] in (b"v10", b"v11"):
body = encrypted[3:]
else:
# Possibly legacy plaintext
try:
return encrypted.decode("utf-8")
except UnicodeDecodeError:
return None
try:
proc = subprocess.run(
[
"openssl",
"enc",
"-d",
"-aes-128-cbc",
"-K",
binascii.hexlify(key).decode(),
"-iv",
binascii.hexlify(IV).decode(),
],
input=body,
capture_output=True,
timeout=5,
)
if proc.returncode != 0:
return None
plain = proc.stdout
# PKCS#7 padding is auto-handled by openssl; trailing spaces may still exist
return plain.decode("utf-8", errors="ignore").rstrip("\x00 ")
except (OSError, subprocess.TimeoutExpired):
return None
def extract(browser: str = "chrome", domain_filter: str = "bilibili.com") -> dict[str, str] | None:
key = _fetch_key(browser)
if not key:
return None
for db_path in _profile_paths(browser):
try:
with tempfile.NamedTemporaryFile(prefix="bbc-chrome-", suffix=".db", delete=False) as tf:
tmp = Path(tf.name)
shutil.copy2(db_path, tmp)
try:
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
cur = conn.execute(
"SELECT name, value, encrypted_value FROM cookies WHERE host_key LIKE ?",
(f"%{domain_filter}%",),
)
cookies: dict[str, str] = {}
for name, value, enc in cur.fetchall():
if value:
cookies[name] = value
elif enc:
dec = _decrypt_value(bytes(enc), key)
if dec is not None:
cookies[name] = dec
conn.close()
finally:
try:
tmp.unlink()
except OSError:
pass
if cookies.get("SESSDATA"):
return cookies
except sqlite3.DatabaseError:
continue
return None
"""Firefox cookie extraction — stdlib sqlite3, no encryption.
Works on macOS, Linux, Windows. Copies DB to a temp file since Firefox may
hold a WAL lock.
"""
import glob
import os
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path
def _profile_dirs() -> list[Path]:
home = Path.home()
patterns: list[str] = []
if sys.platform == "darwin":
patterns.append(str(home / "Library/Application Support/Firefox/Profiles/*"))
elif sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", "")
if appdata:
patterns.append(os.path.join(appdata, "Mozilla/Firefox/Profiles/*"))
else:
patterns.append(str(home / ".mozilla/firefox/*"))
found = []
for pat in patterns:
for p in glob.glob(pat):
path = Path(p)
if (path / "cookies.sqlite").exists():
found.append(path)
return found
def extract(domain_filter: str = "bilibili.com") -> dict[str, str] | None:
for profile in _profile_dirs():
db = profile / "cookies.sqlite"
try:
with tempfile.NamedTemporaryFile(
prefix="bbc-ff-", suffix=".sqlite", delete=False
) as tf:
tmp_path = Path(tf.name)
shutil.copy2(db, tmp_path)
try:
conn = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True)
cur = conn.execute(
"SELECT name, value FROM moz_cookies WHERE host LIKE ?",
(f"%{domain_filter}%",),
)
cookies = {name: value for name, value in cur.fetchall()}
conn.close()
finally:
try:
tmp_path.unlink()
except OSError:
pass
if cookies.get("SESSDATA"):
return cookies
except sqlite3.DatabaseError:
continue
return None
"""Parse Netscape-format cookie files (e.g. exported by browser extensions)."""
from pathlib import Path
def parse(path: Path, domain_filter: str = "bilibili.com") -> dict[str, str]:
cookies: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) < 7:
continue
domain = parts[0]
if domain_filter not in domain:
continue
name, value = parts[5], parts[6]
cookies[name] = value
return cookies
"""JSON envelope, exit codes, TTY detection."""
import json
import os
import sys
import time
import uuid
from typing import Any
from bbc import SCHEMA_VERSION
# Exit codes — see references/agent-contract.md
EXIT_OK = 0
EXIT_RUNTIME = 1
EXIT_AUTH = 2
EXIT_VALIDATION = 3
EXIT_NETWORK = 4
ERROR_CODE_TO_EXIT = {
"validation_error": EXIT_VALIDATION,
"auth_required": EXIT_AUTH,
"auth_expired": EXIT_AUTH,
"not_found": EXIT_RUNTIME,
"rate_limited": EXIT_RUNTIME,
"api_error": EXIT_RUNTIME,
"network_error": EXIT_NETWORK,
"internal_error": EXIT_RUNTIME,
}
def stdout_is_tty() -> bool:
try:
return sys.stdout.isatty()
except Exception:
return False
def effective_format(explicit: str | None) -> str:
if explicit in ("json", "table"):
return explicit
return "table" if stdout_is_tty() else "json"
def new_request_id() -> str:
return "req_" + uuid.uuid4().hex[:12]
class Clock:
def __init__(self) -> None:
self.start = time.monotonic()
def ms(self) -> int:
return int((time.monotonic() - self.start) * 1000)
def success(
data: Any,
*,
request_id: str,
elapsed_ms: int,
extra_meta: dict | None = None,
extra_top: dict | None = None,
) -> dict:
env = {
"ok": True,
"data": data,
"meta": {
"request_id": request_id,
"latency_ms": elapsed_ms,
"schema_version": SCHEMA_VERSION,
**(extra_meta or {}),
},
}
if extra_top:
env.update(extra_top)
return env
def failure(
code: str,
message: str,
*,
request_id: str,
elapsed_ms: int,
retryable: bool = False,
field: str | None = None,
extra: dict | None = None,
) -> dict:
err: dict = {"code": code, "message": message, "retryable": retryable}
if field:
err["field"] = field
if code == "auth_expired":
err["retry_after_auth"] = True
if extra:
err.update(extra)
return {
"ok": False,
"error": err,
"meta": {
"request_id": request_id,
"latency_ms": elapsed_ms,
"schema_version": SCHEMA_VERSION,
},
}
def emit_json(env: dict) -> None:
"""Emit envelope to stdout as JSON (single line + trailing newline)."""
json.dump(env, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
sys.stdout.flush()
def emit_table(env: dict) -> None:
"""Human-readable rendering (very simple)."""
if env.get("ok") is True:
_print_table_success(env)
elif env.get("ok") == "partial":
_print_table_success(env)
else:
err = env.get("error", {})
sys.stdout.write(
f"\x1b[31mERROR\x1b[0m [{err.get('code')}] {err.get('message')}\n"
if _colors_ok()
else f"ERROR [{err.get('code')}] {err.get('message')}\n"
)
if err.get("retryable"):
sys.stdout.write(" (retryable)\n")
sys.stdout.flush()
def _print_table_success(env: dict) -> None:
data = env.get("data")
if isinstance(data, dict):
# Flatten one level
for k, v in data.items():
if isinstance(v, (dict, list)):
sys.stdout.write(f"{k}:\n")
sys.stdout.write(
" "
+ json.dumps(v, ensure_ascii=False, indent=2).replace(
"\n", "\n "
)
+ "\n"
)
else:
sys.stdout.write(f"{k}: {v}\n")
else:
sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
def _colors_ok() -> bool:
if os.environ.get("NO_COLOR"):
return False
return stdout_is_tty()
def emit(env: dict, fmt: str) -> None:
if fmt == "table":
emit_table(env)
else:
emit_json(env)
def exit_for(env: dict) -> int:
if env.get("ok") is True:
return EXIT_OK
if env.get("ok") == "partial":
return EXIT_OK
code = env.get("error", {}).get("code", "internal_error")
return ERROR_CODE_TO_EXIT.get(code, EXIT_RUNTIME)
"""bbc fetch-user — sequentially fetch comments for all videos of a UP主.
Strict sequential: one video at a time. Never parallel. Failures on an
individual video are recorded to channel-summary.json.errors and the next
video continues.
"""
import json
import random
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from bbc import api, fetch, wbi
from bbc.progress import Progress
INTER_VIDEO_SLEEP_MIN = 5.0
INTER_VIDEO_SLEEP_MAX = 10.0
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def default_output_dir(uid: int) -> Path:
return (Path.cwd() / "bilibili-comments" / f"user-{uid}").resolve()
def _list_all_videos(client: api.Client, uid: int, img_key: str, sub_key: str, *, limit: int | None) -> list[dict]:
"""Paginate through all user videos. Returns list of dicts with bvid/title/play/comment/created."""
videos: list[dict] = []
pn = 1
ps = 30
while True:
data = api.list_user_videos(client, uid, img_key=img_key, sub_key=sub_key, pn=pn, ps=ps)
vlist = (data.get("data", {}).get("list", {}) or {}).get("vlist") or []
page_info = (data.get("data", {}).get("page", {})) or {}
for v in vlist:
videos.append(
{
"bvid": v.get("bvid"),
"aid": v.get("aid"),
"title": v.get("title"),
"play": v.get("play"),
"comment": v.get("comment"),
"created": v.get("created"),
"description": v.get("description"),
"pic": v.get("pic"),
}
)
if limit and len(videos) >= limit:
return videos
total = int(page_info.get("count") or 0)
if not vlist or len(videos) >= total:
break
pn += 1
time.sleep(0.5)
return videos
def _load_channel_state(path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"completed_bvids": [], "errors": []}
def run_fetch_user(
*,
uid: int,
cookies: dict,
output: str | None,
video_limit: int | None,
max_top: int | None,
progress: Progress,
inter_video_sleep_range: tuple[float, float] = (INTER_VIDEO_SLEEP_MIN, INTER_VIDEO_SLEEP_MAX),
) -> dict[str, Any]:
out_dir = Path(output).expanduser().resolve() if output else default_output_dir(uid)
out_dir.mkdir(parents=True, exist_ok=True)
state_path = out_dir / ".bbc-channel-state.json"
state = _load_channel_state(state_path)
completed = set(state.get("completed_bvids") or [])
client = api.Client(cookies, min_interval_sec=1.0)
progress.start(uid=uid, output=str(out_dir))
# 1. Get owner info + WBI keys
nav = api.get_nav(client)
img_key, sub_key = wbi.keys_from_nav(nav)
progress.progress(phase="nav", img_key_len=len(img_key))
# 2. List videos
progress.progress(phase="list_videos", message="fetching video list…")
videos = _list_all_videos(client, uid, img_key, sub_key, limit=video_limit)
progress.progress(phase="list_videos", total_videos=len(videos))
(out_dir / "videos.json").write_text(
json.dumps(videos, ensure_ascii=False, indent=2), encoding="utf-8"
)
# 3. Sequentially fetch each video (NEVER parallel)
per_video_results: list[dict] = []
errors: list[dict] = list(state.get("errors") or [])
for idx, v in enumerate(videos, start=1):
bvid = v.get("bvid")
if not bvid:
continue
if bvid in completed:
progress.progress(phase="skip_video", idx=idx, total=len(videos), bvid=bvid, reason="already_done")
continue
video_out = out_dir / bvid
progress.progress(
phase="video_start", idx=idx, total=len(videos),
bvid=bvid, title=v.get("title"), declared_comments=v.get("comment"),
)
sub_progress = Progress(f"fetch-user.video", progress.request_id, enabled=progress.enabled)
try:
result = fetch.run_fetch(
target=bvid,
cookies=cookies,
output=str(video_out),
max_top=max_top,
since_ts=None,
force=False,
progress=sub_progress,
)
per_video_results.append(
{
"bvid": bvid,
"title": v.get("title"),
"output_dir": str(video_out),
"counts": result.get("counts"),
"completeness": result.get("completeness"),
}
)
completed.add(bvid)
progress.progress(
phase="video_done", idx=idx, total=len(videos),
bvid=bvid, counts=result.get("counts"),
)
except api.ApiError as e:
err_rec = {
"bvid": bvid,
"title": v.get("title"),
"code": e.code,
"message": e.message,
"retryable": e.retryable,
"at": _iso_now(),
}
errors.append(err_rec)
progress.warn(f"video {bvid} failed", **err_rec)
except Exception as e:
err_rec = {
"bvid": bvid,
"title": v.get("title"),
"code": "internal_error",
"message": f"{type(e).__name__}: {e}",
"retryable": False,
"at": _iso_now(),
}
errors.append(err_rec)
progress.warn(f"video {bvid} crashed", **err_rec)
# Persist state after every video (resume-safe even if killed)
state_path.write_text(
json.dumps(
{"completed_bvids": sorted(completed), "errors": errors},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
# Inter-video cooldown — only if not last; randomised to mimic human
# cadence and reduce the chance of B站 risk-control banning the session.
if idx < len(videos):
sleep_for = random.uniform(*inter_video_sleep_range)
progress.progress(phase="cooldown", seconds=round(sleep_for, 2))
time.sleep(sleep_for)
# 4. Channel-level summary
summary = {
"schema_version": "1.0.0",
"generated_at": _iso_now(),
"uid": uid,
"video_count_total": len(videos),
"video_count_fetched": len(per_video_results),
"video_count_skipped": len(videos) - len(per_video_results) - len(errors),
"video_count_failed": len(errors),
"videos": per_video_results,
"errors": errors,
}
summary_path = out_dir / "channel-summary.json"
summary_path.write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
progress.complete(
uid=uid, total=len(videos), fetched=len(per_video_results), failed=len(errors)
)
return {
"uid": uid,
"output_dir": str(out_dir),
"video_count_total": len(videos),
"video_count_fetched": len(per_video_results),
"video_count_failed": len(errors),
"files": {
"videos": "videos.json",
"channel_summary": "channel-summary.json",
},
}
def run_dry_run(uid: int, output: str | None, video_limit: int | None) -> dict[str, Any]:
out_dir = Path(output).expanduser().resolve() if output else default_output_dir(uid)
return {
"dry_run": True,
"would": {
"uid": uid,
"output_dir": str(out_dir),
"video_limit": video_limit,
"mode": "sequential (one video at a time, never parallel)",
"endpoints": [
"GET /x/web-interface/nav (WBI key discovery)",
"GET /x/space/wbi/arc/search (WBI-signed, paginated)",
"-- per video --",
"GET /x/web-interface/view",
"GET /x/tag/archive/tags",
"GET /x/v2/reply/main (paginated)",
"GET /x/v2/reply/reply (per thread with rcount>0)",
],
"rate_limit": f"1.0s between API calls; {INTER_VIDEO_SLEEP_MIN}-{INTER_VIDEO_SLEEP_MAX}s random between videos",
"note": "fetch-user is resume-safe: already-completed BVIDs are skipped on re-run.",
},
}
Related skills
FAQ
What auth does it need?
A logged-in Bilibili cookie, provided via cookies.txt, the BBC_SESSDATA env var, or browser auto-detection.
Can it modify comments?
No; it is read-only and does not post, edit, or delete.