cortico-world-mosey 2.0.0 → 2.0.1

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,23 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.0.1
4
+
5
+ **修两个问题:私信被发到群里、列表里看得见却发不出去(都是群友报的)。**
6
+
7
+ ### 群名是「几个人的昵称拼起来」时,私信会发到群里
8
+
9
+ 抖音拉群时群名默认是成员昵称拼的(`海底月、小月月、大萌神`),而群名匹配允许「她写的是群名的一部分」,于是 `target=海底月` 被判成「发到那个群」——**私信内容直接公开在群里**。
10
+
11
+ - 私信和发表情都改成**先认人、再认群**:命中「给她发过消息的陌生人」就不再做群匹配。想发到这种群,把完整群名写出来。
12
+ - 反过来也修了:主列表差集不再把「海底月」当成群「海底月、小月月」——以前那个人的私信会被整条排除,**永远发现不了**。
13
+ - 新增 `stroll target ambiguous` 日志:一个名字同时像人又像群时,写明最后按哪个发的。
14
+
15
+ ### 列表里明明看得见,发的时候说找不到会话
16
+
17
+ - 会话标题黏着时间(`海底月 昨天`、`海底月 00:30`)时,精确匹配永远不成立,只能退到「包含」;而群名也可能是成员昵称拼的(`测试1、海底月`),一包含就命中两条 → 判成「歧义」直接放弃。现在先剥掉尾部时间再比。
18
+ - 会话被新消息刷到列表很下面时,抖音的**虚拟滚动**根本不渲染那一行,`querySelectorAll` 看不到。现在找不到会先滚回顶部,再一屏一屏往下找(最多 10 屏)。
19
+ - 顺手:target 就是她自己登录的抖音号时,直接说「抖音不能给自己发私信」,不再报「找不到会话」让她猜。
20
+
3
21
  ## 2.0.0
4
22
 
5
23
  **第一个大版本:陌生人私信能回了,面板多了「私信 / 群聊」两张额度卡,安全也收紧了一轮。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-mosey",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
6
6
  "license": "MIT",
