cortico-world-mosey 2.1.1 → 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 CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## 2.1.1
4
17
 
5
18
  **修「最近做过什么」被风控记录刷屏,顺带修掉一个连带问题:她可能一直被按着出不了门。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-mosey",
3
- "version": "2.1.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/douyin.js CHANGED
@@ -1036,7 +1036,13 @@ export class DouyinBrowser {
1036
1036
  opened = !!ok;
1037
1037
  // 没有「陌生人消息」文件夹的账号,人就排在主列表里
1038
1038
  if (!ok) ok = await this.clickThread(page, want).catch(() => false);
1039
- if (ok) this.chatThread = want;
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
+ }
1040
1046
  return !!ok;
1041
1047
  } finally {
1042
1048
  // 文件夹是浮层:收起来,别挡着后面打字
@@ -6077,7 +6083,7 @@ export class DouyinBrowser {
6077
6083
  * 在聊天页发一条。mention 是「要真的 @ 到的昵称」(群里才有意义):
6078
6084
  * 传了就会先从候选面板插一个真 chip,再写正文。
6079
6085
  */
6080
- async replyDm(name, text, { mention = '', strict = false } = {}) {
6086
+ async replyDm(name, text, { mention = '', strict = false, requireTitle = false } = {}) {
6081
6087
  const who = String(name ?? '').trim();
6082
6088
  if (!who) throw new DouyinError('BAD_TARGET', '没有会话昵称,回不了');
6083
6089
  // 各段耗时带上:主人在追「一秒内」,得知道慢在哪一段(挂在回执里,不额外打日志)
@@ -6085,12 +6091,19 @@ export class DouyinBrowser {
6085
6091
  // 私信页正停在这个会话上:不用跳转、不用点会话,直接打字回车。
6086
6092
  // 这是「秒回」的关键一段——省掉整页导航(6-8 秒)和点会话后的固定停顿(2.4 秒)。
6087
6093
  const dm = this.dmPage;
6094
+ // 诊断用:只在失败/走兜底时打一行,正常发送零开销
6095
+ const diag = { who, dmThread: this.dmThread, chatThread: this.chatThread, steps: [] };
6088
6096
  if (dm && !dm.isClosed() && this.dmThread === who) {
6089
6097
  // 先确认页面真的停在主人这个会话上。点不回来就别硬发——宁可走慢路径重开,
6090
6098
  // 也不能把私信打进别人的会话里(GLM 审出来的 #1)。
6099
+ // dmPage 是监听器专门停在主人会话上的那一页(群里用的是 groupPages,不共用),
6100
+ // 所以这里不再要求「标题可读」——这套页面读不到标题,强求只会把每条私信都逼到慢兜底。
6091
6101
  const onThread = await this.ensureThreadOpen(dm, who, { strict }).catch(() => false);
6092
6102
  if (!onThread) {
6093
6103
  this.dmThread = '';
6104
+ diag.steps.push('dmPage:verify-fail');
6105
+ diag.dmTitle = await this.currentChatTitle(dm).catch(() => '');
6106
+ diag.dmUrl = String(dm.url?.() ?? '');
6094
6107
  } else {
6095
6108
  const t1 = Date.now();
6096
6109
  const input = await this.waitMsgInput(dm, 3000).catch(() => null);
@@ -6154,29 +6167,74 @@ export class DouyinBrowser {
6154
6167
  try {
6155
6168
  // 已经在这个会话里就直接用:省一次整页跳转和点击,回私信快好几秒
6156
6169
  // 用 chatThread 而不是 openThread:后者被 dmPage 的读取共用,会误判
6157
- if (this.chatThread !== who || !/\/chat/.test(page.url())) {
6170
+ const needSwitch = this.chatThread !== who || !/\/chat/.test(page.url());
6171
+ // 点之前先记一份内容指纹:这套页面读不出标题,靠「内容有没有换」判断切没切过去
6172
+ let before = null;
6173
+ if (needSwitch) {
6158
6174
  if (!/\/chat/.test(page.url())) await this.gotoChat(page);
6175
+ before = await this.threadFingerprint(page);
6159
6176
  let ok = await this.clickThread(page, who);
6177
+ diag.steps.push(`click:${ok ? 'ok' : 'miss'}`);
6160
6178
  if (!ok) {
6161
6179
  // 主列表里没有:抖音会把非好友的私信收进「陌生人消息」文件夹
6162
6180
  ok = await this.clickStrangerThread(page, who);
6181
+ diag.steps.push(`stranger:${ok ? 'ok' : 'miss'}`);
6163
6182
  }
6164
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' });
6165
6187
  throw new DouyinError('NO_DM', '聊天列表和「陌生人消息」里都找不到这个会话,可能被新消息刷下去了');
6166
6188
  }
6167
- this.openThread = who;
6168
- this.chatThread = who;
6169
6189
  // 点完不用干等两秒:下面 waitMsgInput 会一轮一轮等到输入框真的能打字
6170
6190
  await page.waitForTimeout(200);
6171
6191
  }
6172
- // 慢路径也核一次标题:openThread 只是我们记的,页面可能没真的切过去
6173
- if (!(await this.ensureThreadOpen(page, who, { strict }).catch(() => false))) {
6174
- const driftTitle = await this.currentChatTitle(page).catch(() => '');
6175
- this.log?.warn?.('stroll thread drift', { who, title: driftTitle, url: String(page.url?.() ?? '') });
6176
- throw new DouyinError('THREAD_DRIFT', `页面没切到「${who}」这个会话,没发`);
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
+ }
6177
6228
  }
6229
+ this.openThread = who;
6230
+ this.chatThread = who;
6178
6231
  const input = await this.waitMsgInput(page, 6000);
6179
- if (!input) throw new DouyinError('NO_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
+ }
6180
6238
  await this.sendInChat(page, input, text, { mention });
6181
6239
  this.lastAction = `dm ${who}`;
6182
6240
  return { ok: true, sent: true, mentioned: mention ? !!this.lastMention : null, mentionDebug: this.lastMention ? '' : this.lastMentionDump || '' };
@@ -6364,6 +6422,38 @@ export class DouyinBrowser {
6364
6422
  .catch(() => '');
6365
6423
  }
6366
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
+
6367
6457
  /** 会话标题比对:忽略空白、大小写和同名群的「#2」后缀;认不出标题时算匹配(别误伤)。 */
6368
6458
  threadTitleMatches(title, who) {
6369
6459
  const norm = (s) =>
@@ -6389,7 +6479,7 @@ export class DouyinBrowser {
6389
6479
  * 群页是常驻的,抖音可能自己切到别的会话(新群、刷新、消息跳转);
6390
6480
  * 不确认就往当前输入框里打字,话会落到另一个群里——看起来「发了」,群里却没有。
6391
6481
  */
6392
- async ensureThreadOpen(page, who, { strict = false } = {}) {
6482
+ async ensureThreadOpen(page, who, { strict = false, requireTitle = false } = {}) {
6393
6483
  let title = await this.currentChatTitle(page).catch(() => '');
6394
6484
  if (!title) {
6395
6485
  // 刚切换会话时标题可能还没重绘:再给一次机会
@@ -6402,9 +6492,17 @@ export class DouyinBrowser {
6402
6492
  * strict 是给陌生人用的:宁可报没发,也不能把话打进别人的会话里。
6403
6493
  */
6404
6494
  if (!title) {
6405
- // 读不出标题时放行:点会话那一步已经核过(点不到会直接抛 NO_DM),
6406
- // 这里再拦就变成「永远发不出去」。日志留一条,方便后面修标题选择器。
6407
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
+ }
6408
6506
  return true;
6409
6507
  }
6410
6508
  if (this.threadTitleMatches(title, who)) return true;
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
- this.bili = new Bilibili({ dataDir, cfg, log, policy: this.policy, store: this.store });
164
- this.stickers = new StickerBook({ dataDir, log });
165
- this.douyin = new DouyinBrowser({ dataDir, cfg, log, policy: this.policy, stickers: this.stickers });
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;
@@ -4397,10 +4402,22 @@ export class StrollWorld {
4397
4402
  async douyinDmFast(secUid, text) {
4398
4403
  const name = this.douyinOwner().name || (await this.douyinOwnerName().catch(() => ''));
4399
4404
  if (name) {
4405
+ /**
4406
+ * 主人私信也要求「先核实会话再发」:
4407
+ * strict + requireTitle —— 标题读不出来就当作没切过去,走下面按 secUid 的慢路径。
4408
+ * 2026-11-08 那次三条私信落进群聊,就是因为这里不核实就按「当前开着的会话」发了。
4409
+ */
4400
4410
  const ok = await this.douyin
4401
- .replyDm(name, cutChars(text, 300))
4411
+ .replyDm(name, cutChars(text, 300), { strict: true, requireTitle: true })
4402
4412
  .then(() => true)
4403
- .catch(() => false);
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
+ });
4404
4421
  if (ok) return { ok: true, sent: true, via: 'chat' };
4405
4422
  }
4406
4423
  return await this.douyin.dm(secUid, text);