cortico-world-mosey 2.1.0 → 2.1.2
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/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/bilibili.js +2 -2
- package/src/douyin.js +121 -19
- package/src/policy.js +17 -1
- package/src/store.js +3 -0
- package/src/world.js +79 -10
- package/webui/client.js +37 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 2.1.2
|
|
4
|
+
|
|
5
|
+
**修「私信发进了群聊」——会话没切过去也照发。**
|
|
6
|
+
|
|
7
|
+
- 发私信前先核实页面真的停在目标会话上;核实不通过就不发,回执写「没发」,不再把话打进当前开着的那个会话。
|
|
8
|
+
- 核实方式按页面能力来:**标题读得出来就比标题,读不出来就比「会话内容有没有换」**(实测有些账号的抖音聊天页根本读不到标题,只靠标题会误判)。
|
|
9
|
+
- 「现在停在哪个人」的缓存(`chatThread`)改成**核实通过之后**才写,核实失败立刻清空。以前是点完就记:只要那一下点空了,后面每一条都会跳过点击、全打进同一个错误的会话——11-08 那三条私信进群就是这么来的。
|
|
10
|
+
- 主人私信优先走**监听器专门停在主人会话上的那一页**(不和群聊页共用),那一页不要求标题可读,避免每条私信都被逼去「打开主页点私信」的慢路。
|
|
11
|
+
- 陌生人预热(prewarm)同样只在核实后才写缓存。
|
|
12
|
+
- 顺手修了子组件拿不到 logger 的问题(宿主不传 logger 时,抖音/B站的日志会整片丢失),排查时不再抓瞎。
|
|
13
|
+
|
|
14
|
+
> 当时的触发条件:正在被 `-509` 限流,「操作频繁」的 toast 浮在会话列表上,点击被吞掉,但代码认为点成功了。
|
|
15
|
+
|
|
16
|
+
## 2.1.1
|
|
17
|
+
|
|
18
|
+
**修「最近做过什么」被风控记录刷屏,顺带修掉一个连带问题:她可能一直被按着出不了门。**
|
|
19
|
+
|
|
20
|
+
### 风控记录不再占满列表
|
|
21
|
+
|
|
22
|
+
- 只读请求撞 `-509` 不再写进「最近做过什么」,也不再加 `strikes`、不续期限流退避。B站私信轮询每 3 秒打一次 inbox,以前每次被限流都记一条——实测 300 条记录里 **267 条**是「风控停手」,点赞评论反而被挤没了。
|
|
23
|
+
- 限流期间,B站私信轮询从 3 秒降到 60 秒一次,不用每 3 秒去撞一次墙。
|
|
24
|
+
- 「最近做过什么」加了 **动作 / 风控 / 全部** 三个筛选,默认落在「动作」;面板首页的「最近」也不再显示风控条目。
|
|
25
|
+
- 老数据自动清理:历史风控记录只保留最近 20 条,把位置还给真实动作;顺手把被只读请求刷高的 `strikes` 归零(以前累计到 800 多,切严格模式会直接触发 6 小时冷却)。
|
|
26
|
+
|
|
27
|
+
### 为什么这算 bug
|
|
28
|
+
|
|
29
|
+
只读请求把 30 分钟的限流退避一直续期,`until` 永远在将来——面板一直显示「限流歇 N 分钟」,她自己也一直不出门。现在只有**写动作真的被挡**才会退避。
|
|
30
|
+
|
|
3
31
|
## 2.1.0
|
|
4
32
|
|
|
5
33
|
**出门方式自己选:按次数 / 按额度。** 以前「每天出门几次」和「每日额度」两道闸同时生效,谁先撞上谁停——次数 8/8 之后必做还没做完,就只能干等到第二天。现在二选一,只留一道闸门。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-mosey",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
|
|
6
6
|
"license": "MIT",
|
package/src/bilibili.js
CHANGED
|
@@ -364,7 +364,7 @@ export class Bilibili {
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
if ([412, 429].includes(res.status)) {
|
|
367
|
-
this.policy.noteRisk(String(res.status), 'B站要求冷却');
|
|
367
|
+
this.policy.noteRisk(String(res.status), 'B站要求冷却', { source: opts.post ? 'write' : 'read' });
|
|
368
368
|
throw new BiliError('RISK', 'B站要求冷却,已自动停手。');
|
|
369
369
|
}
|
|
370
370
|
if (!res.ok) {
|
|
@@ -380,7 +380,7 @@ export class Bilibili {
|
|
|
380
380
|
|
|
381
381
|
if (data.code !== 0) {
|
|
382
382
|
if ([-412, -352, -509, -799].includes(data.code)) {
|
|
383
|
-
this.policy.noteRisk(String(data.code), data.message || 'B站风控');
|
|
383
|
+
this.policy.noteRisk(String(data.code), data.message || 'B站风控', { source: opts.post ? 'write' : 'read' });
|
|
384
384
|
throw new BiliError('RISK', 'B站风控拦截,已停手。');
|
|
385
385
|
}
|
|
386
386
|
if (data.code === -101) {
|
package/src/douyin.js
CHANGED
|
@@ -843,20 +843,24 @@ export class DouyinBrowser {
|
|
|
843
843
|
}
|
|
844
844
|
|
|
845
845
|
/** 把一次风险命中翻译成人话,顺手记一笔;限流时进入两分钟冷却,别让她连着重试。 */
|
|
846
|
-
|
|
846
|
+
/**
|
|
847
|
+
* source='read':只是读页面时看到风控提示(导航、翻页)——不记动作、不续期退避。
|
|
848
|
+
* source='write':点发送/点赞之后被挡,这种才算「她真的被拦住了」。
|
|
849
|
+
*/
|
|
850
|
+
riskError(f, where = '操作', { source = 'write' } = {}) {
|
|
847
851
|
const why = String(f?.hit || '页面提示').slice(0, 60);
|
|
848
852
|
if (f?.kind === 'rate') {
|
|
849
|
-
this.rateUntil = Date.now() + 2 * 60 * 1000;
|
|
850
|
-
this.policy.noteRisk('DOUYIN_RATE', `${where}后限流(${why}
|
|
853
|
+
if (source !== 'read') this.rateUntil = Date.now() + 2 * 60 * 1000;
|
|
854
|
+
this.policy.noteRisk('DOUYIN_RATE', `${where}后限流(${why})`, { source });
|
|
851
855
|
return new DouyinError('RISK', `抖音限流了(${why}),等两分钟再发,别连着重试。`);
|
|
852
856
|
}
|
|
853
|
-
this.policy.noteRisk('DOUYIN_VERIFY', `${where}后要验证(${why}
|
|
857
|
+
this.policy.noteRisk('DOUYIN_VERIFY', `${where}后要验证(${why})`, { source });
|
|
854
858
|
return new DouyinError('RISK', `抖音要验证(${why}),得主人在手机上过一下验证码。`);
|
|
855
859
|
}
|
|
856
860
|
|
|
857
861
|
async checkRisk(page) {
|
|
858
862
|
const f = await bodyFlags(page);
|
|
859
|
-
if (f.risk) throw this.riskError(f, '刷抖音');
|
|
863
|
+
if (f.risk) throw this.riskError(f, '刷抖音', { source: 'read' });
|
|
860
864
|
}
|
|
861
865
|
|
|
862
866
|
async goto(page, url, waitMs = 4200) {
|
|
@@ -1032,7 +1036,13 @@ export class DouyinBrowser {
|
|
|
1032
1036
|
opened = !!ok;
|
|
1033
1037
|
// 没有「陌生人消息」文件夹的账号,人就排在主列表里
|
|
1034
1038
|
if (!ok) ok = await this.clickThread(page, want).catch(() => false);
|
|
1035
|
-
if (ok)
|
|
1039
|
+
if (ok) {
|
|
1040
|
+
// 点完核一次标题:点空了就别把 chatThread 记成这个人,
|
|
1041
|
+
// 否则下一次回复会跳过点击、直接打进当前开着的会话(11-08 私信进群就是这么来的)
|
|
1042
|
+
await page.waitForTimeout(120);
|
|
1043
|
+
const after = await this.currentChatTitle(page).catch(() => '');
|
|
1044
|
+
if (after && this.threadTitleMatches(after, want)) this.chatThread = want;
|
|
1045
|
+
}
|
|
1036
1046
|
return !!ok;
|
|
1037
1047
|
} finally {
|
|
1038
1048
|
// 文件夹是浮层:收起来,别挡着后面打字
|
|
@@ -6073,7 +6083,7 @@ export class DouyinBrowser {
|
|
|
6073
6083
|
* 在聊天页发一条。mention 是「要真的 @ 到的昵称」(群里才有意义):
|
|
6074
6084
|
* 传了就会先从候选面板插一个真 chip,再写正文。
|
|
6075
6085
|
*/
|
|
6076
|
-
async replyDm(name, text, { mention = '', strict = false } = {}) {
|
|
6086
|
+
async replyDm(name, text, { mention = '', strict = false, requireTitle = false } = {}) {
|
|
6077
6087
|
const who = String(name ?? '').trim();
|
|
6078
6088
|
if (!who) throw new DouyinError('BAD_TARGET', '没有会话昵称,回不了');
|
|
6079
6089
|
// 各段耗时带上:主人在追「一秒内」,得知道慢在哪一段(挂在回执里,不额外打日志)
|
|
@@ -6081,12 +6091,19 @@ export class DouyinBrowser {
|
|
|
6081
6091
|
// 私信页正停在这个会话上:不用跳转、不用点会话,直接打字回车。
|
|
6082
6092
|
// 这是「秒回」的关键一段——省掉整页导航(6-8 秒)和点会话后的固定停顿(2.4 秒)。
|
|
6083
6093
|
const dm = this.dmPage;
|
|
6094
|
+
// 诊断用:只在失败/走兜底时打一行,正常发送零开销
|
|
6095
|
+
const diag = { who, dmThread: this.dmThread, chatThread: this.chatThread, steps: [] };
|
|
6084
6096
|
if (dm && !dm.isClosed() && this.dmThread === who) {
|
|
6085
6097
|
// 先确认页面真的停在主人这个会话上。点不回来就别硬发——宁可走慢路径重开,
|
|
6086
6098
|
// 也不能把私信打进别人的会话里(GLM 审出来的 #1)。
|
|
6099
|
+
// dmPage 是监听器专门停在主人会话上的那一页(群里用的是 groupPages,不共用),
|
|
6100
|
+
// 所以这里不再要求「标题可读」——这套页面读不到标题,强求只会把每条私信都逼到慢兜底。
|
|
6087
6101
|
const onThread = await this.ensureThreadOpen(dm, who, { strict }).catch(() => false);
|
|
6088
6102
|
if (!onThread) {
|
|
6089
6103
|
this.dmThread = '';
|
|
6104
|
+
diag.steps.push('dmPage:verify-fail');
|
|
6105
|
+
diag.dmTitle = await this.currentChatTitle(dm).catch(() => '');
|
|
6106
|
+
diag.dmUrl = String(dm.url?.() ?? '');
|
|
6090
6107
|
} else {
|
|
6091
6108
|
const t1 = Date.now();
|
|
6092
6109
|
const input = await this.waitMsgInput(dm, 3000).catch(() => null);
|
|
@@ -6150,29 +6167,74 @@ export class DouyinBrowser {
|
|
|
6150
6167
|
try {
|
|
6151
6168
|
// 已经在这个会话里就直接用:省一次整页跳转和点击,回私信快好几秒
|
|
6152
6169
|
// 用 chatThread 而不是 openThread:后者被 dmPage 的读取共用,会误判
|
|
6153
|
-
|
|
6170
|
+
const needSwitch = this.chatThread !== who || !/\/chat/.test(page.url());
|
|
6171
|
+
// 点之前先记一份内容指纹:这套页面读不出标题,靠「内容有没有换」判断切没切过去
|
|
6172
|
+
let before = null;
|
|
6173
|
+
if (needSwitch) {
|
|
6154
6174
|
if (!/\/chat/.test(page.url())) await this.gotoChat(page);
|
|
6175
|
+
before = await this.threadFingerprint(page);
|
|
6155
6176
|
let ok = await this.clickThread(page, who);
|
|
6177
|
+
diag.steps.push(`click:${ok ? 'ok' : 'miss'}`);
|
|
6156
6178
|
if (!ok) {
|
|
6157
6179
|
// 主列表里没有:抖音会把非好友的私信收进「陌生人消息」文件夹
|
|
6158
6180
|
ok = await this.clickStrangerThread(page, who);
|
|
6181
|
+
diag.steps.push(`stranger:${ok ? 'ok' : 'miss'}`);
|
|
6159
6182
|
}
|
|
6160
6183
|
if (!ok) {
|
|
6184
|
+
diag.chatUrl = String(page.url?.() ?? '');
|
|
6185
|
+
diag.chatTitle = await this.currentChatTitle(page).catch(() => '');
|
|
6186
|
+
this.log?.info?.('stroll dm diag', { ...diag, fail: 'NO_DM' });
|
|
6161
6187
|
throw new DouyinError('NO_DM', '聊天列表和「陌生人消息」里都找不到这个会话,可能被新消息刷下去了');
|
|
6162
6188
|
}
|
|
6163
|
-
this.openThread = who;
|
|
6164
|
-
this.chatThread = who;
|
|
6165
6189
|
// 点完不用干等两秒:下面 waitMsgInput 会一轮一轮等到输入框真的能打字
|
|
6166
6190
|
await page.waitForTimeout(200);
|
|
6167
6191
|
}
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6192
|
+
/**
|
|
6193
|
+
* 先核实、再认账。
|
|
6194
|
+
* 以前点完就把 chatThread 记成 who:只要那一下点空了(限流 toast 盖住列表时很常见),
|
|
6195
|
+
* 后面每一条都会跳过点击、直接打进当前开着的会话——2026-11-08 那三条私信进群就是这么来的。
|
|
6196
|
+
*/
|
|
6197
|
+
const readTitle = async () => await this.currentChatTitle(page).catch(() => '');
|
|
6198
|
+
/**
|
|
6199
|
+
* 核实「页面真的切过去了」:
|
|
6200
|
+
* 标题读得出来 → 按标题比;
|
|
6201
|
+
* 标题读不出来(这套页面就是这样)→ 看会话内容有没有换过。
|
|
6202
|
+
* 都不通过就不发——比把私信打进群聊强。
|
|
6203
|
+
*/
|
|
6204
|
+
const verifyThread = async () => {
|
|
6205
|
+
const t = await readTitle();
|
|
6206
|
+
if (t) return this.threadTitleMatches(t, who);
|
|
6207
|
+
return needSwitch ? await this.waitThreadChanged(page, before) : true;
|
|
6208
|
+
};
|
|
6209
|
+
if (!(await verifyThread())) {
|
|
6210
|
+
// 可能是点空了(限流 toast 盖住列表时很常见):再点一次再核
|
|
6211
|
+
diag.steps.push('retry-click');
|
|
6212
|
+
await this.clickThread(page, who).catch(() => {});
|
|
6213
|
+
await page.waitForTimeout(300);
|
|
6214
|
+
if (!(await verifyThread())) {
|
|
6215
|
+
// 没核实通过就把缓存清掉,别让下一条继续错下去
|
|
6216
|
+
if (this.chatThread === who) this.chatThread = '';
|
|
6217
|
+
if (this.openThread === who) this.openThread = '';
|
|
6218
|
+
const driftTitle = await readTitle();
|
|
6219
|
+
this.log?.info?.('stroll thread drift', {
|
|
6220
|
+
who,
|
|
6221
|
+
title: driftTitle,
|
|
6222
|
+
url: String(page.url?.() ?? ''),
|
|
6223
|
+
needSwitch,
|
|
6224
|
+
steps: diag.steps,
|
|
6225
|
+
});
|
|
6226
|
+
throw new DouyinError('THREAD_DRIFT', `页面没切到「${who}」这个会话(标题:${driftTitle || '空'}),没发`);
|
|
6227
|
+
}
|
|
6173
6228
|
}
|
|
6229
|
+
this.openThread = who;
|
|
6230
|
+
this.chatThread = who;
|
|
6174
6231
|
const input = await this.waitMsgInput(page, 6000);
|
|
6175
|
-
if (!input)
|
|
6232
|
+
if (!input) {
|
|
6233
|
+
diag.chatUrl = String(page.url?.() ?? '');
|
|
6234
|
+
diag.chatTitle = await this.currentChatTitle(page).catch(() => '');
|
|
6235
|
+
this.log?.info?.('stroll dm diag', { ...diag, fail: 'NO_INPUT' });
|
|
6236
|
+
throw new DouyinError('NO_INPUT', '没找到输入框,抖音可能改版了');
|
|
6237
|
+
}
|
|
6176
6238
|
await this.sendInChat(page, input, text, { mention });
|
|
6177
6239
|
this.lastAction = `dm ${who}`;
|
|
6178
6240
|
return { ok: true, sent: true, mentioned: mention ? !!this.lastMention : null, mentionDebug: this.lastMention ? '' : this.lastMentionDump || '' };
|
|
@@ -6360,6 +6422,38 @@ export class DouyinBrowser {
|
|
|
6360
6422
|
.catch(() => '');
|
|
6361
6423
|
}
|
|
6362
6424
|
|
|
6425
|
+
/**
|
|
6426
|
+
* 会话内容指纹:切换会话之后这里会变。
|
|
6427
|
+
* 这套页面读不到标题(实测 `currentChatTitle` 一直返回空),所以「有没有真的切过去」
|
|
6428
|
+
* 只能靠内容变没变来判断——这也是 2.1.2 把主人私信逼进 20 秒慢路的原因。
|
|
6429
|
+
*/
|
|
6430
|
+
async threadFingerprint(page) {
|
|
6431
|
+
return await page
|
|
6432
|
+
.evaluate(() => {
|
|
6433
|
+
const nodes = Array.from(document.querySelectorAll('[data-e2e="msg-item-content"]'));
|
|
6434
|
+
const txt = (n) => String(n?.innerText ?? '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
6435
|
+
return {
|
|
6436
|
+
n: nodes.length,
|
|
6437
|
+
first: nodes.length ? txt(nodes[0]) : '',
|
|
6438
|
+
last: nodes.length ? txt(nodes[nodes.length - 1]) : '',
|
|
6439
|
+
};
|
|
6440
|
+
})
|
|
6441
|
+
.catch(() => null);
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6444
|
+
/** 等内容真的换掉(切会话的动画/请求要一点时间)。读不到指纹时放行,别误伤。 */
|
|
6445
|
+
async waitThreadChanged(page, before, timeout = 1800) {
|
|
6446
|
+
if (!before) return true;
|
|
6447
|
+
const deadline = Date.now() + timeout;
|
|
6448
|
+
while (Date.now() < deadline) {
|
|
6449
|
+
const now = await this.threadFingerprint(page);
|
|
6450
|
+
if (!now) return true;
|
|
6451
|
+
if (now.n !== before.n || now.last !== before.last || now.first !== before.first) return true;
|
|
6452
|
+
await page.waitForTimeout(120);
|
|
6453
|
+
}
|
|
6454
|
+
return false;
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6363
6457
|
/** 会话标题比对:忽略空白、大小写和同名群的「#2」后缀;认不出标题时算匹配(别误伤)。 */
|
|
6364
6458
|
threadTitleMatches(title, who) {
|
|
6365
6459
|
const norm = (s) =>
|
|
@@ -6385,7 +6479,7 @@ export class DouyinBrowser {
|
|
|
6385
6479
|
* 群页是常驻的,抖音可能自己切到别的会话(新群、刷新、消息跳转);
|
|
6386
6480
|
* 不确认就往当前输入框里打字,话会落到另一个群里——看起来「发了」,群里却没有。
|
|
6387
6481
|
*/
|
|
6388
|
-
async ensureThreadOpen(page, who, { strict = false } = {}) {
|
|
6482
|
+
async ensureThreadOpen(page, who, { strict = false, requireTitle = false } = {}) {
|
|
6389
6483
|
let title = await this.currentChatTitle(page).catch(() => '');
|
|
6390
6484
|
if (!title) {
|
|
6391
6485
|
// 刚切换会话时标题可能还没重绘:再给一次机会
|
|
@@ -6398,9 +6492,17 @@ export class DouyinBrowser {
|
|
|
6398
6492
|
* strict 是给陌生人用的:宁可报没发,也不能把话打进别人的会话里。
|
|
6399
6493
|
*/
|
|
6400
6494
|
if (!title) {
|
|
6401
|
-
// 读不出标题时放行:点会话那一步已经核过(点不到会直接抛 NO_DM),
|
|
6402
|
-
// 这里再拦就变成「永远发不出去」。日志留一条,方便后面修标题选择器。
|
|
6403
6495
|
this.lastTitleMiss = { at: Date.now(), who, url: String(page.url?.() ?? '') };
|
|
6496
|
+
/**
|
|
6497
|
+
* requireTitle:连标题都读不出来,就没法确认页面停在哪。
|
|
6498
|
+
* 主人私信走这条——宁可回执写「没发」,也不能像 11-08 那样把私信打进群聊。
|
|
6499
|
+
* 其它调用照旧放行:点会话那一步已经核过(点不到会直接抛 NO_DM),
|
|
6500
|
+
* 这里再拦就变成「永远发不出去」。
|
|
6501
|
+
*/
|
|
6502
|
+
if (requireTitle) {
|
|
6503
|
+
this.log?.info?.('stroll chat title miss', { who, url: String(page.url?.() ?? '') });
|
|
6504
|
+
return false;
|
|
6505
|
+
}
|
|
6404
6506
|
return true;
|
|
6405
6507
|
}
|
|
6406
6508
|
if (this.threadTitleMatches(title, who)) return true;
|
package/src/policy.js
CHANGED
|
@@ -255,11 +255,27 @@ export class Policy {
|
|
|
255
255
|
* 停掉全部动作只会把主人当场点的事一起埋了——之前评论发不出去、要等一两个小时就是这么来的。
|
|
256
256
|
* 真被验证码/封号挡住时平台自己会报错,她会如实告诉主人。
|
|
257
257
|
* light:只有验证码/封禁这种「再试也没用」的才停 30 分钟。strict:旧的 48h/24h 分档。
|
|
258
|
+
*
|
|
259
|
+
* source:
|
|
260
|
+
* 'write' 写动作被挡(默认)——要退避、要记一笔,面板上能看到;
|
|
261
|
+
* 'read' 只读请求被限流——**不续期退避、不记 action、不加 strikes**。
|
|
262
|
+
* 以前读接口(B站私信每 3 秒一次的 inbox)撞 -509 也照记,结果 300 条记录里 267 条是风控,
|
|
263
|
+
* 退避还被这些只读请求一直续命,她自己反倒永远出不了门。
|
|
258
264
|
*/
|
|
259
|
-
noteRisk(code, message) {
|
|
265
|
+
noteRisk(code, message, { source = 'write' } = {}) {
|
|
260
266
|
const text = `${code ?? ''} ${message ?? ''}`.toLowerCase();
|
|
261
267
|
const verify = /验证|captcha|verify|异常|封禁|ban|-352|-799/.test(text);
|
|
262
268
|
const rate = /429|-509|请求过于频繁|操作频繁|访问过于频繁|rate/.test(text);
|
|
269
|
+
|
|
270
|
+
// 只读请求的限流:只累一个计数(给面板/日志看趋势),不写记录、不动退避。
|
|
271
|
+
// 但如果同时是验证码/封号(verify),还是照常往下走——那种必须让主人知道。
|
|
272
|
+
if (rate && !verify && source === 'read') {
|
|
273
|
+
const prev = this.store.data.rateRead ?? {};
|
|
274
|
+
this.store.data.rateRead = { n: Number(prev.n ?? 0) + 1, at: Date.now(), reason: String(message ?? code ?? '').slice(0, 120) };
|
|
275
|
+
// 不 save:这个数秒级会变,跟着下一次正常写盘落盘就行
|
|
276
|
+
return { until: 0, hours: 0, reason: '', guard: this.guard(), soft: this.rateWait(), read: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
263
279
|
const guard = this.guard();
|
|
264
280
|
const before = Number(this.store.data.risk.strikes ?? 0);
|
|
265
281
|
const strikes = rate ? before + 1 : before;
|
package/src/store.js
CHANGED
|
@@ -20,6 +20,8 @@ function blank() {
|
|
|
20
20
|
risk: { until: 0, reason: '', strikes: 0, lastCode: '' },
|
|
21
21
|
/** 限流退避(-509/429):只拦她自己出门,不拦主人点的事。 */
|
|
22
22
|
softBackoff: { until: 0, at: 0, n: 0, reason: '' },
|
|
23
|
+
/** 只读请求被限流的次数(不算动作、不加 strikes,只用来看看平台压力)。 */
|
|
24
|
+
rateRead: { n: 0, at: 0, reason: '' },
|
|
23
25
|
lastWriteAt: 0,
|
|
24
26
|
/** 每个平台各自的上次公开动作时间:抖音限流不该连带卡住 B站。 */
|
|
25
27
|
lastWriteByPlat: {},
|
|
@@ -53,6 +55,7 @@ export class Store {
|
|
|
53
55
|
this.data = { ...blank(), ...parsed };
|
|
54
56
|
this.data.risk = { ...blank().risk, ...(parsed.risk ?? {}) };
|
|
55
57
|
this.data.softBackoff = { ...blank().softBackoff, ...(parsed.softBackoff ?? {}) };
|
|
58
|
+
this.data.rateRead = { ...blank().rateRead, ...(parsed.rateRead ?? {}) };
|
|
56
59
|
this.data.stats = { ...blank().stats, ...(parsed.stats ?? {}) };
|
|
57
60
|
for (const d of this.data.drafts) {
|
|
58
61
|
if (d.status === 'sending') {
|
package/src/world.js
CHANGED
|
@@ -160,9 +160,14 @@ export class StrollWorld {
|
|
|
160
160
|
this.store = new Store(path.join(dataDir, 'stroll'), this.timeZone).load();
|
|
161
161
|
this.store.data.autoSessions ??= {};
|
|
162
162
|
this.policy = new Policy({ store: this.store, cfg, timeZone: this.timeZone });
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
/**
|
|
164
|
+
* 注意这里必须传 `this.log`,不能传构造参数 `log`:
|
|
165
|
+
* 宿主没给 logger 时 `log` 是 undefined,子组件拿到的就是 undefined,
|
|
166
|
+
* 它们所有 `this.log?.info?.()` 全是空操作——抖音那边的日志会整片消失(2026-11-08 排查踩过)。
|
|
167
|
+
*/
|
|
168
|
+
this.bili = new Bilibili({ dataDir, cfg, log: this.log, policy: this.policy, store: this.store });
|
|
169
|
+
this.stickers = new StickerBook({ dataDir, log: this.log });
|
|
170
|
+
this.douyin = new DouyinBrowser({ dataDir, cfg, log: this.log, policy: this.policy, stickers: this.stickers });
|
|
166
171
|
// 扫码成功后顺手记下「现在登的是哪个抖音号」,面板要显示,也要防「给自己发私信」
|
|
167
172
|
this.douyin.onSelf = (self) => this.noteDouyinSelf(self);
|
|
168
173
|
this.timer = null;
|
|
@@ -372,6 +377,33 @@ export class StrollWorld {
|
|
|
372
377
|
this.log?.info?.('stroll migrate stranger pending', { before: list.length, after: next.length });
|
|
373
378
|
}
|
|
374
379
|
|
|
380
|
+
/**
|
|
381
|
+
* 一次性清理:2.1.0 之前每次只读请求撞 -509 都会记一条 action,
|
|
382
|
+
* 实测 300 条上限里 267 条是「风控停手」,真实动作反而被挤掉。
|
|
383
|
+
* 这里只保留最近 20 条风控记录(够看趋势),把位置还给点赞/评论。
|
|
384
|
+
*/
|
|
385
|
+
pruneRiskActions() {
|
|
386
|
+
const done = this.store.data.migrated ?? {};
|
|
387
|
+
if (done.riskPrune) return;
|
|
388
|
+
const list = this.store.data.actions ?? [];
|
|
389
|
+
const kept = list.map((a, i) => (a?.action === 'risk' ? i : -1)).filter((i) => i >= 0);
|
|
390
|
+
const keepFrom = kept.length > 20 ? kept[kept.length - 20] : -1;
|
|
391
|
+
if (kept.length > 20) {
|
|
392
|
+
this.store.data.actions = list.filter((a, i) => a?.action !== 'risk' || i >= keepFrom);
|
|
393
|
+
}
|
|
394
|
+
// 顺手把被只读请求刷高的 strikes 归零:以前累计到 800 多,切严格模式会直接触发 6 小时冷却
|
|
395
|
+
const strikes = Number(this.store.data.risk?.strikes ?? 0);
|
|
396
|
+
if (strikes > 20) {
|
|
397
|
+
this.store.data.risk = { ...(this.store.data.risk ?? {}), strikes: 0 };
|
|
398
|
+
this.log?.info?.('stroll reset inflated strikes', { before: strikes });
|
|
399
|
+
}
|
|
400
|
+
this.store.data.migrated = { ...done, riskPrune: true };
|
|
401
|
+
this.store.save();
|
|
402
|
+
if (kept.length > 20) {
|
|
403
|
+
this.log?.info?.('stroll prune risk actions', { before: list.length, after: this.store.data.actions.length });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
375
407
|
/**
|
|
376
408
|
* 一次性迁移:2.0 以前用「每天自己出门 = 0」表示不出去。
|
|
377
409
|
* 2.1 起这件事交给「出门方式」,这里把老配置翻译过去,之后主人改的就不动了:
|
|
@@ -402,6 +434,7 @@ export class StrollWorld {
|
|
|
402
434
|
this.migrateStrangerKeys();
|
|
403
435
|
this.migrateStrangerSeenEcho();
|
|
404
436
|
this.migrateOutingMode();
|
|
437
|
+
this.pruneRiskActions();
|
|
405
438
|
this.refundDmLoopQuota();
|
|
406
439
|
const done = this.store.data.migrated ?? {};
|
|
407
440
|
if (done.dmQuota) return;
|
|
@@ -657,6 +690,8 @@ export class StrollWorld {
|
|
|
657
690
|
this.stopDmWatch();
|
|
658
691
|
if (!this.running) return;
|
|
659
692
|
this.dmLastAt = 0;
|
|
693
|
+
/** B站私信上一次真的去拉的时间:限流时用它把轮询从 3 秒降到 60 秒。 */
|
|
694
|
+
this.biliDmAt = 0;
|
|
660
695
|
const live = this.liveConfig() ?? {};
|
|
661
696
|
const warm = this.dmWatchOn(live) && (live.douyin ?? this.cfg.douyin) !== false;
|
|
662
697
|
// 抖音监听要靠浏览器读 DOM:保持后台页面热着,冷启动一次要十几秒,那段时间的消息接不住
|
|
@@ -1236,7 +1271,18 @@ export class StrollWorld {
|
|
|
1236
1271
|
this.dmBusy = true;
|
|
1237
1272
|
try {
|
|
1238
1273
|
const fresh = [];
|
|
1239
|
-
|
|
1274
|
+
/**
|
|
1275
|
+
* B站私信平时按 dmPollSec(3 秒)拉;被 -509 限流时降到 60 秒一次。
|
|
1276
|
+
* 以前限流期间照旧 3 秒一打:每次都被 -509 挡回来,既把「最近」刷满风控记录,
|
|
1277
|
+
* 又不断续期 softBackoff,结果她自己反倒一直出不了门。
|
|
1278
|
+
*/
|
|
1279
|
+
const wait = this.policy.rateWait();
|
|
1280
|
+
const pollSec = Math.max(3, Number(live.dmPollSec ?? this.cfg.dmPollSec ?? 3) || 3);
|
|
1281
|
+
const biliGap = (wait.waiting ? 60 : pollSec) * 1000;
|
|
1282
|
+
if (useBili && Date.now() - Number(this.biliDmAt ?? 0) >= biliGap) {
|
|
1283
|
+
this.biliDmAt = Date.now();
|
|
1284
|
+
fresh.push(...(await this.newBiliDm().catch(() => [])));
|
|
1285
|
+
}
|
|
1240
1286
|
// 抖音已经走事件通道时就不必每 5 秒读一遍 DOM 了;但每 60 秒仍然对一次账,
|
|
1241
1287
|
// 万一观察器漏了(页面卡住、切走了),最多晚一分钟,而不是一直不响。
|
|
1242
1288
|
const dyArmed = !!this.douyin.dmArmed?.();
|
|
@@ -3538,7 +3584,11 @@ export class StrollWorld {
|
|
|
3538
3584
|
history() {
|
|
3539
3585
|
return {
|
|
3540
3586
|
ok: true,
|
|
3541
|
-
|
|
3587
|
+
// 给她看的是「做过什么」;风控记录另算,不塞进动作列表
|
|
3588
|
+
recent: this.store
|
|
3589
|
+
.lastActions(24)
|
|
3590
|
+
.filter((x) => x.platform !== 'system')
|
|
3591
|
+
.slice(0, 8),
|
|
3542
3592
|
drafts: this.store.pendingDrafts().map((d) => ({ id: d.id, preview: this.previewText(d), ageSec: d.ageSec })),
|
|
3543
3593
|
stats: this.store.data.stats,
|
|
3544
3594
|
interests: this.store.topInterests(8),
|
|
@@ -4352,10 +4402,22 @@ export class StrollWorld {
|
|
|
4352
4402
|
async douyinDmFast(secUid, text) {
|
|
4353
4403
|
const name = this.douyinOwner().name || (await this.douyinOwnerName().catch(() => ''));
|
|
4354
4404
|
if (name) {
|
|
4405
|
+
/**
|
|
4406
|
+
* 主人私信也要求「先核实会话再发」:
|
|
4407
|
+
* strict + requireTitle —— 标题读不出来就当作没切过去,走下面按 secUid 的慢路径。
|
|
4408
|
+
* 2026-11-08 那次三条私信落进群聊,就是因为这里不核实就按「当前开着的会话」发了。
|
|
4409
|
+
*/
|
|
4355
4410
|
const ok = await this.douyin
|
|
4356
|
-
.replyDm(name, cutChars(text, 300))
|
|
4411
|
+
.replyDm(name, cutChars(text, 300), { strict: true, requireTitle: true })
|
|
4357
4412
|
.then(() => true)
|
|
4358
|
-
.catch(() =>
|
|
4413
|
+
.catch((error) => {
|
|
4414
|
+
// 诊断用:走兜底就记下原因(NO_DM / NO_INPUT / THREAD_DRIFT…),好定位是哪一步过不去
|
|
4415
|
+
this.log?.info?.('stroll dm fast failed', {
|
|
4416
|
+
code: error?.code ?? '',
|
|
4417
|
+
error: String(error?.message ?? error ?? '').slice(0, 120),
|
|
4418
|
+
});
|
|
4419
|
+
return false;
|
|
4420
|
+
});
|
|
4359
4421
|
if (ok) return { ok: true, sent: true, via: 'chat' };
|
|
4360
4422
|
}
|
|
4361
4423
|
return await this.douyin.dm(secUid, text);
|
|
@@ -5869,7 +5931,7 @@ export class StrollWorld {
|
|
|
5869
5931
|
return { ok: true, items, sig: items.map((x) => x.id).join(',') };
|
|
5870
5932
|
}
|
|
5871
5933
|
if (method === 'usage_detail') return this.usageDetail(String(args[0] ?? ''));
|
|
5872
|
-
if (method === 'timeline') return { ok: true, items: this.timelineItems(args[0]) };
|
|
5934
|
+
if (method === 'timeline') return { ok: true, items: this.timelineItems(args[0]), total: this.store.data.actions.length };
|
|
5873
5935
|
if (method === 'set_ramp') {
|
|
5874
5936
|
const on = args[0] !== false && args[0] !== 'false';
|
|
5875
5937
|
this.persist({ newAccountRamp: on });
|
|
@@ -6196,8 +6258,15 @@ export class StrollWorld {
|
|
|
6196
6258
|
})),
|
|
6197
6259
|
// 「先问我」模式下等主人点头的陌生人私信(面板「待你点头」里显示)
|
|
6198
6260
|
strangerPending: this.strangerPendingList(),
|
|
6199
|
-
|
|
6200
|
-
|
|
6261
|
+
// 面板「最近」只放真动作:风控/系统记录单独算,不跟点赞评论抢位置
|
|
6262
|
+
// (先扫足够多:老数据里风控可能把末尾几十条全占了)
|
|
6263
|
+
recent: this.store
|
|
6264
|
+
.lastActions(300)
|
|
6265
|
+
.filter((x) => x.platform !== 'system')
|
|
6266
|
+
.slice(0, 10)
|
|
6267
|
+
.map((x) => this.describeAction(x)),
|
|
6268
|
+
recentTotal: this.store.data.actions.filter((x) => x.platform !== 'system').length,
|
|
6269
|
+
riskCount: this.store.data.actions.filter((x) => x.action === 'risk').length,
|
|
6201
6270
|
risk: this.store.data.risk,
|
|
6202
6271
|
policy: this.policyView(),
|
|
6203
6272
|
lastOpen: this.lastOpen,
|
package/webui/client.js
CHANGED
|
@@ -1344,16 +1344,46 @@ export default {
|
|
|
1344
1344
|
async function openTimeline() {
|
|
1345
1345
|
let data = null;
|
|
1346
1346
|
try {
|
|
1347
|
-
data = await ctx.invoke('timeline', [
|
|
1347
|
+
data = await ctx.invoke('timeline', [120]);
|
|
1348
1348
|
} catch {
|
|
1349
1349
|
// 读不到就退回面板上那几条
|
|
1350
1350
|
}
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1351
|
+
const all = Array.isArray(data?.items) ? data.items : (lastData?.recent ?? []);
|
|
1352
|
+
const total = Number(data?.total ?? all.length);
|
|
1353
|
+
const isRisk = (x) => x?.action === 'risk' || x?.platform === 'system';
|
|
1354
|
+
const groups = {
|
|
1355
|
+
action: all.filter((x) => !isRisk(x)),
|
|
1356
|
+
risk: all.filter(isRisk),
|
|
1357
|
+
};
|
|
1358
|
+
// 默认落在「动作」:风控记录再多也不该把真正做过的事盖住
|
|
1359
|
+
let active = groups.action.length ? 'action' : groups.risk.length ? 'risk' : 'all';
|
|
1360
|
+
const filters = [
|
|
1361
|
+
{ key: 'action', label: '动作', count: groups.action.length },
|
|
1362
|
+
{ key: 'risk', label: '风控', count: groups.risk.length },
|
|
1363
|
+
{ key: 'all', label: '全部', count: all.length },
|
|
1364
|
+
];
|
|
1365
|
+
const paint = () => {
|
|
1366
|
+
const items = active === 'all' ? all : groups[active] ?? [];
|
|
1367
|
+
openSheet({
|
|
1368
|
+
title: '最近做过什么',
|
|
1369
|
+
hint: `${items.length} 条${total > all.length ? '(更早的已折叠)' : ''}`,
|
|
1370
|
+
tabs: filters,
|
|
1371
|
+
active,
|
|
1372
|
+
onTab: (key) => {
|
|
1373
|
+
if (key === active) return;
|
|
1374
|
+
active = key;
|
|
1375
|
+
paint();
|
|
1376
|
+
},
|
|
1377
|
+
render: (body) =>
|
|
1378
|
+
renderDetailList(
|
|
1379
|
+
body,
|
|
1380
|
+
items,
|
|
1381
|
+
active === 'risk' ? '没被限流过' : '还没出过门',
|
|
1382
|
+
active === 'risk' ? '只有平台限流/要验证的时候才会记在这里。' : '等她自己出门,或者在上面贴个链接。',
|
|
1383
|
+
),
|
|
1384
|
+
});
|
|
1385
|
+
};
|
|
1386
|
+
paint();
|
|
1357
1387
|
}
|
|
1358
1388
|
|
|
1359
1389
|
async function openComments(urlOverride) {
|