dsh-bili-watch 0.2.15 → 0.2.16

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
@@ -146,7 +146,8 @@ macOS/Linux 的 ffmpeg 用系统包管理器装,脚本只给提示。其余目
146
146
 
147
147
  用户的消息里只有一条 B 站视频链接(BV 号或 `b23.tv` 短链)时,插件往消息流里补一句提示,告诉 agent 有这条链接可读、`peek` 最便宜。它**不会**替用户调用工具,也不会自动跑起来——只是让「贴链接不说话」这种场景不容易被漏掉。
148
148
 
149
- - `b23.tv` 短链只提示不展开(插件不解析短链,会请用户换成浏览器地址栏里的完整链接)。
149
+ - `b23.tv` 短链会先自动展开:问公共 DNS(`223.5.5.5` → `119.29.29.29` → `1.1.1.1`)拿真实 IP,再直连读 302 里的 BV 号,绕开系统 DNS;展开失败才提示用户换成浏览器地址栏里的完整链接。
150
+ - 为什么要兜底:部分内网 DNS 会把 `b23.tv` 答成 `31.13.x.x`(Facebook 段),这种环境下只有公共 DNS 这条路能展开(2026-10-23 实测)。
150
151
  - 关掉:`deps/ui-config.json` 里 `"autoHint": false`。
151
152
  - 消息里除了链接还有别的文字(问题、指令)时不提示,避免打断正常对话。
152
153
 
package/index.js CHANGED
@@ -503,6 +503,21 @@ function sendJson(res, code, obj) {
503
503
  res.end(JSON.stringify(obj))
504
504
  }
505
505
 
506
+ /** 写端点的同源闸:Origin 主机名须等于 Host,回环互放;sec-fetch-site 只认 same-origin/none。 */
507
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"])
508
+ function sameOriginBlocked(req) {
509
+ const host = String(req.headers?.host ?? "").split(":")[0]
510
+ const origin = req.headers?.origin
511
+ if (origin) {
512
+ let oh = ""
513
+ try { oh = new URL(origin).hostname } catch { return "非法 Origin 头" }
514
+ if (oh !== host && !(LOOPBACK.has(host) && LOOPBACK.has(oh))) return "跨源访问被拒绝"
515
+ }
516
+ const site = req.headers?.["sec-fetch-site"]
517
+ if (site && site !== "same-origin" && site !== "none") return `跨站请求被拒绝(sec-fetch-site=${site})`
518
+ return null
519
+ }
520
+
506
521
  /** 读 POST 的小 JSON body;读不到就返回 {},端点自己决定默认值。 */
