dsh-bili-watch 0.2.15 → 0.2.17
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/README.md +2 -1
- package/auto-trigger.js +1 -1
- package/client.js +27 -1
- package/cordis.patch.yml +1 -0
- package/index.js +66 -17
- package/package.json +78 -78
- package/tools/look.mjs +148 -13
package/README.md
CHANGED
|
@@ -146,7 +146,8 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
|
|
|
146
146
|
|
|
147
147
|
用户的消息里只有一条 B 站视频链接(BV 号或 `b23.tv` 短链)时,插件往消息流里补一句提示,告诉 agent 有这条链接可读、`peek` 最便宜。它**不会**替用户调用工具,也不会自动跑起来——只是让「贴链接不说话」这种场景不容易被漏掉。
|
|
148
148
|
|
|
149
|
-
- `b23.tv`
|
|
149
|
+
- `b23.tv` 短链会先自动展开:问公共 DNS(`223.5.5.5` → `119.29.29.29` → `1.1.1.1`)拿真实 IP,再直连读 302 里的 BV 号,绕开系统 DNS;展开失败才提示用户换成浏览器地址栏里的完整链接。
|
|
150
|
+
- 为什么要兜底:部分内网 DNS 会把 `b23.tv` 答成 `31.13.x.x`(Facebook 段),这种环境下只有公共 DNS 这条路能展开(2026-10-23 实测)。
|
|
150
151
|
- 关掉:`deps/ui-config.json` 里 `"autoHint": false`。
|
|
151
152
|
- 消息里除了链接还有别的文字(问题、指令)时不提示,避免打断正常对话。
|
|
152
153
|
|
package/auto-trigger.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const RE_FULL = /https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/(BV[0-9A-Za-z]{10})(?:[/?#][^\s<>"'))]*)?/gi
|
|
10
10
|
// 短链:能识别,但 look.mjs 不能展开(tools/look.mjs:1249-1251),调用方要单独处理
|
|
11
11
|
const RE_SHORT = /https?:\/\/b23\.tv\/[0-9A-Za-z]{5,12}(?:[/?#][^\s<>"'))]*)?/gi
|
|
12
|
-
// ?bvid=BV… 形态(tools/look.mjs:1254
|
|
12
|
+
// ?bvid=BV… 形态(tools/look.mjs:1254 支持);必须挂在 bilibili.com 域名下,避免任意站点的同名参数被当成视频
|
|
13
13
|
const RE_BVID_Q = /https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/[^\s<>"'))]*?[?&]bvid=(BV[0-9A-Za-z]{10})[^\s<>"'))]*/gi
|
|
14
14
|
// 裸 BV 号:前后不许粘字母数字
|
|
15
15
|
const RE_BARE = /(?<![0-9A-Za-z])BV[0-9A-Za-z]{10}(?![0-9A-Za-z])/g
|
package/client.js
CHANGED
|
@@ -99,6 +99,7 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
function Panel() {
|
|
100
100
|
const [st, setSt] = React.useState(null)
|
|
101
101
|
const [err, setErr] = React.useState('')
|
|
102
|
+
const [saveErr, setSaveErr] = React.useState('')
|
|
102
103
|
const [msg, setMsg] = React.useState('')
|
|
103
104
|
const [busy, setBusy] = React.useState('')
|
|
104
105
|
const [days, setDays] = React.useState('7')
|
|
@@ -294,15 +295,24 @@ window.__ModuleLoader__.load({
|
|
|
294
295
|
const v = e.target.checked
|
|
295
296
|
try {
|
|
296
297
|
await jpost('/config', { showInSettings: v })
|
|
298
|
+
setSaveErr('')
|
|
297
299
|
try { window.__biliWatchSyncSettings && window.__biliWatchSyncSettings(v) } catch {}
|
|
298
300
|
await load()
|
|
299
|
-
} catch {
|
|
301
|
+
} catch (e) {
|
|
302
|
+
// 用独立状态:load() 成功后会把 err 清空,写进 err 等于这条提示活不过一帧。
|
|
303
|
+
setSaveErr(`保存失败:服务端未确认,刷新后可能回滚(${String(e?.message || e)})`)
|
|
304
|
+
await load()
|
|
305
|
+
}
|
|
300
306
|
},
|
|
301
307
|
}),
|
|
302
308
|
h('span', { 'data-bl-name': '' }, '在设置页也显示'),
|
|
303
309
|
),
|
|
304
310
|
),
|
|
305
311
|
h('div', { 'data-bl-note': '' }, '关掉只隐藏设置页入口;插件详情页这块面板一直保留。'),
|
|
312
|
+
saveErr ? h('div', { 'data-bl-note': '', style: { color: '#c0392b' } }, saveErr) : null,
|
|
313
|
+
(st.warnings || []).length
|
|
314
|
+
? h('div', { 'data-bl-note': '', style: { color: '#b36b00' } }, st.warnings.join(';'))
|
|
315
|
+
: null,
|
|
306
316
|
),
|
|
307
317
|
h('div', { 'data-bl-head': '' },
|
|
308
318
|
h('span', { 'data-bl-title': '' }, '依赖与状态'),
|
|
@@ -390,6 +400,22 @@ window.__ModuleLoader__.load({
|
|
|
390
400
|
)
|
|
391
401
|
}
|
|
392
402
|
|
|
403
|
+
// 多个插件的顶部横幅都固定在 top:0,会互相盖住;按 DOM 顺序统一往下排。
|
|
404
|
+
function relayoutBanners() {
|
|
405
|
+
const els = document.querySelectorAll('[data-pmc-banner],[data-ms-overlay],[data-bl-overlay]');
|
|
406
|
+
let top = 0;
|
|
407
|
+
for (const el of els) {
|
|
408
|
+
const height = el.offsetHeight;
|
|
409
|
+
if (!height) continue;
|
|
410
|
+
el.style.top = top + 'px';
|
|
411
|
+
top += height;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (typeof window !== 'undefined' && !window.__dshBannerRelayout) {
|
|
415
|
+
window.__dshBannerRelayout = setInterval(relayoutBanners, 2000);
|
|
416
|
+
setTimeout(relayoutBanners, 300);
|
|
417
|
+
}
|
|
418
|
+
|
|
393
419
|
// 缺依赖横幅(shell.overlay)
|
|
394
420
|
function OverlayBanner() {
|
|
395
421
|
const [st, setSt] = React.useState(null)
|
package/cordis.patch.yml
CHANGED
package/index.js
CHANGED
|
@@ -41,19 +41,35 @@ const COMMANDS = ['search', 'hot', 'peek', 'view', 'sub', 'comments', 'danmaku',
|
|
|
41
41
|
|
|
42
42
|
// 设置页端点读的落点,与 tools/look.mjs 同源(look.mjs:41/52/54/46/48/51)
|
|
43
43
|
const DEPS = join(HERE, 'deps')
|
|
44
|
-
|
|
44
|
+
// 本机路径来源:env 优先,其次 deps\deps-config.json(写文件即生效,不必重启),最后 deps 自身。
|
|
45
|
+
// 配置文件不进包(deps 只留空目录),发布时无需脱敏。
|
|
46
|
+
// 解析失败不写 stdout/stderr(会污染 --format json 与 PowerShell 管道),
|
|
47
|
+
// 而是记进 cfgWarning,由 /status 的 warnings 带给面板;ENOENT(没配过)不算异常。
|
|
48
|
+
let cfgWarning = null
|
|
49
|
+
function depsCfg() {
|
|
50
|
+
try { return JSON.parse(readFileSync(join(DEPS, 'deps-config.json'), 'utf8')) } catch (e) {
|
|
51
|
+
if (e?.code !== 'ENOENT') {
|
|
52
|
+
cfgWarning = `deps-config.json 解析失败,已改用 deps 目录:${String(e?.message || e)}`
|
|
53
|
+
}
|
|
54
|
+
return {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function projDir() { return process.env.BILI_WATCH_PROJ || depsCfg().proj || DEPS }
|
|
45
58
|
const CRED_FILE = join(DEPS, 'credential.json')
|
|
46
59
|
const LAST_RUN = join(DEPS, 'last-run.json')
|
|
47
60
|
const CACHE = join(tmpdir(), 'bili-watch')
|
|
48
61
|
const PY_DIR = process.platform === 'win32' ? 'Scripts' : 'bin'
|
|
49
62
|
const PY_EXE = process.platform === 'win32' ? 'python.exe' : 'python'
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
function venvAsr() { const p = projDir(); return [join(DEPS, '.venv-asr', PY_DIR, PY_EXE), join(p, '.venv-asr', PY_DIR, PY_EXE)] }
|
|
64
|
+
function venvOcr() { const p = projDir(); return [join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(p, '.venv-ocr', PY_DIR, PY_EXE)] }
|
|
65
|
+
function ffmpegCands() {
|
|
66
|
+
return [
|
|
67
|
+
process.env.BILI_WATCH_FFMPEG,
|
|
68
|
+
depsCfg().ffmpeg,
|
|
69
|
+
join(DEPS, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
|
70
|
+
].filter(Boolean)
|
|
71
|
+
}
|
|
72
|
+
function hfHomes() { const p = projDir(); return [join(DEPS, '.hf-home'), join(p, '.hf-home')] }
|
|
57
73
|
const SCRIPTS = join(HERE, 'scripts')
|
|
58
74
|
const CACHE_CFG = join(DEPS, 'cache-config.json')
|
|
59
75
|
const UI_CFG = join(DEPS, 'ui-config.json')
|
|
@@ -209,7 +225,7 @@ async function loginPollOnce(key) {
|
|
|
209
225
|
null,
|
|
210
226
|
2,
|
|
211
227
|
),
|
|
212
|
-
'utf8',
|
|
228
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
213
229
|
)
|
|
214
230
|
} catch (e) {
|
|
215
231
|
return { ok: false, state: 'error', why: `凭证写入失败:${e?.message ?? e}` }
|
|
@@ -230,9 +246,9 @@ function writeLastRun(command, code) {
|
|
|
230
246
|
function readLastRun() {
|
|
231
247
|
try {
|
|
232
248
|
const j = JSON.parse(readFileSync(LAST_RUN, 'utf8'))
|
|
233
|
-
return { time: j.time ?? null, command: j.command ?? null, ok: j.ok ?? null }
|
|
249
|
+
return { time: j.time ?? null, command: j.command ?? null, code: j.code ?? null, ok: j.ok ?? null }
|
|
234
250
|
} catch {
|
|
235
|
-
return { time: null, command: null, ok: null }
|
|
251
|
+
return { time: null, command: null, code: null, ok: null }
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
254
|
|
|
@@ -354,7 +370,7 @@ function autoCleanCache() {
|
|
|
354
370
|
|
|
355
371
|
/** 面板上的依赖行:一条依赖一个状态 + 体积 + 是否需要下载。 */
|
|
356
372
|
function depRows() {
|
|
357
|
-
const ffmpeg =
|
|
373
|
+
const ffmpeg = ffmpegCands().find((p) => existsSync(p)) || ''
|
|
358
374
|
let ffmpegSize = 0
|
|
359
375
|
try {
|
|
360
376
|
ffmpegSize = ffmpeg ? statSync(ffmpeg).size : 0
|
|
@@ -364,9 +380,9 @@ function depRows() {
|
|
|
364
380
|
try {
|
|
365
381
|
ytdlpSize = existsSync(ytdlp) ? statSync(ytdlp).size : 0
|
|
366
382
|
} catch {}
|
|
367
|
-
const asrPython =
|
|
368
|
-
const ocrPython =
|
|
369
|
-
const model =
|
|
383
|
+
const asrPython = venvAsr().find((p) => existsSync(p)) || ''
|
|
384
|
+
const ocrPython = venvOcr().find((p) => existsSync(p)) || ''
|
|
385
|
+
const model = hfHomes().find((d) => existsSync(d)) || ''
|
|
370
386
|
const rows = [
|
|
371
387
|
{
|
|
372
388
|
id: 'ffmpeg',
|
|
@@ -503,6 +519,36 @@ function sendJson(res, code, obj) {
|
|
|
503
519
|
res.end(JSON.stringify(obj))
|
|
504
520
|
}
|
|
505
521
|
|
|
522
|
+
/** 写端点的同源闸:Host 必须是回环(挡 DNS rebinding),Origin 主机名须等于 Host,sec-fetch-site 只认 same-origin/none。 */
|
|
523
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"])
|
|
524
|
+
/** 从 Host 头取主机名:同时支持 example.com:3080 与 [::1]:3080。 */
|
|
525
|
+
function hostNameOf(value) {
|
|
526
|
+
const raw = String(value ?? "").trim().toLowerCase()
|
|
527
|
+
if (!raw) return ""
|
|
528
|
+
if (raw.startsWith("[")) {
|
|
529
|
+
const close = raw.indexOf("]")
|
|
530
|
+
return close >= 0 ? raw.slice(0, close + 1) : raw
|
|
531
|
+
}
|
|
532
|
+
const colon = raw.lastIndexOf(":")
|
|
533
|
+
return colon >= 0 && /^\d+$/.test(raw.slice(colon + 1)) ? raw.slice(0, colon) : raw
|
|
534
|
+
}
|
|
535
|
+
function sameOriginBlocked(req) {
|
|
536
|
+
const host = hostNameOf(req.headers?.host)
|
|
537
|
+
// DNS rebinding:攻击者把自己的域名解析到 127.0.0.1,Origin 与 Host 就都是他自己的域名,
|
|
538
|
+
// 只比两者相等挡不住(2026-10-24 wb 第二轮实测 Host/Origin 同为 evil.example:3080 返回 200)。
|
|
539
|
+
// 所以先要求 Host 是回环,再比 Origin。
|
|
540
|
+
if (!LOOPBACK.has(host)) return "非本机请求被拒绝(Host 必须是回环地址)"
|
|
541
|
+
const origin = req.headers?.origin
|
|
542
|
+
if (origin) {
|
|
543
|
+
let oh = ""
|
|
544
|
+
try { oh = new URL(origin).hostname } catch { return "非法 Origin 头" }
|
|
545
|
+
if (oh !== host && !(LOOPBACK.has(host) && LOOPBACK.has(oh))) return "跨源访问被拒绝"
|
|
546
|
+
}
|
|
547
|
+
const site = req.headers?.["sec-fetch-site"]
|
|
548
|
+
if (site && site !== "same-origin" && site !== "none") return `跨站请求被拒绝(sec-fetch-site=${site})`
|
|
549
|
+
return null
|
|
550
|
+
}
|
|
551
|
+
|
|
506
552
|
/** 读 POST 的小 JSON body;读不到就返回 {},端点自己决定默认值。 */
|
|
507
553
|
function readJsonBody(req) {
|
|
508
554
|
return new Promise((resolve) => {
|
|
@@ -627,8 +673,8 @@ function apply(ctx) {
|
|
|
627
673
|
const r = matchSingleVideoLink(text)
|
|
628
674
|
if (!r.hit) return null
|
|
629
675
|
const line = r.kind === 'short'
|
|
630
|
-
// 短链:look.mjs
|
|
631
|
-
? '[bili-watch] 这条消息只有一条 b23.tv
|
|
676
|
+
// 短链:look.mjs 会先按公共 DNS 展开;展开失败才让用户贴 BV 号
|
|
677
|
+
? '[bili-watch] 这条消息只有一条 b23.tv 短链。要读内容就调 bili_watch,它会先按公共 DNS 展开短链;只有展开失败时才请用户贴 BV 号。不要凭短链猜内容。'
|
|
632
678
|
: `[bili-watch] 这条消息只有一条 B 站视频链接(BV ${r.bv})。要读内容就调 bili_watch(command=peek 最便宜);用户没说要看时不要自己跑。`
|
|
633
679
|
return createUserMessage({
|
|
634
680
|
content: [{ type: 'text', text: line }],
|
|
@@ -779,6 +825,8 @@ function apply(ctx) {
|
|
|
779
825
|
kind: 'prefix',
|
|
780
826
|
path: '/api/bili-watch',
|
|
781
827
|
handler: async (req, res) => {
|
|
828
|
+
const blocked = sameOriginBlocked(req)
|
|
829
|
+
if (blocked) return sendJson(res, 403, { ok: false, error: blocked })
|
|
782
830
|
try {
|
|
783
831
|
const url = new URL(req.url, 'http://127.0.0.1')
|
|
784
832
|
if (req.method === 'GET' && url.pathname === '/api/bili-watch/status') {
|
|
@@ -791,6 +839,7 @@ function apply(ctx) {
|
|
|
791
839
|
cache: { ...dirStat(CACHE), ...cacheCfg(), lastAutoClean: cleaned },
|
|
792
840
|
lastRun: readLastRun(),
|
|
793
841
|
ui: uiCfg(),
|
|
842
|
+
warnings: cfgWarning ? [cfgWarning] : [],
|
|
794
843
|
},
|
|
795
844
|
})
|
|
796
845
|
}
|
package/package.json
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-bili-watch",
|
|
3
|
+
"version": "0.2.17",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Reads Bilibili (and generic URLs via yt-dlp) into text for DSH agents: search, hot, metadata, comments, danmaku, AI subtitles, local ASR, frame OCR.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"bili-watch": "tools/look.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./client": "./client.js",
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"dsh": {
|
|
18
|
+
"bundle": {
|
|
19
|
+
"patch": "./cordis.patch.yml"
|
|
20
|
+
},
|
|
21
|
+
"client": {
|
|
22
|
+
"platform": "web",
|
|
23
|
+
"inject": [
|
|
24
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
25
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"client.js",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"tools/",
|
|
34
|
+
"scripts/",
|
|
35
|
+
"deps/.gitkeep",
|
|
36
|
+
"deps/_bili-asr.py",
|
|
37
|
+
"auto-trigger.js",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"keywords": [
|
|
41
|
+
"bilibili",
|
|
42
|
+
"video",
|
|
43
|
+
"asr",
|
|
44
|
+
"ocr",
|
|
45
|
+
"danmaku",
|
|
46
|
+
"dsh",
|
|
47
|
+
"deepseek",
|
|
48
|
+
"subtitles"
|
|
49
|
+
],
|
|
50
|
+
"author": "Sacredbluedragon",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": "\u003e=22"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"qrcode": "^1.5.4"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@deepseek-ai/dsh": "\u003e=0.2.0-rc.1 \u003c0.3.0-0",
|
|
68
|
+
"@deepseek-ai/dsh-tools": "\u003e=0.2.0-rc.1 \u003c0.3.0-0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"@deepseek-ai/dsh": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@deepseek-ai/dsh-tools": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/tools/look.mjs
CHANGED
|
@@ -36,9 +36,17 @@ import { tmpdir } from 'node:os'
|
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
37
|
|
|
38
38
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
39
|
-
// 依赖自包含:优先用插件目录 deps\ 下的环境,缺失时回退 BILI_WATCH_PROJ
|
|
39
|
+
// 依赖自包含:优先用插件目录 deps\ 下的环境,缺失时回退 BILI_WATCH_PROJ / deps\deps-config.json
|
|
40
40
|
const DEPS = join(HERE, '..', 'deps')
|
|
41
|
-
|
|
41
|
+
// 解析失败不静默:写 stderr(不进 stdout,JSON 模式安全);ASCII 前缀避免 PS 管道按 GBK 渲染成乱码。
|
|
42
|
+
function readDepsCfg() {
|
|
43
|
+
try { return JSON.parse(readFileSync(join(DEPS, 'deps-config.json'), 'utf8')) } catch (e) {
|
|
44
|
+
if (e?.code !== 'ENOENT') process.stderr.write(`[bili-watch] deps-config.json parse failed, using deps dir: ${String(e?.message || e)}\n`)
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const depsCfg = readDepsCfg()
|
|
49
|
+
const PROJ = process.env.BILI_WATCH_PROJ || depsCfg.proj || DEPS
|
|
42
50
|
const pick = (...cands) => cands.find((p) => existsSync(p)) ?? cands[cands.length - 1]
|
|
43
51
|
const PY_DIR = process.platform === 'win32' ? 'Scripts' : 'bin'
|
|
44
52
|
const PY_EXE = process.platform === 'win32' ? 'python.exe' : 'python'
|
|
@@ -47,7 +55,7 @@ const ASR_SCRIPT = pick(join(DEPS, '_bili-asr.py'), join(PROJ, '_bili-asr.py'))
|
|
|
47
55
|
const OCR_PY = pick(join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(PROJ, '.venv-ocr', PY_DIR, PY_EXE))
|
|
48
56
|
const HF_HOME_DIR = pick(join(DEPS, '.hf-home'), join(PROJ, '.hf-home'))
|
|
49
57
|
const OCR_SCRIPT = join(HERE, 'ocr.py')
|
|
50
|
-
const FFMPEG_CAND = process.env.BILI_WATCH_FFMPEG || ''
|
|
58
|
+
const FFMPEG_CAND = process.env.BILI_WATCH_FFMPEG || depsCfg.ffmpeg || ''
|
|
51
59
|
// 缺 ffmpeg 时的统一指路(一键脚本 / 环境变量 / --ffmpeg 三条出路)
|
|
52
60
|
const FFMPEG_HINT =
|
|
53
61
|
'跑 scripts/fetch-deps.ps1 -Target ffmpeg(macOS/Linux:bash scripts/fetch-deps.sh 或系统包管理器装),或设 BILI_WATCH_FFMPEG,或用 --ffmpeg 传路径'
|
|
@@ -318,14 +326,25 @@ async function downloadTo(url, dest) {
|
|
|
318
326
|
}
|
|
319
327
|
|
|
320
328
|
// 同目录并发保护:拿到锁的进程抽帧,另一个等它写完再按缓存判
|
|
329
|
+
// 判锁的主人是否还活着:被 Ctrl-C / 超时 kill 的进程会留下 .lock,不判活就永远等一个不存在的进程
|
|
330
|
+
function pidAlive(pid) {
|
|
331
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
332
|
+
try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
|
|
333
|
+
}
|
|
321
334
|
function tryLock(dir) {
|
|
322
335
|
const f = join(dir, '.lock')
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
336
|
+
for (let i = 0; i < 2; i++) {
|
|
337
|
+
try {
|
|
338
|
+
writeFileSync(f, String(process.pid), { flag: 'wx' })
|
|
339
|
+
return f
|
|
340
|
+
} catch {
|
|
341
|
+
let owner = 0
|
|
342
|
+
try { owner = Number(readFileSync(f, 'utf8').trim()) } catch {}
|
|
343
|
+
if (pidAlive(owner)) return null
|
|
344
|
+
unlock(f)
|
|
345
|
+
}
|
|
328
346
|
}
|
|
347
|
+
return null
|
|
329
348
|
}
|
|
330
349
|
function unlock(f) {
|
|
331
350
|
try {
|
|
@@ -1585,15 +1604,131 @@ const flag = (name) => argv.includes(`--${name}`)
|
|
|
1585
1604
|
|
|
1586
1605
|
// ── 通用入口(yt-dlp):B 站外任意 http(s) 视频 ──────────────────────
|
|
1587
1606
|
// 目标可以是裸 BV 号,也可以是 App/网页分享出来的整条链接(带 buvid/spmid/unique_k 等参数)。
|
|
1588
|
-
//
|
|
1589
|
-
|
|
1607
|
+
// 短链 b23.tv 先按公共 DNS 展开(本机内网 DNS 会把它答成 31.13.x.x 这类错误地址,见 2026-10-23 实测)。
|
|
1608
|
+
const B23_DNS_SERVERS = ['223.5.5.5', '119.29.29.29', '1.1.1.1']
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* 展开 b23.tv 短链:绕过系统 DNS,直接问公共 DNS 拿 IP,再按 IP 直连读 30x 的 Location。
|
|
1612
|
+
* 返回 { bv } 或 { error }:error 区分 dns(没解析出来)/ network(连不上)/ not-found(短码不存在)/ unknown。
|
|
1613
|
+
* 注意 b23.tv 对不存在的短码返回 HTTP 200 + {"code":-404,...},所以「没拿到 Location」不等于网络坏
|
|
1614
|
+
* (2026-10-24 wb 第二轮实测,归因错会把用户引向查 DNS)。
|
|
1615
|
+
*/
|
|
1616
|
+
async function expandB23(shortUrl) {
|
|
1617
|
+
const m = String(shortUrl).match(/b23\.tv\/([A-Za-z0-9]+)/i)
|
|
1618
|
+
if (!m) return { error: 'unknown' }
|
|
1619
|
+
const code = m[1]
|
|
1620
|
+
let ips = []
|
|
1621
|
+
try {
|
|
1622
|
+
const { Resolver } = await import('node:dns/promises')
|
|
1623
|
+
const resolver = new Resolver({ timeout: 3000, tries: 2 })
|
|
1624
|
+
resolver.setServers(B23_DNS_SERVERS)
|
|
1625
|
+
ips = await resolver.resolve4('b23.tv')
|
|
1626
|
+
} catch { ips = [] }
|
|
1627
|
+
// 31.13.0.0/16 是污染常见落点(Facebook 段),直接剔掉
|
|
1628
|
+
ips = (ips || []).filter((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^31\.13\./.test(ip)).slice(0, 3)
|
|
1629
|
+
if (ips.length === 0) return { error: 'dns' }
|
|
1630
|
+
const { request } = await import('node:https')
|
|
1631
|
+
let netErr = false
|
|
1632
|
+
for (const ip of ips) {
|
|
1633
|
+
let loc = ''
|
|
1634
|
+
let body = ''
|
|
1635
|
+
try {
|
|
1636
|
+
const got = await new Promise((resolve, reject) => {
|
|
1637
|
+
const req = request({
|
|
1638
|
+
host: 'b23.tv', servername: 'b23.tv', port: 443, path: `/${code}`,
|
|
1639
|
+
method: 'GET',
|
|
1640
|
+
headers: { host: 'b23.tv', 'user-agent': UA, referer: REF, accept: '*/*' },
|
|
1641
|
+
// Node 20+ 开了 autoSelectFamily 时会用 {all:true} 回调,两种签名都要兼容
|
|
1642
|
+
lookup: (_hostname, opts, cb) => (opts && opts.all
|
|
1643
|
+
? cb(null, [{ address: ip, family: 4 }])
|
|
1644
|
+
: cb(null, ip, 4)),
|
|
1645
|
+
timeout: 6000,
|
|
1646
|
+
autoSelectFamily: false,
|
|
1647
|
+
}, (res) => {
|
|
1648
|
+
let buf = ''
|
|
1649
|
+
res.setEncoding('utf8')
|
|
1650
|
+
res.on('data', (chunk) => {
|
|
1651
|
+
if (buf.length < 8192) buf += chunk
|
|
1652
|
+
else res.destroy()
|
|
1653
|
+
})
|
|
1654
|
+
res.on('end', () => resolve({ location: String(res.headers.location || ''), body: buf }))
|
|
1655
|
+
res.on('error', () => resolve({ location: String(res.headers.location || ''), body: buf }))
|
|
1656
|
+
})
|
|
1657
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
1658
|
+
req.on('error', reject)
|
|
1659
|
+
req.end()
|
|
1660
|
+
})
|
|
1661
|
+
loc = got.location
|
|
1662
|
+
body = got.body
|
|
1663
|
+
} catch { netErr = true; continue }
|
|
1664
|
+
// 正常短链是 302 + Location;少数短链返回带跳转的 HTML/JS,再从正文里捞一次
|
|
1665
|
+
const bv = `${loc}\n${body}`.match(/BV[0-9A-Za-z]{10}/)
|
|
1666
|
+
if (bv) return { bv: bv[0] }
|
|
1667
|
+
// P2-3:有的旧视频短链跳到 /video/av<数字>,里面没有 BV 号。先换成 bvid 再返回,
|
|
1668
|
+
// 让下游只认一种标识。
|
|
1669
|
+
const av = `${loc}\n${body}`.match(/\/video\/av(\d+)/i)
|
|
1670
|
+
if (av) {
|
|
1671
|
+
const converted = await aidToBvid(av[1])
|
|
1672
|
+
if (converted) return { bv: converted }
|
|
1673
|
+
}
|
|
1674
|
+
// 没拿到跳转:b23.tv 对不存在的短码回 200 + JSON error body(实测 code=-404),
|
|
1675
|
+
// 这种情况是「链接不对」,不是「DNS 坏了」。
|
|
1676
|
+
let bodyCode = null
|
|
1677
|
+
try {
|
|
1678
|
+
const j = JSON.parse(body)
|
|
1679
|
+
if (j && typeof j.code === 'number') bodyCode = j.code
|
|
1680
|
+
} catch { /* 不是 JSON,继续看下一个 IP */ }
|
|
1681
|
+
if (bodyCode !== null && bodyCode !== 0) return { error: 'not-found', code: bodyCode }
|
|
1682
|
+
}
|
|
1683
|
+
return { error: netErr ? 'network' : 'unknown' }
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** P2-3:av 号转 bvid。view 接口两个都收,但下游只认 bvid,这里一次性换掉。 */
|
|
1687
|
+
async function aidToBvid(aid) {
|
|
1688
|
+
try {
|
|
1689
|
+
const r = await fetch(`https://api.bilibili.com/x/web-interface/view?aid=${encodeURIComponent(aid)}`, {
|
|
1690
|
+
headers: { 'user-agent': UA, referer: REF, accept: 'application/json' },
|
|
1691
|
+
signal: AbortSignal.timeout(5000),
|
|
1692
|
+
})
|
|
1693
|
+
const j = await r.json()
|
|
1694
|
+
const bv = j?.data?.bvid
|
|
1695
|
+
return typeof bv === 'string' && /^BV[0-9A-Za-z]{10}$/.test(bv) ? bv : null
|
|
1696
|
+
} catch {
|
|
1697
|
+
return null
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async function resolveSource(input) {
|
|
1590
1702
|
const s = String(input ?? '').trim()
|
|
1591
1703
|
if (!s) return { type: 'empty' }
|
|
1592
1704
|
if (/^https?:\/\/b23\.tv\//i.test(s)) {
|
|
1593
|
-
|
|
1705
|
+
const r = await expandB23(s)
|
|
1706
|
+
if (r && r.bv) return { type: 'bilibili', bv: r.bv }
|
|
1707
|
+
const why = r?.error
|
|
1708
|
+
const msg = why === 'not-found'
|
|
1709
|
+
? `b23.tv 短链展开失败:这个短码不存在(b23.tv 返回 code=${r.code},不是跳转)。先确认链接有没有复制完整;也可以贴浏览器地址栏里的完整链接或 BV 号。`
|
|
1710
|
+
: why === 'dns'
|
|
1711
|
+
? 'b23.tv 短链展开失败:本机与公共 DNS 都没解析出 b23.tv 的地址(内网 DNS 会把它答成 31.13.x.x 这类错误地址)。请贴完整链接或 BV 号。'
|
|
1712
|
+
: why === 'network'
|
|
1713
|
+
? 'b23.tv 短链展开失败:域名解析到了,但连接超时或被拦。请稍后重试,或直接贴完整链接 / BV 号。'
|
|
1714
|
+
: 'b23.tv 短链展开失败:没拿到跳转,原因不明。请贴完整链接或 BV 号。'
|
|
1715
|
+
return { type: 'error', message: msg }
|
|
1594
1716
|
}
|
|
1595
1717
|
const direct = s.match(/BV[0-9A-Za-z]{10}/)
|
|
1596
1718
|
if (direct) return { type: 'bilibili', bv: direct[0] }
|
|
1719
|
+
// 裸 av 号(用户从别处抄来的 aid):先转 BV,转不出来给出可操作的提示。
|
|
1720
|
+
const bareAv = s.match(/^av(\d+)$/i)
|
|
1721
|
+
if (bareAv) {
|
|
1722
|
+
const converted = await aidToBvid(bareAv[1])
|
|
1723
|
+
if (converted) return { type: 'bilibili', bv: converted }
|
|
1724
|
+
return { type: 'error', message: `av 号 ${s} 转换失败。请贴完整链接 https://www.bilibili.com/video/av${bareAv[1]},或直接给 BV 号。` }
|
|
1725
|
+
}
|
|
1726
|
+
// 完整链接也可能是 av 型(老视频),一并换成 bv,避免落到 generic 走 yt-dlp
|
|
1727
|
+
const avDirect = s.match(/\/video\/av(\d+)/i)
|
|
1728
|
+
if (avDirect) {
|
|
1729
|
+
const converted = await aidToBvid(avDirect[1])
|
|
1730
|
+
if (converted) return { type: 'bilibili', bv: converted }
|
|
1731
|
+
}
|
|
1597
1732
|
const q = s.match(/[?&]bvid=([^&#]+)/)
|
|
1598
1733
|
if (q && /^BV[0-9A-Za-z]{10}$/.test(q[1])) return { type: 'bilibili', bv: q[1] }
|
|
1599
1734
|
if (/^https?:\/\//i.test(s)) return { type: 'generic', url: s }
|
|
@@ -1815,7 +1950,7 @@ let BV = ''
|
|
|
1815
1950
|
let src = { type: 'empty' }
|
|
1816
1951
|
let srcErr = ''
|
|
1817
1952
|
try {
|
|
1818
|
-
src = resolveSource(pos[1])
|
|
1953
|
+
src = await resolveSource(pos[1])
|
|
1819
1954
|
if (src.type === 'bilibili') BV = src.bv
|
|
1820
1955
|
} catch (e) {
|
|
1821
1956
|
srcErr = e?.message ?? String(e)
|
|
@@ -1845,7 +1980,7 @@ const HELP = `用法:
|
|
|
1845
1980
|
--format:sub 支持 text|srt|json|md|txt(srt/md 带真时间戳,md 的时间戳可跳回原片,txt 是一行一句);
|
|
1846
1981
|
text 支持 text|json|md(json 带时间轴口径:字幕 exact / 转写与 OCR 是估算);
|
|
1847
1982
|
comments 与 danmaku 支持 json(stdout 只有 JSON,可接 jq)。
|
|
1848
|
-
B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/...
|
|
1983
|
+
B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/... 也会先按公共 DNS 展开成 BV 号。
|
|
1849
1984
|
通用视频 URL(YouTube/X 等)需要 yt-dlp:跑 scripts/fetch-deps.ps1 -Target ytdlp 装到 deps\\yt-dlp.exe(或设 BILI_WATCH_YTDLP / 系统 PATH)。
|
|
1850
1985
|
`
|
|
1851
1986
|
try {
|