dsh-bili-watch 0.2.11 → 0.2.13

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 CHANGED
@@ -124,12 +124,12 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
124
124
  |---|---|
125
125
  | `view <BV或链接或URL>` | 元数据 + 简介 + 热评 |
126
126
  | `peek <BV或链接>` | 元数据 + 热评 + 5 张均匀帧(最便宜;装了 OCR 环境时会顺带识别这 5 帧,成本略高;仅 B 站) |
127
- | `sub <BV或链接> [--format srt|json] [--out 文件]` | AI 字幕 + 三判据(字/秒、关键词命中、时长);`--format` 输出 SRT 或 JSON(带官方时间戳) |
127
+ | `sub <BV或链接> [--format srt|json|md] [--out 文件]` | AI 字幕 + 三判据(字/秒、关键词命中、时长);`--format` 输出 SRT、JSON(带官方时间戳)或 Markdown(每条带跳回原片的链接) |
128
128
  | `comments <BV> [N]` | 评论区,默认按热度排序 |
129
129
  | `asr <BV或URL>` | 本地转写音频 |
130
130
  | `frames <BV或URL> [--step 秒] [--ocr]` | 抽帧;`--ocr` 顺带识别画面文字 |
131
131
  | `ocr <BV>` | 抽帧并 OCR |
132
- | `text <BV或URL> [--format srt|json]` | 四级链:字幕 → 转写 → 抽帧 OCR;`--format srt|json` 带上时间轴口径(字幕 exact,转写与 OCR 是估算) |
132
+ | `text <BV或URL> [--format json|md]` | 四级链:字幕 → 转写 → 抽帧 OCR;`--format json` 带上时间轴口径(字幕 exact,转写与 OCR 是估算);`md` 有字幕时出时间轴,没有就出标题+全文;SRT 只有 `sub` 支持 |
133
133
  | `sheet <BV> [--grid 3x3]` | 均匀挑帧拼成一张图(`NxM` 如 `3x3`、`4x4`,默认 9 帧) |
134
134
  | `substrip <BV> [--step 秒]` | 底部字幕竖拼成一张图 |
135
135
  | `danmaku <BV>` | 弹幕热区(按时间桶统计峰值) |
package/index.js CHANGED
@@ -602,7 +602,7 @@ function apply(ctx) {
602
602
  name: 'bili',
603
603
  description: '看 B 站视频内容:字幕 → 本地转写 → 抽帧 OCR,输出最可信的一份文本',
604
604
  input: {
605
- 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] [--out 文件]',
605
+ 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] [--out 文件(需配合 --format)]',
606
606
  },
