bili-transcript 1.0.2 → 1.1.0
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/SKILL.md +51 -22
- package/package.json +1 -1
- package/src/api.mjs +3 -3
- package/src/auth.mjs +36 -6
- package/src/browser.mjs +471 -0
- package/src/cli.mjs +133 -6
- package/src/doctor.mjs +14 -0
- package/src/fetch.mjs +1 -1
- package/src/install-skill.mjs +1 -1
- package/src/login.mjs +17 -7
- package/src/qr.mjs +4 -2
package/SKILL.md
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bilibili-transcript
|
|
3
3
|
description: 提取 B 站视频的文字稿/字幕/文案,或拿 B 站官方的 AI 视频总结;也用于帮用户登录 B 站账号。当用户给了一个 bilibili.com 链接、BV 号、av 号(包括 b23.tv 短链),并说"把字幕扒下来""视频讲了什么""转成文字""要文字稿/文案""总结这个视频""提取台词"时使用。也适用于用户说"帮我看这个视频的内容"而没有时间自己看的情况。
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires Node.js 18+ and network access to bilibili.com. Bundled scripts are zero-dependency (Node built-ins only); no npm install needed.
|
|
6
|
+
metadata:
|
|
7
|
+
version: "1.0.2"
|
|
8
|
+
author: dongliang
|
|
4
9
|
---
|
|
5
10
|
|
|
6
11
|
# B 站视频文字稿
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
**命令一律用 `node src/cli.mjs`(路径相对本技能目录)。**
|
|
14
|
+
脚本随技能一起来,零 npm 依赖,只用 Node 内置能力。若 `src/` 不在,退回 `npx -y bili-transcript`。
|
|
9
15
|
|
|
10
16
|
## 提取文字稿
|
|
11
17
|
|
|
12
18
|
```sh
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
node src/cli.mjs <BV号|av号|URL> --plain # 最常用:连续文字,无时间戳
|
|
20
|
+
node src/cli.mjs <目标> -o out.txt # 长视频务必写文件
|
|
21
|
+
node src/cli.mjs <目标> --json # 给程序解析
|
|
16
22
|
```
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
**b23.tv 短链先解析**:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
curl -s -o /dev/null -w '%{url_effective}' -L "https://b23.tv/XXXXXX"
|
|
28
|
+
```
|
|
19
29
|
|
|
20
30
|
| 选项 | 作用 |
|
|
21
31
|
|---|---|
|
|
@@ -26,32 +36,36 @@ bili <目标> --json # 给程序解析
|
|
|
26
36
|
| `--summary` | 附带 B 站 AI 总结(带章节时间点) |
|
|
27
37
|
| `-o FILE` | 写入文件 |
|
|
28
38
|
|
|
29
|
-
**b23.tv 短链先解析**:
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
curl -s -o /dev/null -w '%{url_effective}' -L "https://b23.tv/XXXXXX"
|
|
33
|
-
```
|
|
34
|
-
|
|
35
39
|
## 凭据
|
|
36
40
|
|
|
37
41
|
字幕接口强制登录。凭据查找顺序(自动,不用问用户要):
|
|
38
42
|
|
|
39
43
|
1. 环境变量 `BILI_SESSDATA`
|
|
40
|
-
2. `~/.config/bili/auth.json`(`
|
|
41
|
-
3.
|
|
44
|
+
2. `~/.config/bili/auth.json`(`login` 写的位置)
|
|
45
|
+
3. **本机浏览器的 cookie 库**(只读,每次现取)
|
|
46
|
+
4. `/var/minis/offloads/env_cookies_bilibili*.sh`(Minis 里浏览器导出的 cookie)
|
|
47
|
+
|
|
48
|
+
第 3 级覆盖 Firefox(明文,零解密)和 Chrome / Chromium / Edge / Brave / Vivaldi / Opera(AES-128-CBC + 桌面密钥环)。用户日常开着浏览器,多半这一级就命中了,**不需要扫码**。
|
|
49
|
+
|
|
50
|
+
**先跑 `doctor` 看凭据来自哪**,别急着让用户扫码。只有四级全空才轮到扫码,且**扫码前必须问用户**——`bili` 在 TTY 下会自己问,非 TTY 下会打印指引。
|
|
42
51
|
|
|
43
52
|
**任何时候不要打印 SESSDATA 的值**,只说"有没有"。
|
|
44
53
|
|
|
45
|
-
##
|
|
54
|
+
## 登录
|
|
55
|
+
|
|
56
|
+
浏览器里没有 B 站登录态时:
|
|
46
57
|
|
|
47
58
|
```sh
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
node src/cli.mjs login # 扫码;TTY 下会先问一句
|
|
60
|
+
node src/cli.mjs login --from-browser # 从浏览器导入并固化到 auth.json,不扫码
|
|
61
|
+
node src/cli.mjs login --png /tmp/qr.png # 额外写图片,方便手机扫
|
|
62
|
+
node src/cli.mjs login --qr-only # 只生成二维码,存下 key
|
|
63
|
+
node src/cli.mjs login --resume # 用存下的 key 继续轮询
|
|
64
|
+
node src/cli.mjs login --force # 已登录时强制重登
|
|
53
65
|
```
|
|
54
66
|
|
|
67
|
+
`--from-browser` 是把浏览器凭据**固化**下来的场合用的(比如之后要清浏览器数据)。平时不用——查找链第 3 级会自动现取,写进去反而会让「浏览器登出了但 auth.json 还留着」这种状态变得难排查。
|
|
68
|
+
|
|
55
69
|
**推荐两步走**:先 `--qr-only --png <路径>` 生成 → 把图片展示给用户 → 再 `--resume`。用户有充足时间扫,不会因轮询超时白扫。
|
|
56
70
|
|
|
57
71
|
轮询间隔 2 秒,二维码约 3 分钟过期。成功后整组 5 个 cookie 存到 `~/.config/bili/auth.json`(0600)。
|
|
@@ -59,10 +73,12 @@ bili login --force # 已登录时强制重登
|
|
|
59
73
|
## 出问题了先跑自检
|
|
60
74
|
|
|
61
75
|
```sh
|
|
62
|
-
|
|
76
|
+
node src/cli.mjs doctor
|
|
63
77
|
```
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
它会分清是**接口变了**、**凭据过期了**、还是**环境有问题**。输出里的「浏览器」段会说明本机找到了哪些浏览器、凭据取自哪个。
|
|
80
|
+
|
|
81
|
+
读浏览器 cookie 库需要 Node ≥ 22.5(内置 `node:sqlite`)或系统装有 `sqlite3` 命令。都不满足时这一级会自动跳过,其余功能不受影响。
|
|
66
82
|
|
|
67
83
|
## 踩过的坑(别重犯)
|
|
68
84
|
|
|
@@ -72,6 +88,18 @@ bili doctor
|
|
|
72
88
|
4. `x/web-interface/search/type` 未登录会 HTTP 412(风控)。
|
|
73
89
|
5. 字幕 CDN 链接的 `auth_key` 有时效(几小时),别存下来当永久地址。
|
|
74
90
|
6. `--list` 输出每次都会变(auth_key 是时间签名),别拿它做逐字节比对。
|
|
91
|
+
7. **Chromium 系 cookie 解密的三个反直觉点**(`src/browser.mjs` 已处理,改代码时别踩回去):
|
|
92
|
+
- 密文前缀是 `v11`——这标记以前专指 Windows App-Bound,但 Linux 上仍是标准方案,别以为要特殊处理
|
|
93
|
+
- 明文**前 32 字节是 `SHA256(host_key)`**,不是 cookie 值的一部分。不去掉长度会多 32 字节
|
|
94
|
+
- 填充是 **PKCS7**。不去掉末尾会多几个字节,nav 接口会报「连不上」,很难查
|
|
95
|
+
8. **读 cookie 库用 `file:...?mode=ro`,不要用 `?immutable=1`**。`immutable=1` 声称「文件不会变」于是跳过 WAL,**静默**读到过期 cookie(刚登录完拿到旧值,最难查)。
|
|
96
|
+
- **WAL 模式的库必须先复制再读**。SQLite 打开 WAL 库时要在同目录建 `-shm`(32KB) 和 `-wal`,**只读模式也照建**(官方文档承认只读 WAL 库需可写目录)。浏览器运行时这俩文件本就在,直读看不出问题;浏览器干净退出后它们被删了,我们再直读就会凭空留下两个文件——等于偷偷写了用户的浏览器目录。代码里靠读文件头第 18 字节(2 = WAL)判断,WAL 一律复制到 `mkdtemp` 再读,约 45ms。
|
|
97
|
+
- Chrome 是传统回滚日志(不是 WAL),直读无副作用,走快路径。
|
|
98
|
+
9. **域名过滤不能只靠 SQL 的 `like '%bilibili%'`**,它会放过 `bilibili.com.evil.com`。取到值之后还要按标签边界判一遍,只认 `bilibili.com` / `bilibili.cn` 及其子域。
|
|
99
|
+
10. **Firefox 的 profile 在 `~/.config/mozilla/`,不是 `~/.mozilla/`**(XDG 布局)。且目录名随机,要解析 `profiles.ini` 的 `Default=`。
|
|
100
|
+
11. **字幕接口缺凭据时不抛错**,而是正常返回并带 `need_login_subtitle`。判断「要不要扫码」得看 `noSubtitleReason === 'need-login'`,不是 catch 异常。
|
|
101
|
+
12. **`whoami()` 必须走 `getJson()`**(带凭据)才能问出登录态。手拼 headers 不带 cookie 的话永远返回 null——这个 bug 曾让「已登录」守卫失效。
|
|
102
|
+
13. **临时凭据别写 `/tmp`**。二维码的 `qrcode_key` 能换到登录 cookie,谁先轮询谁拿到账号;二维码图片扫一下就能登录。`/tmp` 全局可读、默认权限 644,写在那儿等于晾给别人看。现放在 `~/.config/bili/` 下并设 0600,登录成功后删掉。
|
|
75
103
|
|
|
76
104
|
## 输出给用户时
|
|
77
105
|
|
|
@@ -83,4 +111,5 @@ bili doctor
|
|
|
83
111
|
## 不接受的情况
|
|
84
112
|
|
|
85
113
|
- 用户明确说"不要登录""别用我账号"→ 只剩"下音频 + 外部 ASR"。如实说明,不要假装能本地跑。
|
|
86
|
-
-
|
|
114
|
+
- 用户明确说"别读我的浏览器"→ 尊重,只走环境变量和 auth.json。
|
|
115
|
+
- **不要打包浏览器。** 读 cookie 库是只读的:只认 bilibili 域名下的 5 个白名单字段,不落盘、不打印值、绝不写浏览器文件。
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// B 站接口封装 —— wbi 签名、请求、错误处理
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { findCredentials, cookieHeader } from './auth.mjs';
|
|
4
4
|
|
|
5
5
|
export const API = 'https://api.bilibili.com';
|
|
6
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';
|
|
@@ -13,8 +13,8 @@ const MIXIN_TAB = [
|
|
|
13
13
|
|
|
14
14
|
export function headers() {
|
|
15
15
|
const h = { 'User-Agent': UA, Referer: 'https://www.bilibili.com/' };
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
16
|
+
const cookie = cookieHeader(findCredentials().jar);
|
|
17
|
+
if (cookie) h.Cookie = cookie;
|
|
18
18
|
return h;
|
|
19
19
|
}
|
|
20
20
|
|
package/src/auth.mjs
CHANGED
|
@@ -2,22 +2,34 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
|
+
import { readBrowserCookies, WANTED } from './browser.mjs';
|
|
5
6
|
|
|
6
7
|
export const AUTH_PATH = join(homedir(), '.config', 'bili', 'auth.json');
|
|
7
8
|
export const COOKIE_DIR = '/var/minis/offloads';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
export { WANTED };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 按 环境变量 → auth.json → 本机浏览器 → Minis 导出文件 的顺序凑齐一组 cookie。
|
|
14
|
+
*
|
|
15
|
+
* 浏览器那级是只读、每次现取的,不写进 auth.json ——
|
|
16
|
+
* auth.json 的语义是「用户显式登录的结果」,两者混起来会让人分不清
|
|
17
|
+
* 到底是浏览器登出了还是缓存过期了。
|
|
18
|
+
*/
|
|
19
|
+
export function findCredentials() {
|
|
11
20
|
const env = (process.env.BILI_SESSDATA || '').trim();
|
|
12
|
-
if (env) return {
|
|
21
|
+
if (env) return { jar: { SESSDATA: env }, source: 'env BILI_SESSDATA' };
|
|
13
22
|
|
|
14
23
|
if (existsSync(AUTH_PATH)) {
|
|
15
24
|
try {
|
|
16
25
|
const c = JSON.parse(readFileSync(AUTH_PATH, 'utf8'));
|
|
17
|
-
if (c.SESSDATA) return {
|
|
26
|
+
if (c.SESSDATA) return { jar: c, source: AUTH_PATH };
|
|
18
27
|
} catch { /* 坏了就当没有 */ }
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
const fromBrowser = readBrowserCookies();
|
|
31
|
+
if (fromBrowser) return { jar: fromBrowser.jar, source: `浏览器 ${fromBrowser.source}` };
|
|
32
|
+
|
|
21
33
|
try {
|
|
22
34
|
const files = readdirSync(COOKIE_DIR)
|
|
23
35
|
.filter((f) => f.startsWith('env_cookies_bilibili'))
|
|
@@ -25,11 +37,29 @@ export function findSessdata() {
|
|
|
25
37
|
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
26
38
|
if (files.length) {
|
|
27
39
|
const m = readFileSync(files[0], 'utf8').match(/^(?:export\s+)?COOKIE_SESSDATA=(.+)$/m);
|
|
28
|
-
if (m)
|
|
40
|
+
if (m) {
|
|
41
|
+
return {
|
|
42
|
+
jar: { SESSDATA: m[1].trim().replace(/^["']|["']$/g, '') },
|
|
43
|
+
source: files[0],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
29
46
|
}
|
|
30
47
|
} catch { /* 目录不存在就算了 */ }
|
|
31
48
|
|
|
32
|
-
return {
|
|
49
|
+
return { jar: null, source: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 只要 SESSDATA 时的薄封装(doctor、错误提示等处用) */
|
|
53
|
+
export function findSessdata() {
|
|
54
|
+
const { jar, source } = findCredentials();
|
|
55
|
+
return { value: jar?.SESSDATA || null, source: jar ? source : null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 拼成 Cookie 头。只发白名单里那几个,不把浏览器里 B 站的其他 cookie 全转发出去 */
|
|
59
|
+
export function cookieHeader(jar) {
|
|
60
|
+
if (!jar) return null;
|
|
61
|
+
const parts = WANTED.filter((n) => jar[n]).map((n) => `${n}=${jar[n]}`);
|
|
62
|
+
return parts.length ? parts.join('; ') : null;
|
|
33
63
|
}
|
|
34
64
|
|
|
35
65
|
export function loadAuth() {
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// 从本机浏览器的 cookie 库读 B 站登录态
|
|
2
|
+
//
|
|
3
|
+
// 纯只读:不写任何浏览器文件,也不落盘任何 cookie。
|
|
4
|
+
// 只认 bilibili 域名下的 5 个白名单字段,其余一律不碰。
|
|
5
|
+
//
|
|
6
|
+
// 两条路:
|
|
7
|
+
// Firefox —— 明文存在 moz_cookies.value,一条 SQL 搞定
|
|
8
|
+
// Chromium —— AES-128-CBC,密钥来自桌面密钥环
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, rmSync } from 'node:fs';
|
|
11
|
+
import { homedir, tmpdir } from 'node:os';
|
|
12
|
+
import { basename, dirname, join } from 'node:path';
|
|
13
|
+
import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const HOME = homedir();
|
|
17
|
+
const XDG_CONFIG = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
|
|
18
|
+
|
|
19
|
+
// Node ≥ 22.5 内置;老 Node 上拿不到,届时退到 sqlite3 CLI。
|
|
20
|
+
// 必须在这里同步取 —— openAndQuery() 是模块顶层函数,看不到别的函数里的局部变量。
|
|
21
|
+
let DatabaseSync = null;
|
|
22
|
+
try { ({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite')); } catch { /* 老 Node */ }
|
|
23
|
+
|
|
24
|
+
/** 只取这几个,其余 B 站 cookie 一概不读 */
|
|
25
|
+
export const WANTED = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5', 'sid'];
|
|
26
|
+
|
|
27
|
+
/** Chromium 系的 profile 目录 → 密钥环里的 product name */
|
|
28
|
+
const CHROMIUM_BROWSERS = [
|
|
29
|
+
{ name: 'Chrome', dir: 'google-chrome', product: 'chrome' },
|
|
30
|
+
{ name: 'Chromium', dir: 'chromium', product: 'chromium' },
|
|
31
|
+
{ name: 'Edge', dir: 'microsoft-edge', product: 'microsoft-edge' },
|
|
32
|
+
{ name: 'Brave', dir: 'BraveSoftware/Brave-Browser', product: 'brave' },
|
|
33
|
+
{ name: 'Vivaldi', dir: 'vivaldi', product: 'Vivaldi' },
|
|
34
|
+
{ name: 'Opera', dir: 'opera', product: 'opera' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------- SQLite
|
|
38
|
+
|
|
39
|
+
let _backend; // undefined=未探测, null=不可用, 否则为读取函数
|
|
40
|
+
let _backendName; // 'node:sqlite' | 'sqlite3'
|
|
41
|
+
let _backendWhy = '';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 判断库是不是 WAL 模式 —— 读文件头第 18 字节(1=传统回滚日志,2=WAL)。
|
|
45
|
+
*
|
|
46
|
+
* 为什么非要先问这个:SQLite 打开 WAL 库时必须在同目录建 `-shm`(32KB)和
|
|
47
|
+
* `-wal`,**只读模式也照建不误**,这是它的设计(官方文档明说只读 WAL 库需要
|
|
48
|
+
* 可写目录)。浏览器运行时这两个文件本来就在,直读没影响;但浏览器干净退出后
|
|
49
|
+
* 它们会被删掉,我们再直读就会凭空留下两个文件——等于偷偷写了用户的浏览器目录。
|
|
50
|
+
* 实测确认过,不是理论风险。
|
|
51
|
+
*/
|
|
52
|
+
function isWal(dbPath) {
|
|
53
|
+
let fd;
|
|
54
|
+
try {
|
|
55
|
+
fd = openSync(dbPath, 'r');
|
|
56
|
+
const buf = Buffer.alloc(1);
|
|
57
|
+
return readSync(fd, buf, 0, 1, 18) === 1 && buf[0] === 2;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
} finally {
|
|
61
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 打开一个浏览器 cookie 库并跑一条查询。
|
|
67
|
+
*
|
|
68
|
+
* 分两级,因为两个目标会打架:
|
|
69
|
+
* 1) 直接 `mode=ro` —— 快,且会带上 WAL 里的最新数据
|
|
70
|
+
* 2) 库被浏览器锁住时(报 database is locked),退而复制 db + -wal + -shm
|
|
71
|
+
* 三件套到临时目录再读
|
|
72
|
+
*
|
|
73
|
+
* WAL 库**一律**走第 2 级:直读会在浏览器目录里留下 -shm/-wal,属于副作用。
|
|
74
|
+
* 复制一次约 45ms(Firefox 1.5MB 库),且每进程只读一遍,可以接受。
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ 不要用 `?immutable=1` 抄近路。它声称「文件不会变」于是跳过 WAL,
|
|
77
|
+
* 结果是**静默**读到过期数据 —— 实测用户刚登录完,immutable 拿到的是旧值,
|
|
78
|
+
* 报错还好查,静默拿错值最难查。
|
|
79
|
+
*/
|
|
80
|
+
function openAndQuery(dbPath, sql) {
|
|
81
|
+
const attempt = (uri) => {
|
|
82
|
+
const db = new DatabaseSync(uri, { readOnly: true });
|
|
83
|
+
try { return db.prepare(sql).all(); } finally { db.close(); }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 非 WAL(Chrome 的 rollback journal)直读无副作用,先试这条快路
|
|
87
|
+
if (!isWal(dbPath)) {
|
|
88
|
+
try {
|
|
89
|
+
return attempt(`file:${dbPath}?mode=ro`);
|
|
90
|
+
} catch { /* 多半是被锁了,走复制 */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const tmp = mkdtempSync(join(tmpdir(), 'bili-ck-'));
|
|
94
|
+
try {
|
|
95
|
+
chmodSync(tmp, 0o700);
|
|
96
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
97
|
+
const src = dbPath + suffix;
|
|
98
|
+
if (existsSync(src)) copyFileSync(src, join(tmp, basename(dbPath) + suffix));
|
|
99
|
+
}
|
|
100
|
+
return attempt(join(tmp, basename(dbPath)));
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 同理,sqlite3 CLI 版 */
|
|
107
|
+
function openAndQueryCli(dbPath, sql) {
|
|
108
|
+
const run = (target) => {
|
|
109
|
+
const out = execFileSync('sqlite3', [target, '-json', sql],
|
|
110
|
+
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
111
|
+
const t = out.trim();
|
|
112
|
+
return t ? JSON.parse(t) : [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!isWal(dbPath)) {
|
|
116
|
+
try {
|
|
117
|
+
return run(`file:${dbPath}?mode=ro`);
|
|
118
|
+
} catch { /* 被锁,走复制 */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const tmp = mkdtempSync(join(tmpdir(), 'bili-ck-'));
|
|
122
|
+
try {
|
|
123
|
+
chmodSync(tmp, 0o700);
|
|
124
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
125
|
+
const src = dbPath + suffix;
|
|
126
|
+
if (existsSync(src)) copyFileSync(src, join(tmp, basename(dbPath) + suffix));
|
|
127
|
+
}
|
|
128
|
+
return run(join(tmp, basename(dbPath)));
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sqliteReader() {
|
|
135
|
+
if (_backend !== undefined) return _backend;
|
|
136
|
+
|
|
137
|
+
if (DatabaseSync) {
|
|
138
|
+
_backend = (dbPath, sql) => openAndQuery(dbPath, sql);
|
|
139
|
+
_backendName = 'node:sqlite';
|
|
140
|
+
return _backend;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
execFileSync('sqlite3', ['--version'], { stdio: 'ignore' });
|
|
145
|
+
_backend = (dbPath, sql) => openAndQueryCli(dbPath, sql);
|
|
146
|
+
_backendName = 'sqlite3';
|
|
147
|
+
return _backend;
|
|
148
|
+
} catch { /* 没装 sqlite3 */ }
|
|
149
|
+
|
|
150
|
+
_backend = null;
|
|
151
|
+
_backendName = null;
|
|
152
|
+
_backendWhy = '需要 Node ≥ 22.5(内置 node:sqlite)或系统装有 sqlite3 命令';
|
|
153
|
+
return _backend;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 给 doctor 用:当前走的哪条读库路径 */
|
|
157
|
+
export function sqliteBackendName() {
|
|
158
|
+
sqliteReader();
|
|
159
|
+
return _backendName;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- 浏览器探测
|
|
163
|
+
|
|
164
|
+
function parseIni(text) {
|
|
165
|
+
const secs = {};
|
|
166
|
+
let cur = null;
|
|
167
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
168
|
+
const line = raw.trim();
|
|
169
|
+
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
|
|
170
|
+
const h = line.match(/^\[(.+)\]$/);
|
|
171
|
+
if (h) { cur = h[1]; secs[cur] = {}; continue; }
|
|
172
|
+
const i = line.indexOf('=');
|
|
173
|
+
if (i > 0 && cur) secs[cur][line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
174
|
+
}
|
|
175
|
+
return secs;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Firefox 的 profile 目录名是随机的,得解析 profiles.ini 才知道用哪个 */
|
|
179
|
+
function firefoxCandidates() {
|
|
180
|
+
const ini = join(XDG_CONFIG, 'mozilla', 'firefox', 'profiles.ini');
|
|
181
|
+
if (!existsSync(ini)) return [];
|
|
182
|
+
|
|
183
|
+
let secs;
|
|
184
|
+
try { secs = parseIni(readFileSync(ini, 'utf8')); } catch { return []; }
|
|
185
|
+
const base = dirname(ini);
|
|
186
|
+
|
|
187
|
+
// 统一成绝对路径再收集。[Install*] 段给的是裸目录名,[Profile*] 段给的是
|
|
188
|
+
// 可能相对的 Path —— 两种混在一起处理才不会拼出半截路径。
|
|
189
|
+
const byName = (name) => {
|
|
190
|
+
for (const [k, s] of Object.entries(secs)) {
|
|
191
|
+
if (k.startsWith('Profile') && s.Path && (s.Path === name || s.Name === name)) {
|
|
192
|
+
return s.IsRelative === '0' ? s.Path : join(base, s.Path);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return join(base, name); // [Install*] 的 Default= 就是目录名
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const paths = [];
|
|
199
|
+
// [Install*] 段是 Firefox 实际在用的,优先级最高
|
|
200
|
+
for (const [k, s] of Object.entries(secs)) if (k.startsWith('Install') && s.Default) paths.push(byName(s.Default));
|
|
201
|
+
for (const [k, s] of Object.entries(secs)) if (k.startsWith('Profile') && s.Default === '1' && s.Path) {
|
|
202
|
+
paths.push(s.IsRelative === '0' ? s.Path : join(base, s.Path));
|
|
203
|
+
}
|
|
204
|
+
for (const [k, s] of Object.entries(secs)) if (k.startsWith('Profile') && s.Path) {
|
|
205
|
+
paths.push(s.IsRelative === '0' ? s.Path : join(base, s.Path));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return [...new Set(paths)]
|
|
209
|
+
.map((p) => ({ profile: p.split('/').pop(), dbPath: join(p, 'cookies.sqlite') }))
|
|
210
|
+
.filter((x) => existsSync(x.dbPath));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Chromium 系:读 Local State 里的 last_used 定位实际 profile */
|
|
214
|
+
function chromiumProfileDir(root) {
|
|
215
|
+
let prof = 'Default';
|
|
216
|
+
try {
|
|
217
|
+
const ls = JSON.parse(readFileSync(join(root, 'Local State'), 'utf8'));
|
|
218
|
+
if (ls.profile?.last_used) prof = ls.profile.last_used;
|
|
219
|
+
} catch { /* 读不到就用 Default */ }
|
|
220
|
+
return prof;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 列出本机所有能读的浏览器 cookie 库(不校验有没有凭据) */
|
|
224
|
+
export function detectBrowsers() {
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const c of firefoxCandidates()) {
|
|
227
|
+
out.push({ kind: 'firefox', name: 'Firefox', profile: c.profile, dbPath: c.dbPath });
|
|
228
|
+
}
|
|
229
|
+
for (const b of CHROMIUM_BROWSERS) {
|
|
230
|
+
const root = join(XDG_CONFIG, b.dir);
|
|
231
|
+
if (!existsSync(root)) continue;
|
|
232
|
+
const prof = chromiumProfileDir(root);
|
|
233
|
+
const dbPath = join(root, prof, 'Cookies');
|
|
234
|
+
if (existsSync(dbPath)) {
|
|
235
|
+
out.push({ kind: 'chromium', name: b.name, product: b.product, profile: prof, dbPath });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------- Chromium 解密
|
|
242
|
+
|
|
243
|
+
const SALT = 'saltysalt';
|
|
244
|
+
const IV = Buffer.alloc(16, 0x20); // 16 个空格,不是 0
|
|
245
|
+
const KEYRING_SCHEMA = 'chrome_libsecret_os_crypt_password_v2';
|
|
246
|
+
|
|
247
|
+
function deriveKey(password, len = 16) {
|
|
248
|
+
return pbkdf2Sync(password, SALT, 1, len, 'sha1');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 解开一条 Chromium cookie 密文。解不开返回 null,**不抛异常** ——
|
|
253
|
+
* 调用方要在一堆候选密钥里逐个试,一条解不开不能掐断整轮搜索。
|
|
254
|
+
*
|
|
255
|
+
* 密文布局(v10 / v11 都是这套):
|
|
256
|
+
* [3 字节前缀 "v11"][AES-128-CBC 密文]
|
|
257
|
+
* 明文布局:
|
|
258
|
+
* [32 字节 SHA256(host_key)][cookie 值][PKCS7 填充]
|
|
259
|
+
*
|
|
260
|
+
* 三个坑:前缀是 v11 但 Linux 上仍是标准方案;前 32 字节是域名哈希不是值;
|
|
261
|
+
* 填充必须去掉,否则末尾多几个字节,nav 接口会报「连不上」。
|
|
262
|
+
*/
|
|
263
|
+
function decryptChromium(encBuf, key) {
|
|
264
|
+
if (key.length !== 16) return null; // aes-128-cbc 只收 16 字节密钥
|
|
265
|
+
|
|
266
|
+
const prefix = encBuf.subarray(0, 3).toString('latin1');
|
|
267
|
+
if (prefix !== 'v10' && prefix !== 'v11') return null;
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const d = createDecipheriv('aes-128-cbc', key, IV);
|
|
271
|
+
d.setAutoPadding(false); // 手动去填充,顺便能校验
|
|
272
|
+
const plain = Buffer.concat([d.update(encBuf.subarray(3)), d.final()]);
|
|
273
|
+
|
|
274
|
+
const pad = plain[plain.length - 1];
|
|
275
|
+
const body = pad >= 1 && pad <= 16 ? plain.subarray(0, plain.length - pad) : plain;
|
|
276
|
+
if (body.length < 32) return null;
|
|
277
|
+
|
|
278
|
+
return { hostHash: body.subarray(0, 32), value: body.subarray(32).toString('utf8') };
|
|
279
|
+
} catch {
|
|
280
|
+
return null; // 密文截断、长度不是 16 的倍数等,都当解不开
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 密钥环密码候选:先按 product 精确查,再枚举所有 Safe Storage 兜底 */
|
|
285
|
+
function keyringPasswords(product) {
|
|
286
|
+
const out = [];
|
|
287
|
+
const push = (secret) => {
|
|
288
|
+
if (secret && !out.includes(secret)) out.push(secret);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// 1) 精确查 —— 快,绝大多数情况一次命中
|
|
292
|
+
try {
|
|
293
|
+
const v = execFileSync('secret-tool', ['lookup', 'application', product],
|
|
294
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
295
|
+
push(v);
|
|
296
|
+
} catch { /* 没这条,往下枚举 */ }
|
|
297
|
+
|
|
298
|
+
// 2) 枚举兜底 —— 有的浏览器 product name 和目录名对不上。
|
|
299
|
+
// 注意条目头是 `[/18]`(带斜杠),不是 `[18]`,正则漏了斜杠就只切出 1 块。
|
|
300
|
+
try {
|
|
301
|
+
const all = execFileSync('secret-tool', ['search', '--all', 'xdg:schema', KEYRING_SCHEMA],
|
|
302
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 });
|
|
303
|
+
for (const block of all.split(/\n(?=\[\/?\d+\])/)) {
|
|
304
|
+
const m = block.match(/^secret = (.+)$/m);
|
|
305
|
+
if (m) push(m[1].trim());
|
|
306
|
+
}
|
|
307
|
+
} catch { /* 没有密钥环 */ }
|
|
308
|
+
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 试出能解开这个库的密钥。用 SHA256(host_key) 前缀自证,不靠猜 */
|
|
313
|
+
function crackChromiumKey(product, probe) {
|
|
314
|
+
for (const secret of keyringPasswords(product)) {
|
|
315
|
+
// secret-tool 给的是 base64 文本,Chromium 就是拿这串文本当密码用;
|
|
316
|
+
// 但个别版本存的是解码后的字节,两种都试。
|
|
317
|
+
for (const pw of [Buffer.from(secret, 'utf8'), Buffer.from(secret, 'base64')]) {
|
|
318
|
+
if (!pw.length) continue;
|
|
319
|
+
// 只用 16 字节:Chromium 在 Linux 上就是 AES-128。别顺手加 32 ——
|
|
320
|
+
// aes-128-cbc 传 32 字节密钥会抛 ERR_CRYPTO_INVALID_KEYLEN。
|
|
321
|
+
const got = decryptChromium(Buffer.from(probe.ev, 'hex'), deriveKey(pw, 16));
|
|
322
|
+
if (!got) continue;
|
|
323
|
+
const want = createHash('sha256').update(probe.host_key).digest();
|
|
324
|
+
if (got.hostHash.equals(want)) return deriveKey(pw, 16);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------- 读取
|
|
331
|
+
|
|
332
|
+
const NAMES_IN = WANTED.map((n) => `'${n}'`).join(',');
|
|
333
|
+
|
|
334
|
+
// SQL 里的 like 只是廉价预筛,真正认不认这个域名由 isBilibiliHost() 说了算。
|
|
335
|
+
// 两边的表结构不同:Firefox 只有明文的 value,Chromium 只有密文的 encrypted_value
|
|
336
|
+
const FF_SQL = `select host as host_key, name, value
|
|
337
|
+
from moz_cookies
|
|
338
|
+
where host like '%bilibili%' and name in (${NAMES_IN})`;
|
|
339
|
+
const CR_SQL = `select host_key, name, hex(encrypted_value) as ev
|
|
340
|
+
from cookies
|
|
341
|
+
where host_key like '%bilibili%' and name in (${NAMES_IN})`;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 严格判定是不是 B 站自己的域名。
|
|
345
|
+
*
|
|
346
|
+
* SQL 的 `like '%bilibili%'` 会放过 `bilibili.com.evil.com` 这类域,光靠它
|
|
347
|
+
* 可能把别人的 cookie 当凭据发给 api.bilibili.com。这里按标签边界比:
|
|
348
|
+
* 只认 bilibili.com / bilibili.cn 及其子域。
|
|
349
|
+
*/
|
|
350
|
+
function isBilibiliHost(host) {
|
|
351
|
+
const h = String(host || '').replace(/^\./, '').toLowerCase();
|
|
352
|
+
return h === 'bilibili.com' || h.endsWith('.bilibili.com')
|
|
353
|
+
|| h === 'bilibili.cn' || h.endsWith('.bilibili.cn');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 排序用:越靠前越优先。.bilibili.com 是主域,其他子域次之 */
|
|
357
|
+
function hostRank(host) {
|
|
358
|
+
const h = String(host || '').replace(/^\./, '').toLowerCase();
|
|
359
|
+
if (h === 'bilibili.com') return 0;
|
|
360
|
+
if (h.endsWith('.bilibili.com')) return 1;
|
|
361
|
+
return 2;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 域名先过一遍白名单,再按主域优先取每个字段的第一条 */
|
|
365
|
+
function pickPreferred(rows, getValue) {
|
|
366
|
+
const jar = {};
|
|
367
|
+
const sorted = [...rows].sort((a, b) => hostRank(a.host_key) - hostRank(b.host_key));
|
|
368
|
+
for (const r of sorted) {
|
|
369
|
+
if (jar[r.name] || !WANTED.includes(r.name) || !isBilibiliHost(r.host_key)) continue;
|
|
370
|
+
const v = getValue(r);
|
|
371
|
+
if (v) jar[r.name] = v;
|
|
372
|
+
}
|
|
373
|
+
return jar;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function readFirefox(b, read) {
|
|
377
|
+
const rows = read(b.dbPath, FF_SQL);
|
|
378
|
+
// Firefox 明文,值直接取
|
|
379
|
+
return { jar: pickPreferred(rows, (r) => r.value) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readChromium(b, read) {
|
|
383
|
+
const rows = read(b.dbPath, CR_SQL);
|
|
384
|
+
if (!rows.length) return {};
|
|
385
|
+
|
|
386
|
+
// 拿一条密文当探针试密钥。优先 SESSDATA,但它要是正好损坏了,
|
|
387
|
+
// 换别的行再试 —— 否则「密钥没问题、只是这一条坏了」会被误判成「取不到密钥环」。
|
|
388
|
+
const probes = [
|
|
389
|
+
...rows.filter((r) => r.name === 'SESSDATA' && r.ev),
|
|
390
|
+
...rows.filter((r) => r.ev),
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
let cracked = null;
|
|
394
|
+
for (const p of probes) {
|
|
395
|
+
cracked = crackChromiumKey(b.product, p);
|
|
396
|
+
if (cracked) break;
|
|
397
|
+
}
|
|
398
|
+
if (!cracked) return { needKeyring: true };
|
|
399
|
+
|
|
400
|
+
// 解出来还要拿 SHA256(host_key) 自证,解错的行当没有
|
|
401
|
+
const jar = pickPreferred(rows, (r) => {
|
|
402
|
+
if (!r.ev) return null;
|
|
403
|
+
const got = decryptChromium(Buffer.from(r.ev, 'hex'), cracked);
|
|
404
|
+
if (!got || !got.value) return null;
|
|
405
|
+
const want = createHash('sha256').update(r.host_key).digest();
|
|
406
|
+
return got.hostHash.equals(want) ? got.value : null;
|
|
407
|
+
});
|
|
408
|
+
return jar.SESSDATA ? { jar } : {};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let _cache; // 一次进程内只读一遍,headers() 会被每个请求调到
|
|
412
|
+
|
|
413
|
+
/** 浏览器里刚登录完要重新读:不失效缓存的话,第二次调用只会拿到登录前的旧值 */
|
|
414
|
+
export function clearBrowserCache() {
|
|
415
|
+
_cache = undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 主入口:扫一遍本机浏览器,第一个能拿到 SESSDATA 的就用它。
|
|
420
|
+
* 全程只读,不落盘。
|
|
421
|
+
*/
|
|
422
|
+
export function readBrowserCookies() {
|
|
423
|
+
if (_cache !== undefined) return _cache;
|
|
424
|
+
|
|
425
|
+
// 用户明确说别碰浏览器时,整级跳过
|
|
426
|
+
if (process.env.BILI_NO_BROWSER) { _cache = null; return null; }
|
|
427
|
+
|
|
428
|
+
const read = sqliteReader();
|
|
429
|
+
if (!read) { _cache = null; return null; }
|
|
430
|
+
|
|
431
|
+
for (const b of detectBrowsers()) {
|
|
432
|
+
let r;
|
|
433
|
+
try {
|
|
434
|
+
r = b.kind === 'firefox' ? readFirefox(b, read) : readChromium(b, read);
|
|
435
|
+
} catch { continue; } // 单个库读坏了不该拖垮整体
|
|
436
|
+
if (r && r.jar && r.jar.SESSDATA) {
|
|
437
|
+
_cache = { jar: r.jar, source: `${b.name}(${b.profile})` };
|
|
438
|
+
return _cache;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
_cache = null;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 诊断用:本机浏览器状况,不含 cookie 值 */
|
|
447
|
+
export function probeBrowsers() {
|
|
448
|
+
const found = detectBrowsers();
|
|
449
|
+
if (process.env.BILI_NO_BROWSER) {
|
|
450
|
+
return { found, readable: false, reason: 'BILI_NO_BROWSER 已设置,跳过', hit: null };
|
|
451
|
+
}
|
|
452
|
+
const read = sqliteReader();
|
|
453
|
+
if (!read) return { found, readable: false, reason: _backendWhy, hit: null };
|
|
454
|
+
|
|
455
|
+
const keyringMisses = [];
|
|
456
|
+
for (const b of found) {
|
|
457
|
+
try {
|
|
458
|
+
const r = b.kind === 'firefox' ? readFirefox(b, read) : readChromium(b, read);
|
|
459
|
+
if (r && r.jar && r.jar.SESSDATA) return { found, readable: true, hit: b, reason: null };
|
|
460
|
+
// 别在这里 return —— 后面还有别的浏览器,可能那个才有登录态
|
|
461
|
+
if (r && r.needKeyring) keyringMisses.push(b.name);
|
|
462
|
+
} catch { /* 继续看下一个 */ }
|
|
463
|
+
}
|
|
464
|
+
if (keyringMisses.length) {
|
|
465
|
+
return {
|
|
466
|
+
found, readable: true, hit: null,
|
|
467
|
+
reason: `${keyringMisses.join('、')}:取不到密钥环密码(缺 secret-tool 或未解锁)`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return { found, readable: true, hit: null, reason: found.length ? '浏览器里没有 B 站登录态' : null };
|
|
471
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// bili doctor 自检
|
|
8
8
|
// bili install-skill [选项] 把 skill 装到本地 agent
|
|
9
9
|
//
|
|
10
|
-
import { writeFileSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { createInterface } from 'node:readline/promises';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
11
14
|
import { dirname, join } from 'node:path';
|
|
12
15
|
import { fileURLToPath } from 'node:url';
|
|
13
16
|
import { fetchVideo, renderText } from './fetch.mjs';
|
|
14
17
|
import { login } from './login.mjs';
|
|
15
|
-
import {
|
|
18
|
+
import { readBrowserCookies, clearBrowserCache } from './browser.mjs';
|
|
16
19
|
|
|
17
20
|
// 版本号从 package.json 读,避免写死后漂移
|
|
18
21
|
const VERSION = (() => {
|
|
@@ -29,12 +32,19 @@ bili-transcript —— B 站视频文字稿提取
|
|
|
29
32
|
用法
|
|
30
33
|
bili <BV号|av号|URL> [选项] 提取文字稿
|
|
31
34
|
bili fetch <目标> [选项] 同上(显式写法)
|
|
32
|
-
bili login [选项]
|
|
33
|
-
bili doctor 自检:环境 / 凭据 / 接口
|
|
35
|
+
bili login [选项] 扫码登录(或 --from-browser 从浏览器导入)
|
|
36
|
+
bili doctor 自检:环境 / 凭据 / 浏览器 / 接口
|
|
34
37
|
bili install-skill [选项] 安装 agent skill
|
|
35
38
|
bili --help 看这份帮助
|
|
36
39
|
bili --version 看版本
|
|
37
40
|
|
|
41
|
+
凭据从哪来(自动,按顺序找)
|
|
42
|
+
1. 环境变量 BILI_SESSDATA
|
|
43
|
+
2. ~/.config/bili/auth.json 扫码登录写的
|
|
44
|
+
3. 本机浏览器 cookie 库 只读,每次现取
|
|
45
|
+
4. /var/minis/offloads/ Minis 导出的 cookie
|
|
46
|
+
都没有才会提示你扫码。
|
|
47
|
+
|
|
38
48
|
提取选项
|
|
39
49
|
--plain 只出连续文字,不带时间戳(喂给 AI 用这个)
|
|
40
50
|
--json 输出 JSON(给程序/agent 解析)
|
|
@@ -42,6 +52,7 @@ bili-transcript —— B 站视频文字稿提取
|
|
|
42
52
|
--list 只列出字幕轨,不下载正文
|
|
43
53
|
--summary 附带 B 站 AI 总结
|
|
44
54
|
-o, --out FILE 写入文件
|
|
55
|
+
--no-login-prompt 凭据缺失时不询问,直接报错退出
|
|
45
56
|
|
|
46
57
|
登录选项
|
|
47
58
|
--png <路径> 额外写出二维码图片
|
|
@@ -49,12 +60,14 @@ bili-transcript —— B 站视频文字稿提取
|
|
|
49
60
|
--resume 用上次的二维码继续轮询
|
|
50
61
|
--timeout N 轮询超时秒数(默认 180)
|
|
51
62
|
--force 已登录时强制重新登录
|
|
63
|
+
--from-browser 从浏览器导入凭据并固化到 auth.json(不扫码)
|
|
52
64
|
|
|
53
65
|
示例
|
|
54
66
|
bili BV1xx411c7mD --plain
|
|
55
67
|
bili "https://b23.tv/xxxxx" --summary -o out.txt
|
|
56
|
-
bili login --png /
|
|
68
|
+
bili login --png ~/.config/bili/qr.png
|
|
57
69
|
bili login --qr-only && bili login --resume
|
|
70
|
+
bili login --from-browser
|
|
58
71
|
`.trim();
|
|
59
72
|
|
|
60
73
|
function parseArgs(argv) {
|
|
@@ -68,6 +81,8 @@ function parseArgs(argv) {
|
|
|
68
81
|
else if (a === '--qr-only') o.qrOnly = true;
|
|
69
82
|
else if (a === '--resume') o.resume = true;
|
|
70
83
|
else if (a === '--force') o.force = true;
|
|
84
|
+
else if (a === '--from-browser') o.fromBrowser = true;
|
|
85
|
+
else if (a === '--no-login-prompt') o.noLoginPrompt = true;
|
|
71
86
|
else if (a === '-p' || a === '--page') o.page = parseInt(argv[++i], 10);
|
|
72
87
|
else if (a === '-o' || a === '--out') o.out = argv[++i];
|
|
73
88
|
else if (a === '--png') o.png = argv[++i];
|
|
@@ -84,6 +99,108 @@ function die(msg, code = 1) {
|
|
|
84
99
|
process.exit(code);
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
// 二维码别放 /tmp:扫一下就能登录,全局可读的目录等于把凭据晾给别人看
|
|
103
|
+
const QR_PNG = join(homedir(), '.config', 'bili', 'qr.png');
|
|
104
|
+
|
|
105
|
+
/** 打开默认浏览器。起不来返回 false,由调用方打印 URL 兜底,不报错退出 */
|
|
106
|
+
function openBrowser(url) {
|
|
107
|
+
const cmd = { linux: 'xdg-open', darwin: 'open', win32: 'start' }[process.platform];
|
|
108
|
+
if (!cmd) return Promise.resolve(false);
|
|
109
|
+
// win32 的 start 是 cmd 内建命令,必须借 cmd /c 才执行得到
|
|
110
|
+
const [bin, args] = process.platform === 'win32'
|
|
111
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
112
|
+
: [cmd, [url]];
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const p = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
115
|
+
p.once('spawn', () => { p.unref(); resolve(true); });
|
|
116
|
+
p.once('error', () => resolve(false));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 凭据没了时的兜底询问。只在 TTY 下问 —— 非 TTY(agent / 管道调用)里
|
|
122
|
+
* 弹菜单会把调用方卡死,那种场合打印指引让调用方自己决定。
|
|
123
|
+
*/
|
|
124
|
+
async function offerLogin() {
|
|
125
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.BILI_NO_PROMPT) {
|
|
126
|
+
console.error(`浏览器里也没找到 B 站登录态。\n需要扫码登录,请运行:bili login --png ${QR_PNG}`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
131
|
+
// Ctrl+D 或管道提前结束会让 question 抛 AbortError —— 一律当成「用户放弃」,别甩堆栈
|
|
132
|
+
const ask = async (q) => { try { return await rl.question(q); } catch { return null; } };
|
|
133
|
+
let fromBrowser = false;
|
|
134
|
+
try {
|
|
135
|
+
// 最多问 3 次,避免手滑输错就整个流程重来
|
|
136
|
+
let choice = null;
|
|
137
|
+
for (let i = 0; i < 3 && !choice; i++) {
|
|
138
|
+
const raw = await ask(
|
|
139
|
+
'没找到可用的 B 站登录态。选一个:\n' +
|
|
140
|
+
' 1) 打开浏览器登录(登录完自动读取)\n' +
|
|
141
|
+
' 2) 显示二维码扫码登录\n' +
|
|
142
|
+
'请输入 1 或 2:'
|
|
143
|
+
);
|
|
144
|
+
if (raw === null) { console.error('\n已取消。'); return false; }
|
|
145
|
+
const a = raw.trim();
|
|
146
|
+
if (a === '1' || a === '2') choice = a;
|
|
147
|
+
}
|
|
148
|
+
if (!choice) { console.error('没有收到 1 或 2,已放弃。'); return false; }
|
|
149
|
+
|
|
150
|
+
if (choice === '1') {
|
|
151
|
+
const url = 'https://www.bilibili.com';
|
|
152
|
+
if (!(await openBrowser(url))) console.log(`没能自动打开浏览器,请手动打开:${url}`);
|
|
153
|
+
if ((await ask('在浏览器里登录 B 站,完成后按回车继续')) === null) {
|
|
154
|
+
console.error('\n已取消。'); return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 必须清缓存:不清的话读到的是本次登录前的旧值,功能会静默失效
|
|
158
|
+
clearBrowserCache();
|
|
159
|
+
const got = readBrowserCookies();
|
|
160
|
+
if (got) {
|
|
161
|
+
console.log(`已从 浏览器 ${got.source} 读到登录态`);
|
|
162
|
+
fromBrowser = true;
|
|
163
|
+
} else {
|
|
164
|
+
console.log('浏览器里还是没读到 B 站登录态,改用二维码登录。');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
rl.close();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 选了 2,或浏览器那条路没读到登录态,都落到扫码
|
|
172
|
+
if (fromBrowser) return true;
|
|
173
|
+
// 首次运行时 ~/.config/bili 可能还不存在,writePNG 不会自己建目录
|
|
174
|
+
mkdirSync(dirname(QR_PNG), { recursive: true });
|
|
175
|
+
const r = await login({ png: QR_PNG });
|
|
176
|
+
if (r?.alreadyLoggedIn) return true;
|
|
177
|
+
if (!r?.ok) { console.error('登录没成功。'); return false; }
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 从浏览器导入并固化到 auth.json */
|
|
182
|
+
async function importFromBrowser() {
|
|
183
|
+
const { readBrowserCookies } = await import('./browser.mjs');
|
|
184
|
+
const { saveAuth, AUTH_PATH, WANTED } = await import('./auth.mjs');
|
|
185
|
+
|
|
186
|
+
const got = readBrowserCookies();
|
|
187
|
+
if (!got) {
|
|
188
|
+
console.error('本机浏览器里没找到 B 站登录态。先确认浏览器里登录着 B 站,或改用 `bili login` 扫码。');
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const jar = {};
|
|
193
|
+
for (const n of WANTED) if (got.jar[n]) jar[n] = got.jar[n];
|
|
194
|
+
saveAuth(jar);
|
|
195
|
+
|
|
196
|
+
const { whoami } = await import('./login.mjs');
|
|
197
|
+
const who = await whoami().catch(() => null);
|
|
198
|
+
console.log(`已从 ${got.source} 导入 ${Object.keys(jar).length} 项凭据`);
|
|
199
|
+
if (who) console.log(` 账号:${who.uname}(uid ${who.mid})`);
|
|
200
|
+
console.log(` 已写入 ${AUTH_PATH}(权限 0600)`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
87
204
|
async function main() {
|
|
88
205
|
const argv = process.argv.slice(2);
|
|
89
206
|
if (!argv.length) { console.log(HELP); return; }
|
|
@@ -102,6 +219,7 @@ async function main() {
|
|
|
102
219
|
|
|
103
220
|
// ---- login ----
|
|
104
221
|
if (cmd === 'login') {
|
|
222
|
+
if (o.fromBrowser) process.exit(await importFromBrowser());
|
|
105
223
|
try {
|
|
106
224
|
const r = await login(o);
|
|
107
225
|
if (r.qrOnly && o.json) console.log(JSON.stringify(r, null, 2));
|
|
@@ -131,10 +249,19 @@ async function main() {
|
|
|
131
249
|
try {
|
|
132
250
|
v = await fetchVideo(target, { page: o.page, summary: o.summary });
|
|
133
251
|
} catch (e) {
|
|
134
|
-
if (e instanceof BiliError && e.code === -101) die(e.message);
|
|
135
252
|
die(e.message || String(e));
|
|
136
253
|
}
|
|
137
254
|
|
|
255
|
+
// 字幕接口缺凭据时不会抛错,只是返回 need-login。这时才轮到扫码兜底:
|
|
256
|
+
// 先问一声,别不打招呼就把二维码糊人脸上。
|
|
257
|
+
if (v.noSubtitleReason === 'need-login' && !o.noLoginPrompt && !o.json) {
|
|
258
|
+
if (await offerLogin()) {
|
|
259
|
+
try {
|
|
260
|
+
v = await fetchVideo(target, { page: o.page, summary: o.summary });
|
|
261
|
+
} catch (e) { die(e.message || String(e)); }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
138
265
|
let text;
|
|
139
266
|
if (o.json) {
|
|
140
267
|
text = JSON.stringify({
|
package/src/doctor.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 自检 —— 一眼分清「接口变了」还是「该重新登录了」
|
|
2
2
|
import { API, getJson, wbiQuery, headers } from './api.mjs';
|
|
3
3
|
import { findSessdata, AUTH_PATH } from './auth.mjs';
|
|
4
|
+
import { probeBrowsers, sqliteBackendName } from './browser.mjs';
|
|
4
5
|
|
|
5
6
|
const R = { pass: 0, fail: 0, warn: 0 };
|
|
6
7
|
const ok = (m) => { R.pass++; console.log(` ✅ ${m}`); };
|
|
@@ -20,6 +21,19 @@ export async function runDoctor({ json = false } = {}) {
|
|
|
20
21
|
if (sd) ok(`SESSDATA 长度 ${sd.length},来自 ${source}`);
|
|
21
22
|
else bad(`找不到凭据。运行 \`bili login\` 扫码登录(默认位置 ${AUTH_PATH})`);
|
|
22
23
|
|
|
24
|
+
log('\n== 浏览器 ==');
|
|
25
|
+
const bp = probeBrowsers();
|
|
26
|
+
const backend = sqliteBackendName();
|
|
27
|
+
if (!backend) {
|
|
28
|
+
soft(`读不了浏览器 cookie 库:${bp.reason}`);
|
|
29
|
+
} else {
|
|
30
|
+
const list = bp.found.map((b) => `${b.name}(${b.profile})`).join('、');
|
|
31
|
+
if (bp.hit) ok(`${bp.hit.name}(${bp.hit.profile})—— 已从中取到凭据`);
|
|
32
|
+
else if (bp.reason) soft(`${bp.reason}${list ? `(找到 ${list})` : ''}`);
|
|
33
|
+
else if (list) soft(`找到 ${list},但没有 B 站登录态`);
|
|
34
|
+
else soft('本机没找到浏览器 cookie 库');
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
if (sd) {
|
|
24
38
|
log('\n== 凭据可用性 ==');
|
|
25
39
|
const r = await getJson(`${API}/x/web-interface/nav`).catch(() => null);
|
package/src/fetch.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function renderText(v, { plain = false, list = false } = {}) {
|
|
|
99
99
|
out.push(`UP: ${v.uploader} | 时长 ${v.duration}s | ${v.url}`);
|
|
100
100
|
|
|
101
101
|
if (v.noSubtitleReason === 'need-login') {
|
|
102
|
-
out.push('\n[
|
|
102
|
+
out.push('\n[字幕接口要求登录:环境变量、auth.json、浏览器 cookie 里都没找到凭据。运行 `bili login` 扫码登录]');
|
|
103
103
|
} else if (v.noSubtitleReason === 'none-available') {
|
|
104
104
|
out.push('\n[该视频没有字幕轨。纯音乐 / MV / CG 类视频通常没有。可加 --summary 试试 B 站的 AI 总结。]');
|
|
105
105
|
}
|
package/src/install-skill.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export async function installSkill({ global: isGlobal = true } = {}) {
|
|
|
22
22
|
|
|
23
23
|
// 1) 首选:npx skills add(走官方生态,兼容性最好)
|
|
24
24
|
log('尝试 `npx skills add` …');
|
|
25
|
-
const r = spawnSync('npx', ['-y', 'skills@latest', 'add', 'dongliang/
|
|
25
|
+
const r = spawnSync('npx', ['-y', 'skills@latest', 'add', 'dongliang/skills', '--skill', 'bilibili-transcript',
|
|
26
26
|
isGlobal ? '-g' : '-p', '-y'],
|
|
27
27
|
{ stdio: 'inherit', shell: process.platform === 'win32' });
|
|
28
28
|
if (r.status === 0) {
|
package/src/login.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// 扫码登录 —— 纯命令行,不需要任何浏览器
|
|
2
|
-
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
3
5
|
import { encodeQR, renderText, writePNG } from './qr.mjs';
|
|
4
|
-
import { UA, API } from './api.mjs';
|
|
5
|
-
import { saveAuth, AUTH_PATH
|
|
6
|
+
import { UA, API, getJson } from './api.mjs';
|
|
7
|
+
import { saveAuth, AUTH_PATH } from './auth.mjs';
|
|
6
8
|
|
|
7
9
|
const H = { 'User-Agent': UA, Referer: 'https://www.bilibili.com/' };
|
|
8
10
|
const QRAPI = 'https://passport.bilibili.com/x/passport-login/web/qrcode';
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
// 放在用户目录而不是 /tmp:这个 key 能换到登录 cookie,谁先轮询谁拿到账号。
|
|
13
|
+
// /tmp 是全局可读的,写在那儿等于把凭据晾给别人看(实测默认权限 644)。
|
|
14
|
+
const KEYFILE = join(homedir(), '.config', 'bili', 'qr-pending.json');
|
|
10
15
|
|
|
11
16
|
/** 读 cookie 头。不同 Node 构建行为不一致:getSetCookie() 可能不存在,get() 可能返回数组 */
|
|
12
17
|
function readSetCookies(resp) {
|
|
@@ -20,9 +25,10 @@ function readSetCookies(resp) {
|
|
|
20
25
|
return raw ? [raw] : [];
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
/** 当前登录的是谁。走 getJson 才能带上凭据 —— 手拼 H 的话永远问不出登录态 */
|
|
23
29
|
export async function whoami() {
|
|
24
|
-
const nav = await (
|
|
25
|
-
return nav
|
|
30
|
+
const nav = await getJson(`${API}/x/web-interface/nav`).catch(() => null);
|
|
31
|
+
return nav?.data?.isLogin ? nav.data : null;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export async function login({ png = null, qrOnly = false, resume = false, timeout = 180, force = false, log = console.log }) {
|
|
@@ -64,7 +70,9 @@ export async function login({ png = null, qrOnly = false, resume = false, timeou
|
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
if (qrOnly) {
|
|
67
|
-
|
|
73
|
+
mkdirSync(dirname(KEYFILE), { recursive: true });
|
|
74
|
+
writeFileSync(KEYFILE, JSON.stringify({ url, key, ts: Date.now() }) + '\n', { mode: 0o600 });
|
|
75
|
+
try { chmodSync(KEYFILE, 0o600); } catch { /* 有的文件系统不支持,忽略 */ }
|
|
68
76
|
log(`已保存二维码到 ${KEYFILE}。给用户看完后用 --resume 继续轮询。`);
|
|
69
77
|
return { qrOnly: true, url, key, png };
|
|
70
78
|
}
|
|
@@ -103,6 +111,8 @@ export async function login({ png = null, qrOnly = false, resume = false, timeou
|
|
|
103
111
|
if (!jar.SESSDATA) throw new Error('超时,或没能拿到凭据。请重试。');
|
|
104
112
|
|
|
105
113
|
saveAuth(jar);
|
|
114
|
+
// 登录成了就把待用 key 删掉,不留在盘上
|
|
115
|
+
if (resume) try { unlinkSync(KEYFILE); } catch { /* 已经没了就算了 */ }
|
|
106
116
|
const who = await whoami().catch(() => null);
|
|
107
117
|
|
|
108
118
|
log('\n=== 登录成功 ===');
|
package/src/qr.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync as _wfs } from 'node:fs';
|
|
1
|
+
import { writeFileSync as _wfs, chmodSync as _chmod } from 'node:fs';
|
|
2
2
|
import { deflateSync } from 'node:zlib';
|
|
3
3
|
// 纯 JS 二维码编码器 —— 零依赖
|
|
4
4
|
// 支持:字节模式、纠错等级 M、版本 1–10、自动选版本、掩码自动择优
|
|
@@ -379,7 +379,9 @@ export function writePNG(matrix, path, { scale = 8, quiet = 4 } = {}) {
|
|
|
379
379
|
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
380
380
|
chunk('IEND', Buffer.alloc(0)),
|
|
381
381
|
]);
|
|
382
|
-
|
|
382
|
+
// 0600:这张图扫一下就能登录,不该让同机其他人读到
|
|
383
|
+
_wfs(path, png, { mode: 0o600 });
|
|
384
|
+
try { _chmod(path, 0o600); } catch { /* 有的文件系统不支持,忽略 */ }
|
|
383
385
|
return { width: w, height: h, bytes: png.length };
|
|
384
386
|
}
|
|
385
387
|
|