dsh-bili-watch 0.2.16 → 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/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
@@ -3,3 +3,4 @@
3
3
  - insert:
4
4
  - id: dsh-bili-watch
5
5
  name: dsh-bili-watch
6
+ disabled: true
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
- const PROJ = process.env.BILI_WATCH_PROJ || DEPS
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 VENV_ASR = [join(DEPS, '.venv-asr', PY_DIR, PY_EXE), join(PROJ, '.venv-asr', PY_DIR, PY_EXE)]
51
- const VENV_OCR = [join(DEPS, '.venv-ocr', PY_DIR, PY_EXE), join(PROJ, '.venv-ocr', PY_DIR, PY_EXE)]
52
- const FFMPEG_CANDS = [
53
- process.env.BILI_WATCH_FFMPEG,
54
- join(DEPS, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
55
- ].filter(Boolean)
56
- const HF_HOME = [join(DEPS, '.hf-home'), join(PROJ, '.hf-home')]
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 = FFMPEG_CANDS.find((p) => existsSync(p)) || ''
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 = VENV_ASR.find((p) => existsSync(p)) || ''
368
- const ocrPython = VENV_OCR.find((p) => existsSync(p)) || ''
369
- const model = HF_HOME.find((d) => existsSync(d)) || ''
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,10 +519,25 @@ function sendJson(res, code, obj) {
503
519
  res.end(JSON.stringify(obj))
504
520
  }
505
521
 
506
- /** 写端点的同源闸:Origin 主机名须等于 Host,回环互放;sec-fetch-site 只认 same-origin/none。 */
522
+ /** 写端点的同源闸:Host 必须是回环(挡 DNS rebinding),Origin 主机名须等于 Host,sec-fetch-site 只认 same-origin/none。 */
507
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
+ }
508
535
  function sameOriginBlocked(req) {
509
- const host = String(req.headers?.host ?? "").split(":")[0]
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 必须是回环地址)"
510
541
  const origin = req.headers?.origin
511
542
  if (origin) {
512
543
  let oh = ""
@@ -808,6 +839,7 @@ function apply(ctx) {
808
839
  cache: { ...dirStat(CACHE), ...cacheCfg(), lastAutoClean: cleaned },
809
840
  lastRun: readLastRun(),
810
841
  ui: uiCfg(),
842
+ warnings: cfgWarning ? [cfgWarning] : [],
811
843
  },
812
844
  })
813
845
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-watch",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
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
- const PROJ = process.env.BILI_WATCH_PROJ || DEPS
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 传路径'
@@ -1601,11 +1609,13 @@ const B23_DNS_SERVERS = ['223.5.5.5', '119.29.29.29', '1.1.1.1']
1601
1609
 
1602
1610
  /**
1603
1611
  * 展开 b23.tv 短链:绕过系统 DNS,直接问公共 DNS 拿 IP,再按 IP 直连读 30x 的 Location。
1604
- * 失败返回 null,由调用方给出可执行的替代方案(贴 BV 号)。
1612
+ * 返回 { bv } 或 { error }:error 区分 dns(没解析出来)/ network(连不上)/ not-found(短码不存在)/ unknown。
1613
+ * 注意 b23.tv 对不存在的短码返回 HTTP 200 + {"code":-404,...},所以「没拿到 Location」不等于网络坏
1614
+ * (2026-10-24 wb 第二轮实测,归因错会把用户引向查 DNS)。
1605
1615
  */
1606
1616
  async function expandB23(shortUrl) {
1607
1617
  const m = String(shortUrl).match(/b23\.tv\/([A-Za-z0-9]+)/i)
1608
- if (!m) return null
1618
+ if (!m) return { error: 'unknown' }
1609
1619
  const code = m[1]
1610
1620
  let ips = []
1611
1621
  try {
@@ -1616,8 +1626,9 @@ async function expandB23(shortUrl) {
1616
1626
  } catch { ips = [] }
1617
1627
  // 31.13.0.0/16 是污染常见落点(Facebook 段),直接剔掉
1618
1628
  ips = (ips || []).filter((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^31\.13\./.test(ip)).slice(0, 3)
1619
- if (ips.length === 0) return null
1629
+ if (ips.length === 0) return { error: 'dns' }
1620
1630
  const { request } = await import('node:https')
1631
+ let netErr = false
1621
1632
  for (const ip of ips) {
1622
1633
  let loc = ''
1623
1634
  let body = ''
@@ -1649,24 +1660,75 @@ async function expandB23(shortUrl) {
1649
1660
  })
1650
1661
  loc = got.location
1651
1662
  body = got.body
1652
- } catch { continue }
1663
+ } catch { netErr = true; continue }
1653
1664
  // 正常短链是 302 + Location;少数短链返回带跳转的 HTML/JS,再从正文里捞一次
1654
1665
  const bv = `${loc}\n${body}`.match(/BV[0-9A-Za-z]{10}/)
1655
- if (bv) return bv[0]
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
1656
1698
  }
1657
- return null
1658
1699
  }
1659
1700
 
1660
1701
  async function resolveSource(input) {
1661
1702
  const s = String(input ?? '').trim()
1662
1703
  if (!s) return { type: 'empty' }
1663
1704
  if (/^https?:\/\/b23\.tv\//i.test(s)) {
1664
- const bv = await expandB23(s)
1665
- if (bv) return { type: 'bilibili', bv }
1666
- return { type: 'error', message: 'b23.tv 短链展开失败:本机 DNS 把 b23.tv 解析到了错误地址,公共 DNS 也没连上。请在浏览器打开短链,把地址栏里的完整链接复制过来,或直接贴 BV 号。' }
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 }
1667
1716
  }
1668
1717
  const direct = s.match(/BV[0-9A-Za-z]{10}/)
1669
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
+ }
1670
1732
  const q = s.match(/[?&]bvid=([^&#]+)/)
1671
1733
  if (q && /^BV[0-9A-Za-z]{10}$/.test(q[1])) return { type: 'bilibili', bv: q[1] }
1672
1734
  if (/^https?:\/\//i.test(s)) return { type: 'generic', url: s }