607
607
  handler: async (invocation) => {
608
608
  const { command, bv, extra } = parseCommandLine(invocation?.rawInput)
@@ -707,11 +707,11 @@ function apply(ctx) {
707
707
  },
708
708
  format: {
709
709
  type: 'string',
710
- description: 'command=sub 时可选 srt 或 json(带官方时间戳);command=text 时可选 json(含时间轴口径:字幕 exact,转写与 OCR 是估算)。不传默认纯文本。',
710
+ description: 'command=sub 时可选 srt、json(带官方时间戳)或 md(Markdown,每条带跳回原片的链接);command=text 时可选 json(含时间轴口径:字幕 exact,转写与 OCR 是估算)或 md。不传默认纯文本。',
711
711
  },
712
712
  out: {
713
713
  type: 'string',
714
- description: 'command=sub/text 且传了 format 时,把结果写到这个文件路径;不传打印到标准输出。',
714
+ description: 'command=sub/text 且传了 format 时,把结果写到这个文件(只接受工作区内的相对路径);不传打印到标准输出。',
715
715
  },
716
716
  limit: {
717
717
  type: 'number',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-watch",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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",
@@ -34,7 +34,8 @@
34
34
  "scripts/",
35
35
  "deps/.gitkeep",
36
36
  "deps/_bili-asr.py",
37
- "auto-trigger.js"
37
+ "auto-trigger.js",
38
+ "LICENSE"
38
39
  ],
39
40
  "keywords": [
40
41
  "bilibili",
package/tools/look.mjs CHANGED
@@ -691,6 +691,32 @@ function cuesToSrt(cues) {
691
691
  return rows.join('\n')
692
692
  }
693
693
 
694
+ // Markdown(2026-10-22 加):每条带「跳回原片」的时间戳链接,进笔记软件是原生的
695
+ function cuesToMd(cues, meta = {}) {
696
+ const rows = []
697
+ const bv = String(meta.bv || '').trim()
698
+ const title = String(meta.title || '').trim()
699
+ if (title) rows.push(`# ${title}`, '')
700
+ const info = []
701
+ if (meta.up) info.push(`UP:${meta.up}`)
702
+ if (meta.dur) info.push(`时长:${fmtSec(meta.dur)}`)
703
+ if (meta.lan) info.push(`字幕语言:${meta.lan}`)
704
+ if (meta.estimated) info.push('时间轴:估算(不是官方字幕)')
705
+ if (info.length) rows.push(info.join(' · '), '')
706
+ let n = 0
707
+ for (const c of cues || []) {
708
+ const text = String(c?.content ?? '').trim()
709
+ if (!text) continue
710
+ const sec = Math.max(0, Math.floor(Number(c?.from ?? 0)))
711
+ const mm = String(Math.floor(sec / 60)).padStart(2, '0')
712
+ const ss = String(sec % 60).padStart(2, '0')
713
+ const link = bv ? `https://www.bilibili.com/video/${bv}?t=${sec}` : ''
714
+ n++
715
+ rows.push(link ? `- [${mm}:${ss}](${link}) ${text}` : `- ${mm}:${ss} ${text}`)
716
+ }
717
+ return { text: rows.join('\n') + '\n', count: n }
718
+ }
719
+
694
720
  // text 命令的 --format json:把「这一档拿到的东西 + 时间轴口径」一起吐出去
695
721
  function emitTextJson(opts, payload) {
696
722
  if (String(opts?.format || '').toLowerCase() !== 'json') return false
@@ -702,21 +728,61 @@ function emitTextJson(opts, payload) {
702
728
  return true
703
729
  }
704
730
 
731
+ // text 命令的 --format md:有 cues 就出时间轴(可跳回原片),没有就出「标题 + 元信息 + 全文」
732
+ function emitTextMd(opts, payload) {
733
+ if (String(opts?.format || '').toLowerCase() !== 'md') return false
734
+ const cues = Array.isArray(payload?.cues) ? payload.cues : []
735
+ const meta = {
736
+ bv: payload?.video?.bv,
737
+ title: payload?.video?.title,
738
+ up: payload?.video?.up,
739
+ dur: payload?.video?.dur,
740
+ estimated: payload?.timeline?.exact === false,
741
+ }
742
+ let text
743
+ if (cues.length) {
744
+ text = cuesToMd(cues, meta).text
745
+ } else {
746
+ const head = []
747
+ if (meta.title) head.push(`# ${meta.title}`, '')
748
+ const info = []
749
+ if (meta.up) info.push(`UP:${meta.up}`)
750
+ if (meta.dur) info.push(`时长:${fmtSec(meta.dur)}`)
751
+ if (payload?.source) info.push(`来源:${payload.source}`)
752
+ if (meta.estimated) info.push('时间轴:估算(不是官方字幕)')
753
+ if (info.length) head.push(info.join(' · '), '')
754
+ head.push(String(payload?.text ?? '').trim(), '')
755
+ text = head.join('\n')
756
+ }
757
+ if (opts.out) {
758
+ writeFileSync(opts.out, text, 'utf8')
759
+ console.log(`✓ 已写入 ${opts.out}(source=${payload?.source ?? '?'})`)
760
+ } else console.log(text)
761
+ return true
762
+ }
763
+
764
+ // 按 --format 分派(json / md;其他格式返回 false 走原流程)
765
+ function emitText(opts, payload) {
766
+ if (emitTextJson(opts, payload)) return true
767
+ if (emitTextMd(opts, payload)) return true
768
+ return false
769
+ }
770
+
705
771
  // --format 输出结构化数据时,过程说明一律走 stderr,保证 stdout 能被 jq / 管道直接消费
706
772
  let QUIET = false
707
773
  const say = (...a) => (QUIET ? console.error : console.log)(...a)
708
774
 
709
775
  async function cmdSub(bv, credPath, p = 1, opts = {}) {
710
- QUIET = opts.format === 'srt' || opts.format === 'json'
776
+ QUIET = opts.format === 'srt' || opts.format === 'json' || opts.format === 'md'
711
777
  const format = String(opts.format || 'text').toLowerCase()
712
778
  const out = String(opts.out || '')
713
- if (out && format !== 'json' && format !== 'srt') {
714
- console.log('✗ --out 要配合 --format srt|json;纯文本会直接打印到标准输出')
779
+ if (out && format !== 'json' && format !== 'srt' && format !== 'md') {
780
+ console.log('✗ --out 要配合 --format srt|json|md;纯文本会直接打印到标准输出')
715
781
  process.exitCode = 1
716
782
  return
717
783
  }
718
- if (!['text', 'json', 'srt'].includes(format)) {
719
- console.log(`✗ 不认识的 --format:${format}(可选 text|srt|json)`)
784
+ if (!['text', 'json', 'srt', 'md'].includes(format)) {
785
+ console.log(`✗ 不认识的 --format:${format}(可选 text|srt|json|md)`)
720
786
  process.exitCode = 1
721
787
  return
722
788
  }
@@ -732,7 +798,7 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
732
798
  process.exitCode = 1
733
799
  return
734
800
  }
735
- if (format === 'json' || format === 'srt') {
801
+ if (format === 'json' || format === 'srt' || format === 'md') {
736
802
  const cues = Array.isArray(s.cues) ? s.cues : []
737
803
  const usable = cues.filter((c) => String(c?.content ?? '').trim()).length
738
804
  if (usable === 0) {
@@ -748,6 +814,14 @@ async function cmdSub(bv, credPath, p = 1, opts = {}) {
748
814
  } else console.log(text)
749
815
  return
750
816
  }
817
+ if (format === 'md') {
818
+ const r = cuesToMd(cues, { bv, title: v.title, up: v.owner, dur: v.dur, lan: s.lan ?? '' })
819
+ if (out) {
820
+ writeFileSync(out, r.text, 'utf8')
821
+ console.log(`✓ 已写入 ${out}(${r.count} 条,时间戳可跳回原片)`)
822
+ } else console.log(r.text)
823
+ return
824
+ }
751
825
  const payload = {
752
826
  video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
753
827
  lan: s.lan ?? null,
@@ -1171,6 +1245,7 @@ async function cmdLogin(opts = {}) {
1171
1245
  }
1172
1246
  if (r.state === 'expired') {
1173
1247
  console.log('\n✗ 二维码已过期,重新跑 node look.mjs login')
1248
+ process.exitCode = 1
1174
1249
  return
1175
1250
  }
1176
1251
  if (r.state !== last) {
@@ -1180,6 +1255,7 @@ async function cmdLogin(opts = {}) {
1180
1255
  await sleep(3000)
1181
1256
  }
1182
1257
  console.log('\n✗ 超时(5 分钟),重新跑 node look.mjs login')
1258
+ process.exitCode = 1
1183
1259
  return
1184
1260
  }
1185
1261
 
@@ -1220,7 +1296,7 @@ function markSeen(bv, title) {
1220
1296
 
1221
1297
  /** 四级链:字幕 → ASR → 抽帧 OCR。输出最可信的一份文本。 */
1222
1298
  async function cmdText(bv, credPath, opts = {}) {
1223
- QUIET = opts.format === 'json'
1299
+ QUIET = opts.format === 'json' || opts.format === 'md'
1224
1300
  const t0 = Date.now()
1225
1301
  const cred = credFor(credPath)
1226
1302
  const v = await viewOf(bv, opts.p)
@@ -1247,7 +1323,7 @@ async function cmdText(bv, credPath, opts = {}) {
1247
1323
  // 信道优先:官方字幕能拿到就是可信内容,语速/关键词只作提示,不拦
1248
1324
  // 口径差异(有意):自动化选片链路会把「语速/关键词不合格」当硬拦,
1249
1325
  // 这里是人看的排查通道,取到就用、质量交给调用方判断。同一判据两种策略,别当 bug 报。
1250
- if (emitTextJson(opts, {
1326
+ if (emitText(opts, {
1251
1327
  video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
1252
1328
  source: 'subtitle',
1253
1329
  lan: s.lan ?? null,
@@ -1277,7 +1353,7 @@ async function cmdText(bv, credPath, opts = {}) {
1277
1353
  // 这时候转写出来的「字」只是把杂音听成了字,继续往下走 OCR 更靠谱。
1278
1354
  const rate = v.dur > 0 ? a.chars / v.dur : 0
1279
1355
  if (rate >= 2) {
1280
- if (emitTextJson(opts, {
1356
+ if (emitText(opts, {
1281
1357
  video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
1282
1358
  source: 'asr',
1283
1359
  timeline: { exact: false, source: 'asr-estimate', note: '无官方时间戳;按标点与字数估算' },
@@ -1307,7 +1383,7 @@ async function cmdText(bv, credPath, opts = {}) {
1307
1383
  const r = await grabFrames(bv, { step: opts.step ?? 0, p: v.p })
1308
1384
  const text = await runOcr(r.dir)
1309
1385
  if (text && !text.startsWith('[ocr] 失败')) {
1310
- if (emitTextJson(opts, {
1386
+ if (emitText(opts, {
1311
1387
  video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
1312
1388
  source: 'ocr',
1313
1389
  timeline: { exact: false, source: 'frame-ocr', step: opts.step ?? 'auto' },
@@ -1327,7 +1403,7 @@ async function cmdText(bv, credPath, opts = {}) {
1327
1403
  }
1328
1404
 
1329
1405
  // ④ 兜底:抽帧读图
1330
- if (emitTextJson(opts, {
1406
+ if (emitText(opts, {
1331
1407
  video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
1332
1408
  source: 'vision',
1333
1409
  timeline: { exact: false, source: 'frames-for-vision' },