507
522
  function readJsonBody(req) {
508
523
  return new Promise((resolve) => {
@@ -627,8 +642,8 @@ function apply(ctx) {
627
642
  const r = matchSingleVideoLink(text)
628
643
  if (!r.hit) return null
629
644
  const line = r.kind === 'short'
630
- // 短链:look.mjs 不能展开,只让用户展开
631
- ? '[bili-watch] 这条消息只有一条 b23.tv 短链。本插件不能展开短链,先请用户把浏览器地址栏里的完整链接发过来;不要凭短链猜内容。'
645
+ // 短链:look.mjs 会先按公共 DNS 展开;展开失败才让用户贴 BV 号
646
+ ? '[bili-watch] 这条消息只有一条 b23.tv 短链。要读内容就调 bili_watch,它会先按公共 DNS 展开短链;只有展开失败时才请用户贴 BV 号。不要凭短链猜内容。'
632
647
  : `[bili-watch] 这条消息只有一条 B 站视频链接(BV ${r.bv})。要读内容就调 bili_watch(command=peek 最便宜);用户没说要看时不要自己跑。`
633
648
  return createUserMessage({
634
649
  content: [{ type: 'text', text: line }],
@@ -779,6 +794,8 @@ function apply(ctx) {
779
794
  kind: 'prefix',
780
795
  path: '/api/bili-watch',
781
796
  handler: async (req, res) => {
797
+ const blocked = sameOriginBlocked(req)
798
+ if (blocked) return sendJson(res, 403, { ok: false, error: blocked })
782
799
  try {
783
800
  const url = new URL(req.url, 'http://127.0.0.1')
784
801
  if (req.method === 'GET' && url.pathname === '/api/bili-watch/status') {
package/package.json CHANGED
@@ -1,78 +1,78 @@
1
- {
2
- "name": "dsh-bili-watch",
3
- "version": "0.2.15",
4
- "private": false,
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
- "type": "module",
7
- "main": "index.js",
8
- "bin": {
9
- "bili-watch": "tools/look.mjs"
10
- },
11
- "exports": {
12
- ".": "./index.js",
13
- "./client": "./client.js",
14
- "./cordis.patch.yml": "./cordis.patch.yml",
15
- "./package.json": "./package.json"
16
- },
17
- "dsh": {
18
- "bundle": {
19
- "patch": "./cordis.patch.yml"
20
- },
21
- "client": {
22
- "platform": "web",
23
- "inject": [
24
- "@deepseek-ai/dsh-client-runtime",
25
- "@deepseek-ai/dsh-client-ui-settings"
26
- ]
27
- }
28
- },
29
- "files": [
30
- "index.js",
31
- "client.js",
32
- "cordis.patch.yml",
33
- "tools/",
34
- "scripts/",
35
- "deps/.gitkeep",
36
- "deps/_bili-asr.py",
37
- "auto-trigger.js",
38
- "LICENSE"
39
- ],
40
- "keywords": [
41
- "bilibili",
42
- "video",
43
- "asr",
44
- "ocr",
45
- "danmaku",
46
- "dsh",
47
- "deepseek",
48
- "subtitles"
49
- ],
50
- "author": "Sacredbluedragon",
51
- "license": "MIT",
52
- "repository": {
53
- "type": "git",
54
- "url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
55
- },
56
- "bugs": {
57
- "url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
58
- },
59
- "homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
60
- "engines": {
61
- "node": ">=22"
62
- },
63
- "dependencies": {
64
- "qrcode": "^1.5.4"
65
- },
66
- "peerDependencies": {
67
- "@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.3.0-0",
68
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.3.0-0"
69
- },
70
- "peerDependenciesMeta": {
71
- "@deepseek-ai/dsh": {
72
- "optional": true
73
- },
74
- "@deepseek-ai/dsh-tools": {
75
- "optional": true
76
- }
77
- }
1
+ {
2
+ "name": "dsh-bili-watch",
3
+ "version": "0.2.16",
4
+ "private": false,
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
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": {
9
+ "bili-watch": "tools/look.mjs"
10
+ },
11
+ "exports": {
12
+ ".": "./index.js",
13
+ "./client": "./client.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "platform": "web",
23
+ "inject": [
24
+ "@deepseek-ai/dsh-client-runtime",
25
+ "@deepseek-ai/dsh-client-ui-settings"
26
+ ]
27
+ }
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "client.js",
32
+ "cordis.patch.yml",
33
+ "tools/",
34
+ "scripts/",
35
+ "deps/.gitkeep",
36
+ "deps/_bili-asr.py",
37
+ "auto-trigger.js",
38
+ "LICENSE"
39
+ ],
40
+ "keywords": [
41
+ "bilibili",
42
+ "video",
43
+ "asr",
44
+ "ocr",
45
+ "danmaku",
46
+ "dsh",
47
+ "deepseek",
48
+ "subtitles"
49
+ ],
50
+ "author": "Sacredbluedragon",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Sacredbluedragon/dsh-bili-watch.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/Sacredbluedragon/dsh-bili-watch/issues"
58
+ },
59
+ "homepage": "https://github.com/Sacredbluedragon/dsh-bili-watch#readme",
60
+ "engines": {
61
+ "node": "\u003e=22"
62
+ },
63
+ "dependencies": {
64
+ "qrcode": "^1.5.4"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/dsh": "\u003e=0.2.0-rc.1 \u003c0.3.0-0",
68
+ "@deepseek-ai/dsh-tools": "\u003e=0.2.0-rc.1 \u003c0.3.0-0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@deepseek-ai/dsh": {
72
+ "optional": true
73
+ },
74
+ "@deepseek-ai/dsh-tools": {
75
+ "optional": true
76
+ }
77
+ }
78
78
  }
package/tools/look.mjs CHANGED
@@ -318,14 +318,25 @@ async function downloadTo(url, dest) {
318
318
  }
319
319
 
320
320
  // 同目录并发保护:拿到锁的进程抽帧,另一个等它写完再按缓存判
321
+ // 判锁的主人是否还活着:被 Ctrl-C / 超时 kill 的进程会留下 .lock,不判活就永远等一个不存在的进程
322
+ function pidAlive(pid) {
323
+ if (!Number.isInteger(pid) || pid <= 0) return false
324
+ try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
325
+ }
321
326
  function tryLock(dir) {
322
327
  const f = join(dir, '.lock')
323
- try {
324
- writeFileSync(f, String(process.pid), { flag: 'wx' })
325
- return f
326
- } catch {
327
- return null
328
+ for (let i = 0; i < 2; i++) {
329
+ try {
330
+ writeFileSync(f, String(process.pid), { flag: 'wx' })
331
+ return f
332
+ } catch {
333
+ let owner = 0
334
+ try { owner = Number(readFileSync(f, 'utf8').trim()) } catch {}
335
+ if (pidAlive(owner)) return null
336
+ unlock(f)
337
+ }
328
338
  }
339
+ return null
329
340
  }
330
341
  function unlock(f) {
331
342
  try {
@@ -1585,12 +1596,74 @@ const flag = (name) => argv.includes(`--${name}`)
1585
1596
 
1586
1597
  // ── 通用入口(yt-dlp):B 站外任意 http(s) 视频 ──────────────────────
1587
1598
  // 目标可以是裸 BV 号,也可以是 App/网页分享出来的整条链接(带 buvid/spmid/unique_k 等参数)。
1588
- // 只认链接里的 BV 号;短链 b23.tv 需要网络重定向解析,这里不支持,会按原样返回让调用方看到错误。
1589
- function resolveSource(input) {
1599
+ // 短链 b23.tv 先按公共 DNS 展开(本机内网 DNS 会把它答成 31.13.x.x 这类错误地址,见 2026-10-23 实测)。
1600
+ const B23_DNS_SERVERS = ['223.5.5.5', '119.29.29.29', '1.1.1.1']
1601
+
1602
+ /**
1603
+ * 展开 b23.tv 短链:绕过系统 DNS,直接问公共 DNS 拿 IP,再按 IP 直连读 30x 的 Location。
1604
+ * 失败返回 null,由调用方给出可执行的替代方案(贴 BV 号)。
1605
+ */
1606
+ async function expandB23(shortUrl) {
1607
+ const m = String(shortUrl).match(/b23\.tv\/([A-Za-z0-9]+)/i)
1608
+ if (!m) return null
1609
+ const code = m[1]
1610
+ let ips = []
1611
+ try {
1612
+ const { Resolver } = await import('node:dns/promises')
1613
+ const resolver = new Resolver({ timeout: 3000, tries: 2 })
1614
+ resolver.setServers(B23_DNS_SERVERS)
1615
+ ips = await resolver.resolve4('b23.tv')
1616
+ } catch { ips = [] }
1617
+ // 31.13.0.0/16 是污染常见落点(Facebook 段),直接剔掉
1618
+ ips = (ips || []).filter((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^31\.13\./.test(ip)).slice(0, 3)
1619
+ if (ips.length === 0) return null
1620
+ const { request } = await import('node:https')
1621
+ for (const ip of ips) {
1622
+ let loc = ''
1623
+ let body = ''
1624
+ try {
1625
+ const got = await new Promise((resolve, reject) => {
1626
+ const req = request({
1627
+ host: 'b23.tv', servername: 'b23.tv', port: 443, path: `/${code}`,
1628
+ method: 'GET',
1629
+ headers: { host: 'b23.tv', 'user-agent': UA, referer: REF, accept: '*/*' },
1630
+ // Node 20+ 开了 autoSelectFamily 时会用 {all:true} 回调,两种签名都要兼容
1631
+ lookup: (_hostname, opts, cb) => (opts && opts.all
1632
+ ? cb(null, [{ address: ip, family: 4 }])
1633
+ : cb(null, ip, 4)),
1634
+ timeout: 6000,
1635
+ autoSelectFamily: false,
1636
+ }, (res) => {
1637
+ let buf = ''
1638
+ res.setEncoding('utf8')
1639
+ res.on('data', (chunk) => {
1640
+ if (buf.length < 8192) buf += chunk
1641
+ else res.destroy()
1642
+ })
1643
+ res.on('end', () => resolve({ location: String(res.headers.location || ''), body: buf }))
1644
+ res.on('error', () => resolve({ location: String(res.headers.location || ''), body: buf }))
1645
+ })
1646
+ req.on('timeout', () => req.destroy(new Error('timeout')))
1647
+ req.on('error', reject)
1648
+ req.end()
1649
+ })
1650
+ loc = got.location
1651
+ body = got.body
1652
+ } catch { continue }
1653
+ // 正常短链是 302 + Location;少数短链返回带跳转的 HTML/JS,再从正文里捞一次
1654
+ const bv = `${loc}\n${body}`.match(/BV[0-9A-Za-z]{10}/)
1655
+ if (bv) return bv[0]
1656
+ }
1657
+ return null
1658
+ }
1659
+
1660
+ async function resolveSource(input) {
1590
1661
  const s = String(input ?? '').trim()
1591
1662
  if (!s) return { type: 'empty' }
1592
1663
  if (/^https?:\/\/b23\.tv\//i.test(s)) {
1593
- return { type: 'error', message: 'b23.tv 短链要先展开:在浏览器打开它,把地址栏里的完整 https://www.bilibili.com/video/BV... 链接复制过来' }
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 号。' }
1594
1667
  }
1595
1668
  const direct = s.match(/BV[0-9A-Za-z]{10}/)
1596
1669
  if (direct) return { type: 'bilibili', bv: direct[0] }
@@ -1815,7 +1888,7 @@ let BV = ''
1815
1888
  let src = { type: 'empty' }
1816
1889
  let srcErr = ''
1817
1890
  try {
1818
- src = resolveSource(pos[1])
1891
+ src = await resolveSource(pos[1])
1819
1892
  if (src.type === 'bilibili') BV = src.bv
1820
1893
  } catch (e) {
1821
1894
  srcErr = e?.message ?? String(e)
@@ -1845,7 +1918,7 @@ const HELP = `用法:
1845
1918
  --format:sub 支持 text|srt|json|md|txt(srt/md 带真时间戳,md 的时间戳可跳回原片,txt 是一行一句);
1846
1919
  text 支持 text|json|md(json 带时间轴口径:字幕 exact / 转写与 OCR 是估算);
1847
1920
  comments 与 danmaku 支持 json(stdout 只有 JSON,可接 jq)。
1848
- B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/... 要先展开成完整链接。
1921
+ B 站目标可以直接贴分享链接,会自己提取 BV 号;短链 b23.tv/... 也会先按公共 DNS 展开成 BV 号。
1849
1922
  通用视频 URL(YouTube/X 等)需要 yt-dlp:跑 scripts/fetch-deps.ps1 -Target ytdlp 装到 deps\\yt-dlp.exe(或设 BILI_WATCH_YTDLP / 系统 PATH)。
1850
1923
  `
1851
1924
  try {