bili-transcript 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dongliang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # bili-transcript
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 提取 B 站视频的**文字稿、字幕和 AI 总结**。零依赖,扫码登录,纯命令行。
4
+
5
+ ```sh
6
+ npx bili-transcript BV1xx411c7mD --plain
7
+ ```
8
+
9
+ ## 它能做什么
10
+
11
+ - 把任意 B 站视频的字幕扒成文字,带不带时间戳都行
12
+ - 拿 B 站官方的 **AI 视频总结**(带章节和时间点)
13
+ - 支持 BV 号 / av 号 / 完整链接 / b23.tv 短链
14
+ - 支持合集视频的分P
15
+ - 给 agent 用的 `--json` 结构化输出
16
+
17
+ ## 安装
18
+
19
+ ```sh
20
+ npm install -g bili-transcript # 全局
21
+ npx bili-transcript <目标> # 或者直接用,不装
22
+ ```
23
+
24
+ 需要 **Node 18+**(用到内置 fetch)。没有其他依赖,一条 `npm i` 装完不到 100 KB。
25
+
26
+ 装好后有两个等价的命令:**`bili-transcript`**(全名)和 **`bili`**(简写)。
27
+
28
+ > 全局安装报 `EACCES: permission denied, mkdir '/usr/lib/node_modules/...'`?
29
+ > 那是 npm 想往系统目录写。别用 sudo,改成装到自己的目录:
30
+ > ```sh
31
+ > npm config set prefix ~/.local
32
+ > npm install -g bili-transcript
33
+ > ```
34
+ > 再把 `~/.local/bin` 加进 PATH(fish:`fish_add_path ~/.local/bin`)。
35
+ > 或者干脆不装,用 `npx`。
36
+
37
+ ## 快速开始
38
+
39
+ ```sh
40
+ bili login # 扫码登录,一次就够
41
+ bili BV1xx411c7mD --plain # 提取文字稿
42
+ bili "https://b23.tv/xxxxx" --plain -o out.txt
43
+ bili BV1xx411c7mD --summary # 顺带拿 AI 总结
44
+ bili doctor # 出问题了先跑这个
45
+ ```
46
+
47
+ ## 为什么需要登录
48
+
49
+ B 站的字幕接口现在强制登录。不登录的话,接口返回的是**空数组**——看起来像"这视频没字幕",其实是被挡住了。所以必须先登一次。
50
+
51
+ **登录全程在你的机器上完成,凭据只存在本地。** 扫码用的是 B 站官方的二维码登录接口(就是网页版"扫码登录"用的那个),和任何第三方服务器无关。凭据写在 `~/.config/bili/auth.json`,权限 `0600`。
52
+
53
+ 扫码登录**不会把你手机上的 B 站 App 踢下线**——同一个账号支持多端同时在线。
54
+
55
+ ## 命令
56
+
57
+ ```
58
+ bili <BV号|av号|URL> [选项] 提取文字稿(默认动作)
59
+ bili fetch <目标> [选项] 同上,显式写法
60
+ bili login [选项] 扫码登录
61
+ bili doctor 自检:环境 / 凭据 / 接口
62
+ bili install-skill [选项] 把 skill 装到本地 agent
63
+ ```
64
+
65
+ ### 提取选项
66
+
67
+ | 选项 | 作用 |
68
+ |---|---|
69
+ | `--plain` | 只出连续文字,不带时间戳 |
70
+ | `--json` | 结构化输出,给程序或 agent 解析 |
71
+ | `-p, --page N` | 取第 N 个分P |
72
+ | `--list` | 只列出字幕轨,不下载正文 |
73
+ | `--summary` | 附带 B 站 AI 总结 |
74
+ | `-o, --out FILE` | 写入文件 |
75
+
76
+ ### 登录选项
77
+
78
+ | 选项 | 作用 |
79
+ |---|---|
80
+ | `--png <路径>` | 额外写出二维码图片 |
81
+ | `--qr-only` | 只生成二维码,稍后用 `--resume` 继续 |
82
+ | `--resume` | 用上次的二维码继续轮询 |
83
+ | `--timeout N` | 轮询超时秒数(默认 180) |
84
+ | `--force` | 已登录时强制重新登录 |
85
+
86
+ ## 给 agent 用
87
+
88
+ ```sh
89
+ npx skills add dongliang/bili-transcript
90
+ ```
91
+
92
+ 装了之后 Claude Code、Codex 这类 agent 会在你提到"这个视频讲了什么"时自动调用它。
93
+
94
+ agent 内部请用 `--json` 拿结构化数据:
95
+
96
+ ```json
97
+ {
98
+ "ok": true,
99
+ "bvid": "BV14Z4R6KEHS",
100
+ "title": "128G 超大内存,密密麻麻全是颗粒",
101
+ "duration": 175,
102
+ "uploader": "韩宇修机",
103
+ "lines": [{ "from": 0, "to": 1.96, "text": "128G大内存" }],
104
+ "summary": { "text": "…", "outline": [] }
105
+ }
106
+ ```
107
+
108
+ ## 已知限制
109
+
110
+ - **字幕是 AI 自动生成的,有错别字。** 实测中 `CAMM` 被写成 "cam"、`信号线阻值` 被写成 "信号线组织"。**专有名词请核实物件丝印或官方资料**,别直接引用。
111
+ - 没有字幕轨的视频(纯音乐、MV、CG 常见)拿不到文字,会明确告诉你而不是编造。
112
+ - 只支持 B 站。其他平台是另一件事。
113
+ - 登录状态会过期(通常几个月)。失效时 `doctor` 会告诉你,重新 `bili login --force` 即可。
114
+
115
+ ## 设计说明
116
+
117
+ **零依赖是刻意的。** 核心只用 Node 内置的 `fetch`、`crypto`、`fs`。唯一的难点是二维码——没有引 `qrcode` 这类库,而是自己实现了一个(GF(256) 纠错、Reed-Solomon、掩码择优、连 PNG 都是手写的)。代价是 13 KB 代码,换来的是整个包不到 100 KB,且没有供应链风险。
118
+
119
+ **只用 `x/player/wbi/v2` 拿字幕,不回退到旧接口。** 旧接口 `x/player/v2` 会返回**别的视频**的字幕——对一条无字幕的 MV 连测 4 次,拿到 4 段完全无关的内容。这种错用户看不出来,只会觉得字幕莫名其妙,所以宁可报"没有字幕"也不给错数据。
120
+
121
+ **内置数据自洽校验。** 拿到字幕后会检查末条时间戳贴不贴合视频时长。不贴合就告警——这是防"拿到错误数据"的最后一道闸。
122
+
123
+ ## 开发
124
+
125
+ ```sh
126
+ git clone https://github.com/dongliang/bili-transcript
127
+ cd bili-transcript
128
+ npm link # 让全局 bili 指向本地代码
129
+ sh tests/fulltest.sh
130
+ ```
131
+
132
+ 测试脚本在 `tests/`。改动二维码编码器(`src/qr.mjs`)后必须跑二维码的两个专项测试——**二维码错了不会报错,只会扫不出来**。
133
+
134
+ ## License
135
+
136
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: bilibili-transcript
3
+ description: 提取 B 站视频的文字稿/字幕/文案,或拿 B 站官方的 AI 视频总结;也用于帮用户登录 B 站账号。当用户给了一个 bilibili.com 链接、BV 号、av 号(包括 b23.tv 短链),并说"把字幕扒下来""视频讲了什么""转成文字""要文字稿/文案""总结这个视频""提取台词"时使用。也适用于用户说"帮我看这个视频的内容"而没有时间自己看的情况。
4
+ ---
5
+
6
+ # B 站视频文字稿
7
+
8
+ 用 `bili` 命令。已装(`npm i -g bili-transcript` 或 `npx bili-transcript`)。
9
+
10
+ ## 提取文字稿
11
+
12
+ ```sh
13
+ bili <BV号|av号|URL> --plain # 最常用:连续文字,无时间戳
14
+ bili <目标> -o out.txt # 长视频务必写文件
15
+ bili <目标> --json # 给程序解析
16
+ ```
17
+
18
+ | 选项 | 作用 |
19
+ |---|---|
20
+ | `--plain` | 只出连续文字,不带时间戳。**给 AI 读、给人读都用这个** |
21
+ | `--json` | 结构化输出(含 `lines[]` / `subtitles[]` / `summary`) |
22
+ | `-p N` | 取第 N 个分P(合集视频必用) |
23
+ | `--list` | 只列出字幕轨 |
24
+ | `--summary` | 附带 B 站 AI 总结(带章节时间点) |
25
+ | `-o FILE` | 写入文件 |
26
+
27
+ **b23.tv 短链先解析**:
28
+
29
+ ```sh
30
+ curl -s -o /dev/null -w '%{url_effective}' -L "https://b23.tv/XXXXXX"
31
+ ```
32
+
33
+ ## 凭据
34
+
35
+ 字幕接口强制登录。凭据查找顺序(自动,不用问用户要):
36
+
37
+ 1. 环境变量 `BILI_SESSDATA`
38
+ 2. `~/.config/bili/auth.json`(`bili login` 写的位置)
39
+ 3. `/var/minis/offloads/env_cookies_bilibili*.sh`(Minis 里浏览器导出的 cookie)
40
+
41
+ **任何时候不要打印 SESSDATA 的值**,只说"有没有"。
42
+
43
+ ## 登录(扫码)
44
+
45
+ ```sh
46
+ bili login # 终端画出二维码(SSH / CI 也能用)
47
+ bili login --png /tmp/qr.png # 额外写图片,方便手机扫
48
+ bili login --qr-only # 只生成二维码,存下 key
49
+ bili login --resume # 用存下的 key 继续轮询
50
+ bili login --force # 已登录时强制重登
51
+ ```
52
+
53
+ **推荐两步走**:先 `--qr-only --png <路径>` 生成 → 把图片展示给用户 → 再 `--resume`。用户有充足时间扫,不会因轮询超时白扫。
54
+
55
+ 轮询间隔 2 秒,二维码约 3 分钟过期。成功后整组 5 个 cookie 存到 `~/.config/bili/auth.json`(0600)。
56
+
57
+ ## 出问题了先跑自检
58
+
59
+ ```sh
60
+ bili doctor
61
+ ```
62
+
63
+ 它会分清是**接口变了**、**凭据过期了**、还是**环境有问题**。凭据失效时输出里会出现 `-101`,让用户 `bili login --force` 重登即可。
64
+
65
+ ## 踩过的坑(别重犯)
66
+
67
+ 1. **只用 `x/player/wbi/v2`,绝对不要碰 `x/player/v2`。** 老接口会返回**别的视频**的字幕——实测对一条无字幕的 MV 连测 4 次,得到 4 段完全无关的内容。工具已改成拿不到就明说,**不要静默给出错误数据**。
68
+ 2. **wbi 签名**:`w_rid` = md5(排序后 query + mixin)。混淆表要完整。
69
+ 3. **`getSetCookie()` 在部分 Node 构建上对 fetch 响应不可用**,`get('set-cookie')` 可能返回**数组**。两种都兼容。
70
+ 4. `x/web-interface/search/type` 未登录会 HTTP 412(风控)。
71
+ 5. 字幕 CDN 链接的 `auth_key` 有时效(几小时),别存下来当永久地址。
72
+ 6. `--list` 输出每次都会变(auth_key 是时间签名),别拿它做逐字节比对。
73
+
74
+ ## 输出给用户时
75
+
76
+ - 长视频文字稿**不要打印到对话里**,写文件 + 给 `[name](minis://workspace/...)` 链接。
77
+ - 字幕是 **AI 自动生成**的,错别字和发音误识别很常见。实测例:`CAMM` 全篇写成 "cam"、`信号线阻值` 写成 "信号线组织"、`焊球` 写成 "气球"。
78
+ - **遇到专有名词,别信字幕,去核实物件上的丝印或官方资料。** 硬件类视频尤其如此——封面标签往往比口播准确。
79
+ - 没有字幕轨(纯音乐/MV/CG 常见)就直说没有,别硬编。
80
+
81
+ ## 不接受的情况
82
+
83
+ - 用户明确说"不要登录""别用我账号"→ 只剩"下音频 + 外部 ASR"。如实说明,不要假装能本地跑。
84
+ - **不要打包浏览器、不要去读系统浏览器的 cookie 库。** 扫码方案已验证可行且更轻。
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "bili-transcript",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "提取 B 站视频的文字稿、字幕和 AI 总结。零依赖,扫码登录,纯命令行。",
5
+ "type": "module",
6
+ "bin": {
7
+ "bili-transcript": "src/cli.mjs",
8
+ "bili": "src/cli.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "SKILL.md",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "bilibili",
20
+ "b站",
21
+ "字幕",
22
+ "subtitle",
23
+ "transcript",
24
+ "cli",
25
+ "agent-skill",
26
+ "claude-code",
27
+ "codex"
28
+ ],
29
+ "license": "MIT",
30
+ "author": "dongliang",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/dongliang/bili-transcript.git"
34
+ },
35
+ "homepage": "https://dongliang.github.io/bili-transcript/",
36
+ "bugs": {
37
+ "url": "https://github.com/dongliang/bili-transcript/issues"
38
+ },
39
+ "scripts": {
40
+ "test": "node tests/ci-check.mjs",
41
+ "doctor": "node src/cli.mjs doctor"
42
+ },
43
+ "dependencies": {},
44
+ "optionalDependencies": {}
45
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // B 站接口封装 —— wbi 签名、请求、错误处理
2
+ import { createHash } from 'node:crypto';
3
+ import { findSessdata } from './auth.mjs';
4
+
5
+ export const API = 'https://api.bilibili.com';
6
+ export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
7
+
8
+ const MIXIN_TAB = [
9
+ 46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,
10
+ 29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,
11
+ 22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52,
12
+ ];
13
+
14
+ export function headers() {
15
+ const h = { 'User-Agent': UA, Referer: 'https://www.bilibili.com/' };
16
+ const { value } = findSessdata();
17
+ if (value) h.Cookie = 'SESSDATA=' + value;
18
+ return h;
19
+ }
20
+
21
+ export async function getJson(url, extraHeaders) {
22
+ const r = await fetch(url, { headers: { ...headers(), ...(extraHeaders || {}) } });
23
+ return r.json();
24
+ }
25
+
26
+ /** wbi 签名:w_rid = md5(排序后 query + mixin) */
27
+ export async function wbiQuery(params) {
28
+ const nav = (await getJson(API + '/x/web-interface/nav')).data.wbi_img;
29
+ const orig = nav.img_url.split('/').pop().split('.')[0] + nav.sub_url.split('/').pop().split('.')[0];
30
+ const mixin = MIXIN_TAB.map((i) => orig[i]).join('').slice(0, 32);
31
+ const p = { ...params, wts: Math.floor(Date.now() / 1000) };
32
+ const enc = (o) => new URLSearchParams(Object.entries(o).sort((a, b) => (a[0] < b[0] ? -1 : 1))).toString();
33
+ const q = enc(p).replace(/[!'()*]/g, '');
34
+ p.w_rid = createHash('md5').update(q + mixin).digest('hex');
35
+ return enc(p);
36
+ }
37
+
38
+ export class BiliError extends Error {
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.code = code;
42
+ this.name = 'BiliError';
43
+ }
44
+ }
45
+
46
+ export function check(r, what) {
47
+ if (r.code !== 0) {
48
+ if (r.code === -101) {
49
+ throw new BiliError(-101, `${what}:账号未登录。请先运行 \`bili login\` 扫码登录。`);
50
+ }
51
+ throw new BiliError(r.code, `${what} 失败:code=${r.code} ${r.message || ''}`);
52
+ }
53
+ return r.data;
54
+ }
55
+
56
+ export function parseBvid(s) {
57
+ let m = String(s).match(/BV[0-9A-Za-z]{10}/);
58
+ if (m) return m[0];
59
+ m = String(s).match(/av(\d+)/i);
60
+ if (m) return 'av' + m[1];
61
+ throw new BiliError('bad-input', `认不出视频号:${s}`);
62
+ }
63
+
64
+ /** 时间戳格式化:<1 分钟用 mm:ss.mmm,超过 1 小时用 hh:mm:ss.mmm */
65
+ export function ts(sec) {
66
+ sec = Number(sec || 0);
67
+ const h = Math.floor(sec / 3600);
68
+ const m = Math.floor((sec % 3600) / 60);
69
+ const s = sec % 60;
70
+ const p = (n, w) => String(n).padStart(w, '0');
71
+ const ms = p(Math.round((s % 1) * 1000), 3);
72
+ return h ? `${p(h, 2)}:${p(m, 2)}:${p(Math.floor(s), 2)}.${ms}`
73
+ : `${p(m, 2)}:${p(Math.floor(s), 2)}.${ms}`;
74
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // 凭据管理 —— 查找、读取、保存、校验
2
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+
6
+ export const AUTH_PATH = join(homedir(), '.config', 'bili', 'auth.json');
7
+ export const COOKIE_DIR = '/var/minis/offloads';
8
+
9
+ /** 按 环境变量 → auth.json → 浏览器导出文件 的顺序找 SESSDATA */
10
+ export function findSessdata() {
11
+ const env = (process.env.BILI_SESSDATA || '').trim();
12
+ if (env) return { value: env, source: 'env BILI_SESSDATA' };
13
+
14
+ if (existsSync(AUTH_PATH)) {
15
+ try {
16
+ const c = JSON.parse(readFileSync(AUTH_PATH, 'utf8'));
17
+ if (c.SESSDATA) return { value: c.SESSDATA, source: AUTH_PATH };
18
+ } catch { /* 坏了就当没有 */ }
19
+ }
20
+
21
+ try {
22
+ const files = readdirSync(COOKIE_DIR)
23
+ .filter((f) => f.startsWith('env_cookies_bilibili'))
24
+ .map((f) => join(COOKIE_DIR, f))
25
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
26
+ if (files.length) {
27
+ const m = readFileSync(files[0], 'utf8').match(/^(?:export\s+)?COOKIE_SESSDATA=(.+)$/m);
28
+ if (m) return { value: m[1].trim().replace(/^["']|["']$/g, ''), source: files[0] };
29
+ }
30
+ } catch { /* 目录不存在就算了 */ }
31
+
32
+ return { value: null, source: null };
33
+ }
34
+
35
+ export function loadAuth() {
36
+ if (!existsSync(AUTH_PATH)) return null;
37
+ try { return JSON.parse(readFileSync(AUTH_PATH, 'utf8')); } catch { return null; }
38
+ }
39
+
40
+ export function saveAuth(jar) {
41
+ mkdirSync(dirname(AUTH_PATH), { recursive: true });
42
+ writeFileSync(AUTH_PATH, JSON.stringify(jar, null, 2) + '\n', 'utf8');
43
+ try { chmodSync(AUTH_PATH, 0o600); } catch { /* 有的文件系统不支持,忽略 */ }
44
+ return AUTH_PATH;
45
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // bili —— B 站视频文字稿提取工具
3
+ //
4
+ // bili <BV号|av号|URL> [选项] 提取文字稿(最常用,可省略 fetch)
5
+ // bili fetch <目标> [选项] 同上
6
+ // bili login [选项] 扫码登录
7
+ // bili doctor 自检
8
+ // bili install-skill [选项] 把 skill 装到本地 agent
9
+ //
10
+ import { writeFileSync, readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { fetchVideo, renderText } from './fetch.mjs';
14
+ import { login } from './login.mjs';
15
+ import { BiliError } from './api.mjs';
16
+
17
+ // 版本号从 package.json 读,避免写死后漂移
18
+ const VERSION = (() => {
19
+ try {
20
+ const p = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
21
+ return JSON.parse(readFileSync(p, 'utf8')).version;
22
+ } catch { return 'unknown'; }
23
+ })();
24
+
25
+ const HELP = `
26
+ bili-transcript —— B 站视频文字稿提取
27
+ (命令名 bili-transcript,简写 bili,两者等价)
28
+
29
+ 用法
30
+ bili <BV号|av号|URL> [选项] 提取文字稿
31
+ bili fetch <目标> [选项] 同上(显式写法)
32
+ bili login [选项] 扫码登录
33
+ bili doctor 自检:环境 / 凭据 / 接口
34
+ bili install-skill [选项] 安装 agent skill
35
+ bili --help 看这份帮助
36
+ bili --version 看版本
37
+
38
+ 提取选项
39
+ --plain 只出连续文字,不带时间戳(喂给 AI 用这个)
40
+ --json 输出 JSON(给程序/agent 解析)
41
+ -p, --page N 取第 N 个分P
42
+ --list 只列出字幕轨,不下载正文
43
+ --summary 附带 B 站 AI 总结
44
+ -o, --out FILE 写入文件
45
+
46
+ 登录选项
47
+ --png <路径> 额外写出二维码图片
48
+ --qr-only 只生成二维码,稍后用 --resume 继续
49
+ --resume 用上次的二维码继续轮询
50
+ --timeout N 轮询超时秒数(默认 180)
51
+ --force 已登录时强制重新登录
52
+
53
+ 示例
54
+ bili BV1xx411c7mD --plain
55
+ bili "https://b23.tv/xxxxx" --summary -o out.txt
56
+ bili login --png /tmp/qr.png
57
+ bili login --qr-only && bili login --resume
58
+ `.trim();
59
+
60
+ function parseArgs(argv) {
61
+ const o = { _: [], plain: false, json: false, list: false, summary: false, out: null, page: 1 };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === '--plain') o.plain = true;
65
+ else if (a === '--json') o.json = true;
66
+ else if (a === '--list') o.list = true;
67
+ else if (a === '--summary') o.summary = true;
68
+ else if (a === '--qr-only') o.qrOnly = true;
69
+ else if (a === '--resume') o.resume = true;
70
+ else if (a === '--force') o.force = true;
71
+ else if (a === '-p' || a === '--page') o.page = parseInt(argv[++i], 10);
72
+ else if (a === '-o' || a === '--out') o.out = argv[++i];
73
+ else if (a === '--png') o.png = argv[++i];
74
+ else if (a === '--timeout') o.timeout = parseInt(argv[++i], 10);
75
+ else if (a === '-h' || a === '--help') o.help = true;
76
+ else if (a === '-v' || a === '--version') o.version = true;
77
+ else o._.push(a);
78
+ }
79
+ return o;
80
+ }
81
+
82
+ function die(msg, code = 1) {
83
+ console.error(msg);
84
+ process.exit(code);
85
+ }
86
+
87
+ async function main() {
88
+ const argv = process.argv.slice(2);
89
+ if (!argv.length) { console.log(HELP); return; }
90
+
91
+ // 允许 `bili --help` / `bili --json <url>` 这类全局选项打头
92
+ const SUBCOMMANDS = ['fetch', 'login', 'doctor', 'install-skill'];
93
+ let cmd, rest;
94
+ if (SUBCOMMANDS.includes(argv[0])) {
95
+ cmd = argv[0]; rest = argv.slice(1);
96
+ } else {
97
+ cmd = 'fetch'; rest = argv;
98
+ }
99
+ const o = parseArgs(rest);
100
+ if (o.help) { console.log(HELP); return; }
101
+ if (o.version) { console.log(VERSION); return; }
102
+
103
+ // ---- login ----
104
+ if (cmd === 'login') {
105
+ try {
106
+ const r = await login(o);
107
+ if (r.qrOnly && o.json) console.log(JSON.stringify(r, null, 2));
108
+ } catch (e) { die(`登录失败:${e.message}`); }
109
+ return;
110
+ }
111
+
112
+ // ---- doctor ----
113
+ if (cmd === 'doctor') {
114
+ const { runDoctor } = await import('./doctor.mjs');
115
+ const ok = await runDoctor({ json: o.json });
116
+ process.exit(ok ? 0 : 1);
117
+ }
118
+
119
+ // ---- install-skill ----
120
+ if (cmd === 'install-skill') {
121
+ const { installSkill } = await import('./install-skill.mjs');
122
+ const ok = await installSkill(o);
123
+ process.exit(ok ? 0 : 1);
124
+ }
125
+
126
+ // ---- fetch(默认动作)----
127
+ const target = o._[0];
128
+ if (!target) die('请提供视频 BV号 / av号 / URL。用 `bili --help` 看用法。');
129
+
130
+ let v;
131
+ try {
132
+ v = await fetchVideo(target, { page: o.page, summary: o.summary });
133
+ } catch (e) {
134
+ if (e instanceof BiliError && e.code === -101) die(e.message);
135
+ die(e.message || String(e));
136
+ }
137
+
138
+ let text;
139
+ if (o.json) {
140
+ text = JSON.stringify({
141
+ ok: true,
142
+ bvid: v.bvid, title: v.title, part: v.part, page: v.page, pageCount: v.pageCount,
143
+ duration: v.duration, uploader: v.uploader, url: v.url,
144
+ subtitles: v.subtitles,
145
+ lines: v.body,
146
+ warning: v.warning,
147
+ noSubtitleReason: v.noSubtitleReason || null,
148
+ summary: v.summary,
149
+ }, null, 2) + '\n';
150
+ } else {
151
+ text = renderText(v, { plain: o.plain, list: o.list });
152
+ }
153
+
154
+ if (o.out) {
155
+ writeFileSync(o.out, text, 'utf8');
156
+ console.log(`已写入 ${o.out}(${text.length} 字符,${v.body.length} 条字幕)`);
157
+ } else {
158
+ process.stdout.write(text);
159
+ }
160
+ }
161
+
162
+ main().catch((e) => die(e.stack || String(e)));
package/src/doctor.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // 自检 —— 一眼分清「接口变了」还是「该重新登录了」
2
+ import { API, getJson, wbiQuery, headers } from './api.mjs';
3
+ import { findSessdata, AUTH_PATH } from './auth.mjs';
4
+
5
+ const R = { pass: 0, fail: 0, warn: 0 };
6
+ const ok = (m) => { R.pass++; console.log(` ✅ ${m}`); };
7
+ const bad = (m) => { R.fail++; console.log(` ❌ ${m}`); };
8
+ const soft = (m) => { R.warn++; console.log(` ⚠️ ${m}`); };
9
+
10
+ export async function runDoctor({ json = false } = {}) {
11
+ const log = json ? () => {} : console.log;
12
+
13
+ log('\n== 运行时 ==');
14
+ const major = Number(process.versions.node.split('.')[0]);
15
+ major >= 18 ? ok(`Node ${process.versions.node}`) : bad(`Node ${process.versions.node} 太旧,需要 >=18(自带 fetch)`);
16
+ typeof fetch === 'function' ? ok('全局 fetch 可用') : bad('没有全局 fetch');
17
+
18
+ log('\n== 凭据 ==');
19
+ const { value: sd, source } = findSessdata();
20
+ if (sd) ok(`SESSDATA 长度 ${sd.length},来自 ${source}`);
21
+ else bad(`找不到凭据。运行 \`bili login\` 扫码登录(默认位置 ${AUTH_PATH})`);
22
+
23
+ if (sd) {
24
+ log('\n== 凭据可用性 ==');
25
+ const r = await getJson(`${API}/x/web-interface/nav`).catch(() => null);
26
+ if (r && r.code === 0 && r.data.isLogin) ok(`已登录:${r.data.uname}(uid ${r.data.mid})`);
27
+ else if (r && r.code === -101) bad('凭据已失效,运行 `bili login --force` 重新登录');
28
+ else bad(`nav 接口异常:${r ? 'code=' + r.code : '连不上'}`);
29
+ }
30
+
31
+ log('\n== 接口连通 ==');
32
+ const BV = 'BV14Z4R6KEHS'; // 固定样本,便于跨时间对比
33
+ const view = await getJson(`${API}/x/web-interface/view?bvid=${BV}`).catch(() => null);
34
+ if (view && view.code === 0) ok(`view 接口正常(${String(view.data.title).slice(0, 20)}…)`);
35
+ else { bad(`view 接口异常:${view ? 'code=' + view.code : '连不上'}`); return finish(json); }
36
+
37
+ const { cid, aid, duration } = view.data;
38
+ log('\n== 字幕接口 ==');
39
+ let pv;
40
+ try {
41
+ const qs = await wbiQuery({ bvid: BV, cid, aid });
42
+ pv = await getJson(`${API}/x/player/wbi/v2?${qs}`);
43
+ } catch (e) { bad(`字幕接口请求失败:${e.message}`); return finish(json); }
44
+
45
+ if (pv.code !== 0) {
46
+ bad(`wbi/v2 异常 code=${pv.code} ${pv.message || ''}`);
47
+ } else {
48
+ const subs = pv.data.subtitle?.subtitles || [];
49
+ subs.length ? ok(`wbi/v2 正常,${subs.length} 条字幕轨`)
50
+ : bad(`wbi/v2 返回 0 条字幕轨(need_login=${pv.data.need_login_subtitle})`);
51
+ if (subs.length) {
52
+ let u = subs[0].subtitle_url;
53
+ if (u.startsWith('//')) u = 'https:' + u;
54
+ const body = (await getJson(u).catch(() => null))?.body || [];
55
+ body.length ? ok(`字幕正文可取(${body.length} 条)`) : bad('字幕正文取不到');
56
+ if (body.length) {
57
+ const end = Number(body[body.length - 1].to || 0);
58
+ // 这一项是防「拿到别的视频字幕」的关键校验
59
+ end >= duration * 0.7
60
+ ? ok(`末条 ${end.toFixed(1)}s 贴合视频时长 ${duration}s(数据自洽)`)
61
+ : bad(`末条只到 ${end.toFixed(1)}s,视频有 ${duration}s —— 可能拿到错误数据!`);
62
+ }
63
+ }
64
+ }
65
+
66
+ log('\n== AI 总结接口 ==');
67
+ if (sd) {
68
+ try {
69
+ const qs = await wbiQuery({ bvid: BV, cid, up_mid: String(view.data.owner.mid) });
70
+ const c = await getJson(`${API}/x/web-interface/view/conclusion/get?${qs}`);
71
+ c.code === 0 && c.data ? ok('AI 总结可用') : soft(`AI 总结不可用 code=${c.code}`);
72
+ } catch (e) { soft(`AI 总结请求失败:${e.message}`); }
73
+ } else soft('无凭据,跳过');
74
+
75
+ return finish(json);
76
+ }
77
+
78
+ function finish(json) {
79
+ const line = `通过 ${R.pass} / ${R.pass + R.fail}${R.warn ? `(${R.warn} 条提示)` : ''}`;
80
+ if (json) console.log(JSON.stringify({ ok: R.fail === 0, pass: R.pass, fail: R.fail, warn: R.warn }, null, 2));
81
+ else console.log(`\n== 结果:${line} ==\n`);
82
+ return R.fail === 0;
83
+ }