package/src/douyin.js CHANGED
@@ -6207,7 +6207,19 @@ export class DouyinBrowser {
6207
6207
  const titleOf = (el) => {
6208
6208
  const t = el.querySelector('[class*="ConversationItemtitle"]');
6209
6209
  const line = String((el.innerText || '').split('\n')[0] || '');
6210
- return norm(t?.innerText || line);
6210
+ const raw = norm(t?.innerText || line);
6211
+ /**
6212
+ * 列表标题常黏着时间(`海底月 刚刚`、`海底月 10:55`)。
6213
+ * 不剥的话 exact 永远不成立,只能落到「包含」;而群名也可能含这个人名
6214
+ * (`测试1、海底月`),包含就会同时命中两条 → 判成歧义 → 直接返回 false,
6215
+ * 表现就是「列表里明明看得见,发的时候说找不到」。
6216
+ */
6217
+ return raw
6218
+ .replace(
6219
+ /\s+(刚刚|\d{1,2}:\d{2}|昨天|前天|星期[一二三四五六日]|\d+\s*(?:秒|分钟|小时|天)前|\d{1,2}-\d{1,2})$/,
6220
+ '',
6221
+ )
6222
+ .trim();
6211
6223
  };
6212
6224
  let hit = null;
6213
6225
  if (cid) hit = items.find((el) => el.getAttribute('data-stroll-cid') === cid) ?? null;
@@ -6230,11 +6242,100 @@ export class DouyinBrowser {
6230
6242
  { target: base, nth, cid },
6231
6243
  )
6232
6244
  .catch(() => null);
6233
- if (!box) return false;
6234
- await page.mouse.click(box.x, box.y).catch(() => {});
6245
+ if (box) {
6246
+ await page.mouse.click(box.x, box.y).catch(() => {});
6247
+ return true;
6248
+ }
6249
+ /**
6250
+ * 还没找到:会话可能在列表很下面(昨晚的消息,中间刷了十几个小时)。
6251
+ * 抖音的会话列表是**虚拟滚动**,屏幕外的行根本不在 DOM 里,`querySelectorAll`
6252
+ * 看不到——所以先滚到顶,再一屏一屏往下找。
6253
+ */
6254
+ const found = await this.scrollConversationList(page, async () =>
6255
+ page
6256
+ .evaluate(
6257
+ ({ target, nth, cid }) => {
6258
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
6259
+ const items = Array.from(document.querySelectorAll('[data-e2e="conversation-item"]'));
6260
+ const titleOf = (el) => {
6261
+ const t = el.querySelector('[class*="ConversationItemtitle"]');
6262
+ const line = String((el.innerText || '').split('\n')[0] || '');
6263
+ return norm(t?.innerText || line)
6264
+ .replace(
6265
+ /\s+(刚刚|\d{1,2}:\d{2}|昨天|前天|星期[一二三四五六日]|\d+\s*(?:秒|分钟|小时|天)前|\d{1,2}-\d{1,2})$/,
6266
+ '',
6267
+ )
6268
+ .trim();
6269
+ };
6270
+ let hit = null;
6271
+ if (cid) hit = items.find((el) => el.getAttribute('data-stroll-cid') === cid) ?? null;
6272
+ if (!hit) {
6273
+ const exact = items.filter((el) => titleOf(el) === norm(target));
6274
+ hit = (nth > 0 ? exact[nth - 1] : exact[0]) ?? null;
6275
+ }
6276
+ if (!hit) {
6277
+ const partial = items.filter((el) => titleOf(el).includes(norm(target)));
6278
+ if (partial.length === 1) hit = partial[0];
6279
+ }
6280
+ if (!hit) return null;
6281
+ hit.scrollIntoView({ block: 'nearest' });
6282
+ const r = hit.getBoundingClientRect();
6283
+ if (r.width < 10 || r.height < 10) return null;
6284
+ return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
6285
+ },
6286
+ { target: base, nth, cid },
6287
+ )
6288
+ .catch(() => null),
6289
+ );
6290
+ if (!found) return false;
6291
+ await page.mouse.click(found.x, found.y).catch(() => {});
6235
6292
  return true;
6236
6293
  }
6237
6294
 
6295
+ /**
6296
+ * 在会话列表里一段一段往下滚,每滚一次调一次 `find()`。
6297
+ * 不依赖类名:从第一条会话往上找「overflow-y 真的能滚」的那个祖先。
6298
+ */
6299
+ async scrollConversationList(page, find, { tries = 10 } = {}) {
6300
+ const toTop = () => {
6301
+ const el = document.querySelector('[data-e2e="conversation-item"]');
6302
+ let p = el?.parentElement ?? null;
6303
+ while (p) {
6304
+ const st = getComputedStyle(p);
6305
+ if (/(auto|scroll|overlay)/.test(st.overflowY) && p.scrollHeight > p.clientHeight + 20) {
6306
+ p.scrollTop = 0;
6307
+ return;
6308
+ }
6309
+ p = p.parentElement;
6310
+ }
6311
+ };
6312
+ const step = () => {
6313
+ const el = document.querySelector('[data-e2e="conversation-item"]');
6314
+ let p = el?.parentElement ?? null;
6315
+ while (p) {
6316
+ const st = getComputedStyle(p);
6317
+ if (/(auto|scroll|overlay)/.test(st.overflowY) && p.scrollHeight > p.clientHeight + 20) {
6318
+ const before = p.scrollTop;
6319
+ p.scrollTop = before + Math.max(80, Math.round(p.clientHeight * 0.75));
6320
+ return p.scrollTop !== before;
6321
+ }
6322
+ p = p.parentElement;
6323
+ }
6324
+ return false;
6325
+ };
6326
+ // 先回到顶部:目标可能在上面,从当前位置往下滚永远到不了
6327
+ await page.evaluate(toTop).catch(() => {});
6328
+ await page.waitForTimeout(120);
6329
+ for (let i = 0; i < tries; i += 1) {
6330
+ const moved = await page.evaluate(step).catch(() => false);
6331
+ if (!moved) break;
6332
+ await page.waitForTimeout(160);
6333
+ const hit = await find();
6334
+ if (hit) return hit;
6335
+ }
6336
+ return null;
6337
+ }
6338
+
6238
6339
  /** 读聊天页顶部那个会话标题(认不出就返回空串,调用方当「不校验」处理)。 */
