dsh-bili-watch 0.2.27 → 0.2.29

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.
Files changed (3) hide show
  1. package/index.js +26 -9
  2. package/package.json +1 -1
  3. package/tools/look.mjs +27 -13
package/index.js CHANGED
@@ -30,7 +30,9 @@ function cleanKey(s) {
30
30
 
31
31
  /** 按视频分目录:CACHE/<BV>/<cmd>.txt —— 同一个视频各命令的输出收在一处 */
32
32
  function videoDir(key) {
33
- const base = cleanKey(key)
33
+ // T34-P0-1:模型可能把整条链接当 key 传进来,先取 BV 再建目录,别在缓存根下建乱码目录
34
+ const bvHit = /(BV[0-9A-Za-z]{10})/.exec(String(key || ''))
35
+ const base = cleanKey(bvHit ? bvHit[1] : key)
34
36
  mkdirSync(CACHE, { recursive: true })
35
37
  // 同一个 BV 已经有目录(可能被 CLI 改成「BV_标题」)就用它,别建第二个兄弟目录(2026-10-26 审查 T01/T02)
36
38
  try {
@@ -433,7 +435,11 @@ function pickFolderDialog() {
433
435
  * 缓存自动清理:先按天数删过期文件,再按总量从最旧删到阈值内。
434
436
  * 返回被删统计,供面板显示。
435
437
  */
436
- function autoCleanCache() {
438
+ let lastCleanAt = 0
439
+
440
+ function autoCleanCache(opts = {}) {
441
+ // T31-P2-6:面板每 8 秒轮询 /status,不许每次都全量 walk 缓存目录
442
+ if (!opts.force && Date.now() - lastCleanAt < 10 * 60 * 1000) return { removed: 0, bytes: 0, skipped: true }
437
443
  if (!existsSync(CACHE)) return { removed: 0, bytes: 0 }
438
444
  const { days, maxMb } = cacheCfg()
439
445
  const cutoff = Date.now() - days * 86400000
@@ -466,6 +472,7 @@ function autoCleanCache() {
466
472
  if (total <= limit) break
467
473
  }
468
474
  }
475
+ lastCleanAt = Date.now()
469
476
  return { removed, bytes }
470
477
  }
471
478
 
@@ -682,7 +689,13 @@ function parseCommandLine(raw) {
682
689
  let cred = ''
683
690
  let badOut = false
684
691
  while (tokens.length) {
685
- const t = tokens.shift()
692
+ let t = tokens.shift()
693
+ // T15-4:支持 --option=value(原来整串被当位置参数静默吞掉)
694
+ if (t.startsWith('--') && t.includes('=')) {
695
+ const eq = t.indexOf('=')
696
+ tokens.unshift(t.slice(eq + 1))
697
+ t = t.slice(0, eq)
698
+ }
686
699
  if (t === '--step' && tokens.length) {
687
700
  step = tokens.shift()
688
701
  extra.push('--step', step)
@@ -719,10 +732,10 @@ function parseCommandLine(raw) {
719
732
  positionals.push(t)
720
733
  }
721
734
  }
722
- return { command, bv: positionals[0] || '', extra, step, cred, badOut }
735
+ return { command, bv: positionals[0] || '', positionals, extra, step, cred, badOut }
723
736
  }
724
737
 
725
- async function lookup({ command, bv, extra = [], badOut = false }) {
738
+ async function lookup({ command, bv, positionals = [], extra = [], badOut = false }) {
726
739
  if (badOut) return { ok: false, text: '--out 只接受工作区内的相对路径(不能用绝对路径、盘符、~ 或 ..)' }
727
740
  if (!command || !COMMANDS.includes(command)) {
728
741
  return { ok: false, text: `用法:/bili <${COMMANDS.join('|')}> <BV> [--step 2] [--cred 凭证路径]` }
@@ -737,7 +750,9 @@ async function lookup({ command, bv, extra = [], badOut = false }) {
737
750
  ? ['login', ...extra]
738
751
  : command === 'hot'
739
752
  ? ['hot', ...extra]
740
- : [command, bv, ...extra]
753
+ : command === 'comments' && String(positionals[1] || '').trim()
754
+ ? [command, bv, String(positionals[1]).trim(), ...extra] // T34-P0-3:评论条数是第二个位置参数,别静默丢掉
755
+ : [command, bv, ...extra]
741
756
  const r = await runLook(args)
742
757
  // login 只生成二维码,真正的登录成功由面板 poll 写;命令层不记 lastRun,避免「生成成功」被读成「已登录」
743
758
  if (command !== 'login') writeLastRun(command, r.code)
@@ -756,7 +771,7 @@ async function lookup({ command, bv, extra = [], badOut = false }) {
756
771
  function apply(ctx) {
757
772
  // 启动时按「天数 + 总量阈值」清一次缓存,不点按钮也不会无限涨
758
773
  try {
759
- const r = autoCleanCache()
774
+ const r = autoCleanCache({ force: true })
760
775
  if (r.removed) ctx.logger?.info?.(`[dsh-bili-watch] 自动清理缓存 ${r.removed} 个文件 / ${(r.bytes / 1048576).toFixed(1)} MB`)
761
776
  } catch {}
762
777
 
@@ -769,8 +784,8 @@ function apply(ctx) {
769
784
  hint: '<search 关键词|hot|peek|view|sub|comments|danmaku|asr|frames|sheet|substrip|ocr|text|login|clean> [BV] [--p N] [--step 2] [--days 3] [--order pubdate|click] [--cred 凭证路径] [--fast] [--scene] [--grid 3x3] [--format srt|json|md|txt] [--out 文件(需配合 --format)]',
770
785
  },
771
786
  handler: async (invocation) => {
772
- const { command, bv, extra, badOut } = parseCommandLine(invocation?.rawInput)
773
- const r = await lookup({ command, bv, extra, badOut })
787
+ const { command, bv, positionals, extra, badOut } = parseCommandLine(invocation?.rawInput)
788
+ const r = await lookup({ command, bv, positionals, extra, badOut })
774
789
  return { kind: r.ok ? 'success' : 'error', text: trimForModel(r.text, command, bv) }
775
790
  },
776
791
  })
@@ -780,6 +795,7 @@ function apply(ctx) {
780
795
  try {
781
796
  if (createUserMessage && uiCfg().autoHint !== false) {
782
797
  const hinted = new Map() // message.id → true,同一条消息只提示一次
798
+ const HINT_CAP = 500 // T31-P2-5:常驻 Map 不能无限涨,满了整体清空(只影响重复提示)
783
799
  const hint = (msg) => {
784
800
  const { ok, text } = textOfUserMessage(msg)
785
801
  if (!ok) return null
@@ -806,6 +822,7 @@ function apply(ctx) {
806
822
  const extra = hint(msg)
807
823
  if (!extra) continue
808
824
  hinted.set(msg.id, true)
825
+ if (hinted.size > HINT_CAP) hinted.clear()
809
826
  fresh.push(extra)
810
827
  }
811
828
  if (!fresh.length) return decision
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-watch",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "private": false,
5
5
  "description": "把 B 站视频读成文本给 DSH agent 用:元数据、评论、弹幕、字幕、本地转写、抽帧 OCR;支持 b23.tv 短链与 av 号,抽帧缓存目录可换。只读命令零依赖。",
6
6
  "type": "module",
package/tools/look.mjs CHANGED
@@ -579,7 +579,9 @@ async function cmdPeek(bv, opts = {}) {
579
579
  async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page = 1, scene = false } = {}) {
580
580
  const v = await viewOf(bv, page)
581
581
  const useStep = step > 0 ? step : autoStep(v.dur)
582
- const dirTag = scene ? `scene-${useStep}s` : `${useStep}s`
582
+ // T34-P0-2:时长拿不到(0/异常)时别用 2 秒间隔——3 小时视频会抽几千帧
583
+ const safeStep = (v.dur > 0) ? safeStep : Math.max(safeStep, 10)
584
+ const dirTag = scene ? `scene-${safeStep}s` : `${safeStep}s`
583
585
  const dir = outDir || join(CACHE, 'frames', `${bv}-p${v.p}-${dirTag}`)
584
586
  // --out 指向用户目录时绝不整目录递归删:只覆盖本工具产物(下面会重下 mp4、清 jpg)
585
587
  // --fresh 的清理**必须等拿到锁之后**:锁外删会把别人刚写的 .lock 一起删掉,两个进程都以为持锁(2026-10-26 审查 T24)
@@ -588,13 +590,13 @@ async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page
588
590
  const jpgs = () => readdirSync(dir).filter((f) => f.endsWith('.jpg'))
589
591
  // 只有 manifest 说这批帧抽完了才算缓存有效;残帧(中途被打断)会重抽
590
592
  const m0 = readManifest(dir)
591
- if (!fresh && m0?.ok && m0.step === useStep && m0.scene === scene && jpgs().length > 0) return { dir, mp4, cached: true, frames: jpgs().length }
593
+ if (!fresh && m0?.ok && m0.step === safeStep && m0.scene === scene && jpgs().length > 0) return { dir, mp4, cached: true, frames: jpgs().length }
592
594
  const lock = tryLock(dir)
593
595
  if (!lock) {
594
596
  for (let i = 0; i < 60; i++) {
595
597
  await sleep(1000)
596
598
  const m = readManifest(dir)
597
- if (m?.ok && m.step === useStep && m.scene === scene && jpgs().length > 0) return { dir, mp4, cached: true, frames: jpgs().length }
599
+ if (m?.ok && m.step === safeStep && m.scene === scene && jpgs().length > 0) return { dir, mp4, cached: true, frames: jpgs().length }
598
600
  }
599
601
  throw new Error('同目录有另一个实例在抽帧,等不到结果')
600
602
  }
@@ -619,7 +621,7 @@ async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page
619
621
  let sceneTh = 0
620
622
  if (scene) {
621
623
  // 等间隔模式会抽多少帧,scene 模式就挑多少个变化点
622
- const targetN = Math.max(3, Math.ceil(v.dur / useStep))
624
+ const targetN = Math.max(3, Math.ceil(v.dur / safeStep))
623
625
  for (const th of [0.3, 0.15, 0.08]) {
624
626
  const pts = sceneTimes(mp4, th, bin)
625
627
  if (pts.length >= targetN) { sceneTh = th; frameTimes = pickEvenly(pts, targetN); break }
@@ -638,12 +640,12 @@ async function grabFrames(bv, { step = 0, outDir, ffmpeg, fresh = false, p: page
638
640
  } else {
639
641
  // 默认等间隔
640
642
  const pattern = join(dir, `${bv}-%02d.jpg`)
641
- execFileSync(bin, ['-y', '-threads', '2', '-i', mp4, '-vf', `fps=1/${useStep}`, '-q:v', '2', pattern], { stdio: 'ignore', timeout: 300000 })
643
+ execFileSync(bin, ['-y', '-threads', '2', '-i', mp4, '-vf', `fps=1/${safeStep}`, '-q:v', '2', pattern], { stdio: 'ignore', timeout: 300000 })
642
644
  }
643
645
 
644
646
  const n = jpgs().length
645
647
  if (!n) throw new Error('抽帧没产出图片(可能源流异常)')
646
- writeManifest(dir, { ok: true, bv, p: v.p, step: useStep, scene, sceneTh: scene ? sceneTh : 0, frames: n })
648
+ writeManifest(dir, { ok: true, bv, p: v.p, step: safeStep, scene, sceneTh: scene ? sceneTh : 0, frames: n })
647
649
  return { dir, mp4, frames: n, sceneTh: scene ? sceneTh : 0 }
648
650
  } finally {
649
651
  unlock(lock)
@@ -1070,7 +1072,7 @@ async function cmdComments(bv, ps = 10, opts = {}) {
1070
1072
  if (format === 'json') {
1071
1073
  const payload = { ok: true, source: 'comments', bv, title: v.title, count: items.length, items }
1072
1074
  const s = JSON.stringify(payload, null, 2)
1073
- if (out) { writeFileSync(out, s + '\n', 'utf8'); console.error(`✓ 已写入 ${out}(${items.length} 条)`) }
1075
+ if (out) { try { mkdirSync(dirname(out), { recursive: true }) } catch {}; writeFileSync(out, s + '\n', 'utf8'); console.error(`✓ 已写入 ${out}(${items.length} 条)`) }
1074
1076
  else process.stdout.write(s + '\n')
1075
1077
  return
1076
1078
  }
@@ -1133,7 +1135,7 @@ async function cmdDanmaku(bv, p = 1, opts = {}) {
1133
1135
  const items = all.map((x) => ({ time: x.t, mode: x.mode, size: x.size, color: x.color, text: x.text }))
1134
1136
  const payload = { ok: true, source: 'danmaku', bv, cid: v.cid, count: items.length, items }
1135
1137
  const s = JSON.stringify(payload, null, 2)
1136
- if (out) { writeFileSync(out, s + '\n', 'utf8'); console.error(`✓ 已写入 ${out}(${items.length} 条)`) }
1138
+ if (out) { try { mkdirSync(dirname(out), { recursive: true }) } catch {}; writeFileSync(out, s + '\n', 'utf8'); console.error(`✓ 已写入 ${out}(${items.length} 条)`) }
1137
1139
  else process.stdout.write(s + '\n')
1138
1140
  return
1139
1141
  }
@@ -1376,6 +1378,18 @@ async function cmdSheet(bv, opts = {}) {
1376
1378
  mkdirSync(dirname(out), { recursive: true })
1377
1379
  execFileSync(bin, ['-y', '-threads', '2', '-i', join(tmp, '%02d.jpg'), '-vf', `scale=640:-1,tile=${cols}x${rows}`, '-frames:v', '1', out], { stdio: 'ignore', timeout: 300000 })
1378
1380
  console.log(`拼图: ${out}(${(statSync(out).size / 1024).toFixed(0)} KiB,共 ${picked.length} 帧)`)
1381
+ // T42:九宫格没有时间轴,模型看图不知道「第几格是第几秒」;写一份 manifest 并打一行
1382
+ const stepUsed = Number((/-([0-9]+)s$/.exec(r.dir) || [])[1] || opts.step || 0)
1383
+ const cellSec = (f, i) => {
1384
+ const m = /-([0-9]+)\.jpg$/.exec(f)
1385
+ const n = m ? Number(m[1]) : i + 1
1386
+ return stepUsed > 0 ? Math.max(0, (n - 1) * stepUsed) : null
1387
+ }
1388
+ const cells = picked.map((f, i) => { const sec = cellSec(f, i); return { cell: i + 1, file: basename(f), sec, mmss: sec == null ? '?' : fmtSec(sec) } })
1389
+ try {
1390
+ writeFileSync(out.replace(/\.jpg$/, '.manifest.json'), JSON.stringify({ bv, grid, step: stepUsed || null, cells }, null, 2) + '\n', 'utf8')
1391
+ } catch {}
1392
+ console.log('每格时间:' + cells.map((x) => `#${x.cell}=${x.mmss}`).join(' '))
1379
1393
  console.log('读这张图就能过一遍画面;要精确读字用 ocr <BV>。')
1380
1394
  }
1381
1395
 
@@ -1781,12 +1795,12 @@ async function cmdText(bv, credPath, opts = {}) {
1781
1795
  say(`[track] 字幕: miss(${s.why})`)
1782
1796
 
1783
1797
  // ② 本地 ASR 转写(无字幕时最靠谱,零 token;--fast 跳过,直接走画面)
1784
- if (!opts.fast && asrReady() && v.dur > 1800) {
1785
- say(`[track] 转写: skip(时长 ${fmtSec(v.dur)} 超过 30 分钟,转写会超时)`)
1798
+ if (!opts.fast && asrReady() && v.dur > 900) {
1799
+ say(`[track] 转写: skip(时长 ${fmtSec(v.dur)} 超过 15 分钟,转写会超时)`)
1786
1800
  }
1787
1801
  if (!opts.fast && asrReady() && v.dur <= 1800) {
1788
- say(`[track] 转写: 开始(faster-whisper,最多等 3 分钟)…`)
1789
- const a = await transcribe(bv, { timeoutMs: 180_000, p: v.p })
1802
+ say(`[track] 转写: 开始(faster-whisper,最多等 4 分钟)…`)
1803
+ const a = await transcribe(bv, { timeoutMs: 240_000, p: v.p })
1790
1804
  if (a.ok) {
1791
1805
  // 口播类正常 4–6 字/秒;低于 2 字/秒多半是背景音乐、外语歌或纯画面,
1792
1806
  // 这时候转写出来的「字」只是把杂音听成了字,继续往下走 OCR 更靠谱。
@@ -2140,7 +2154,7 @@ async function transcribeLocal(wav, tag, { model = 'small', timeoutMs = 900_000
2140
2154
  }
2141
2155
  } catch {}
2142
2156
  if (!text && existsSync(cacheTxt)) text = readFileSync(cacheTxt, 'utf8').trim()
2143
- if (!text) return { ok: false, why: `转写零字(stderr 末尾:${String(r.stderr || '').slice(-160)})` }
2157
+ if (!text) return { ok: false, why: `转写零字(stderr 末尾:${String(r.stderr || '').split(String.fromCharCode(10)).filter(Boolean).slice(-6).join(' | ').slice(-1200)})` }
2144
2158
  return { ok: true, text, chars: text.length, verify }
2145
2159
  } catch (e) {
2146
2160
  return { ok: false, why: `转写失败:${String(e?.stderr ?? e?.message ?? e).slice(-200)}` }