dsh-bili-watch 0.2.12 → 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 +2 -2
- package/index.js +2 -2
- package/package.json +1 -1
- package/tools/look.mjs +85 -11
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
|
|
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 json]` | 四级链:字幕 → 转写 → 抽帧 OCR;`--format 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 文件(需配合 --format)]',
|
|
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,7 +707,7 @@ function apply(ctx) {
|
|
|
707
707
|
},
|
|
708
708
|
format: {
|
|
709
709
|
type: 'string',
|
|
710
|
-
description: 'command=sub 时可选 srt
|
|
710
|
+
description: 'command=sub 时可选 srt、json(带官方时间戳)或 md(Markdown,每条带跳回原片的链接);command=text 时可选 json(含时间轴口径:字幕 exact,转写与 OCR 是估算)或 md。不传默认纯文本。',
|
|
711
711
|
},
|
|
712
712
|
out: {
|
|
713
713
|
type: 'string',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-watch",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
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,
|
|
@@ -1222,7 +1296,7 @@ function markSeen(bv, title) {
|
|
|
1222
1296
|
|
|
1223
1297
|
/** 四级链:字幕 → ASR → 抽帧 OCR。输出最可信的一份文本。 */
|
|
1224
1298
|
async function cmdText(bv, credPath, opts = {}) {
|
|
1225
|
-
QUIET = opts.format === 'json'
|
|
1299
|
+
QUIET = opts.format === 'json' || opts.format === 'md'
|
|
1226
1300
|
const t0 = Date.now()
|
|
1227
1301
|
const cred = credFor(credPath)
|
|
1228
1302
|
const v = await viewOf(bv, opts.p)
|
|
@@ -1249,7 +1323,7 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1249
1323
|
// 信道优先:官方字幕能拿到就是可信内容,语速/关键词只作提示,不拦
|
|
1250
1324
|
// 口径差异(有意):自动化选片链路会把「语速/关键词不合格」当硬拦,
|
|
1251
1325
|
// 这里是人看的排查通道,取到就用、质量交给调用方判断。同一判据两种策略,别当 bug 报。
|
|
1252
|
-
if (
|
|
1326
|
+
if (emitText(opts, {
|
|
1253
1327
|
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1254
1328
|
source: 'subtitle',
|
|
1255
1329
|
lan: s.lan ?? null,
|
|
@@ -1279,7 +1353,7 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1279
1353
|
// 这时候转写出来的「字」只是把杂音听成了字,继续往下走 OCR 更靠谱。
|
|
1280
1354
|
const rate = v.dur > 0 ? a.chars / v.dur : 0
|
|
1281
1355
|
if (rate >= 2) {
|
|
1282
|
-
if (
|
|
1356
|
+
if (emitText(opts, {
|
|
1283
1357
|
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1284
1358
|
source: 'asr',
|
|
1285
1359
|
timeline: { exact: false, source: 'asr-estimate', note: '无官方时间戳;按标点与字数估算' },
|
|
@@ -1309,7 +1383,7 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1309
1383
|
const r = await grabFrames(bv, { step: opts.step ?? 0, p: v.p })
|
|
1310
1384
|
const text = await runOcr(r.dir)
|
|
1311
1385
|
if (text && !text.startsWith('[ocr] 失败')) {
|
|
1312
|
-
if (
|
|
1386
|
+
if (emitText(opts, {
|
|
1313
1387
|
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1314
1388
|
source: 'ocr',
|
|
1315
1389
|
timeline: { exact: false, source: 'frame-ocr', step: opts.step ?? 'auto' },
|
|
@@ -1329,7 +1403,7 @@ async function cmdText(bv, credPath, opts = {}) {
|
|
|
1329
1403
|
}
|
|
1330
1404
|
|
|
1331
1405
|
// ④ 兜底:抽帧读图
|
|
1332
|
-
if (
|
|
1406
|
+
if (emitText(opts, {
|
|
1333
1407
|
video: { bv, title: v.title, up: v.owner, dur: v.dur, p: v.p, pages: v.pages },
|
|
1334
1408
|
source: 'vision',
|
|
1335
1409
|
timeline: { exact: false, source: 'frames-for-vision' },
|