6239
6340
  async currentChatTitle(page) {
6240
6341
  return await page
package/src/world.js CHANGED
@@ -618,6 +618,31 @@ export class StrollWorld {
618
618
  return x === y || x.includes(y) || y.includes(x);
619
619
  }
620
620
 
621
+ /**
622
+ * 这一行/这个名字是不是「已知的群」。
623
+ *
624
+ * 和 `sameGroup` 的区别是**单向包含**:只认「会话标题包含群名」,不认反过来。
625
+ * 因为抖音拉群时群名默认是**几个成员的昵称拼起来**(`海底月、小月月、大萌神`),
626
+ * 用双向包含会把「海底月」这个**人**也判成群——后果有两个:
627
+ * 1. 她的私信永远发现不了(主列表差集把它当群排掉);
628
+ * 2. 发给「海底月」的私信被判成「发群」,内容公开在群里。
629
+ * 所以只留「标题 ⊇ 群名」这个方向:群标题通常就是群名(可能带人数后缀),
630
+ * 而人名一定比群名短。
631
+ */
632
+ looksLikeKnownGroup(name, known = []) {
633
+ const norm = (s) =>
634
+ String(s ?? '')
635
+ .replace(/[\s\u3000]+/g, '')
636
+ .replace(/\(\d+\)$|(\d+)$/, '')
637
+ .toLowerCase();
638
+ const y = norm(name);
639
+ if (!y) return false;
640
+ return (known ?? []).some((n) => {
641
+ const x = norm(n);
642
+ return !!x && (x === y || y.includes(x));
643
+ });
644
+ }
645
+
621
646
  /** 群名的比较键:去掉空白、大小写,以及同名群加的「#2」后缀。 */
622
647
  groupKey(name) {
623
648
  return String(name ?? '')
@@ -1623,7 +1648,7 @@ export class StrollWorld {
1623
1648
  const list = Object.keys(this.store.data.strangerSeen ?? {})
1624
1649
  .filter((k) => k.startsWith('douyin:'))
1625
1650
  .map((k) => stripRowTime(k.slice('douyin:'.length)))
1626
- .filter((who) => !groups.some((g) => this.sameGroup(g, who)));
1651
+ .filter((who) => !this.looksLikeKnownGroup(who, groups));
1627
1652
  return this.matchStranger(list, wanted);
1628
1653
  }
1629
1654
 
@@ -1657,7 +1682,7 @@ export class StrollWorld {
1657
1682
  .filter((k) => k.startsWith('douyin:'))
1658
1683
  .map((k) => stripRowTime(k.slice('douyin:'.length)))
1659
1684
  // 名单里可能混进过群(旧版本没排除干净):认得出的群不当人看
1660
- .filter((who) => !groups.some((g) => this.sameGroup(g, who)));
1685
+ .filter((who) => !this.looksLikeKnownGroup(who, groups));
1661
1686
  let who = this.matchStranger(seen, wanted);
1662
1687
  if (!who) {
1663
1688
  const found =
@@ -1679,6 +1704,20 @@ export class StrollWorld {
1679
1704
  const { mode } = this.strangerDmPolicy();
1680
1705
  const name = String(who ?? '').trim();
1681
1706
  if (!name) return { ok: false };
1707
+ /**
1708
+ * 目标就是她自己登录的那个抖音号:抖音不允许给自己发私信。
1709
+ * 以前会一路走到「点开会话」,然后报「找不到这个会话」——听着像找不到人,
1710
+ * 其实是根本不该发(她也只能跟着猜「是不是我自己的号」)。
1711
+ */
1712
+ const selfName = String(this.store.data.douyinSelf?.name ?? '').trim();
1713
+ const norm = (v) => String(v ?? '').replace(/[\s\u3000]+/g, '').toLowerCase();
1714
+ if (selfName && norm(selfName) === norm(name)) {
1715
+ return {
1716
+ ok: false,
1717
+ code: 'SELF',
1718
+ why: `「${name}」就是她自己登录的抖音号,抖音不能给自己发私信`,
1719
+ };
1720
+ }
1682
1721
  if (mode === 'off') return { ok: false, code: 'STRANGER_OFF', why: '陌生人私信设成「关」了' };
1683
1722
  if (this.isStrangerBlocked(name)) {
1684
1723
  return { ok: false, code: 'BLOCKED', why: `「${name}」在黑名单里,不回` };
@@ -1890,7 +1929,7 @@ export class StrollWorld {
1890
1929
  const pv = String(r.preview ?? '');
1891
1930
  let why = 'stranger';
1892
1931
  if (ownerName && this.sameGroup(ownerName, who)) why = 'owner';
1893
- else if (known.some((n) => this.sameGroup(n, who))) why = 'group';
1932
+ else if (this.looksLikeKnownGroup(who, known)) why = 'group';
1894
1933
  else if (!pv) why = 'no-preview';
1895
1934
  else if (/^\d+分钟内在线$|^在线$/.test(pv)) why = 'online';
1896
1935
  return `${truncate(who, 12)}|${truncate(pv, 12)}|${why}`;
@@ -1898,7 +1937,7 @@ export class StrollWorld {
1898
1937
  });
1899
1938
  }
1900
1939
  const extra = rows
1901
- .filter((r) => r.name && !known.some((n) => this.sameGroup(n, r.name)))
1940
+ .filter((r) => r.name && !this.looksLikeKnownGroup(r.name, known))
1902
1941
  .map((r) => ({ who: r.name, at: r.at || '', preview: r.preview || '', unread: 0 }))
1903
1942
  .filter((r) => r.preview && !/^\d+分钟内在线$|^在线$/.test(r.preview));
1904
1943
  if (extra.length) {
@@ -1943,7 +1982,7 @@ export class StrollWorld {
1943
1982
  ...(extra ? { hit: truncate(String(extra), 24) } : {}),
1944
1983
  });
1945
1984
  if (!row.preview || /^\d+分钟内在线$|^在线$/.test(row.preview)) continue;
1946
- if (knownGroups.some((g) => this.sameGroup(g, row.who))) continue;
1985
+ if (this.looksLikeKnownGroup(row.who, knownGroups)) continue;
1947
1986
  const key = `douyin:${row.who}`;
1948
1987
  /**
1949
1988
  * 指纹只用正文,**不带时间**。
@@ -4240,15 +4279,19 @@ export class StrollWorld {
4240
4279
  const OWNER_ALIAS = /^(主人|主人私信|私信|私聊|owner|dm|你|您)$/i;
4241
4280
  let groups = await this.resolveGroups().catch(() => this.groupNames());
4242
4281
  if (wanted && !OWNER_ALIAS.test(wanted)) {
4243
- let hit = this.matchGroup(groups, wanted);
4244
- if (!hit) {
4282
+ // 和私信一样:群名可能是「几个人的昵称拼起来的」,命中陌生人名单就优先当人
4283
+ const quick = this.cachedStranger(wanted, groups);
4284
+ let hit = quick ? null : this.matchGroup(groups, wanted);
4285
+ if (!hit && !quick) {
4245
4286
  // 名单可能是旧的(刚被拉进新群):强刷一次再认,别把群名当成「不认识的人」
4246
4287
  groups = await this.resolveGroups(true, { deep: true }).catch(() => groups);
4247
4288
  hit = this.matchGroup(groups, wanted);
4248
4289
  }
4249
4290
  if (hit) return { ok: true, who: hit, group: true };
4250
4291
  // 不是群:也可能是「陌生人消息」里给她发过话的人(图和表情一样能发过去)
4251
- const stranger = await this.strangerTargetOf(wanted).catch(() => ({ ok: false }));
4292
+ const stranger = quick
4293
+ ? this.strangerGuard(quick)
4294
+ : await this.strangerTargetOf(wanted).catch(() => ({ ok: false }));
4252
4295
  if (stranger.ok) return { ok: true, who: stranger.who, group: false, stranger: true };
4253
4296
  const list = groups.length ? groups.map((g) => `「${g}」`).join(' 或 ') : '(现在一个群都没认出来)';
4254
4297
  return {
@@ -4920,20 +4963,38 @@ export class StrollWorld {
4920
4963
  !dmFresh && this.lastGroupPoke && Date.now() - Number(this.lastGroupPoke.at ?? 0) < 10 * 60 * 1000
4921
4964
  ? String(this.lastGroupPoke.group ?? '')
4922
4965
  : '';
4923
- let asGroup = wanted ? this.matchGroup(groups, wanted) : null;
4924
- if (!asGroup && wanted && groups.length === 1 && /^(群|群聊|那个群|抖音群|group|the group)$/i.test(wanted)) {
4925
- asGroup = groups[0];
4926
- }
4927
- if (!asGroup && wanted && pokedGroup && this.matchGroup([pokedGroup], wanted)) asGroup = pokedGroup;
4928
4966
  /**
4929
4967
  * 先看「这是不是一个给她发过消息的陌生人」——**只查本地名单,不读页面**。
4930
4968
  *
4931
- * 以前这一步排在「强刷群名单」后面:任何不是群名的 target 都要先深刷一次
4932
- * 会话列表(真的去读页面,实测 0.85 秒),陌生人也照刷。
4933
- * 现在命中本地名单就直接走陌生人分支,那 0.85 秒全省下来。
4969
+ * 顺序很关键。抖音拉群时群名默认是**几个成员的昵称拼起来**
4970
+ * (`海底月、小月月、大萌神`),而 `matchGroup` 允许「她写的是群名的一部分」。
4971
+ * 于是 `target=海底月` 会被判成「发到那个群」——**她的私信内容直接公开在群里**。
4972
+ * 所以:能当人就当人,命中陌生人名单就不再做群匹配。
4973
+ * (副作用:想发到这种群得写完整群名,这个代价可以接受。)
4934
4974
  */
4935
- let quickStranger = '';
4936
- if (!asGroup && wanted) quickStranger = this.cachedStranger(wanted, groups);
4975
+ let quickStranger = wanted ? this.cachedStranger(wanted, groups) : '';
4976
+ if (quickStranger) {
4977
+ const wouldBeGroup = this.matchGroup(groups, wanted);
4978
+ if (wouldBeGroup) {
4979
+ this.log?.warn?.('stroll target ambiguous', {
4980
+ target: wanted,
4981
+ stranger: quickStranger,
4982
+ group: wouldBeGroup,
4983
+ pick: 'dm',
4984
+ });
4985
+ }
4986
+ }
4987
+ let asGroup = wanted && !quickStranger ? this.matchGroup(groups, wanted) : null;
4988
+ if (
4989
+ !asGroup &&
4990
+ !quickStranger &&
4991
+ wanted &&
4992
+ groups.length === 1 &&
4993
+ /^(群|群聊|那个群|抖音群|group|the group)$/i.test(wanted)
4994
+ ) {
4995
+ asGroup = groups[0];
4996
+ }
4997
+ if (!asGroup && !quickStranger && wanted && pokedGroup && this.matchGroup([pokedGroup], wanted)) asGroup = pokedGroup;
4937
4998
  if (!asGroup && wanted && !quickStranger) {
4938
4999
  // 名单可能是旧的(主人刚把她拉进新群):滚动会话列表强刷一次再认
4939
5000
  groups = await this.resolveGroups(true, { deep: true }).catch(() => groups);