bili-transcript 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/SKILL.md +84 -0
- package/package.json +43 -4
- package/src/api.mjs +74 -0
- package/src/auth.mjs +45 -0
- package/src/cli.mjs +162 -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/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));
|