bili-transcript 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +124 -2
- package/SKILL.md +84 -0
- package/package.json +42 -4
- package/src/api.mjs +74 -0
- package/src/auth.mjs +45 -0
- package/src/cli.mjs +151 -0
- package/src/doctor.mjs +83 -0
- package/src/fetch.mjs +128 -0
- package/src/install-skill.mjs +63 -0
- package/src/login.mjs +116 -0
- package/src/qr.mjs +402 -0
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,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bili-transcript
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
## 快速开始
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bili login # 扫码登录,一次就够
|
|
30
|
+
bili BV1xx411c7mD --plain # 提取文字稿
|
|
31
|
+
bili "https://b23.tv/xxxxx" --plain -o out.txt
|
|
32
|
+
bili BV1xx411c7mD --summary # 顺带拿 AI 总结
|
|
33
|
+
bili doctor # 出问题了先跑这个
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 为什么需要登录
|
|
37
|
+
|
|
38
|
+
B 站的字幕接口现在强制登录。不登录的话,接口返回的是**空数组**——看起来像"这视频没字幕",其实是被挡住了。所以必须先登一次。
|
|
39
|
+
|
|
40
|
+
**登录全程在你的机器上完成,凭据只存在本地。** 扫码用的是 B 站官方的二维码登录接口(就是网页版"扫码登录"用的那个),和任何第三方服务器无关。凭据写在 `~/.config/bili/auth.json`,权限 `0600`。
|
|
41
|
+
|
|
42
|
+
扫码登录**不会把你手机上的 B 站 App 踢下线**——同一个账号支持多端同时在线。
|
|
43
|
+
|
|
44
|
+
## 命令
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
bili <BV号|av号|URL> [选项] 提取文字稿(默认动作)
|
|
48
|
+
bili fetch <目标> [选项] 同上,显式写法
|
|
49
|
+
bili login [选项] 扫码登录
|
|
50
|
+
bili doctor 自检:环境 / 凭据 / 接口
|
|
51
|
+
bili install-skill [选项] 把 skill 装到本地 agent
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 提取选项
|
|
55
|
+
|
|
56
|
+
| 选项 | 作用 |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `--plain` | 只出连续文字,不带时间戳 |
|
|
59
|
+
| `--json` | 结构化输出,给程序或 agent 解析 |
|
|
60
|
+
| `-p, --page N` | 取第 N 个分P |
|
|
61
|
+
| `--list` | 只列出字幕轨,不下载正文 |
|
|
62
|
+
| `--summary` | 附带 B 站 AI 总结 |
|
|
63
|
+
| `-o, --out FILE` | 写入文件 |
|
|
64
|
+
|
|
65
|
+
### 登录选项
|
|
66
|
+
|
|
67
|
+
| 选项 | 作用 |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `--png <路径>` | 额外写出二维码图片 |
|
|
70
|
+
| `--qr-only` | 只生成二维码,稍后用 `--resume` 继续 |
|
|
71
|
+
| `--resume` | 用上次的二维码继续轮询 |
|
|
72
|
+
| `--timeout N` | 轮询超时秒数(默认 180) |
|
|
73
|
+
| `--force` | 已登录时强制重新登录 |
|
|
74
|
+
|
|
75
|
+
## 给 agent 用
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npx skills add dongliang/bili-transcript
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
装了之后 Claude Code、Codex 这类 agent 会在你提到"这个视频讲了什么"时自动调用它。
|
|
82
|
+
|
|
83
|
+
agent 内部请用 `--json` 拿结构化数据:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"ok": true,
|
|
88
|
+
"bvid": "BV14Z4R6KEHS",
|
|
89
|
+
"title": "128G 超大内存,密密麻麻全是颗粒",
|
|
90
|
+
"duration": 175,
|
|
91
|
+
"uploader": "韩宇修机",
|
|
92
|
+
"lines": [{ "from": 0, "to": 1.96, "text": "128G大内存" }],
|
|
93
|
+
"summary": { "text": "…", "outline": [] }
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 已知限制
|
|
98
|
+
|
|
99
|
+
- **字幕是 AI 自动生成的,有错别字。** 实测中 `CAMM` 被写成 "cam"、`信号线阻值` 被写成 "信号线组织"。**专有名词请核实物件丝印或官方资料**,别直接引用。
|
|
100
|
+
- 没有字幕轨的视频(纯音乐、MV、CG 常见)拿不到文字,会明确告诉你而不是编造。
|
|
101
|
+
- 只支持 B 站。其他平台是另一件事。
|
|
102
|
+
- 登录状态会过期(通常几个月)。失效时 `doctor` 会告诉你,重新 `bili login --force` 即可。
|
|
103
|
+
|
|
104
|
+
## 设计说明
|
|
105
|
+
|
|
106
|
+
**零依赖是刻意的。** 核心只用 Node 内置的 `fetch`、`crypto`、`fs`。唯一的难点是二维码——没有引 `qrcode` 这类库,而是自己实现了一个(GF(256) 纠错、Reed-Solomon、掩码择优、连 PNG 都是手写的)。代价是 13 KB 代码,换来的是整个包不到 100 KB,且没有供应链风险。
|
|
107
|
+
|
|
108
|
+
**只用 `x/player/wbi/v2` 拿字幕,不回退到旧接口。** 旧接口 `x/player/v2` 会返回**别的视频**的字幕——对一条无字幕的 MV 连测 4 次,拿到 4 段完全无关的内容。这种错用户看不出来,只会觉得字幕莫名其妙,所以宁可报"没有字幕"也不给错数据。
|
|
109
|
+
|
|
110
|
+
**内置数据自洽校验。** 拿到字幕后会检查末条时间戳贴不贴合视频时长。不贴合就告警——这是防"拿到错误数据"的最后一道闸。
|
|
111
|
+
|
|
112
|
+
## 开发
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
git clone https://github.com/dongliang/bili-transcript
|
|
116
|
+
cd bili-transcript
|
|
117
|
+
npm link # 让全局 bili 指向本地代码
|
|
118
|
+
sh tests/fulltest.sh
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
测试脚本在 `tests/`。改动二维码编码器(`src/qr.mjs`)后必须跑二维码的两个专项测试——**二维码错了不会报错,只会扫不出来**。
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
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,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bili-transcript",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "提取 B 站视频的文字稿、字幕和 AI 总结。零依赖,扫码登录,纯命令行。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bili": "src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"SKILL.md",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"bilibili",
|
|
19
|
+
"b站",
|
|
20
|
+
"字幕",
|
|
21
|
+
"subtitle",
|
|
22
|
+
"transcript",
|
|
23
|
+
"cli",
|
|
24
|
+
"agent-skill",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"codex"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "dongliang",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/dongliang/bili-transcript.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://dongliang.github.io/bili-transcript/",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/dongliang/bili-transcript/issues"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "sh tests/fulltest.sh",
|
|
40
|
+
"selftest": "node src/selftest.mjs"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {},
|
|
43
|
+
"optionalDependencies": {}
|
|
44
|
+
}
|
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,151 @@
|
|
|
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 } from 'node:fs';
|
|
11
|
+
import { fetchVideo, renderText } from './fetch.mjs';
|
|
12
|
+
import { login } from './login.mjs';
|
|
13
|
+
import { BiliError } from './api.mjs';
|
|
14
|
+
|
|
15
|
+
const HELP = `
|
|
16
|
+
bili —— B 站视频文字稿提取
|
|
17
|
+
|
|
18
|
+
用法
|
|
19
|
+
bili <BV号|av号|URL> [选项] 提取文字稿
|
|
20
|
+
bili fetch <目标> [选项] 同上(显式写法)
|
|
21
|
+
bili login [选项] 扫码登录
|
|
22
|
+
bili doctor 自检:环境 / 凭据 / 接口
|
|
23
|
+
bili install-skill [选项] 安装 agent skill
|
|
24
|
+
bili --help 看这份帮助
|
|
25
|
+
bili --version 看版本
|
|
26
|
+
|
|
27
|
+
提取选项
|
|
28
|
+
--plain 只出连续文字,不带时间戳(喂给 AI 用这个)
|
|
29
|
+
--json 输出 JSON(给程序/agent 解析)
|
|
30
|
+
-p, --page N 取第 N 个分P
|
|
31
|
+
--list 只列出字幕轨,不下载正文
|
|
32
|
+
--summary 附带 B 站 AI 总结
|
|
33
|
+
-o, --out FILE 写入文件
|
|
34
|
+
|
|
35
|
+
登录选项
|
|
36
|
+
--png <路径> 额外写出二维码图片
|
|
37
|
+
--qr-only 只生成二维码,稍后用 --resume 继续
|
|
38
|
+
--resume 用上次的二维码继续轮询
|
|
39
|
+
--timeout N 轮询超时秒数(默认 180)
|
|
40
|
+
--force 已登录时强制重新登录
|
|
41
|
+
|
|
42
|
+
示例
|
|
43
|
+
bili BV1xx411c7mD --plain
|
|
44
|
+
bili "https://b23.tv/xxxxx" --summary -o out.txt
|
|
45
|
+
bili login --png /tmp/qr.png
|
|
46
|
+
bili login --qr-only && bili login --resume
|
|
47
|
+
`.trim();
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const o = { _: [], plain: false, json: false, list: false, summary: false, out: null, page: 1 };
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--plain') o.plain = true;
|
|
54
|
+
else if (a === '--json') o.json = true;
|
|
55
|
+
else if (a === '--list') o.list = true;
|
|
56
|
+
else if (a === '--summary') o.summary = true;
|
|
57
|
+
else if (a === '--qr-only') o.qrOnly = true;
|
|
58
|
+
else if (a === '--resume') o.resume = true;
|
|
59
|
+
else if (a === '--force') o.force = true;
|
|
60
|
+
else if (a === '-p' || a === '--page') o.page = parseInt(argv[++i], 10);
|
|
61
|
+
else if (a === '-o' || a === '--out') o.out = argv[++i];
|
|
62
|
+
else if (a === '--png') o.png = argv[++i];
|
|
63
|
+
else if (a === '--timeout') o.timeout = parseInt(argv[++i], 10);
|
|
64
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
65
|
+
else if (a === '-v' || a === '--version') o.version = true;
|
|
66
|
+
else o._.push(a);
|
|
67
|
+
}
|
|
68
|
+
return o;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function die(msg, code = 1) {
|
|
72
|
+
console.error(msg);
|
|
73
|
+
process.exit(code);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main() {
|
|
77
|
+
const argv = process.argv.slice(2);
|
|
78
|
+
if (!argv.length) { console.log(HELP); return; }
|
|
79
|
+
|
|
80
|
+
// 允许 `bili --help` / `bili --json <url>` 这类全局选项打头
|
|
81
|
+
const SUBCOMMANDS = ['fetch', 'login', 'doctor', 'install-skill'];
|
|
82
|
+
let cmd, rest;
|
|
83
|
+
if (SUBCOMMANDS.includes(argv[0])) {
|
|
84
|
+
cmd = argv[0]; rest = argv.slice(1);
|
|
85
|
+
} else {
|
|
86
|
+
cmd = 'fetch'; rest = argv;
|
|
87
|
+
}
|
|
88
|
+
const o = parseArgs(rest);
|
|
89
|
+
if (o.help) { console.log(HELP); return; }
|
|
90
|
+
if (o.version) { console.log(process.env.npm_package_version || '1.0.0'); return; }
|
|
91
|
+
|
|
92
|
+
// ---- login ----
|
|
93
|
+
if (cmd === 'login') {
|
|
94
|
+
try {
|
|
95
|
+
const r = await login(o);
|
|
96
|
+
if (r.qrOnly && o.json) console.log(JSON.stringify(r, null, 2));
|
|
97
|
+
} catch (e) { die(`登录失败:${e.message}`); }
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- doctor ----
|
|
102
|
+
if (cmd === 'doctor') {
|
|
103
|
+
const { runDoctor } = await import('./doctor.mjs');
|
|
104
|
+
const ok = await runDoctor({ json: o.json });
|
|
105
|
+
process.exit(ok ? 0 : 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- install-skill ----
|
|
109
|
+
if (cmd === 'install-skill') {
|
|
110
|
+
const { installSkill } = await import('./install-skill.mjs');
|
|
111
|
+
const ok = await installSkill(o);
|
|
112
|
+
process.exit(ok ? 0 : 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- fetch(默认动作)----
|
|
116
|
+
const target = o._[0];
|
|
117
|
+
if (!target) die('请提供视频 BV号 / av号 / URL。用 `bili --help` 看用法。');
|
|
118
|
+
|
|
119
|
+
let v;
|
|
120
|
+
try {
|
|
121
|
+
v = await fetchVideo(target, { page: o.page, summary: o.summary });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (e instanceof BiliError && e.code === -101) die(e.message);
|
|
124
|
+
die(e.message || String(e));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let text;
|
|
128
|
+
if (o.json) {
|
|
129
|
+
text = JSON.stringify({
|
|
130
|
+
ok: true,
|
|
131
|
+
bvid: v.bvid, title: v.title, part: v.part, page: v.page, pageCount: v.pageCount,
|
|
132
|
+
duration: v.duration, uploader: v.uploader, url: v.url,
|
|
133
|
+
subtitles: v.subtitles,
|
|
134
|
+
lines: v.body,
|
|
135
|
+
warning: v.warning,
|
|
136
|
+
noSubtitleReason: v.noSubtitleReason || null,
|
|
137
|
+
summary: v.summary,
|
|
138
|
+
}, null, 2) + '\n';
|
|
139
|
+
} else {
|
|
140
|
+
text = renderText(v, { plain: o.plain, list: o.list });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (o.out) {
|
|
144
|
+
writeFileSync(o.out, text, 'utf8');
|
|
145
|
+
console.log(`已写入 ${o.out}(${text.length} 字符,${v.body.length} 条字幕)`);
|
|
146
|
+
} else {
|
|
147
|
+
process.stdout.write(text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
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
|
+
}
|
package/src/fetch.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 提取字幕 / AI 总结 —— 核心逻辑,返回结构化数据
|
|
2
|
+
import { API, getJson, wbiQuery, check, parseBvid, ts } from './api.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 取视频信息 + 字幕 +(可选)AI 总结
|
|
6
|
+
* 返回结构化对象,由调用方决定怎么渲染。
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchVideo(target, { page = 1, summary = false } = {}) {
|
|
9
|
+
const vid = parseBvid(target);
|
|
10
|
+
const view = check(
|
|
11
|
+
await getJson(vid.startsWith('av')
|
|
12
|
+
? `${API}/x/web-interface/view?aid=${vid.slice(2)}`
|
|
13
|
+
: `${API}/x/web-interface/view?bvid=${vid}`),
|
|
14
|
+
'视频信息');
|
|
15
|
+
|
|
16
|
+
const bvid = view.bvid;
|
|
17
|
+
const pages = view.pages;
|
|
18
|
+
if (page > pages.length) {
|
|
19
|
+
const e = new Error(`该视频只有 ${pages.length} 个分P`);
|
|
20
|
+
e.code = 'bad-page';
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
const pg = pages[page - 1];
|
|
24
|
+
const aid = view.aid;
|
|
25
|
+
const cid = pg.cid;
|
|
26
|
+
|
|
27
|
+
const result = {
|
|
28
|
+
bvid,
|
|
29
|
+
aid,
|
|
30
|
+
cid,
|
|
31
|
+
title: view.title,
|
|
32
|
+
part: pages.length > 1 ? pg.part : null,
|
|
33
|
+
page,
|
|
34
|
+
pageCount: pages.length,
|
|
35
|
+
duration: pg.duration,
|
|
36
|
+
uploader: view.owner.name,
|
|
37
|
+
uploaderMid: view.owner.mid,
|
|
38
|
+
url: `https://www.bilibili.com/video/${bvid}`,
|
|
39
|
+
subtitles: [],
|
|
40
|
+
// ⚠️ 只用 wbi 版。老接口 x/player/v2 会返回别的视频的字幕,绝不回退。
|
|
41
|
+
body: [],
|
|
42
|
+
warning: null,
|
|
43
|
+
summary: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const pv = check(await getJson(`${API}/x/player/wbi/v2?${await wbiQuery({ bvid, cid, aid })}`), '字幕列表');
|
|
47
|
+
const subs = (pv.subtitle && pv.subtitle.subtitles) || [];
|
|
48
|
+
result.subtitles = subs.map((s) => ({
|
|
49
|
+
lan: s.lan,
|
|
50
|
+
lanDoc: s.lan_doc,
|
|
51
|
+
url: s.subtitle_url && s.subtitle_url.startsWith('//') ? 'https:' + s.subtitle_url : s.subtitle_url,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
if (!subs.length) {
|
|
55
|
+
result.noSubtitleReason = pv.need_login_subtitle ? 'need-login' : 'none-available';
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const items = (await getJson(result.subtitles[0].url)).body || [];
|
|
60
|
+
result.body = items
|
|
61
|
+
.filter((it) => (it.content || '').trim())
|
|
62
|
+
.map((it) => ({ from: Number(it.from || 0), to: Number(it.to || 0), text: it.content.trim() }));
|
|
63
|
+
|
|
64
|
+
// 数据自洽校验:防「拿到别的视频的字幕」
|
|
65
|
+
if (result.body.length) {
|
|
66
|
+
const end = result.body[result.body.length - 1].to;
|
|
67
|
+
if (end < pg.duration * 0.7) {
|
|
68
|
+
result.warning = `字幕只到 ${end.toFixed(0)}s,视频有 ${pg.duration}s,可能是分段轨或数据不匹配,请核对。`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (summary) {
|
|
73
|
+
try {
|
|
74
|
+
const qs = await wbiQuery({ bvid, cid, up_mid: String(view.owner.mid) });
|
|
75
|
+
const r = await getJson(`${API}/x/web-interface/view/conclusion/get?${qs}`);
|
|
76
|
+
if (r.code === 0 && r.data) {
|
|
77
|
+
const m = r.data.model_result || {};
|
|
78
|
+
result.summary = {
|
|
79
|
+
text: m.summary || '',
|
|
80
|
+
outline: (m.outline || []).map((o) => ({
|
|
81
|
+
title: o.title,
|
|
82
|
+
points: (o.part_outline || []).map((pt) => ({
|
|
83
|
+
timestamp: Number(pt.timestamp || 0),
|
|
84
|
+
text: pt.content,
|
|
85
|
+
})),
|
|
86
|
+
})),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
} catch { /* 拿不到总结不算失败 */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 渲染成人读的文本 */
|
|
96
|
+
export function renderText(v, { plain = false, list = false } = {}) {
|
|
97
|
+
const out = [];
|
|
98
|
+
out.push('# ' + v.title + (v.part ? ` [P${v.page} ${v.part}]` : ''));
|
|
99
|
+
out.push(`UP: ${v.uploader} | 时长 ${v.duration}s | ${v.url}`);
|
|
100
|
+
|
|
101
|
+
if (v.noSubtitleReason === 'need-login') {
|
|
102
|
+
out.push('\n[字幕接口要求登录:当前无有效凭据,拿不到字幕轨。运行 `bili login` 扫码登录]');
|
|
103
|
+
} else if (v.noSubtitleReason === 'none-available') {
|
|
104
|
+
out.push('\n[该视频没有字幕轨。纯音乐 / MV / CG 类视频通常没有。可加 --summary 试试 B 站的 AI 总结。]');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (list) {
|
|
108
|
+
if (!v.subtitles.length) out.push('该视频没有字幕轨。');
|
|
109
|
+
for (const s of v.subtitles) out.push(`- ${s.lanDoc} (${s.lan}) ${s.url}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (v.body.length) {
|
|
113
|
+
out.push(`\n## 字幕:${v.subtitles[0].lanDoc || v.subtitles[0].lan}(${v.body.length} 条)\n`);
|
|
114
|
+
if (v.warning) out.push(`⚠️ ${v.warning}\n`);
|
|
115
|
+
for (const it of v.body) out.push(plain ? it.text : `[${ts(it.from)}] ${it.text}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (v.summary) {
|
|
119
|
+
out.push('\n## B站 AI 总结\n');
|
|
120
|
+
if (v.summary.text) out.push(v.summary.text);
|
|
121
|
+
for (const o of v.summary.outline) {
|
|
122
|
+
out.push(`\n### ${o.title}`);
|
|
123
|
+
for (const p of o.points) out.push(`- [${ts(p.timestamp)}] ${p.text}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return out.join('\n') + '\n';
|
|
128
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 安装 agent skill —— 首选 npx skills add,失败则自己往已知目录写
|
|
2
|
+
import { existsSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PKG_ROOT = join(HERE, '..');
|
|
10
|
+
const SKILL_MD = join(PKG_ROOT, 'SKILL.md');
|
|
11
|
+
|
|
12
|
+
// 各 agent 的 skill 目录(装了就往这些地方放)
|
|
13
|
+
const KNOWN_DIRS = [
|
|
14
|
+
'~/.claude/skills',
|
|
15
|
+
'~/.codex/skills',
|
|
16
|
+
'~/.config/agent-skills',
|
|
17
|
+
'~/.agents/skills',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function installSkill({ global: isGlobal = true } = {}) {
|
|
21
|
+
const log = console.log;
|
|
22
|
+
|
|
23
|
+
// 1) 首选:npx skills add(走官方生态,兼容性最好)
|
|
24
|
+
log('尝试 `npx skills add` …');
|
|
25
|
+
const r = spawnSync('npx', ['-y', 'skills@latest', 'add', 'dongliang/bili-transcript',
|
|
26
|
+
isGlobal ? '-g' : '-p', '-y'],
|
|
27
|
+
{ stdio: 'inherit', shell: process.platform === 'win32' });
|
|
28
|
+
if (r.status === 0) {
|
|
29
|
+
log('\n✅ 已通过 npx skills 安装。');
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2) 降级:自己往已知目录写
|
|
34
|
+
log(`\n⚠️ npx skills 不可用(退出码 ${r.status}),改用内置安装:`);
|
|
35
|
+
if (!existsSync(SKILL_MD)) {
|
|
36
|
+
log(`❌ 找不到 ${SKILL_MD},无法安装。`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const targets = KNOWN_DIRS
|
|
41
|
+
.map((d) => d.replace(/^~/, homedir()))
|
|
42
|
+
.filter((d) => existsSync(dirname(d)));
|
|
43
|
+
|
|
44
|
+
if (!targets.length) {
|
|
45
|
+
log('❌ 没有找到任何已知的 agent skill 目录。');
|
|
46
|
+
log(' 请手动把包里的 SKILL.md 复制到你的 agent skill 目录。');
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const t of targets) {
|
|
51
|
+
const dest = join(t, 'bilibili-transcript');
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dest, { recursive: true });
|
|
54
|
+
copyFileSync(SKILL_MD, join(dest, 'SKILL.md'));
|
|
55
|
+
log(` ✅ ${dest}/SKILL.md`);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log(` ❌ ${dest}:${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log('\n注意:降级安装只放 SKILL.md,agent 需要能通过 `npx bili-transcript` 调用工具。');
|
|
62
|
+
return true;
|
|
63
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// 扫码登录 —— 纯命令行,不需要任何浏览器
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { encodeQR, renderText, writePNG } from './qr.mjs';
|
|
4
|
+
import { UA, API } from './api.mjs';
|
|
5
|
+
import { saveAuth, AUTH_PATH, findSessdata } from './auth.mjs';
|
|
6
|
+
|
|
7
|
+
const H = { 'User-Agent': UA, Referer: 'https://www.bilibili.com/' };
|
|
8
|
+
const QRAPI = 'https://passport.bilibili.com/x/passport-login/web/qrcode';
|
|
9
|
+
const KEYFILE = '/tmp/bili_qr.json';
|
|
10
|
+
|
|
11
|
+
/** 读 cookie 头。不同 Node 构建行为不一致:getSetCookie() 可能不存在,get() 可能返回数组 */
|
|
12
|
+
function readSetCookies(resp) {
|
|
13
|
+
const h = resp.headers;
|
|
14
|
+
if (typeof h.getSetCookie === 'function') {
|
|
15
|
+
const v = h.getSetCookie();
|
|
16
|
+
if (v && v.length) return v;
|
|
17
|
+
}
|
|
18
|
+
const raw = h.get('set-cookie');
|
|
19
|
+
if (Array.isArray(raw)) return raw;
|
|
20
|
+
return raw ? [raw] : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function whoami() {
|
|
24
|
+
const nav = await (await fetch(`${API}/x/web-interface/nav`, { headers: H })).json();
|
|
25
|
+
return nav.data?.isLogin ? nav.data : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function login({ png = null, qrOnly = false, resume = false, timeout = 180, force = false, log = console.log }) {
|
|
29
|
+
if (!force && !resume && !qrOnly) {
|
|
30
|
+
const who = await whoami().catch(() => null);
|
|
31
|
+
if (who) {
|
|
32
|
+
log(`已经登录过了:${who.uname}(uid ${who.mid})`);
|
|
33
|
+
log(`凭据位置:${AUTH_PATH}`);
|
|
34
|
+
log('要重新登录请加 --force');
|
|
35
|
+
return { alreadyLoggedIn: true, user: who };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let url, key;
|
|
40
|
+
if (resume) {
|
|
41
|
+
if (!existsSync(KEYFILE)) throw new Error('没有找到之前生成的二维码,请先运行 `bili login --qr-only`');
|
|
42
|
+
const s = JSON.parse(readFileSync(KEYFILE, 'utf8'));
|
|
43
|
+
url = s.url; key = s.key;
|
|
44
|
+
const age = Math.round((Date.now() - s.ts) / 1000);
|
|
45
|
+
log(`沿用 ${age}s 前生成的二维码`);
|
|
46
|
+
if (age > 170) log('⚠️ 已接近过期,若失败请重新生成');
|
|
47
|
+
} else {
|
|
48
|
+
const gen = await (await fetch(`${QRAPI}/generate`, { headers: H })).json();
|
|
49
|
+
if (gen.code !== 0) throw new Error(`生成二维码失败:code=${gen.code} ${gen.message || ''}`);
|
|
50
|
+
url = gen.data.url; key = gen.data.qrcode_key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const matrix = encodeQR(url);
|
|
54
|
+
|
|
55
|
+
if (!resume && !qrOnly) {
|
|
56
|
+
log('\n用手机 B 站 App 扫码登录:\n');
|
|
57
|
+
log(renderText(matrix));
|
|
58
|
+
log(`链接:${url}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (png) {
|
|
62
|
+
const r = writePNG(matrix, png, { scale: 8, quiet: 4 });
|
|
63
|
+
log(`二维码图片已写入 ${png}(${r.width}×${r.height})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (qrOnly) {
|
|
67
|
+
writeFileSync(KEYFILE, JSON.stringify({ url, key, ts: Date.now() }) + '\n');
|
|
68
|
+
log(`已保存二维码到 ${KEYFILE}。给用户看完后用 --resume 继续轮询。`);
|
|
69
|
+
return { qrOnly: true, url, key, png };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log('等待扫码…(二维码约 3 分钟过期)');
|
|
73
|
+
const deadline = Date.now() + timeout * 1000;
|
|
74
|
+
const jar = {};
|
|
75
|
+
let lastState = null;
|
|
76
|
+
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
let resp;
|
|
79
|
+
try {
|
|
80
|
+
resp = await fetch(`${QRAPI}/poll?qrcode_key=${key}`, { headers: H });
|
|
81
|
+
} catch {
|
|
82
|
+
await sleep(2000);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const c of readSetCookies(resp)) {
|
|
86
|
+
const nv = c.split(';')[0];
|
|
87
|
+
const i = nv.indexOf('=');
|
|
88
|
+
if (i > 0) jar[nv.slice(0, i)] = nv.slice(i + 1);
|
|
89
|
+
}
|
|
90
|
+
let body;
|
|
91
|
+
try { body = await resp.json(); } catch { await sleep(2000); continue; }
|
|
92
|
+
const code = body.data?.code;
|
|
93
|
+
if (code !== lastState) {
|
|
94
|
+
lastState = code;
|
|
95
|
+
const label = { 86101: '未扫码', 86090: '已扫码,等待确认', 86038: '二维码已过期' }[code] || `code=${code}`;
|
|
96
|
+
log(` [${new Date().toTimeString().slice(0, 8)}] ${label}`);
|
|
97
|
+
}
|
|
98
|
+
if (code === 0) break;
|
|
99
|
+
if (code === 86038) throw new Error('二维码已过期,请重新运行');
|
|
100
|
+
await sleep(2000);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!jar.SESSDATA) throw new Error('超时,或没能拿到凭据。请重试。');
|
|
104
|
+
|
|
105
|
+
saveAuth(jar);
|
|
106
|
+
const who = await whoami().catch(() => null);
|
|
107
|
+
|
|
108
|
+
log('\n=== 登录成功 ===');
|
|
109
|
+
if (who) log(` 账号:${who.uname}(uid ${who.mid})`);
|
|
110
|
+
log(` 拿到 ${Object.keys(jar).length} 项凭据:${Object.keys(jar).join(', ')}`);
|
|
111
|
+
log(` 已保存到 ${AUTH_PATH}(权限 0600)`);
|
|
112
|
+
|
|
113
|
+
return { ok: true, user: who, cookies: Object.keys(jar) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/src/qr.mjs
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { writeFileSync as _wfs } from 'node:fs';
|
|
2
|
+
import { deflateSync } from 'node:zlib';
|
|
3
|
+
// 纯 JS 二维码编码器 —— 零依赖
|
|
4
|
+
// 支持:字节模式、纠错等级 M、版本 1–10、自动选版本、掩码自动择优
|
|
5
|
+
// 正确性由 tests/qr-verify 与 Python qrcode 库逐模块比对验证
|
|
6
|
+
|
|
7
|
+
// ---------- GF(256) ----------
|
|
8
|
+
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
|
9
|
+
(() => {
|
|
10
|
+
let x = 1;
|
|
11
|
+
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
|
|
12
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
13
|
+
})();
|
|
14
|
+
const gmul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
|
|
15
|
+
|
|
16
|
+
function rsGenPoly(n) {
|
|
17
|
+
let p = [1];
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
const q = [1, EXP[i]];
|
|
20
|
+
const r = new Array(p.length + 1).fill(0);
|
|
21
|
+
for (let a = 0; a < p.length; a++)
|
|
22
|
+
for (let b = 0; b < 2; b++) r[a + b] ^= gmul(p[a], q[b]);
|
|
23
|
+
p = r;
|
|
24
|
+
}
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rsEncode(data, ecLen) {
|
|
29
|
+
const gen = rsGenPoly(ecLen);
|
|
30
|
+
const res = new Array(data.length + ecLen).fill(0);
|
|
31
|
+
data.forEach((v, i) => (res[i] = v));
|
|
32
|
+
for (let i = 0; i < data.length; i++) {
|
|
33
|
+
const coef = res[i];
|
|
34
|
+
if (coef === 0) continue;
|
|
35
|
+
for (let j = 0; j < gen.length; j++) res[i + j] ^= gmul(gen[j], coef);
|
|
36
|
+
}
|
|
37
|
+
return res.slice(data.length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------- 版本表(纠错等级 M,版本 1–10)----------
|
|
41
|
+
// [ [块数, 每块总码字, 每块数据码字], ... ]
|
|
42
|
+
const RS_M = {
|
|
43
|
+
1: [[1, 26, 16]],
|
|
44
|
+
2: [[1, 44, 28]],
|
|
45
|
+
3: [[1, 70, 44]],
|
|
46
|
+
4: [[2, 50, 32]],
|
|
47
|
+
5: [[2, 67, 43]],
|
|
48
|
+
6: [[4, 43, 27]],
|
|
49
|
+
7: [[4, 49, 31]],
|
|
50
|
+
8: [[2, 60, 38], [2, 61, 39]],
|
|
51
|
+
9: [[3, 58, 36], [2, 59, 37]],
|
|
52
|
+
10: [[4, 69, 43], [1, 70, 44]],
|
|
53
|
+
};
|
|
54
|
+
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
|
|
55
|
+
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
|
56
|
+
|
|
57
|
+
function dataCapacity(v) {
|
|
58
|
+
return RS_M[v].reduce((s, [n, , d]) => s + n * d, 0);
|
|
59
|
+
}
|
|
60
|
+
function ccBits(v) { return v <= 9 ? 8 : 16; } // 字节模式字符计数指示符位数
|
|
61
|
+
|
|
62
|
+
// ---------- 比特流 ----------
|
|
63
|
+
class Bits {
|
|
64
|
+
constructor() { this.arr = []; }
|
|
65
|
+
put(val, len) { for (let i = len - 1; i >= 0; i--) this.arr.push((val >>> i) & 1); }
|
|
66
|
+
get length() { return this.arr.length; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function encodeData(bytes, v) {
|
|
70
|
+
const cap = dataCapacity(v) * 8;
|
|
71
|
+
const b = new Bits();
|
|
72
|
+
b.put(0b0100, 4); // 字节模式
|
|
73
|
+
b.put(bytes.length, ccBits(v)); // 字符数
|
|
74
|
+
for (const x of bytes) b.put(x, 8);
|
|
75
|
+
// 终止符
|
|
76
|
+
const term = Math.min(4, cap - b.length);
|
|
77
|
+
b.put(0, term);
|
|
78
|
+
// 补齐到字节
|
|
79
|
+
while (b.length % 8) b.arr.push(0);
|
|
80
|
+
// 填充码字
|
|
81
|
+
const pads = [0xec, 0x11];
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (b.length < cap) { b.put(pads[i++ % 2], 8); }
|
|
84
|
+
// 转成码字
|
|
85
|
+
const out = [];
|
|
86
|
+
for (let k = 0; k < b.length; k += 8) {
|
|
87
|
+
let byte = 0;
|
|
88
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | b.arr[k + j];
|
|
89
|
+
out.push(byte);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function interleave(dataCodewords, v) {
|
|
95
|
+
const blocks = [];
|
|
96
|
+
let off = 0;
|
|
97
|
+
for (const [n, tot, dat] of RS_M[v]) {
|
|
98
|
+
for (let i = 0; i < n; i++) {
|
|
99
|
+
const d = dataCodewords.slice(off, off + dat); off += dat;
|
|
100
|
+
blocks.push({ d, e: rsEncode(d, tot - dat) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const out = [];
|
|
104
|
+
const maxD = Math.max(...blocks.map((b) => b.d.length));
|
|
105
|
+
for (let i = 0; i < maxD; i++) for (const b of blocks) if (i < b.d.length) out.push(b.d[i]);
|
|
106
|
+
const maxE = Math.max(...blocks.map((b) => b.e.length));
|
|
107
|
+
for (let i = 0; i < maxE; i++) for (const b of blocks) if (i < b.e.length) out.push(b.e[i]);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------- 矩阵 ----------
|
|
112
|
+
function newMatrix(size) {
|
|
113
|
+
return Array.from({ length: size }, () => new Array(size).fill(null));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function placeFinder(m, r, c) {
|
|
117
|
+
for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) {
|
|
118
|
+
const y = r + i, x = c + j;
|
|
119
|
+
if (y < 0 || x < 0 || y >= m.length || x >= m.length) continue;
|
|
120
|
+
const on = (i >= 0 && i <= 6 && (j === 0 || j === 6)) ||
|
|
121
|
+
(j >= 0 && j <= 6 && (i === 0 || i === 6)) ||
|
|
122
|
+
(i >= 2 && i <= 4 && j >= 2 && j <= 4);
|
|
123
|
+
m[y][x] = on;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function placeAlignment(m, size, v) {
|
|
128
|
+
const pos = ALIGN[v];
|
|
129
|
+
for (const r of pos) for (const c of pos) {
|
|
130
|
+
if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue;
|
|
131
|
+
for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) {
|
|
132
|
+
m[r + i][c + j] = Math.max(Math.abs(i), Math.abs(j)) !== 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reservedMask(size, v) {
|
|
138
|
+
const r = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
139
|
+
const mark = (rr, cc) => { if (rr >= 0 && cc >= 0 && rr < size && cc < size) r[rr][cc] = true; };
|
|
140
|
+
// 定位图形 + 分隔符(锚点必须是 n-7,不是 n-8)
|
|
141
|
+
for (const [y, x] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
|
|
142
|
+
for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) mark(y + i, x + j);
|
|
143
|
+
}
|
|
144
|
+
// 定时图形
|
|
145
|
+
for (let i = 0; i < size; i++) { mark(6, i); mark(i, 6); }
|
|
146
|
+
// 校正图形
|
|
147
|
+
for (const rr of ALIGN[v]) for (const cc of ALIGN[v]) {
|
|
148
|
+
if ((rr === 6 && cc === 6) || (rr === 6 && cc === size - 7) || (rr === size - 7 && cc === 6)) continue;
|
|
149
|
+
for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) mark(rr + i, cc + j);
|
|
150
|
+
}
|
|
151
|
+
// 格式信息
|
|
152
|
+
for (let i = 0; i <= 8; i++) { mark(8, i); mark(i, 8); }
|
|
153
|
+
for (let i = 0; i < 8; i++) { mark(8, size - 1 - i); mark(size - 1 - i, 8); }
|
|
154
|
+
// 版本信息(v>=7)
|
|
155
|
+
if (v >= 7) {
|
|
156
|
+
for (let i = 0; i < 6; i++) for (let j = 0; j < 3; j++) { mark(size - 11 + j, i); mark(i, size - 11 + j); }
|
|
157
|
+
}
|
|
158
|
+
return r;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildMatrix(v, codewords) {
|
|
162
|
+
const size = 4 * v + 17;
|
|
163
|
+
const m = newMatrix(size);
|
|
164
|
+
const res = reservedMask(size, v);
|
|
165
|
+
placeFinder(m, 0, 0); placeFinder(m, 0, size - 7); placeFinder(m, size - 7, 0);
|
|
166
|
+
placeAlignment(m, size, v);
|
|
167
|
+
for (let i = 8; i < size - 8; i++) { m[6][i] = i % 2 === 0; m[i][6] = i % 2 === 0; }
|
|
168
|
+
m[size - 8][8] = true; // 固定暗模块
|
|
169
|
+
|
|
170
|
+
// 数据按之字形填入
|
|
171
|
+
const bits = [];
|
|
172
|
+
for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >>> i) & 1);
|
|
173
|
+
let bi = 0, up = true;
|
|
174
|
+
for (let col = size - 1; col > 0; col -= 2) {
|
|
175
|
+
if (col === 6) col--;
|
|
176
|
+
for (let k = 0; k < size; k++) {
|
|
177
|
+
const row = up ? size - 1 - k : k;
|
|
178
|
+
for (const c of [col, col - 1]) {
|
|
179
|
+
if (res[row][c]) continue;
|
|
180
|
+
m[row][c] = bi < bits.length ? bits[bi++] === 1 : false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
up = !up;
|
|
184
|
+
}
|
|
185
|
+
return { m, res, size };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function maskFn(id, r, c) {
|
|
189
|
+
switch (id) {
|
|
190
|
+
case 0: return (r + c) % 2 === 0;
|
|
191
|
+
case 1: return r % 2 === 0;
|
|
192
|
+
case 2: return c % 3 === 0;
|
|
193
|
+
case 3: return (r + c) % 3 === 0;
|
|
194
|
+
case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
|
|
195
|
+
case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
|
|
196
|
+
case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
|
|
197
|
+
case 7: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function penalty(m, size) {
|
|
202
|
+
let p = 0;
|
|
203
|
+
// 规则 1:同色连续
|
|
204
|
+
for (let r = 0; r < size; r++) {
|
|
205
|
+
let run = 1;
|
|
206
|
+
for (let c = 1; c < size; c++) {
|
|
207
|
+
if (m[r][c] === m[r][c - 1]) run++; else { if (run >= 5) p += run - 2; run = 1; }
|
|
208
|
+
}
|
|
209
|
+
if (run >= 5) p += run - 2;
|
|
210
|
+
}
|
|
211
|
+
for (let c = 0; c < size; c++) {
|
|
212
|
+
let run = 1;
|
|
213
|
+
for (let r = 1; r < size; r++) {
|
|
214
|
+
if (m[r][c] === m[r - 1][c]) run++; else { if (run >= 5) p += run - 2; run = 1; }
|
|
215
|
+
}
|
|
216
|
+
if (run >= 5) p += run - 2;
|
|
217
|
+
}
|
|
218
|
+
// 规则 2:2x2 同色块
|
|
219
|
+
for (let r = 0; r < size - 1; r++) for (let c = 0; c < size - 1; c++) {
|
|
220
|
+
const v = m[r][c];
|
|
221
|
+
if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) p += 3;
|
|
222
|
+
}
|
|
223
|
+
// 规则 3:1:1:3:1:1 图形
|
|
224
|
+
const pat = [true, false, true, true, true, false, true];
|
|
225
|
+
const check = (get) => {
|
|
226
|
+
for (let i = 0; i + 7 <= size; i++) {
|
|
227
|
+
let hit = true;
|
|
228
|
+
for (let j = 0; j < 7; j++) if (get(i + j) !== pat[j]) { hit = false; break; }
|
|
229
|
+
if (!hit) continue;
|
|
230
|
+
const before = [i - 4, i - 3, i - 2, i - 1];
|
|
231
|
+
const after = [i + 7, i + 8, i + 9, i + 10];
|
|
232
|
+
const allQuiet = (idx) => idx.every((k) => k < 0 || k >= size || get(k) === false);
|
|
233
|
+
if (allQuiet(before) || allQuiet(after)) p += 40;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
for (let r = 0; r < size; r++) check((i) => m[r][i]);
|
|
237
|
+
for (let c = 0; c < size; c++) check((i) => m[i][c]);
|
|
238
|
+
// 规则 4:暗模块比例
|
|
239
|
+
let dark = 0;
|
|
240
|
+
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (m[r][c]) dark++;
|
|
241
|
+
const ratio = (dark * 100) / (size * size);
|
|
242
|
+
p += Math.floor(Math.abs(ratio - 50) / 5) * 10;
|
|
243
|
+
return p;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const BCH_FMT = 0x537, BCH_VER = 0x1f25;
|
|
247
|
+
function bch(data, gen, genBits) {
|
|
248
|
+
let d = data << (genBits - 1);
|
|
249
|
+
while (Math.floor(Math.log2(d)) >= genBits - 1) d ^= gen << (Math.floor(Math.log2(d)) - (genBits - 1));
|
|
250
|
+
return ((data << (genBits - 1)) | d);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function applyFormat(m, size, ecBits, maskId) {
|
|
254
|
+
const data = (ecBits << 3) | maskId;
|
|
255
|
+
const f = bch(data, BCH_FMT, 11) ^ 0x5412;
|
|
256
|
+
const bit = (i) => ((f >>> i) & 1) === 1;
|
|
257
|
+
// 垂直(列 8):b0-b5 在行 0-5,b6/b7 在行 7/8,b8-b14 在行 n-7..n-1
|
|
258
|
+
for (let i = 0; i < 15; i++) {
|
|
259
|
+
const v = bit(i);
|
|
260
|
+
if (i < 6) m[i][8] = v;
|
|
261
|
+
else if (i < 8) m[i + 1][8] = v;
|
|
262
|
+
else m[size - 15 + i][8] = v;
|
|
263
|
+
}
|
|
264
|
+
// 水平(行 8):b0-b7 在列 n-1..n-8,b8 在列 7,b9-b14 在列 5..0
|
|
265
|
+
for (let i = 0; i < 15; i++) {
|
|
266
|
+
const v = bit(i);
|
|
267
|
+
if (i < 8) m[8][size - i - 1] = v;
|
|
268
|
+
else if (i < 9) m[8][7] = v;
|
|
269
|
+
else m[8][14 - i] = v;
|
|
270
|
+
}
|
|
271
|
+
m[size - 8][8] = true; // 固定暗模块
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function applyVersionInfo(m, size, v) {
|
|
275
|
+
if (v < 7) return;
|
|
276
|
+
let d = v;
|
|
277
|
+
let rem = v;
|
|
278
|
+
for (let i = 0; i < 12; i++) {
|
|
279
|
+
rem = (rem << 1) ^ ((rem >>> 11) * BCH_VER);
|
|
280
|
+
}
|
|
281
|
+
const bits = (d << 12) | rem;
|
|
282
|
+
for (let i = 0; i < 18; i++) {
|
|
283
|
+
const b = ((bits >>> i) & 1) === 1;
|
|
284
|
+
const r = Math.floor(i / 3), c = i % 3;
|
|
285
|
+
m[size - 11 + c][r] = b;
|
|
286
|
+
m[r][size - 11 + c] = b;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 编码:返回二维布尔数组(true = 深色) */
|
|
291
|
+
export function encodeQR(text, { ecc = 'M', mask: forcedMask = null } = {}) {
|
|
292
|
+
if (ecc !== 'M') throw new Error('只支持纠错等级 M');
|
|
293
|
+
const bytes = [...Buffer.from(text, 'utf8')];
|
|
294
|
+
let v = 1;
|
|
295
|
+
// 头部位数:模式(4) + 计数(ccBits) ;需要字节对齐后的总位数 <= 容量
|
|
296
|
+
for (v = 1; v <= 10; v++) {
|
|
297
|
+
const need = 4 + ccBits(v) + bytes.length * 8;
|
|
298
|
+
if (need <= dataCapacity(v) * 8) break;
|
|
299
|
+
}
|
|
300
|
+
if (v > 10) throw new Error('内容太长(超过版本 10 容量)');
|
|
301
|
+
|
|
302
|
+
const dataCw = encodeData(bytes, v);
|
|
303
|
+
const all = interleave(dataCw, v);
|
|
304
|
+
const { m, res, size } = buildMatrix(v, all);
|
|
305
|
+
|
|
306
|
+
// 掩码择优(forcedMask 用于测试)
|
|
307
|
+
let best = null, bestScore = Infinity;
|
|
308
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
309
|
+
if (forcedMask !== null && mask !== forcedMask) continue;
|
|
310
|
+
const t = m.map((row) => row.slice());
|
|
311
|
+
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) {
|
|
312
|
+
if (res[r][c]) continue;
|
|
313
|
+
if (maskFn(mask, r, c)) t[r][c] = !t[r][c];
|
|
314
|
+
}
|
|
315
|
+
applyVersionInfo(t, size, v);
|
|
316
|
+
applyFormat(t, size, 0, mask); // 纠错等级 M 的 ecBits = 0
|
|
317
|
+
const s = penalty(t, size);
|
|
318
|
+
if (s < bestScore) { bestScore = s; best = t; }
|
|
319
|
+
}
|
|
320
|
+
return best;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 渲染成文本(两个字符一行,便于终端显示) */
|
|
324
|
+
export function renderText(matrix, dark = '██', light = ' ') {
|
|
325
|
+
return matrix.map((row) => row.map((b) => (b ? dark : light)).join('')).join('\n');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------- 极简 PNG 写出(1 位灰度,零依赖)----------
|
|
329
|
+
const CRC_TABLE = (() => {
|
|
330
|
+
const t = new Int32Array(256);
|
|
331
|
+
for (let n = 0; n < 256; n++) {
|
|
332
|
+
let c = n;
|
|
333
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
334
|
+
t[n] = c;
|
|
335
|
+
}
|
|
336
|
+
return t;
|
|
337
|
+
})();
|
|
338
|
+
function crc32(buf) {
|
|
339
|
+
let c = -1;
|
|
340
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
341
|
+
return (c ^ -1) >>> 0;
|
|
342
|
+
}
|
|
343
|
+
function chunk(type, data) {
|
|
344
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
345
|
+
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
346
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
|
347
|
+
return Buffer.concat([len, td, crc]);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 把矩阵写成 PNG 文件(白底黑点,带静默区) */
|
|
351
|
+
export function writePNG(matrix, path, { scale = 8, quiet = 4 } = {}) {
|
|
352
|
+
const n = matrix.length;
|
|
353
|
+
const w = (n + quiet * 2) * scale;
|
|
354
|
+
const h = w;
|
|
355
|
+
const rowBytes = Math.ceil(w / 8);
|
|
356
|
+
const raw = Buffer.alloc((rowBytes + 1) * h, 0xff); // 全白
|
|
357
|
+
for (let y = 0; y < h; y++) raw[y * (rowBytes + 1)] = 0; // 过滤器类型 0
|
|
358
|
+
for (let r = 0; r < n; r++) {
|
|
359
|
+
for (let c = 0; c < n; c++) {
|
|
360
|
+
if (!matrix[r][c]) continue;
|
|
361
|
+
for (let dy = 0; dy < scale; dy++) {
|
|
362
|
+
const y = (r + quiet) * scale + dy;
|
|
363
|
+
const base = y * (rowBytes + 1) + 1;
|
|
364
|
+
for (let dx = 0; dx < scale; dx++) {
|
|
365
|
+
const x = (c + quiet) * scale + dx;
|
|
366
|
+
raw[base + (x >> 3)] &= ~(0x80 >> (x & 7)); // 置为黑
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const ihdr = Buffer.alloc(13);
|
|
373
|
+
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
|
374
|
+
ihdr[8] = 1; // 位深 1
|
|
375
|
+
ihdr[9] = 0; // 灰度
|
|
376
|
+
const png = Buffer.concat([
|
|
377
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
378
|
+
chunk('IHDR', ihdr),
|
|
379
|
+
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
380
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
381
|
+
]);
|
|
382
|
+
_wfs(path, png);
|
|
383
|
+
return { width: w, height: h, bytes: png.length };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 调试用:导出中间产物 */
|
|
387
|
+
export function debugCodewords(text) {
|
|
388
|
+
const bytes = [...Buffer.from(text, 'utf8')];
|
|
389
|
+
let v = 1;
|
|
390
|
+
for (v = 1; v <= 10; v++) {
|
|
391
|
+
if (4 + ccBits(v) + bytes.length * 8 <= dataCapacity(v) * 8) break;
|
|
392
|
+
}
|
|
393
|
+
const dataCw = encodeData(bytes, v);
|
|
394
|
+
return { version: v, dataCw, all: interleave(dataCw, v) };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 调试用:导出「非数据格」分布 */
|
|
398
|
+
export function debugReserved(version) {
|
|
399
|
+
const size = 4 * version + 17;
|
|
400
|
+
const res = reservedMask(size, version);
|
|
401
|
+
return res.map((row) => row.map((b) => (b ? 1 : 0)).join(''));
|
|
402
|
+
}
|