dsh-bili-scroll 0.1.5 → 0.1.7
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/AGENTS.md +55 -50
- package/README.md +147 -137
- package/client.js +577 -522
- package/cordis.patch.yml +20 -20
- package/index.js +1347 -1076
- package/lib/login.mjs +103 -103
- package/lib/mcp.mjs +119 -115
- package/lib/state.mjs +151 -100
- package/lib/verify.mjs +179 -186
- package/package.json +72 -75
- package/prompts/duty.md +175 -124
- package/selfcheck.mjs +119 -0
- package/vendor/bilibili-mcp/mcp_server.py +1415 -1364
- package/vendor/bilibili-mcp/requirements.txt +8 -8
package/lib/state.mjs
CHANGED
|
@@ -1,100 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/state.mjs —— 插件的"家"与配置
|
|
3
|
-
* ============================================================================
|
|
4
|
-
* 规矩(抄 whale_craft):**配置、凭证、报告、日志都放插件自己的家** ——
|
|
5
|
-
* `$DSH_HOME/bili-duty/`,**绝不写进插件包目录**(装进 node_modules 后可能只读、升级也会被覆盖)。
|
|
6
|
-
*
|
|
7
|
-
* 还有一件小事但很要紧:vendored 的 `mcp_server.py` 里,凭证路径是
|
|
8
|
-
* `Path(__file__).parent / "bili_credential.json"` —— 所以**运行时要把脚本拷到家目录里再跑**,
|
|
9
|
-
* 这样凭证才会落在家的目录,而不是插件包里。
|
|
10
|
-
* ============================================================================
|
|
11
|
-
*/
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
[join(pluginDir, '
|
|
63
|
-
[join(pluginDir, 'lib', 'asr.py'), join(stateDir, 'asr.py'), true],
|
|
64
|
-
]
|
|
65
|
-
for (const [from, to, overwrite] of copies) {
|
|
66
|
-
try {
|
|
67
|
-
if (!existsSync(from)) { log(`缺文件:${from}`); continue }
|
|
68
|
-
if (!overwrite && existsSync(to)) continue
|
|
69
|
-
if (overwrite && existsSync(to) && readFileSync(from, 'utf8') === readFileSync(to, 'utf8')) continue
|
|
70
|
-
copyFileSync(from, to)
|
|
71
|
-
log(`已铺好:${to}`)
|
|
72
|
-
} catch (e) { log(`铺 ${to} 失败:${e?.message ?? e}`) }
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1
|
+
/**
|
|
2
|
+
* lib/state.mjs —— 插件的"家"与配置
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 规矩(抄 whale_craft):**配置、凭证、报告、日志都放插件自己的家** ——
|
|
5
|
+
* `$DSH_HOME/bili-duty/`,**绝不写进插件包目录**(装进 node_modules 后可能只读、升级也会被覆盖)。
|
|
6
|
+
*
|
|
7
|
+
* 还有一件小事但很要紧:vendored 的 `mcp_server.py` 里,凭证路径是
|
|
8
|
+
* `Path(__file__).parent / "bili_credential.json"` —— 所以**运行时要把脚本拷到家目录里再跑**,
|
|
9
|
+
* 这样凭证才会落在家的目录,而不是插件包里。
|
|
10
|
+
* ============================================================================
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 谷时窗口(北京时间)—— 想改就改这一处。
|
|
19
|
+
* ⭐ 官方口径(2026-09-10 起的新定价):**高峰 = 工作日 9:00–12:00 与 14:00–18:00,其余全是谷时**
|
|
20
|
+
* ⇒ 12:00–14:00(夹在两个高峰之间)✓、18:00 之后的一整晚 ✓、以及**整个周末** ✓ 都便宜。
|
|
21
|
+
* ⚠️ 我们原来写的 00:30–08:30 是 DeepSeek **早年**的错峰优惠时段,**已经过时** ✗
|
|
22
|
+
* —— 2026-10-14 使用者在"陌生人验收"里问「晚上峰谷真的是12点半才开始的吗」才查出来 ✓。
|
|
23
|
+
*/
|
|
24
|
+
export const WINDOWS = {
|
|
25
|
+
noon: { label: '中午谷时', start: '12:00', end: '14:00' },
|
|
26
|
+
night: { label: '晚上/夜间谷时', start: '18:00', end: '09:00' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* ⭐ 2026-10-14 真机上踩到的坑(dsh 只留一句 `warning: 1 entry did not activate` + 堆栈):
|
|
31
|
+
* `ctx.get('dshHomePath')` 返回的是**函数**,不是字符串!
|
|
32
|
+
* 形状:`dshHomePath(...segments) => join(resolveDshHome(), ...segments)`(whale_craft 就是这么用的)。
|
|
33
|
+
* 我一开始当字符串用(`String(p)`),路径就成了「函数源码 + \bili-duty」⇒ mkdir ENOENT ⇒ apply 抛错 ⇒ 插件不激活。
|
|
34
|
+
*/
|
|
35
|
+
export function stateDirOf(ctx) {
|
|
36
|
+
let dir = ''
|
|
37
|
+
try {
|
|
38
|
+
const p = ctx?.get?.('dshHomePath')
|
|
39
|
+
if (typeof p === 'function') {
|
|
40
|
+
const v = p('bili-duty') // ← 函数形态:把目录名传进去
|
|
41
|
+
if (v) dir = String(v)
|
|
42
|
+
} else if (typeof p === 'string' && p) {
|
|
43
|
+
dir = join(p, 'bili-duty')
|
|
44
|
+
}
|
|
45
|
+
} catch { /* 拿不到就用下面的兜底 */ }
|
|
46
|
+
if (!dir) dir = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-duty')
|
|
47
|
+
mkdirSync(dir, { recursive: true })
|
|
48
|
+
return dir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readJson(file, fallback) {
|
|
52
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeJson(file, value) {
|
|
56
|
+
writeFileSync(file, JSON.stringify(value, null, 2), 'utf8')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 首次运行:把 vendored 的脚本拷进家里(脚本变了就覆盖)。章程不在这里 —— 它走下面的 ensureCharter */
|
|
60
|
+
export function ensureHomeFiles(pluginDir, stateDir, log = () => {}) {
|
|
61
|
+
const copies = [
|
|
62
|
+
[join(pluginDir, 'vendor', 'bilibili-mcp', 'mcp_server.py'), join(stateDir, 'mcp_server.py'), true],
|
|
63
|
+
[join(pluginDir, 'lib', 'asr.py'), join(stateDir, 'asr.py'), true],
|
|
64
|
+
]
|
|
65
|
+
for (const [from, to, overwrite] of copies) {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(from)) { log(`缺文件:${from}`); continue }
|
|
68
|
+
if (!overwrite && existsSync(to)) continue
|
|
69
|
+
if (overwrite && existsSync(to) && readFileSync(from, 'utf8') === readFileSync(to, 'utf8')) continue
|
|
70
|
+
copyFileSync(from, to)
|
|
71
|
+
log(`已铺好:${to}`)
|
|
72
|
+
} catch (e) { log(`铺 ${to} 失败:${e?.message ?? e}`) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 值班章程(prompts/duty.md)放在哪一份 —— 家里那份优先,没有才用插件里那份。
|
|
78
|
+
* (家里那份由下面的 ensureCharter 保证铺好)
|
|
79
|
+
*/
|
|
80
|
+
export function charterPath(pluginDir, stateDir) {
|
|
81
|
+
const home = join(stateDir, 'duty.md')
|
|
82
|
+
try { if (existsSync(home)) return home } catch { /* 兜底:用插件里那份 */ }
|
|
83
|
+
return join(pluginDir, 'prompts', 'duty.md')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ⭐ 值班章程怎么升级 —— 2026-10-17 审查里的 M6 就是这里。
|
|
88
|
+
* 老规矩(index.js 里比 mtime):家里那份比 shipped 新 ⇒ 听使用者的。听着合理,实际是一把**单向锁**:
|
|
89
|
+
* 使用者只要改过章程一个字,家里那份就永远比 shipped 新 ⇒ 插件后续版本改进的章程
|
|
90
|
+
* (0.1.5 的注入防护、空输出定义恰好就写在章程里)**永远进不来** ✗。
|
|
91
|
+
* 改成记账制 —— 上次铺的是哪一版(sha256)记在 charter.sha256:
|
|
92
|
+
* · 家里那份 === 我们上次铺的 ⇒ 他没动过 ⇒ 直接用 shipped 覆盖(升级 ✓)
|
|
93
|
+
* · 家里那份变过 ⇒ 他改过 ⇒ **一个字都不动**,新版另存 duty.md.new 让他自己合 ✓
|
|
94
|
+
* · 没有账本(带账本的第一个版本刚跑起来)⇒ 分不清「没改只是版本旧」和「改过」,
|
|
95
|
+
* 按「改过」处理 —— 宁可让他手动合一次,也不能偷偷吃掉他的改动 ✓
|
|
96
|
+
*/
|
|
97
|
+
export function ensureCharter(pluginDir, stateDir, log = () => {}) {
|
|
98
|
+
const shipped = join(pluginDir, 'prompts', 'duty.md')
|
|
99
|
+
const home = join(stateDir, 'duty.md')
|
|
100
|
+
const ledger = join(stateDir, 'charter.sha256')
|
|
101
|
+
const fresh = join(stateDir, 'duty.md.new')
|
|
102
|
+
const readIf = (f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }
|
|
103
|
+
try {
|
|
104
|
+
if (!existsSync(shipped)) { log(`缺文件:${shipped}`); return }
|
|
105
|
+
const sha = (s) => createHash('sha256').update(s, 'utf8').digest('hex')
|
|
106
|
+
const newText = readFileSync(shipped, 'utf8')
|
|
107
|
+
const hShip = sha(newText)
|
|
108
|
+
const hLast = readIf(ledger).trim()
|
|
109
|
+
if (!existsSync(home)) {
|
|
110
|
+
writeFileSync(home, newText, 'utf8'); writeFileSync(ledger, hShip, 'utf8')
|
|
111
|
+
log('已铺好值班章程')
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const hHome = sha(readFileSync(home, 'utf8'))
|
|
115
|
+
if (hHome === hShip) { // 家里那份就是最新版(他可能只是没动过)
|
|
116
|
+
if (hLast !== hShip) writeFileSync(ledger, hShip, 'utf8')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (!hLast || hHome !== hLast) { // 他改过(或没账本、分不清)⇒ 只另存新版
|
|
120
|
+
if (hShip !== hLast && readIf(fresh) !== newText) {
|
|
121
|
+
writeFileSync(fresh, newText, 'utf8')
|
|
122
|
+
log(`值班章程有新版、但家里那份你改过 ⇒ 新版另存为 ${fresh},自己合一下`)
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
writeFileSync(home, newText, 'utf8'); writeFileSync(ledger, hShip, 'utf8') // 他没动过 ⇒ 直接升级
|
|
127
|
+
log('值班章程已更新到插件里这一版')
|
|
128
|
+
} catch (e) { log(`铺值班章程失败:${e?.message ?? e}`) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 'HH:MM' → 当天的分钟数 */
|
|
132
|
+
function minutesOf(hhmm) {
|
|
133
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm).trim())
|
|
134
|
+
if (!m) return null
|
|
135
|
+
return Number(m[1]) * 60 + Number(m[2])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 现在在不在这个窗口里(支持跨零点,比如 23:00–02:00) */
|
|
139
|
+
export function inWindow(now, win) {
|
|
140
|
+
const start = minutesOf(win.start); const end = minutesOf(win.end)
|
|
141
|
+
if (start === null || end === null) return false
|
|
142
|
+
const cur = now.getHours() * 60 + now.getMinutes()
|
|
143
|
+
return start <= end ? (cur >= start && cur < end) : (cur >= start || cur < end)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 本地日期 YYYY-MM-DD */
|
|
147
|
+
export function dayKey(now = new Date()) {
|
|
148
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
149
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`
|
|
150
|
+
}
|
|
151
|
+
|
package/lib/verify.mjs
CHANGED
|
@@ -1,186 +1,179 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/verify.mjs —— 「评论发出后复核」
|
|
3
|
-
* ============================================================================
|
|
4
|
-
* 为什么要有它(2026-10-16,用户 `你的deepseek` 在评论区反馈 + 我们 10/10 亲踩):
|
|
5
|
-
* **接口回「发送成功」≠ 公开可见** —— 新号 / 风控会让评论进审核,你自己看不见,
|
|
6
|
-
* 而插件以为发出去了、还把它记进判重表 ⇒ 下一轮不再发 ⇒ **一条都没到人**。
|
|
7
|
-
* 还有更坏的一层:**不公开时连通知都不会推**,于是"@ 了但没人收到"。
|
|
8
|
-
*
|
|
9
|
-
* 复核靠两条只读接口(2026-10-16 实测可行):
|
|
10
|
-
* ① **顶层评论**:`/x/v2/reply/main?type=1&oid=<aid>&mode=2&next=0&ps=20`(按时间)
|
|
11
|
-
* —— 刚发出来的通常在**第一页**;找得到 ⇒ 公开可见(还能顺便读到赞数)。
|
|
12
|
-
* ② **楼中楼**:`/x/v2/reply/reply?type=1&oid=<aid>&root=<root>&ps=20`
|
|
13
|
-
* —— 直接按 root 拉那一串,找我们的 rpid。
|
|
14
|
-
* ⚠️ **看不到 ≠ 没发出**(翻页 / 热度排序都会漏)⇒ 结论一律用"**没查到**"这种说法,
|
|
15
|
-
* 不写成"没发出去"(这是 2026-10-15 那条教训的同一把尺子)。
|
|
16
|
-
* ✗ `/x/v2/reply/detail` 走不通(回 `-400 请求错误`),别无谓地换它。
|
|
17
|
-
* ============================================================================
|
|
18
|
-
*/
|
|
19
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
20
|
-
import { join } from 'node:path'
|
|
21
|
-
import { dayKey } from './state.mjs'
|
|
22
|
-
|
|
23
|
-
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36'
|
|
24
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
25
|
-
|
|
26
|
-
/** 从判重表里抠出「我们发过的评论」记录:容忍 markdown 表格 / 列表 / 随手写的行 */
|
|
27
|
-
export function parseSeen(text) {
|
|
28
|
-
const rows = []
|
|
29
|
-
for (const line of String(text).split(/\r?\n/)) {
|
|
30
|
-
const bv = /BV[0-9A-Za-z]{10}/.exec(line)
|
|
31
|
-
if (!bv) continue
|
|
32
|
-
const nums = [...line.matchAll(/\b(\d{9,})\b/g)].map((m) => Number(m[1]))
|
|
33
|
-
if (!nums.length) continue
|
|
34
|
-
// 约定:第一个长数字 = rpid;如果后面还有第二个,且明显是另一个 id,就当 root(楼中楼)
|
|
35
|
-
const rpid = nums[0]
|
|
36
|
-
const root = nums.length > 1 ? nums[1] : 0
|
|
37
|
-
rows.push({ bv: bv[0], rpid, root, line: line.trim().slice(0, 160) })
|
|
38
|
-
}
|
|
39
|
-
return rows
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function loadCredential(stateDir) {
|
|
43
|
-
const f = join(stateDir, 'bili_credential.json')
|
|
44
|
-
if (!existsSync(f)) return null
|
|
45
|
-
try {
|
|
46
|
-
const c = JSON.parse(readFileSync(f, 'utf8'))
|
|
47
|
-
if (!c?.sessdata) return null
|
|
48
|
-
return c
|
|
49
|
-
} catch { return null }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function headersFor(cred) {
|
|
53
|
-
return {
|
|
54
|
-
'User-Agent': UA,
|
|
55
|
-
Referer: 'https://www.bilibili.com/',
|
|
56
|
-
Cookie: `SESSDATA=${cred.sessdata}; bili_jct=${cred.bili_jct}; buvid3=${cred.buvid3}`,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function jget(url, headers) {
|
|
61
|
-
try {
|
|
62
|
-
const r = await fetch(url, { headers })
|
|
63
|
-
const t = await r.text()
|
|
64
|
-
try { return JSON.parse(t) } catch { return { code: -999, message: 'not json' } }
|
|
65
|
-
} catch (e) { return { code: -998, message: String(e?.message ?? e) } }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── 单独跑:node lib/verify.mjs <stateDir>(方便手工验 / 也方便我测) ──
|
|
182
|
-
if (process.argv[1] && /verify\.mjs$/.test(process.argv[1])) {
|
|
183
|
-
const dir = process.argv[2] || join(process.env.DSH_HOME || join(process.env.USERPROFILE || '.', '.dsh'), 'bili-duty')
|
|
184
|
-
const r = await verifyRecent(dir, { log: (m) => console.log(' ' + m) })
|
|
185
|
-
console.log(r.text)
|
|
186
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* lib/verify.mjs —— 「评论发出后复核」
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么要有它(2026-10-16,用户 `你的deepseek` 在评论区反馈 + 我们 10/10 亲踩):
|
|
5
|
+
* **接口回「发送成功」≠ 公开可见** —— 新号 / 风控会让评论进审核,你自己看不见,
|
|
6
|
+
* 而插件以为发出去了、还把它记进判重表 ⇒ 下一轮不再发 ⇒ **一条都没到人**。
|
|
7
|
+
* 还有更坏的一层:**不公开时连通知都不会推**,于是"@ 了但没人收到"。
|
|
8
|
+
*
|
|
9
|
+
* 复核靠两条只读接口(2026-10-16 实测可行):
|
|
10
|
+
* ① **顶层评论**:`/x/v2/reply/main?type=1&oid=<aid>&mode=2&next=0&ps=20`(按时间)
|
|
11
|
+
* —— 刚发出来的通常在**第一页**;找得到 ⇒ 公开可见(还能顺便读到赞数)。
|
|
12
|
+
* ② **楼中楼**:`/x/v2/reply/reply?type=1&oid=<aid>&root=<root>&ps=20`
|
|
13
|
+
* —— 直接按 root 拉那一串,找我们的 rpid。
|
|
14
|
+
* ⚠️ **看不到 ≠ 没发出**(翻页 / 热度排序都会漏)⇒ 结论一律用"**没查到**"这种说法,
|
|
15
|
+
* 不写成"没发出去"(这是 2026-10-15 那条教训的同一把尺子)。
|
|
16
|
+
* ✗ `/x/v2/reply/detail` 走不通(回 `-400 请求错误`),别无谓地换它。
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { dayKey } from './state.mjs'
|
|
22
|
+
|
|
23
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36'
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
25
|
+
|
|
26
|
+
/** 从判重表里抠出「我们发过的评论」记录:容忍 markdown 表格 / 列表 / 随手写的行 */
|
|
27
|
+
export function parseSeen(text) {
|
|
28
|
+
const rows = []
|
|
29
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
30
|
+
const bv = /BV[0-9A-Za-z]{10}/.exec(line)
|
|
31
|
+
if (!bv) continue
|
|
32
|
+
const nums = [...line.matchAll(/\b(\d{9,})\b/g)].map((m) => Number(m[1]))
|
|
33
|
+
if (!nums.length) continue
|
|
34
|
+
// 约定:第一个长数字 = rpid;如果后面还有第二个,且明显是另一个 id,就当 root(楼中楼)
|
|
35
|
+
const rpid = nums[0]
|
|
36
|
+
const root = nums.length > 1 ? nums[1] : 0
|
|
37
|
+
rows.push({ bv: bv[0], rpid, root, line: line.trim().slice(0, 160) })
|
|
38
|
+
}
|
|
39
|
+
return rows
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadCredential(stateDir) {
|
|
43
|
+
const f = join(stateDir, 'bili_credential.json')
|
|
44
|
+
if (!existsSync(f)) return null
|
|
45
|
+
try {
|
|
46
|
+
const c = JSON.parse(readFileSync(f, 'utf8'))
|
|
47
|
+
if (!c?.sessdata) return null
|
|
48
|
+
return c
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function headersFor(cred) {
|
|
53
|
+
return {
|
|
54
|
+
'User-Agent': UA,
|
|
55
|
+
Referer: 'https://www.bilibili.com/',
|
|
56
|
+
Cookie: `SESSDATA=${cred.sessdata}; bili_jct=${cred.bili_jct}; buvid3=${cred.buvid3}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function jget(url, headers) {
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetch(url, { headers })
|
|
63
|
+
const t = await r.text()
|
|
64
|
+
try { return JSON.parse(t) } catch { return { code: -999, message: 'not json' } }
|
|
65
|
+
} catch (e) { return { code: -998, message: String(e?.message ?? e) } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 复核一条:先试顶层(按时间第一页),再试楼中楼(如果记了 root)
|
|
70
|
+
* @returns {{rpid:number, bv:string, visible:boolean|null, likes?:number, how:string, note:string}}
|
|
71
|
+
*/
|
|
72
|
+
export async function verifyOne(record, { cred, aidCache = new Map(), headers }) {
|
|
73
|
+
const out = { rpid: record.rpid, bv: record.bv, visible: null, how: '', note: '' }
|
|
74
|
+
if (!aidCache.has(record.bv)) {
|
|
75
|
+
const v = await jget(`https://api.bilibili.com/x/web-interface/view?bvid=${record.bv}`, headers)
|
|
76
|
+
aidCache.set(record.bv, v?.code === 0 ? { aid: v.data.aid, mid: v.data.owner?.mid, title: v.data.title } : null)
|
|
77
|
+
await sleep(300)
|
|
78
|
+
}
|
|
79
|
+
const meta = aidCache.get(record.bv)
|
|
80
|
+
if (!meta) { out.how = '取视频信息失败'; out.note = '这条跳过(视频可能已删/不可见)'; return out }
|
|
81
|
+
const aid = meta.aid
|
|
82
|
+
|
|
83
|
+
// ① 顶层:按时间第一页(刚发的就在这儿)
|
|
84
|
+
if (!record.root) {
|
|
85
|
+
const d = await jget(`https://api.bilibili.com/x/v2/reply/main?type=1&oid=${aid}&mode=2&next=0&ps=20`, headers)
|
|
86
|
+
const list = d?.data?.replies || []
|
|
87
|
+
const hit = list.find((r) => r.rpid === record.rpid)
|
|
88
|
+
if (hit && hit.rpid === record.rpid) {
|
|
89
|
+
out.visible = true; out.likes = hit.like; out.how = '顶层·最新一页'
|
|
90
|
+
out.note = hit.invisible ? '(但接口标了 invisible)' : ''
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
out.how = '顶层·最新一页'
|
|
94
|
+
out.note = '最新一页里没找到 —— 可能还在审核,也可能只是翻页看不到(**不等于没发出去**)'
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ② 楼中楼:按 root 拉那一串
|
|
99
|
+
const d = await jget(`https://api.bilibili.com/x/v2/reply/reply?type=1&oid=${aid}&root=${record.root}&ps=20&pn=1`, headers)
|
|
100
|
+
const subs = d?.data?.replies || []
|
|
101
|
+
const hit = subs.find((r) => r.rpid === record.rpid)
|
|
102
|
+
if (hit) {
|
|
103
|
+
out.visible = true; out.likes = hit.like; out.how = `楼中楼·root=${record.root}`
|
|
104
|
+
out.note = hit.invisible ? '(但接口标了 invisible)' : ''
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
out.how = `楼中楼·root=${record.root}`
|
|
108
|
+
out.note = subs.length
|
|
109
|
+
? `那一串里有 ${subs.length} 条,但没有我们这条 —— 可能还在审核(**不等于没发出去**)`
|
|
110
|
+
: '那一串暂时拉不到内容 —— 可能还在审核(**不等于没发出去**)'
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 复核最近的一批(默认最多 6 条、只挑没复核过的)
|
|
116
|
+
* @returns {Promise<{checked:Array, skipped:number, text:string}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function verifyRecent(stateDir, { max = 6, log = () => {} } = {}) {
|
|
119
|
+
const seenFile = join(stateDir, 'seen.md')
|
|
120
|
+
if (!existsSync(seenFile)) return { checked: [], skipped: 0, text: '⚠️ 判重表还没生成,没什么可复核的' }
|
|
121
|
+
const cred = loadCredential(stateDir)
|
|
122
|
+
if (!cred) return { checked: [], skipped: 0, text: '⚠️ 还没扫码登录,没法复核(复核要用你自己的登录态)' }
|
|
123
|
+
|
|
124
|
+
const rows = parseSeen(readFileSync(seenFile, 'utf8'))
|
|
125
|
+
if (!rows.length) return { checked: [], skipped: 0, text: '判重表里还没有「我们发过的评论」记录' }
|
|
126
|
+
|
|
127
|
+
// 状态账本:已经确认真见过的,7 天内不重复查
|
|
128
|
+
const stateFile = join(stateDir, 'verify-state.json')
|
|
129
|
+
let state = {}
|
|
130
|
+
try { state = JSON.parse(readFileSync(stateFile, 'utf8')) } catch { /* 头一次跑 */ }
|
|
131
|
+
const now = Date.now()
|
|
132
|
+
const fresh = []
|
|
133
|
+
let skipped = 0
|
|
134
|
+
for (const r of rows.slice().reverse()) { // 从最新的往回看
|
|
135
|
+
const prev = state[String(r.rpid)]
|
|
136
|
+
if (prev?.visible === true && now - (prev.at || 0) < 7 * 864e5) { skipped++; continue }
|
|
137
|
+
fresh.push(r)
|
|
138
|
+
if (fresh.length >= max) break
|
|
139
|
+
}
|
|
140
|
+
if (!fresh.length) return { checked: [], skipped, text: `都复核过了(跳过的旧记录 ${skipped} 条)` }
|
|
141
|
+
|
|
142
|
+
const headers = headersFor(cred)
|
|
143
|
+
const aidCache = new Map()
|
|
144
|
+
const checked = []
|
|
145
|
+
for (const r of fresh) {
|
|
146
|
+
const res = await verifyOne(r, { cred, aidCache, headers })
|
|
147
|
+
checked.push(res)
|
|
148
|
+
state[String(r.rpid)] = { visible: res.visible, at: now, how: res.how, bv: r.bv, likes: res.likes ?? null }
|
|
149
|
+
log(`复核 ${r.bv} rpid=${r.rpid} → ${res.visible === true ? '公开可见' : '没查到'}(${res.how})`)
|
|
150
|
+
await sleep(700)
|
|
151
|
+
}
|
|
152
|
+
try { writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8') } catch { /* 写不了就算了,复核不该因此失败 */ }
|
|
153
|
+
|
|
154
|
+
const lines = ['', '## 评论复核(宿主自动跑的,不是那一轮自己说的)', '']
|
|
155
|
+
for (const c of checked) {
|
|
156
|
+
const t = c.visible === true ? `公开可见(赞 ${c.likes ?? 0})` : '没查到'
|
|
157
|
+
lines.push(`- \`${c.bv}\` rpid **${c.rpid}** —— ${t} · 依据:${c.how}${c.note ? ' · ' + c.note : ''}`)
|
|
158
|
+
}
|
|
159
|
+
if (skipped) lines.push(`- (另外 ${skipped} 条最近已复核过,没重复查)`)
|
|
160
|
+
const text = lines.join('\n')
|
|
161
|
+
return { checked, skipped, text }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 把复核结果追加到当天的报告末尾(报告可能还不存在 —— 那就别硬造一份) */
|
|
165
|
+
export function appendVerifyToReport(stateDir, text) {
|
|
166
|
+
try {
|
|
167
|
+
const f = join(stateDir, 'out', `${dayKey()}.md`)
|
|
168
|
+
if (!existsSync(f)) return null
|
|
169
|
+
writeFileSync(f, readFileSync(f, 'utf8').replace(/\s*$/, '') + '\n' + text + '\n', 'utf8')
|
|
170
|
+
return f
|
|
171
|
+
} catch { return null }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 单独跑:node lib/verify.mjs <stateDir>(方便手工验 / 也方便我测) ──
|
|
175
|
+
if (process.argv[1] && /verify\.mjs$/.test(process.argv[1])) {
|
|
176
|
+
const dir = process.argv[2] || join(process.env.DSH_HOME || join(process.env.USERPROFILE || '.', '.dsh'), 'bili-duty')
|
|
177
|
+
const r = await verifyRecent(dir, { log: (m) => console.log(' ' + m) })
|
|
178
|
+
console.log(r.text)
|
|
179
|
+
}
|