dsh-bili-watch 0.2.16 → 0.2.19
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 +1 -0
- package/auto-trigger.js +1 -1
- package/client.js +47 -1
- package/cordis.patch.yml +1 -0
- package/index.js +111 -20
- package/package.json +2 -2
- package/tools/look.mjs +76 -13
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ dsh plugin --profile web remove dsh-bili-watch
|
|
|
54
54
|
- **删除**:面板按实际路径判断——装在插件目录里的(yt-dlp、转写模型、登录凭证)可直接删;装在插件目录外的(比如复用系统 ffmpeg 或已有的 Python 环境)按钮置灰,旁边标出它实际在哪。
|
|
55
55
|
- **批量**:标题行的「批量下载 / 批量清理」打开勾选清单,默认勾上所有可操作的项,删除前会再确认一次。
|
|
56
56
|
- **抽帧缓存**:显示当前占用与文件数,「立即清除」一键清空;不点则会按规则自动清理——保留天数(默认 7 天)与总量上限(默认 2048 MB),超过则从最旧的删起。规则存在 `deps/cache-config.json`,插件启动和打开面板时各检查一次。
|
|
57
|
+
- **缓存位置**:默认在系统临时目录(`tmpdir()/bili-watch`)。要换位置就设环境变量 `BILI_WATCH_CACHE`,或在 `deps/deps-config.json` 里写 `"cache": "D:\\你想要的目录"`;改完重启 DSH 生效,面板「抽帧缓存 → 位置」会显示当前实际路径。
|
|
57
58
|
- **最近一次调用**:显示命令、时间、成败,用来判断上一次看视频是否卡住。
|
|
58
59
|
|
|
59
60
|
装完第一次重启 DSH,面板才会出现。
|
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': '' }, '依赖与状态'),
|
|
@@ -369,6 +379,26 @@ window.__ModuleLoader__.load({
|
|
|
369
379
|
h('span', { 'data-bl-name': '' }, '当前占用'),
|
|
370
380
|
h('span', { 'data-bl-detail': '' }, `${fmtBytes(cache.bytes)} / ${cache.files} 个文件`),
|
|
371
381
|
h('button', { 'data-bl-btn': '', onClick: saveCache }, '立即清除')),
|
|
382
|
+
h('div', { 'data-bl-row': '' },
|
|
383
|
+
h('span', { 'data-bl-name': '' }, '位置'),
|
|
384
|
+
h('span', { 'data-bl-detail': '' }, cache.dir || '(系统临时目录)'),
|
|
385
|
+
h('button', {
|
|
386
|
+
'data-bl-btn': '',
|
|
387
|
+
onClick: async () => {
|
|
388
|
+
setSaveErr('')
|
|
389
|
+
try {
|
|
390
|
+
const r = await jpost('/cache/pick', {})
|
|
391
|
+
if (r.ok && r.data?.dir) setMsg(`已选择:${r.data.dir}(重启 DSH 生效)`)
|
|
392
|
+
else if (r.ok === false) setMsg(r.why || '已取消')
|
|
393
|
+
else setMsg('选择失败')
|
|
394
|
+
} catch (e) { setSaveErr(`选择目录失败:${String(e?.message || e)}`) }
|
|
395
|
+
},
|
|
396
|
+
}, '选择目录…')),
|
|
397
|
+
h('div', { 'data-bl-row': '' },
|
|
398
|
+
h('span', { 'data-bl-name': '' }, '改位置'),
|
|
399
|
+
h('span', { 'data-bl-detail': '' }, '或用环境变量 BILI_WATCH_CACHE / deps/deps-config.json 的 "cache";改完重启 DSH 生效。'),
|
|
400
|
+
null),
|
|
401
|
+
msg ? h('div', { 'data-bl-note': '' }, msg) : null,
|
|
372
402
|
h('div', { 'data-bl-row': '' },
|
|
373
403
|
h('span', { 'data-bl-name': '' }, '自动清理规则'),
|
|
374
404
|
h('span', { 'data-bl-detail': '' },
|
|
@@ -390,6 +420,22 @@ window.__ModuleLoader__.load({
|
|
|
390
420
|
)
|
|
391
421
|
}
|
|
392
422
|
|
|
423
|
+
// 多个插件的顶部横幅都固定在 top:0,会互相盖住;按 DOM 顺序统一往下排。
|
|
424
|
+
function relayoutBanners() {
|
|
425
|
+
const els = document.querySelectorAll('[data-pmc-banner],[data-ms-overlay],[data-bl-overlay]');
|
|
426
|
+
let top = 0;
|
|
427
|
+
for (const el of els) {
|
|
428
|
+
const height = el.offsetHeight;
|
|
429
|
+
if (!height) continue;
|
|
430
|
+
el.style.top = top + 'px';
|
|
431
|
+
top += height;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (typeof window !== 'undefined' && !window.__dshBannerRelayout) {
|
|
435
|
+
window.__dshBannerRelayout = setInterval(relayoutBanners, 2000);
|
|
436
|
+
setTimeout(relayoutBanners, 300);
|
|
437
|
+
}
|
|
438
|
+
|
|
393
439
|
// 缺依赖横幅(shell.overlay)
|
|
394
440
|
function OverlayBanner() {
|
|
395
441
|
const [st, setSt] = React.useState(null)
|
package/cordis.patch.yml
CHANGED
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 只读、匿名,不改任何业务状态。
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
|
-
import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { tmpdir } from 'node:os'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
@@ -41,19 +41,53 @@ 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
|
+
// 缓存位置:env BILI_WATCH_CACHE > deps\deps-config.json 的 cache > 系统临时目录。发布版不含本机路径。
|
|
61
|
+
// 自定义目录不可写时不让功能哑掉:回退到临时目录,并把原因挂进 /status.warnings。
|
|
62
|
+
function pickCacheDir() {
|
|
63
|
+
const want = process.env.BILI_WATCH_CACHE || depsCfg().cache || join(tmpdir(), 'bili-watch')
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(want, { recursive: true })
|
|
66
|
+
const probe = join(want, `.write-test-${process.pid}`)
|
|
67
|
+
writeFileSync(probe, 'ok')
|
|
68
|
+
rmSync(probe, { force: true })
|
|
69
|
+
return { dir: want, fallback: null }
|
|
70
|
+
} catch (e) {
|
|
71
|
+
const fb = join(tmpdir(), 'bili-watch')
|
|
72
|
+
try { mkdirSync(fb, { recursive: true }) } catch {}
|
|
73
|
+
return { dir: fb, fallback: String(e?.message || e) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const cachePick = pickCacheDir()
|
|
77
|
+
const CACHE = cachePick.dir
|
|
78
|
+
if (cachePick.fallback) cfgWarning = `缓存目录不可写,已回退到 ${CACHE}:${cachePick.fallback}`
|
|
48
79
|
const PY_DIR = process.platform === 'win32' ? 'Scripts' : 'bin'
|
|
49
80
|
const PY_EXE = process.platform === 'win32' ? 'python.exe' : 'python'
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
function venvAsr() { const p = projDir(); return [join(DEPS, '.venv-asr', PY_DIR, PY_EXE), join(p, '.venv-asr', PY_DIR, PY_EXE)] }
|
|
82
|
+
function venvOcr() { const p = projDir(); return [join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(p, '.venv-ocr', PY_DIR, PY_EXE)] }
|
|
83
|
+
function ffmpegCands() {
|
|
84
|
+
return [
|
|
85
|
+
process.env.BILI_WATCH_FFMPEG,
|
|
86
|
+
depsCfg().ffmpeg,
|
|
87
|
+
join(DEPS, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
|
88
|
+
].filter(Boolean)
|
|
89
|
+
}
|
|
90
|
+
function hfHomes() { const p = projDir(); return [join(DEPS, '.hf-home'), join(p, '.hf-home')] }
|
|
57
91
|
const SCRIPTS = join(HERE, 'scripts')
|
|
58
92
|
const CACHE_CFG = join(DEPS, 'cache-config.json')
|
|
59
93
|
const UI_CFG = join(DEPS, 'ui-config.json')
|
|
@@ -209,7 +243,7 @@ async function loginPollOnce(key) {
|
|
|
209
243
|
null,
|
|
210
244
|
2,
|
|
211
245
|
),
|
|
212
|
-
'utf8',
|
|
246
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
213
247
|
)
|
|
214
248
|
} catch (e) {
|
|
215
249
|
return { ok: false, state: 'error', why: `凭证写入失败:${e?.message ?? e}` }
|
|
@@ -230,9 +264,9 @@ function writeLastRun(command, code) {
|
|
|
230
264
|
function readLastRun() {
|
|
231
265
|
try {
|
|
232
266
|
const j = JSON.parse(readFileSync(LAST_RUN, 'utf8'))
|
|
233
|
-
return { time: j.time ?? null, command: j.command ?? null, ok: j.ok ?? null }
|
|
267
|
+
return { time: j.time ?? null, command: j.command ?? null, code: j.code ?? null, ok: j.ok ?? null }
|
|
234
268
|
} catch {
|
|
235
|
-
return { time: null, command: null, ok: null }
|
|
269
|
+
return { time: null, command: null, code: null, ok: null }
|
|
236
270
|
}
|
|
237
271
|
}
|
|
238
272
|
|
|
@@ -312,6 +346,37 @@ function writeCacheCfg(days, maxMb) {
|
|
|
312
346
|
} catch {}
|
|
313
347
|
}
|
|
314
348
|
|
|
349
|
+
/** 合并写 deps\deps-config.json(只动指定字段,保留用户原有配置)。 */
|
|
350
|
+
function writeDepsCfg(patch) {
|
|
351
|
+
const p = join(DEPS, 'deps-config.json')
|
|
352
|
+
let cur = {}
|
|
353
|
+
try { cur = JSON.parse(readFileSync(p, 'utf8')) } catch {}
|
|
354
|
+
const next = { ...cur, ...patch }
|
|
355
|
+
writeFileSync(p, JSON.stringify(next, null, 2), 'utf8')
|
|
356
|
+
return next
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 弹原生选目录框(Windows)。脚本里只用 ASCII,避免 PS 5.1 按 ANSI 解码中文变乱。
|
|
361
|
+
* 取消返回 null;非 Windows 直接报错,让面板提示手填。
|
|
362
|
+
*/
|
|
363
|
+
function pickFolderDialog() {
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
if (process.platform !== 'win32') return reject(new Error('folder picker is Windows only; set the path manually'))
|
|
366
|
+
const script = 'Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = "bili-watch cache folder"; if ($d.ShowDialog() -eq "OK") { [Console]::Out.Write($d.SelectedPath) }'
|
|
367
|
+
const ps = spawn('powershell.exe', ['-NoProfile', '-STA', '-Command', script], { windowsHide: false })
|
|
368
|
+
let out = ''
|
|
369
|
+
let err = ''
|
|
370
|
+
ps.stdout.on('data', (c) => { out += c })
|
|
371
|
+
ps.stderr.on('data', (c) => { err += c })
|
|
372
|
+
ps.on('close', (code) => {
|
|
373
|
+
if (code !== 0) return reject(new Error(err.trim() || ('powershell exit ' + code)))
|
|
374
|
+
resolve(out.trim() || null)
|
|
375
|
+
})
|
|
376
|
+
ps.on('error', reject)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
315
380
|
/**
|
|
316
381
|
* 缓存自动清理:先按天数删过期文件,再按总量从最旧删到阈值内。
|
|
317
382
|
* 返回被删统计,供面板显示。
|
|
@@ -354,7 +419,7 @@ function autoCleanCache() {
|
|
|
354
419
|
|
|
355
420
|
/** 面板上的依赖行:一条依赖一个状态 + 体积 + 是否需要下载。 */
|
|
356
421
|
function depRows() {
|
|
357
|
-
const ffmpeg =
|
|
422
|
+
const ffmpeg = ffmpegCands().find((p) => existsSync(p)) || ''
|
|
358
423
|
let ffmpegSize = 0
|
|
359
424
|
try {
|
|
360
425
|
ffmpegSize = ffmpeg ? statSync(ffmpeg).size : 0
|
|
@@ -364,9 +429,9 @@ function depRows() {
|
|
|
364
429
|
try {
|
|
365
430
|
ytdlpSize = existsSync(ytdlp) ? statSync(ytdlp).size : 0
|
|
366
431
|
} catch {}
|
|
367
|
-
const asrPython =
|
|
368
|
-
const ocrPython =
|
|
369
|
-
const model =
|
|
432
|
+
const asrPython = venvAsr().find((p) => existsSync(p)) || ''
|
|
433
|
+
const ocrPython = venvOcr().find((p) => existsSync(p)) || ''
|
|
434
|
+
const model = hfHomes().find((d) => existsSync(d)) || ''
|
|
370
435
|
const rows = [
|
|
371
436
|
{
|
|
372
437
|
id: 'ffmpeg',
|
|
@@ -503,10 +568,25 @@ function sendJson(res, code, obj) {
|
|
|
503
568
|
res.end(JSON.stringify(obj))
|
|
504
569
|
}
|
|
505
570
|
|
|
506
|
-
/** 写端点的同源闸:Origin 主机名须等于 Host
|
|
571
|
+
/** 写端点的同源闸:Host 必须是回环(挡 DNS rebinding),Origin 主机名须等于 Host,sec-fetch-site 只认 same-origin/none。 */
|
|
507
572
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"])
|
|
573
|
+
/** 从 Host 头取主机名:同时支持 example.com:3080 与 [::1]:3080。 */
|
|
574
|
+
function hostNameOf(value) {
|
|
575
|
+
const raw = String(value ?? "").trim().toLowerCase()
|
|
576
|
+
if (!raw) return ""
|
|
577
|
+
if (raw.startsWith("[")) {
|
|
578
|
+
const close = raw.indexOf("]")
|
|
579
|
+
return close >= 0 ? raw.slice(0, close + 1) : raw
|
|
580
|
+
}
|
|
581
|
+
const colon = raw.lastIndexOf(":")
|
|
582
|
+
return colon >= 0 && /^\d+$/.test(raw.slice(colon + 1)) ? raw.slice(0, colon) : raw
|
|
583
|
+
}
|
|
508
584
|
function sameOriginBlocked(req) {
|
|
509
|
-
const host =
|
|
585
|
+
const host = hostNameOf(req.headers?.host)
|
|
586
|
+
// DNS rebinding:攻击者把自己的域名解析到 127.0.0.1,Origin 与 Host 就都是他自己的域名,
|
|
587
|
+
// 只比两者相等挡不住(2026-10-24 wb 第二轮实测 Host/Origin 同为 evil.example:3080 返回 200)。
|
|
588
|
+
// 所以先要求 Host 是回环,再比 Origin。
|
|
589
|
+
if (!LOOPBACK.has(host)) return "非本机请求被拒绝(Host 必须是回环地址)"
|
|
510
590
|
const origin = req.headers?.origin
|
|
511
591
|
if (origin) {
|
|
512
592
|
let oh = ""
|
|
@@ -805,12 +885,23 @@ function apply(ctx) {
|
|
|
805
885
|
data: {
|
|
806
886
|
credential: credState(),
|
|
807
887
|
deps: depRows(),
|
|
808
|
-
cache: { ...dirStat(CACHE), ...cacheCfg(), lastAutoClean: cleaned },
|
|
888
|
+
cache: { ...dirStat(CACHE), ...cacheCfg(), dir: CACHE, lastAutoClean: cleaned },
|
|
809
889
|
lastRun: readLastRun(),
|
|
810
890
|
ui: uiCfg(),
|
|
891
|
+
warnings: cfgWarning ? [cfgWarning] : [],
|
|
811
892
|
},
|
|
812
893
|
})
|
|
813
894
|
}
|
|
895
|
+
if (req.method === 'POST' && url.pathname === '/api/bili-watch/cache/pick') {
|
|
896
|
+
try {
|
|
897
|
+
const dir = await pickFolderDialog()
|
|
898
|
+
if (!dir) return sendJson(res, 200, { ok: false, why: '已取消' })
|
|
899
|
+
writeDepsCfg({ cache: dir })
|
|
900
|
+
return sendJson(res, 200, { ok: true, data: { dir, restart: true } })
|
|
901
|
+
} catch (e) {
|
|
902
|
+
return sendJson(res, 500, { ok: false, why: String(e?.message || e) })
|
|
903
|
+
}
|
|
904
|
+
}
|
|
814
905
|
if (req.method === 'POST' && url.pathname === '/api/bili-watch/cache/clear') {
|
|
815
906
|
const body = await readJsonBody(req)
|
|
816
907
|
if (Number.isFinite(body?.days) || Number.isFinite(body?.maxMb)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-watch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
4
4
|
"private": false,
|
|
5
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
6
|
"type": "module",
|
|
@@ -75,4 +75,4 @@
|
|
|
75
75
|
"optional": true
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
}
|
|
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,11 +55,12 @@ 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 传路径'
|
|
54
|
-
|
|
62
|
+
// 缓存位置:env BILI_WATCH_CACHE > deps\deps-config.json 的 cache > 系统临时目录(与 index.js 同一口径)。
|
|
63
|
+
const CACHE = process.env.BILI_WATCH_CACHE || depsCfg.cache || join(tmpdir(), 'bili-watch')
|
|
55
64
|
const require = createRequire(import.meta.url)
|
|
56
65
|
const CRED_FILE = join(DEPS, 'credential.json')
|
|
57
66
|
const LOGIN_KEY_FILE = join(DEPS, 'login-key.json')
|
|
@@ -1601,11 +1610,13 @@ const B23_DNS_SERVERS = ['223.5.5.5', '119.29.29.29', '1.1.1.1']
|
|
|
1601
1610
|
|
|
1602
1611
|
/**
|
|
1603
1612
|
* 展开 b23.tv 短链:绕过系统 DNS,直接问公共 DNS 拿 IP,再按 IP 直连读 30x 的 Location。
|
|
1604
|
-
*
|
|
1613
|
+
* 返回 { bv } 或 { error }:error 区分 dns(没解析出来)/ network(连不上)/ not-found(短码不存在)/ unknown。
|
|
1614
|
+
* 注意 b23.tv 对不存在的短码返回 HTTP 200 + {"code":-404,...},所以「没拿到 Location」不等于网络坏
|
|
1615
|
+
* (2026-10-24 wb 第二轮实测,归因错会把用户引向查 DNS)。
|
|
1605
1616
|
*/
|
|
1606
1617
|
async function expandB23(shortUrl) {
|
|
1607
1618
|
const m = String(shortUrl).match(/b23\.tv\/([A-Za-z0-9]+)/i)
|
|
1608
|
-
if (!m) return
|
|
1619
|
+
if (!m) return { error: 'unknown' }
|
|
1609
1620
|
const code = m[1]
|
|
1610
1621
|
let ips = []
|
|
1611
1622
|
try {
|
|
@@ -1616,8 +1627,9 @@ async function expandB23(shortUrl) {
|
|
|
1616
1627
|
} catch { ips = [] }
|
|
1617
1628
|
// 31.13.0.0/16 是污染常见落点(Facebook 段),直接剔掉
|
|
1618
1629
|
ips = (ips || []).filter((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^31\.13\./.test(ip)).slice(0, 3)
|
|
1619
|
-
if (ips.length === 0) return
|
|
1630
|
+
if (ips.length === 0) return { error: 'dns' }
|
|
1620
1631
|
const { request } = await import('node:https')
|
|
1632
|
+
let netErr = false
|
|
1621
1633
|
for (const ip of ips) {
|
|
1622
1634
|
let loc = ''
|
|
1623
1635
|
let body = ''
|
|
@@ -1649,24 +1661,75 @@ async function expandB23(shortUrl) {
|
|
|
1649
1661
|
})
|
|
1650
1662
|
loc = got.location
|
|
1651
1663
|
body = got.body
|
|
1652
|
-
} catch { continue }
|
|
1664
|
+
} catch { netErr = true; continue }
|
|
1653
1665
|
// 正常短链是 302 + Location;少数短链返回带跳转的 HTML/JS,再从正文里捞一次
|
|
1654
1666
|
const bv = `${loc}\n${body}`.match(/BV[0-9A-Za-z]{10}/)
|
|
1655
|
-
if (bv) return bv[0]
|
|
1667
|
+
if (bv) return { bv: bv[0] }
|
|
1668
|
+
// P2-3:有的旧视频短链跳到 /video/av<数字>,里面没有 BV 号。先换成 bvid 再返回,
|
|
1669
|
+
// 让下游只认一种标识。
|
|
1670
|
+
const av = `${loc}\n${body}`.match(/\/video\/av(\d+)/i)
|
|
1671
|
+
if (av) {
|
|
1672
|
+
const converted = await aidToBvid(av[1])
|
|
1673
|
+
if (converted) return { bv: converted }
|
|
1674
|
+
}
|
|
1675
|
+
// 没拿到跳转:b23.tv 对不存在的短码回 200 + JSON error body(实测 code=-404),
|
|
1676
|
+
// 这种情况是「链接不对」,不是「DNS 坏了」。
|
|
1677
|
+
let bodyCode = null
|
|
1678
|
+
try {
|
|
1679
|
+
const j = JSON.parse(body)
|
|
1680
|
+
if (j && typeof j.code === 'number') bodyCode = j.code
|
|
1681
|
+
} catch { /* 不是 JSON,继续看下一个 IP */ }
|
|
1682
|
+
if (bodyCode !== null && bodyCode !== 0) return { error: 'not-found', code: bodyCode }
|
|
1683
|
+
}
|
|
1684
|
+
return { error: netErr ? 'network' : 'unknown' }
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** P2-3:av 号转 bvid。view 接口两个都收,但下游只认 bvid,这里一次性换掉。 */
|
|
1688
|
+
async function aidToBvid(aid) {
|
|
1689
|
+
try {
|
|
1690
|
+
const r = await fetch(`https://api.bilibili.com/x/web-interface/view?aid=${encodeURIComponent(aid)}`, {
|
|
1691
|
+
headers: { 'user-agent': UA, referer: REF, accept: 'application/json' },
|
|
1692
|
+
signal: AbortSignal.timeout(5000),
|
|
1693
|
+
})
|
|
1694
|
+
const j = await r.json()
|
|
1695
|
+
const bv = j?.data?.bvid
|
|
1696
|
+
return typeof bv === 'string' && /^BV[0-9A-Za-z]{10}$/.test(bv) ? bv : null
|
|
1697
|
+
} catch {
|
|
1698
|
+
return null
|
|
1656
1699
|
}
|
|
1657
|
-
return null
|
|
1658
1700
|
}
|
|
1659
1701
|
|
|
1660
1702
|
async function resolveSource(input) {
|
|
1661
1703
|
const s = String(input ?? '').trim()
|
|
1662
1704
|
if (!s) return { type: 'empty' }
|
|
1663
1705
|
if (/^https?:\/\/b23\.tv\//i.test(s)) {
|
|
1664
|
-
const
|
|
1665
|
-
if (bv) return { type: 'bilibili', bv }
|
|
1666
|
-
|
|
1706
|
+
const r = await expandB23(s)
|
|
1707
|
+
if (r && r.bv) return { type: 'bilibili', bv: r.bv }
|
|
1708
|
+
const why = r?.error
|
|
1709
|
+
const msg = why === 'not-found'
|
|
1710
|
+
? `b23.tv 短链展开失败:这个短码不存在(b23.tv 返回 code=${r.code},不是跳转)。先确认链接有没有复制完整;也可以贴浏览器地址栏里的完整链接或 BV 号。`
|
|
1711
|
+
: why === 'dns'
|
|
1712
|
+
? 'b23.tv 短链展开失败:本机与公共 DNS 都没解析出 b23.tv 的地址(内网 DNS 会把它答成 31.13.x.x 这类错误地址)。请贴完整链接或 BV 号。'
|
|
1713
|
+
: why === 'network'
|
|
1714
|
+
? 'b23.tv 短链展开失败:域名解析到了,但连接超时或被拦。请稍后重试,或直接贴完整链接 / BV 号。'
|
|
1715
|
+
: 'b23.tv 短链展开失败:没拿到跳转,原因不明。请贴完整链接或 BV 号。'
|
|
1716
|
+
return { type: 'error', message: msg }
|
|
1667
1717
|
}
|
|
1668
1718
|
const direct = s.match(/BV[0-9A-Za-z]{10}/)
|
|
1669
1719
|
if (direct) return { type: 'bilibili', bv: direct[0] }
|
|
1720
|
+
// 裸 av 号(用户从别处抄来的 aid):先转 BV,转不出来给出可操作的提示。
|
|
1721
|
+
const bareAv = s.match(/^av(\d+)$/i)
|
|
1722
|
+
if (bareAv) {
|
|
1723
|
+
const converted = await aidToBvid(bareAv[1])
|
|
1724
|
+
if (converted) return { type: 'bilibili', bv: converted }
|
|
1725
|
+
return { type: 'error', message: `av 号 ${s} 转换失败。请贴完整链接 https://www.bilibili.com/video/av${bareAv[1]},或直接给 BV 号。` }
|
|
1726
|
+
}
|
|
1727
|
+
// 完整链接也可能是 av 型(老视频),一并换成 bv,避免落到 generic 走 yt-dlp
|
|
1728
|
+
const avDirect = s.match(/\/video\/av(\d+)/i)
|
|
1729
|
+
if (avDirect) {
|
|
1730
|
+
const converted = await aidToBvid(avDirect[1])
|
|
1731
|
+
if (converted) return { type: 'bilibili', bv: converted }
|
|
1732
|
+
}
|
|
1670
1733
|
const q = s.match(/[?&]bvid=([^&#]+)/)
|
|
1671
1734
|
if (q && /^BV[0-9A-Za-z]{10}$/.test(q[1])) return { type: 'bilibili', bv: q[1] }
|
|
1672
1735
|
if (/^https?:\/\//i.test(s)) return { type: 'generic', url: s }
|