@bilibili-notify/dynamic 0.0.1-alpha.0
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/LICENSE +21 -0
- package/lib/index.cjs +556 -0
- package/lib/index.d.cts +316 -0
- package/lib/index.d.mts +316 -0
- package/lib/index.mjs +553 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akokko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/index.cjs
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _bilibili_notify_internal = require("@bilibili-notify/internal");
|
|
3
|
+
let cron = require("cron");
|
|
4
|
+
let luxon = require("luxon");
|
|
5
|
+
//#region src/types.ts
|
|
6
|
+
let DynamicFilterReason = /* @__PURE__ */ function(DynamicFilterReason) {
|
|
7
|
+
DynamicFilterReason["BlacklistKeyword"] = "blacklist-keyword";
|
|
8
|
+
DynamicFilterReason["BlacklistForward"] = "blacklist-forward";
|
|
9
|
+
DynamicFilterReason["BlacklistArticle"] = "blacklist-article";
|
|
10
|
+
DynamicFilterReason["WhitelistUnmatched"] = "whitelist-unmatched";
|
|
11
|
+
return DynamicFilterReason;
|
|
12
|
+
}({});
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/dynamic-filter.ts
|
|
15
|
+
function collectRichText(dynamic, texts) {
|
|
16
|
+
const richTextNodes = dynamic.modules?.module_dynamic?.desc?.rich_text_nodes;
|
|
17
|
+
if (richTextNodes?.length) texts.push(richTextNodes.map((n) => n.text ?? "").join(""));
|
|
18
|
+
const summaryNodes = dynamic.modules?.module_dynamic?.major?.opus?.summary?.rich_text_nodes;
|
|
19
|
+
if (summaryNodes?.length) texts.push(summaryNodes.map((n) => n.text ?? "").join(""));
|
|
20
|
+
const title = dynamic.modules?.module_dynamic?.major?.opus?.title;
|
|
21
|
+
if (title) texts.push(title);
|
|
22
|
+
const archiveTitle = dynamic.modules?.module_dynamic?.major?.archive?.title;
|
|
23
|
+
if (archiveTitle) texts.push(archiveTitle);
|
|
24
|
+
}
|
|
25
|
+
function getDynamicText(dynamic) {
|
|
26
|
+
const texts = [];
|
|
27
|
+
collectRichText(dynamic, texts);
|
|
28
|
+
if (dynamic.orig) collectRichText(dynamic.orig, texts);
|
|
29
|
+
return texts.join("\n");
|
|
30
|
+
}
|
|
31
|
+
const MAX_REGEX_TEST_TEXT_LEN = 1e4;
|
|
32
|
+
function safeRegexTest(pattern, text, logger) {
|
|
33
|
+
if (!pattern) return false;
|
|
34
|
+
const check = (0, _bilibili_notify_internal.checkUserRegex)(pattern);
|
|
35
|
+
if (!check.ok) {
|
|
36
|
+
logger?.warn(`[bilibili-notify-dynamic] 拒绝执行正则(${check.reason}):"${pattern.slice(0, 80)}"`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const subject = text.length > MAX_REGEX_TEST_TEXT_LEN ? text.slice(0, MAX_REGEX_TEST_TEXT_LEN) : text;
|
|
41
|
+
return new RegExp(pattern).test(subject);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
logger?.warn(`[bilibili-notify-dynamic] 无效的正则表达式 "${pattern}": ${e.message}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function testKeywordMatched(text, keywords) {
|
|
48
|
+
if (!keywords?.length) return false;
|
|
49
|
+
return keywords.some((kw) => kw && text.includes(kw));
|
|
50
|
+
}
|
|
51
|
+
function filterDynamic(dynamic, config, logger) {
|
|
52
|
+
const cfg = {
|
|
53
|
+
enable: false,
|
|
54
|
+
regex: "",
|
|
55
|
+
keywords: [],
|
|
56
|
+
forward: false,
|
|
57
|
+
article: false,
|
|
58
|
+
whitelistEnable: false,
|
|
59
|
+
whitelistRegex: "",
|
|
60
|
+
whitelistKeywords: [],
|
|
61
|
+
...config
|
|
62
|
+
};
|
|
63
|
+
const text = getDynamicText(dynamic);
|
|
64
|
+
if (cfg.enable) {
|
|
65
|
+
if (cfg.forward && dynamic.type === "DYNAMIC_TYPE_FORWARD") return {
|
|
66
|
+
blocked: true,
|
|
67
|
+
reason: "blacklist-forward"
|
|
68
|
+
};
|
|
69
|
+
if (cfg.article && dynamic.type === "DYNAMIC_TYPE_ARTICLE") return {
|
|
70
|
+
blocked: true,
|
|
71
|
+
reason: "blacklist-article"
|
|
72
|
+
};
|
|
73
|
+
if (safeRegexTest(cfg.regex, text, logger) || testKeywordMatched(text, cfg.keywords)) return {
|
|
74
|
+
blocked: true,
|
|
75
|
+
reason: "blacklist-keyword"
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (cfg.whitelistEnable) {
|
|
79
|
+
if ((!!cfg.whitelistRegex || cfg.whitelistKeywords.length > 0) && !safeRegexTest(cfg.whitelistRegex, text, logger) && !testKeywordMatched(text, cfg.whitelistKeywords)) return {
|
|
80
|
+
blocked: true,
|
|
81
|
+
reason: "whitelist-unmatched"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { blocked: false };
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/dynamic-engine.ts
|
|
88
|
+
const LOG_TAG = "bilibili-notify-dynamic";
|
|
89
|
+
/**
|
|
90
|
+
* 风控/瞬时错误停 cron 后的退避重启间隔。原实现:任何非鉴权错误(-509 限流、
|
|
91
|
+
* 瞬时 -403、未知码)都永久 stop cron 且唯一重启路径 `auth-restored` 不会触发
|
|
92
|
+
* → 动态轮询永久静默直到重启进程。退避后自动重探,瞬时错误/`bili cap` 解风控
|
|
93
|
+
* 后即自愈,无需人工重启进程。
|
|
94
|
+
*/
|
|
95
|
+
const DETECTOR_RESTART_BACKOFF_MS = 5 * 6e4;
|
|
96
|
+
/** 从动态数据中提取图片 URL,用于多模态 AI 点评(最多 4 张) */
|
|
97
|
+
function extractDynamicImages(item) {
|
|
98
|
+
const mod = item.modules.module_dynamic;
|
|
99
|
+
const urls = [];
|
|
100
|
+
if (mod.major?.draw?.items) {
|
|
101
|
+
for (const img of mod.major.draw.items) if (typeof img.src === "string" && img.src) urls.push(img.src);
|
|
102
|
+
}
|
|
103
|
+
if (mod.major?.opus?.pics) {
|
|
104
|
+
for (const pic of mod.major.opus.pics) if (typeof pic.url === "string" && pic.url) urls.push(pic.url);
|
|
105
|
+
}
|
|
106
|
+
const archiveCover = mod.major?.archive?.cover;
|
|
107
|
+
if (typeof archiveCover === "string" && archiveCover) urls.push(archiveCover);
|
|
108
|
+
return urls.slice(0, 4);
|
|
109
|
+
}
|
|
110
|
+
/** 从动态数据中提取纯文本内容,用于 AI 点评 */
|
|
111
|
+
function extractDynamicText(item) {
|
|
112
|
+
const mod = item.modules.module_dynamic;
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (mod.desc?.text) parts.push(mod.desc.text);
|
|
115
|
+
if (mod.major?.opus?.summary?.text) {
|
|
116
|
+
if (mod.major.opus.title) parts.push(`标题:${mod.major.opus.title}`);
|
|
117
|
+
parts.push(mod.major.opus.summary.text);
|
|
118
|
+
}
|
|
119
|
+
if (mod.major?.archive?.title) parts.push(`视频标题:${mod.major.archive.title}`);
|
|
120
|
+
if (item.orig) {
|
|
121
|
+
const origMod = item.orig.modules?.module_dynamic;
|
|
122
|
+
const origAuthor = item.orig.modules?.module_author?.name;
|
|
123
|
+
const origParts = [];
|
|
124
|
+
if (origMod?.desc?.text) origParts.push(origMod.desc.text);
|
|
125
|
+
if (origMod?.major?.opus?.summary?.text) origParts.push(origMod.major.opus.summary.text);
|
|
126
|
+
if (origMod?.major?.archive?.title) origParts.push(`视频标题:${origMod.major.archive.title}`);
|
|
127
|
+
if (origParts.length > 0) parts.push(`(转发自 ${origAuthor ?? "未知"}:${origParts.join(" ")})`);
|
|
128
|
+
}
|
|
129
|
+
return parts.join("\n").trim();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 平台中立的动态轮询/过滤/渲染核心。
|
|
133
|
+
*
|
|
134
|
+
* - 不依赖 koishi runtime;adapter 提供 ServiceContext / MessageBus / PushLike。
|
|
135
|
+
* - image / ai 通过 **构造期注入**(不在 detect 循环内做服务查找),缺失时降级。
|
|
136
|
+
* - 时间线、过滤、API 错误处理逻辑与原 koishi 版 BilibiliNotifyDynamic 一致。
|
|
137
|
+
*/
|
|
138
|
+
var DynamicEngine = class {
|
|
139
|
+
serviceCtx;
|
|
140
|
+
bus;
|
|
141
|
+
api;
|
|
142
|
+
push;
|
|
143
|
+
image;
|
|
144
|
+
ai;
|
|
145
|
+
logger;
|
|
146
|
+
getSubs;
|
|
147
|
+
config;
|
|
148
|
+
dynamicJob;
|
|
149
|
+
/** 风控/瞬时错误后的一次性退避重启句柄;非 undefined 表示已排程,不叠加。 */
|
|
150
|
+
detectorRestartTimer;
|
|
151
|
+
/**
|
|
152
|
+
* -352 风控态边沿标记。进入风控只 error+DM+engine-error 一次;退避重探仍
|
|
153
|
+
* 风控 → debug(不重复告警);成功拉取(code 0)→ info 一次并清除。避免在
|
|
154
|
+
* 退避重试热路径反复刷 error(Q1)。
|
|
155
|
+
*/
|
|
156
|
+
riskControlled = false;
|
|
157
|
+
dynamicSubManager = /* @__PURE__ */ new Map();
|
|
158
|
+
dynamicTimelineManager = /* @__PURE__ */ new Map();
|
|
159
|
+
/** 连续图片渲染失败计数,达到阈值时仅通知一次但不停 cron */
|
|
160
|
+
imageFailureStreak = 0;
|
|
161
|
+
imageFailureNotified = false;
|
|
162
|
+
busHandles = [];
|
|
163
|
+
constructor(opts) {
|
|
164
|
+
this.serviceCtx = opts.serviceCtx;
|
|
165
|
+
this.bus = opts.bus;
|
|
166
|
+
this.api = opts.api;
|
|
167
|
+
this.push = opts.push;
|
|
168
|
+
this.image = opts.image;
|
|
169
|
+
this.ai = opts.ai;
|
|
170
|
+
this.config = opts.config;
|
|
171
|
+
this.getSubs = opts.getSubs;
|
|
172
|
+
this.logger = opts.serviceCtx.logger;
|
|
173
|
+
}
|
|
174
|
+
/** 启动钩子。Adapter 在 ServiceContext 就绪、订阅可访问后调用。 */
|
|
175
|
+
start() {
|
|
176
|
+
this.dynamicTimelineManager = /* @__PURE__ */ new Map();
|
|
177
|
+
const initial = this.getSubs();
|
|
178
|
+
this.logger.info(`[start] 动态引擎启动(${initial ? "订阅已就绪,立即启动检测" : "等待订阅数据"})`);
|
|
179
|
+
if (initial) this.startDynamicDetector(initial);
|
|
180
|
+
else this.logger.debug("[start] 订阅尚未就绪,等待 subscription-changed 事件");
|
|
181
|
+
this.busHandles.push(this.bus.on("auth-restored", () => {
|
|
182
|
+
const subs = this.getSubs();
|
|
183
|
+
if (!subs) return;
|
|
184
|
+
this.logger.info("[detector] 收到 auth-restored,重启动态检测");
|
|
185
|
+
this.startDynamicDetector(subs);
|
|
186
|
+
}));
|
|
187
|
+
this.serviceCtx.onDispose(() => this.stop());
|
|
188
|
+
}
|
|
189
|
+
/** 停止钩子。停止 cron、释放事件订阅。 */
|
|
190
|
+
stop() {
|
|
191
|
+
this.riskControlled = false;
|
|
192
|
+
this.detectorRestartTimer?.dispose();
|
|
193
|
+
this.detectorRestartTimer = void 0;
|
|
194
|
+
if (this.dynamicJob) {
|
|
195
|
+
this.dynamicJob.stop();
|
|
196
|
+
this.dynamicJob = void 0;
|
|
197
|
+
this.logger.info("[stop] 动态检测任务已停止");
|
|
198
|
+
}
|
|
199
|
+
while (this.busHandles.length > 0) this.busHandles.pop()?.dispose();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 替换运行时配置(adapter 在 koishi config / dashboard 编辑后调用)。
|
|
203
|
+
* `dynamicCron` 变化时会自动停掉旧 CronJob 并按新表达式重新 schedule —— 否则
|
|
204
|
+
* 配置已经写进 this.config,但 node-cron 句柄还在跑旧节奏,纯粹的字段更新
|
|
205
|
+
* 是看不见的 bug。
|
|
206
|
+
*/
|
|
207
|
+
updateConfig(config) {
|
|
208
|
+
const cronChanged = this.config.dynamicCron !== config.dynamicCron;
|
|
209
|
+
this.config = config;
|
|
210
|
+
if (cronChanged && this.dynamicJob) {
|
|
211
|
+
this.logger.info(`[detector] dynamicCron 已更新为 "${config.dynamicCron}",重启检测任务`);
|
|
212
|
+
this.dynamicJob.stop();
|
|
213
|
+
this.dynamicJob = void 0;
|
|
214
|
+
if (this.dynamicSubManager.size > 0) this.startJob();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 热替换 CommentaryGenerator 实例。adapter 在用户运行时打开 / 关闭 / 更换 AI
|
|
219
|
+
* 配置后调用,引擎随后的动态点评会立即用新实例 (或回退到纯文字) ,无需重启 server。
|
|
220
|
+
*/
|
|
221
|
+
setAi(ai) {
|
|
222
|
+
this.ai = ai;
|
|
223
|
+
}
|
|
224
|
+
get isActive() {
|
|
225
|
+
return this.dynamicJob?.running ?? false;
|
|
226
|
+
}
|
|
227
|
+
/** 用最新订阅快照重启动态检测;保留已有 UID 的时间戳避免重推旧动态。 */
|
|
228
|
+
startDynamicDetector(subs) {
|
|
229
|
+
this.detectorRestartTimer?.dispose();
|
|
230
|
+
this.detectorRestartTimer = void 0;
|
|
231
|
+
if (this.dynamicJob) {
|
|
232
|
+
this.logger.info("[detector] 停止旧的动态检测任务");
|
|
233
|
+
this.dynamicJob.stop();
|
|
234
|
+
this.dynamicJob = void 0;
|
|
235
|
+
}
|
|
236
|
+
const dynamicSubManager = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const sub of Object.values(subs)) if (sub.dynamic) {
|
|
238
|
+
if (!this.dynamicTimelineManager.has(sub.uid)) {
|
|
239
|
+
this.dynamicTimelineManager.set(sub.uid, Math.floor(luxon.DateTime.now().toSeconds()));
|
|
240
|
+
this.logger.debug(`[detector] 初始化 UID:${sub.uid} 时间戳`);
|
|
241
|
+
}
|
|
242
|
+
dynamicSubManager.set(sub.uid, sub);
|
|
243
|
+
}
|
|
244
|
+
for (const uid of this.dynamicTimelineManager.keys()) if (!dynamicSubManager.has(uid)) {
|
|
245
|
+
this.dynamicTimelineManager.delete(uid);
|
|
246
|
+
this.logger.debug(`[detector] 清理已移除 UID:${uid} 的时间戳`);
|
|
247
|
+
}
|
|
248
|
+
if (dynamicSubManager.size === 0) {
|
|
249
|
+
this.logger.info("[detector] 没有需要动态检测的订阅对象");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.logger.debug(`[detector] 动态检测 UID 列表:${[...dynamicSubManager.keys()].join(", ")}`);
|
|
253
|
+
this.dynamicSubManager = dynamicSubManager;
|
|
254
|
+
this.startJob();
|
|
255
|
+
}
|
|
256
|
+
startDynamicForUid(uid, sub) {
|
|
257
|
+
if (!this.dynamicTimelineManager.has(uid)) {
|
|
258
|
+
this.dynamicTimelineManager.set(uid, Math.floor(luxon.DateTime.now().toSeconds()));
|
|
259
|
+
this.logger.debug(`[ops] 初始化 UID:${uid} 时间戳`);
|
|
260
|
+
}
|
|
261
|
+
this.dynamicSubManager.set(uid, structuredClone(sub));
|
|
262
|
+
this.logger.debug(`[ops] 开启动态订阅 UID:${uid}`);
|
|
263
|
+
}
|
|
264
|
+
stopDynamicForUid(uid) {
|
|
265
|
+
if (!this.dynamicSubManager.has(uid)) return;
|
|
266
|
+
this.dynamicSubManager.delete(uid);
|
|
267
|
+
this.dynamicTimelineManager.delete(uid);
|
|
268
|
+
this.logger.debug(`[ops] 移除动态订阅 UID:${uid}`);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* UID 是否仍订阅。detectDynamics 在 image/AI/broadcast 等多个 await 处挂起,
|
|
272
|
+
* `applyOps`(由 adapter 在 subscription-changed 时调,**不**在 withLock 内)
|
|
273
|
+
* 可在挂起期 stopDynamicForUid 删表。每个 dispatch / 时间线回写前用它重校,
|
|
274
|
+
* 否则会给已退订 UID 推送、并把其时间线“复活”进而长期抑制再订阅后的动态。
|
|
275
|
+
*/
|
|
276
|
+
stillSubscribed(uid, expected) {
|
|
277
|
+
if (!this.dynamicSubManager.has(uid)) return false;
|
|
278
|
+
return expected === void 0 || this.dynamicSubManager.get(uid) === expected;
|
|
279
|
+
}
|
|
280
|
+
/** Incrementally apply subscription ops without restarting the cron job. */
|
|
281
|
+
applyOps(ops) {
|
|
282
|
+
let jobNeedsReconcile = false;
|
|
283
|
+
let opened = 0;
|
|
284
|
+
let removed = 0;
|
|
285
|
+
for (const op of ops) switch (op.type) {
|
|
286
|
+
case "add":
|
|
287
|
+
if (!op.sub.dynamic) break;
|
|
288
|
+
this.startDynamicForUid(op.sub.uid, op.sub);
|
|
289
|
+
opened++;
|
|
290
|
+
jobNeedsReconcile = true;
|
|
291
|
+
break;
|
|
292
|
+
case "delete":
|
|
293
|
+
if (!this.dynamicSubManager.has(op.uid)) break;
|
|
294
|
+
this.stopDynamicForUid(op.uid);
|
|
295
|
+
removed++;
|
|
296
|
+
jobNeedsReconcile = true;
|
|
297
|
+
break;
|
|
298
|
+
case "update":
|
|
299
|
+
for (const change of op.changes) {
|
|
300
|
+
if (change.scope !== "dynamic") continue;
|
|
301
|
+
if (change.dynamic) {
|
|
302
|
+
const fullSub = this.getSubs()?.[op.uid];
|
|
303
|
+
if (fullSub) {
|
|
304
|
+
this.startDynamicForUid(op.uid, fullSub);
|
|
305
|
+
opened++;
|
|
306
|
+
}
|
|
307
|
+
jobNeedsReconcile = true;
|
|
308
|
+
} else if (change.dynamic === false) {
|
|
309
|
+
if (!this.dynamicSubManager.has(op.uid)) continue;
|
|
310
|
+
this.stopDynamicForUid(op.uid);
|
|
311
|
+
removed++;
|
|
312
|
+
jobNeedsReconcile = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (jobNeedsReconcile) {
|
|
318
|
+
this.logger.info(`[ops] 动态订阅变更已应用:+${opened} 开启 / -${removed} 移除(当前 ${this.dynamicSubManager.size} 个动态订阅)`);
|
|
319
|
+
this.reconcileJob();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
startJob() {
|
|
323
|
+
this.dynamicJob = new cron.CronJob(this.config.dynamicCron, (0, _bilibili_notify_internal.withLock)(() => this.detectDynamics(), (err) => this.logger.error(`[detector] 动态检测执行异常:${err instanceof Error ? err.message : String(err)}`)));
|
|
324
|
+
this.dynamicJob.start();
|
|
325
|
+
this.logger.info("[detector] 动态检测任务已启动");
|
|
326
|
+
}
|
|
327
|
+
reconcileJob() {
|
|
328
|
+
if (this.dynamicSubManager.size === 0) {
|
|
329
|
+
if (this.dynamicJob?.running) {
|
|
330
|
+
this.dynamicJob.stop();
|
|
331
|
+
this.dynamicJob = void 0;
|
|
332
|
+
this.logger.info("[detector] 订阅清空,动态检测任务已停止");
|
|
333
|
+
}
|
|
334
|
+
} else if (!this.dynamicJob?.running) {
|
|
335
|
+
this.logger.debug(`[detector] 动态检测 UID 列表:${[...this.dynamicSubManager.keys()].join(", ")}`);
|
|
336
|
+
this.startJob();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async detectDynamics() {
|
|
340
|
+
this.logger.debug("[detector] 开始获取动态信息");
|
|
341
|
+
let content;
|
|
342
|
+
try {
|
|
343
|
+
content = await this.api.getAllDynamic();
|
|
344
|
+
} catch (e) {
|
|
345
|
+
this.logger.warn(`[api] 获取动态失败:${e instanceof Error ? e.message : String(e)}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (!content) return;
|
|
349
|
+
if (content.code !== 0) {
|
|
350
|
+
await this.handleApiError(content.code, content.message);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (this.riskControlled) {
|
|
354
|
+
this.riskControlled = false;
|
|
355
|
+
this.logger.info("[api] 风控已解除,动态检测恢复正常");
|
|
356
|
+
}
|
|
357
|
+
this.logger.debug("[detector] 成功获取动态信息,开始处理");
|
|
358
|
+
const okTs = {};
|
|
359
|
+
const failTs = {};
|
|
360
|
+
const markOk = (u, ts) => {
|
|
361
|
+
const arr = okTs[u];
|
|
362
|
+
if (arr) arr.push(ts);
|
|
363
|
+
else okTs[u] = [ts];
|
|
364
|
+
};
|
|
365
|
+
const markFail = (u, ts) => {
|
|
366
|
+
const arr = failTs[u];
|
|
367
|
+
if (arr) arr.push(ts);
|
|
368
|
+
else failTs[u] = [ts];
|
|
369
|
+
};
|
|
370
|
+
for (const item of content.data.items) {
|
|
371
|
+
if (!item) continue;
|
|
372
|
+
const postTime = item.modules.module_author.pub_ts;
|
|
373
|
+
if (typeof postTime !== "number" || !Number.isFinite(postTime)) {
|
|
374
|
+
this.logger.warn(`[detector] 跳过无效动态:pub_ts 缺失或非数字,ID=${item.id_str ?? "unknown"}`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const uid = item.modules.module_author.mid.toString();
|
|
378
|
+
const name = item.modules.module_author.name;
|
|
379
|
+
const timeline = this.dynamicTimelineManager.get(uid);
|
|
380
|
+
if (timeline === void 0) continue;
|
|
381
|
+
this.logger.debug(`[detector] 检查动态 UP=${name} UID=${uid} 发布时间=${luxon.DateTime.fromSeconds(postTime).toFormat("yyyy-MM-dd HH:mm:ss")}`);
|
|
382
|
+
if (timeline >= postTime) continue;
|
|
383
|
+
const subAtCapture = this.dynamicSubManager.get(uid);
|
|
384
|
+
try {
|
|
385
|
+
const effFilter = this.dynamicSubManager.get(uid)?.filter ?? this.config.filter ?? {};
|
|
386
|
+
const filterResult = filterDynamic(item, effFilter, this.logger);
|
|
387
|
+
if (filterResult.blocked) {
|
|
388
|
+
this.logger.debug(`[filter] 动态 ID=${item.id_str} 被过滤,原因:${filterResult.reason}`);
|
|
389
|
+
if (effFilter.notify && this.stillSubscribed(uid, subAtCapture)) {
|
|
390
|
+
const msgs = {
|
|
391
|
+
["blacklist-keyword"]: `${name}发布了一条含有屏蔽关键字的动态`,
|
|
392
|
+
["blacklist-forward"]: `${name}转发了一条动态,已屏蔽`,
|
|
393
|
+
["blacklist-article"]: `${name}投稿了一条专栏,已屏蔽`,
|
|
394
|
+
["whitelist-unmatched"]: `${name}发布了一条不在白名单范围内的动态,已屏蔽`
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
await this.push.broadcastDynamic(uid, [{
|
|
398
|
+
type: "text",
|
|
399
|
+
text: msgs[filterResult.reason]
|
|
400
|
+
}], "dynamic");
|
|
401
|
+
} catch (e) {
|
|
402
|
+
this.logger.warn(`[filter] 屏蔽提示发送失败(忽略,不重试以免重复轰炸): ${e.message}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
markOk(uid, postTime);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const sub = this.dynamicSubManager.get(uid);
|
|
409
|
+
let buffer;
|
|
410
|
+
try {
|
|
411
|
+
if (this.image && this.config.imageEnabled !== false) buffer = await this.image.generateDynamicCard(item, sub?.customCardStyle?.enable ? sub.customCardStyle : void 0);
|
|
412
|
+
} catch (e) {
|
|
413
|
+
const err = e;
|
|
414
|
+
if (err.message === "直播开播动态,不做处理") {
|
|
415
|
+
markOk(uid, postTime);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
this.imageFailureStreak++;
|
|
419
|
+
this.logger.error(`[image] 生成动态图片失败 (连续 ${this.imageFailureStreak} 次): ${err.message}`);
|
|
420
|
+
if (!this.imageFailureNotified) try {
|
|
421
|
+
await this.push.sendErrorMsg(`生成动态图片失败:${err.message},已降级为纯文字推送,请检查图片插件状态`);
|
|
422
|
+
this.bus.emit("engine-error", LOG_TAG, `生成动态图片失败:${err.message}`);
|
|
423
|
+
this.imageFailureNotified = true;
|
|
424
|
+
} catch (notifyErr) {
|
|
425
|
+
this.logger.warn(`[image] 失败通知发送失败,下轮将重试通知: ${notifyErr.message}`);
|
|
426
|
+
}
|
|
427
|
+
buffer = void 0;
|
|
428
|
+
}
|
|
429
|
+
if (buffer) {
|
|
430
|
+
if (this.imageFailureStreak > 0) this.logger.info(`[image] 图片渲染已恢复(之前连续失败 ${this.imageFailureStreak} 次)`);
|
|
431
|
+
this.imageFailureStreak = 0;
|
|
432
|
+
this.imageFailureNotified = false;
|
|
433
|
+
}
|
|
434
|
+
let dUrl = "";
|
|
435
|
+
if (this.config.dynamicUrl) if (item.type === "DYNAMIC_TYPE_AV") {
|
|
436
|
+
const jumpUrl = item.modules.module_dynamic.major?.archive?.jump_url ?? "";
|
|
437
|
+
if (this.config.dynamicVideoUrlToBV) {
|
|
438
|
+
const bvMatch = jumpUrl.match(/BV[0-9A-Za-z]+/);
|
|
439
|
+
dUrl = bvMatch ? bvMatch[0] : "";
|
|
440
|
+
} else dUrl = `${name}发布了新视频:https:${jumpUrl}`;
|
|
441
|
+
} else dUrl = `${name}发布了一条动态:https://t.bilibili.com/${item.id_str}`;
|
|
442
|
+
let aiComment;
|
|
443
|
+
if (this.ai && this.config.aiEnabled !== false) {
|
|
444
|
+
const dynamicText = extractDynamicText(item);
|
|
445
|
+
if (dynamicText) {
|
|
446
|
+
const imageUrls = extractDynamicImages(item);
|
|
447
|
+
const subForAi = this.dynamicSubManager.get(uid);
|
|
448
|
+
this.logger.debug(`[ai] 开始生成动态点评,文本长度=${dynamicText.length},图片数=${imageUrls.length}${subForAi?.aiOverride ? ",命中 per-UP override" : ""}`);
|
|
449
|
+
try {
|
|
450
|
+
aiComment = await this.ai.comment(`${name}发布了一条动态,内容如下:\n${dynamicText}`, "dynamic", imageUrls, subForAi?.aiOverride);
|
|
451
|
+
this.logger.debug(`[ai] 动态点评生成完毕,长度=${aiComment?.length ?? 0}`);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
this.logger.error(`[ai] AI 点评生成失败:${e.message},回退到普通文字`);
|
|
454
|
+
}
|
|
455
|
+
} else this.logger.debug("[ai] 动态无可提取文本,跳过 AI 点评");
|
|
456
|
+
}
|
|
457
|
+
if (!this.stillSubscribed(uid, subAtCapture)) {
|
|
458
|
+
this.logger.debug(`[detector] UID=${uid} 在本轮处理中已退订/被替换,跳过推送`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const textPart = aiComment ?? (dUrl || void 0);
|
|
462
|
+
const segments = buffer ? [{
|
|
463
|
+
type: "image",
|
|
464
|
+
buffer,
|
|
465
|
+
mime: "image/jpeg"
|
|
466
|
+
}, ...textPart ? [{
|
|
467
|
+
type: "text",
|
|
468
|
+
text: textPart
|
|
469
|
+
}] : []] : [{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: aiComment ?? `${name}发布了一条动态${dUrl ? `:${dUrl}` : ""}`
|
|
472
|
+
}];
|
|
473
|
+
await this.push.broadcastDynamic(uid, segments, "dynamic");
|
|
474
|
+
if (this.config.pushImgsInDynamic && item.type === "DYNAMIC_TYPE_DRAW") {
|
|
475
|
+
const major = item.modules?.module_dynamic?.major;
|
|
476
|
+
const urls = [];
|
|
477
|
+
for (const it of major?.draw?.items ?? []) if (it.src) urls.push(it.src);
|
|
478
|
+
for (const pic of major?.opus?.pics ?? []) if (pic.url) urls.push(pic.url);
|
|
479
|
+
if (urls.length) await this.push.broadcastDynamic(uid, [{
|
|
480
|
+
type: "image-group",
|
|
481
|
+
forward: true,
|
|
482
|
+
urls
|
|
483
|
+
}], "dynamic-images");
|
|
484
|
+
}
|
|
485
|
+
markOk(uid, postTime);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
markFail(uid, postTime);
|
|
488
|
+
this.logger.warn(`[detector] 推送失败 UID=${uid} ID=${item.id_str ?? "?"}:${e.message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (const uid of new Set([...Object.keys(okTs), ...Object.keys(failTs)])) {
|
|
492
|
+
if (!this.stillSubscribed(uid)) {
|
|
493
|
+
this.logger.debug(`[timeline] UID=${uid} 已退订,跳过时间线回写(不复活)`);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const fails = failTs[uid] ?? [];
|
|
497
|
+
const minFail = fails.length ? Math.min(...fails) : Number.POSITIVE_INFINITY;
|
|
498
|
+
const safeOks = (okTs[uid] ?? []).filter((t) => t < minFail);
|
|
499
|
+
if (safeOks.length === 0) continue;
|
|
500
|
+
const existing = this.dynamicTimelineManager.get(uid) ?? 0;
|
|
501
|
+
const next = Math.max(existing, ...safeOks);
|
|
502
|
+
if (next <= existing) continue;
|
|
503
|
+
this.dynamicTimelineManager.set(uid, next);
|
|
504
|
+
this.logger.debug(`[timeline] 更新时间线 UID=${uid} 时间=${luxon.DateTime.fromSeconds(next).toFormat("yyyy-MM-dd HH:mm:ss")}`);
|
|
505
|
+
}
|
|
506
|
+
this.logger.debug(`[detector] 本次成功处理 ${Object.keys(okTs).length} 个 UP 的动态`);
|
|
507
|
+
}
|
|
508
|
+
async handleApiError(code, message) {
|
|
509
|
+
this.dynamicJob?.stop();
|
|
510
|
+
this.dynamicJob = void 0;
|
|
511
|
+
switch (code) {
|
|
512
|
+
case -101:
|
|
513
|
+
this.logger.error("[api] 账号未登录,动态检测已停止(待 auth-restored 重启)");
|
|
514
|
+
this.bus.emit("engine-error", LOG_TAG, "账号未登录");
|
|
515
|
+
this.riskControlled = false;
|
|
516
|
+
break;
|
|
517
|
+
case -352:
|
|
518
|
+
if (!this.riskControlled) {
|
|
519
|
+
this.riskControlled = true;
|
|
520
|
+
this.logger.error("[api] 账号被风控,动态检测暂停,将退避后自动重试");
|
|
521
|
+
await this.push.sendPrivateMsg("账号被风控,请使用 `bili cap` 指令解除风控");
|
|
522
|
+
this.bus.emit("engine-error", LOG_TAG, "账号被风控");
|
|
523
|
+
} else this.logger.debug("[api] 仍处于风控态,退避后继续重探(不重复告警)");
|
|
524
|
+
this.scheduleDetectorRestart("风控");
|
|
525
|
+
break;
|
|
526
|
+
default:
|
|
527
|
+
this.logger.warn(`[api] 获取动态信息失败,错误码:${code},${message},将退避后自动重试`);
|
|
528
|
+
await this.push.sendPrivateMsg(`获取动态信息失败,错误码:${code}`);
|
|
529
|
+
this.bus.emit("engine-error", LOG_TAG, `获取动态失败,错误码:${code}`);
|
|
530
|
+
this.scheduleDetectorRestart(`错误码 ${code}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 风控/瞬时错误后排一次性退避重启。已有待执行的不叠加(`detectorRestartTimer`
|
|
535
|
+
* 非空即跳过)。到点取最新订阅快照重启检测;`stop()` / 显式 `startDynamicDetector`
|
|
536
|
+
* 会作废本计时(避免 dispose 后 / 重启后仍触发陈旧重启)。
|
|
537
|
+
*/
|
|
538
|
+
scheduleDetectorRestart(reason) {
|
|
539
|
+
if (this.detectorRestartTimer) return;
|
|
540
|
+
this.logger.info(`[detector] ${reason},将在 ${DETECTOR_RESTART_BACKOFF_MS / 1e3}s 后自动重试动态检测`);
|
|
541
|
+
this.detectorRestartTimer = this.serviceCtx.setTimeout(() => {
|
|
542
|
+
this.detectorRestartTimer = void 0;
|
|
543
|
+
const subs = this.getSubs();
|
|
544
|
+
if (!subs) {
|
|
545
|
+
this.logger.debug("[detector] 退避重启:订阅快照不可用,跳过本次(等下个触发)");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
this.logger.info("[detector] 退避计时到,重启动态检测");
|
|
549
|
+
this.startDynamicDetector(subs);
|
|
550
|
+
}, DETECTOR_RESTART_BACKOFF_MS);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
//#endregion
|
|
554
|
+
exports.DynamicEngine = DynamicEngine;
|
|
555
|
+
exports.DynamicFilterReason = DynamicFilterReason;
|
|
556
|
+
exports.filterDynamic = filterDynamic;
|