cortico-world-mosey 2.0.0 → 2.0.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,29 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.0.2
4
+
5
+ **文档脱敏。**
6
+
7
+ 更新日志、README、提示词和配置项说明里的示例昵称,全部换成虚构占位(以前用的是真实账号的昵称,不该跟着包发出去)。功能没有任何变化。
8
+
9
+ ## 2.0.1
10
+
11
+ **修两个问题:私信被发到群里、列表里看得见却发不出去(都是群友报的)。**
12
+
13
+ ### 群名是「几个人的昵称拼起来」时,私信会发到群里
14
+
15
+ 抖音拉群时群名默认是成员昵称拼的(`小鹿、阿柚、老K`),而群名匹配允许「她写的是群名的一部分」,于是 `target=小鹿` 被判成「发到那个群」——**私信内容直接公开在群里**。
16
+
17
+ - 私信和发表情都改成**先认人、再认群**:命中「给她发过消息的陌生人」就不再做群匹配。想发到这种群,把完整群名写出来。
18
+ - 反过来也修了:主列表差集不再把「小鹿」当成群「小鹿、阿柚」——以前那个人的私信会被整条排除,**永远发现不了**。
19
+ - 新增 `stroll target ambiguous` 日志:一个名字同时像人又像群时,写明最后按哪个发的。
20
+
21
+ ### 列表里明明看得见,发的时候说找不到会话
22
+
23
+ - 会话标题黏着时间(`小鹿 昨天`、`小鹿 00:30`)时,精确匹配永远不成立,只能退到「包含」;而群名也可能是成员昵称拼的(`群A、小鹿`),一包含就命中两条 → 判成「歧义」直接放弃。现在先剥掉尾部时间再比。
24
+ - 会话被新消息刷到列表很下面时,抖音的**虚拟滚动**根本不渲染那一行,`querySelectorAll` 看不到。现在找不到会先滚回顶部,再一屏一屏往下找(最多 10 屏)。
25
+ - 顺手:target 就是她自己登录的抖音号时,直接说「抖音不能给自己发私信」,不再报「找不到会话」让她猜。
26
+
3
27
  ## 2.0.0
4
28
 
5
29
  **第一个大版本:陌生人私信能回了,面板多了「私信 / 群聊」两张额度卡,安全也收紧了一轮。**
@@ -18,7 +42,7 @@
18
42
  - 包含比对加长度门槛:少于 4 个字不参与「包含」判断(完全相同的短句仍算她说的)。
19
43
  - 一次性重置:启动时把 2 小时内「用正确逻辑看明显不是她说的」指纹删掉,让下一拍重新发现一次(`stroll seen reset`)。
20
44
  - `skip` 日志现在带 `hit`:命中的是哪句她说过的话,误判时不用再猜。
21
- - **待批记录里的昵称也洗了**:以前只洗「陌生人名单」,`strangerPending` 里还存着 `海底月 刚刚` 这种带时间的昵称——点「让她回」会拿这个名字去发,会话永远找不到。现在新增时统一洗,历史数据启动时清洗并合并同人的重复条目。
45
+ - **待批记录里的昵称也洗了**:以前只洗「陌生人名单」,`strangerPending` 里还存着 `小鹿 刚刚` 这种带时间的昵称——点「让她回」会拿这个名字去发,会话永远找不到。现在新增时统一洗,历史数据启动时清洗并合并同人的重复条目。
22
46
 
23
47
  ### 陌生人私信、额度卡、安全收紧
24
48
 
@@ -45,19 +69,19 @@
45
69
  实现上:
46
70
 
47
71
  - 抖音的陌生私信**单独收在「陌生人消息」文件夹里**,主会话列表根本看不到。现在的做法是:先读入口上的预览判断有没有新东西,有才点进文件夹读列表,读完点返回箭头退回主列表(实测退回后能正常点回主人会话)。
48
- - 解析用「时间」当锚点切昵称和预览——群名带空格(`测试2 3`)、时间写成「7分钟前」都能切对,行尾的未读数会剥出来。
72
+ - 解析用「时间」当锚点切昵称和预览——群名带空格(`群B 3`)、时间写成「7分钟前」都能切对,行尾的未读数会剥出来。
49
73
  - 回复时如果主列表找不到这个会话,会去「陌生人消息」文件夹里点开再发。
50
74
  - 10 秒内最多扫一次会话列表,不加重页面负担。
51
75
  - B站读会话列表里的未读会话(非主人),复用本来就有的那一次请求,不多打接口。
52
76
  - 首次见到某个会话只记基线,不会把攒了几天的历史私信一次全推。
53
77
  - 自己发的最后一句(预览以她自己昵称开头)不会被当成对方来消息。
54
- - **「她回不了陌生人」修好了**:以前发送时只认群和主人,`target=海底月` 会被判成「没认出是哪个群」,她就说「陌生人除了那格,别处都不认」。现在发送前会先在她收到的陌生人名单里找人(名单里没有就去文件夹里现查一次),找到就按昵称点进会话回;没给她发过消息的人仍然发不出去。
78
+ - **「她回不了陌生人」修好了**:以前发送时只认群和主人,`target=小鹿` 会被判成「没认出是哪个群」,她就说「陌生人除了那格,别处都不认」。现在发送前会先在她收到的陌生人名单里找人(名单里没有就去文件夹里现查一次),找到就按昵称点进会话回;没给她发过消息的人仍然发不出去。
55
79
  - 图片和表情包也能发给陌生人(同样只限给她发过消息的人),不再只认群和主人。
56
80
  - **她自己会盯着陌生人了(1 秒一拍)**:以前只靠页面事件,抖音把入口换成红点时观察器不动,就一直发现不了。现在单开一条轻量定时器,每 1 秒读一次入口(纯读页面,不发请求),入口红点变了也算新消息;安静时段不吵她。主列表差集 10 秒一次、文件夹仍然只在有新东西时才点开,不会把页面折腾坏。
57
81
  - **面板自己会刷了**:以前「待你点头」就算写进去了,也得你手点「刷新」才看得见。现在每秒轻量查一次待办(只读内存,不查登录态),1 秒内自己冒出来。
58
82
  - 点开「陌生人消息」文件夹的两处写死等待(700ms / 900ms)改成条件等待(50ms 一轮),给陌生人的第一条回复快 1 秒多。
59
83
  - **修掉两个线上 bug**:
60
- - 主列表兜底把列表上的时间黏进了昵称(`海底月 刚刚`),名字每分钟变一次 → 找不到会话 → 发送走了最慢的兜底,实测一次花了 **9 秒**。现在统一剥掉时间,历史数据启动时自动清一遍。
84
+ - 主列表兜底把列表上的时间黏进了昵称(`小鹿 刚刚`),名字每分钟变一次 → 找不到会话 → 发送走了最慢的兜底,实测一次花了 **9 秒**。现在统一剥掉时间,历史数据启动时自动清一遍。
61
85
  - **推送死循环**:她自己回完一句,列表最后一句就变成她的话,又被当成「对方发来新消息」推给她,她就一直回自己的话(一天推了 20 次)。现在去重指纹只看正文(不再带「9分钟前」这种会变的文案)、会拿发出去的原文比对「这是不是我说的」、回完某个人 3 分钟内不再推他。
62
86
  - 标题读不出来时不再硬发:陌生人这条路径改成「认不出就不发」,报 `THREAD_DRIFT`,避免把话打进别人的会话。
63
87
  - **回陌生人提速**:
@@ -95,7 +119,7 @@
95
119
  - **常驻页数量做成了设置**(私信与群 → 陌生人常驻页数量,0-4,默认 2)。每个常驻页 = 一个后台标签页(约 50-100MB);填 0 = 不开,省内存。
96
120
  - **修掉「常驻页一直建不起来」**(日志里全是 `stroll stranger page { ok: false }`):
97
121
  - 新开的页面还没渲染出「陌生人消息」入口就去找,直接判失败 → 现在先等入口出现(最多 6 秒);
98
- - 文件夹里那一行的标题黏着时间(`海底月 刚刚`),整串相等永远不成立 → 现在先剥掉时间再比。
122
+ - 文件夹里那一行的标题黏着时间(`小鹿 刚刚`),整串相等永远不成立 → 现在先剥掉时间再比。
99
123
  - **表情只看名字也去取图**:抖音有些表情不是 `<img>`,而是 div 的 background-image(还有些是加密下发的),那种元素里 `img` 数量是 0,旧逻辑会当成文字。现在只要整条是 `[名称]`,就走取图那条路(canvas / blob / 背景图都会试一遍)。
100
124
  - **修掉「有些账号根本没有陌生人文件夹」导致的连锁失败**(日志里 `hasBox: false`):这类账号对方的会话就在主列表里,而常驻页只会去点「陌生人消息」文件夹 → 永远建不起来(`stroll stranger page ok: false`)→ 一直读不到图。现在文件夹点不到会退回主列表点击。
101
125
  - **内容核对认得任意表情名**:以前只认「[表情]」「[图片]」这类关键词,`[大力拥抱]` 这种名字对不上,读到了也会被安全校验丢掉。现在整条是 `[名称]` 就算对得上。
@@ -435,7 +459,7 @@ due = ended || (dur > 0 && waited >= dur + 3) || waited >= 90;
435
459
 
436
460
  **「发一遍」失败:不是没有功能,是抖音的上传被节流了。**
437
461
 
438
- 翻了 12:57-12:59 的动作记录:同一个 `recv-48bbc047.webp`,12:57 发到测试2 成功、12:57/12:58 两次 NOT_SENT、12:59 发到主人又成功。失败现场的 dump 里**没有确认弹层、也没有预览图**——说明文件根本没被选进去,不是点错按钮。
462
+ 翻了 12:57-12:59 的动作记录:同一个 `recv-48bbc047.webp`,12:57 发到群B 成功、12:57/12:58 两次 NOT_SENT、12:59 发到主人又成功。失败现场的 dump 里**没有确认弹层、也没有预览图**——说明文件根本没被选进去,不是点错按钮。
439
463
 
440
464
  三处一起改:
441
465
 
@@ -578,7 +602,7 @@ stroll image url missing (dm|group)
578
602
  ### 回复提速(插件这一侧)
579
603
 
580
604
  - `body.innerText` → 只查弹层/toast/验证码 iframe。整页 innerText 会强制重排,聊天记录一多就是几百毫秒,而它每次发完消息都要跑。
581
- - 会话标题比对容忍「测试3(3)」「测试3 3人」这类后缀。以前对不上就会白点一次会话:多一次点击 + 220ms 等待 + 输入框重建的 400ms,一次白花半秒多。
605
+ - 会话标题比对容忍「群C(3)」「群C 3人」这类后缀。以前对不上就会白点一次会话:多一次点击 + 220ms 等待 + 输入框重建的 400ms,一次白花半秒多。
582
606
  - 等输入框的轮询 400ms → 120ms;输入框点击跳过可点性检查(force)。
583
607
  - 只解析最新 `2n` 条消息(以前整屏几百条每条都走一遍 React props)。
584
608
  - 推送文案压缩:去掉那一大段「别先查 status/feed…」,模型的上下文短了,首字也快一点。
@@ -593,15 +617,15 @@ stroll image url missing (dm|group)
593
617
  ```
594
618
  dm text="在。怎么了。" → 私信主人(没写 target)
595
619
  dm text="看到了。又一个群。" → 私信主人(没写 target)
596
- dm target="小月月一直在玩" text="回了…" → 群里(被当成了群一)
620
+ dm target="阿柚" text="回了…" → 群里(被当成了群一)
597
621
  dm text="刚回了,是不是你那边没刷出来。" → 私信主人(没写 target)
598
622
  ```
599
623
 
600
624
  三个原因叠在一起:
601
625
 
602
626
  - **没写 target 就默认私信主人**。她在回群消息,但手上没填 target,系统一路掉到「私信主人」,还回执「发出去了」。群二当然一条都没有。
603
- - **target 写主人的昵称会被群名匹配走**。她写 `target="小月月一直在玩"`(主人的名字),而群标题是「小月月一直在玩, 穹, 大萌神」——包含匹配直接命中群一。她以为在私信主人,实际发进了群。
604
- - **群二根本没被发现**。日志里只有 `groups detected ['小月月一直在玩, 穹, 大萌神']` 一个群;`matchGroup` 又允许「反向包含」,写更长的群二名字时会命中更短的群一。于是所有回复都进了群一。
627
+ - **target 写主人的昵称会被群名匹配走**。她写 `target="阿柚"`(主人的名字),而群标题是「阿柚, 阿澈, 老K」——包含匹配直接命中群一。她以为在私信主人,实际发进了群。
628
+ - **群二根本没被发现**。日志里只有 `groups detected ['阿柚, 阿澈, 老K']` 一个群;`matchGroup` 又允许「反向包含」,写更长的群二名字时会命中更短的群一。于是所有回复都进了群一。
605
629
 
606
630
  改法:
607
631
 
@@ -639,7 +663,7 @@ dm text="刚回了,是不是你那边没刷出来。" → 私信主人
639
663
  <div class="MsgInputMentionmentionContainer"> ← 真正撑开的是这层
640
664
  <div class="MentionMentionItemcontainer">
641
665
  <div class="MentionMentionIteminnerContainer">…头像 + 「群主」标签…</div>
642
- <span class="MentionMentionItemuserName">小月月一直在玩</span>
666
+ <span class="MentionMentionItemuserName">阿柚</span>
643
667
  ```
644
668
 
645
669
  上一版拿**外层容器**的尺寸判断「面板开没开」:外层 0 高 → 直接返回 `count: 0` → 回执「没找到选人面板」。她照着回执说「抖音这边 @ 面板打不开」,其实面板就在那儿。
@@ -647,7 +671,7 @@ dm text="刚回了,是不是你那边没刷出来。" → 私信主人
647
671
  - 不再看容器尺寸,把几层 mention 容器里的行全收上来
648
672
  - **首选名字节点** `[class*="userName"]`:文字就是昵称本身,比猜「哪一层是整行」稳得多(整行的高度/宽度随样式变,名字节点不变)
649
673
  - 点也点名字那一截(事件冒泡到整行);名字点不动就改点整行;locator 点不中再用坐标兜底
650
- - 用线上那份 HTML 做了离线回归:3 个人全认出来,`小月月一直在玩`(带「群主」标签)、`大萌神`、`群聊小助手` 都能精确命中,点击坐标落在候选行内
674
+ - 用线上那份 HTML 做了离线回归:3 个人全认出来,`阿柚`(带「群主」标签)、`老K`、`群聊小助手` 都能精确命中,点击坐标落在候选行内
651
675
  - 顺带:从表情面板选中后,**先关掉遮罩再回车**——遮罩挡着输入框,回车根本进不去(这也是「点了表情没反应」的一个原因)
652
676
  - 发表情失败现在也会写进动作记录(`[表情没发出去]` + code + dump 路径),不再只有一句「试了四五次都不行」
653
677
 
@@ -659,13 +683,13 @@ dm text="刚回了,是不是你那边没刷出来。" → 私信主人
659
683
 
660
684
  ```
661
685
  semi-modal-wrap semi-modal-wrap-center
662
- 「发送给 小月月一直在玩, 穹, 大萌神(3) s_811c597ed5c8.png 118.2 KB 取消 发送(1)」
686
+ 「发送给 阿柚, 阿澈, 老K(3) s_811c597ed5c8.png 118.2 KB 取消 发送(1)」
663
687
  ```
664
688
 
665
689
  - **发图少点了最后一步**:抖音选完图不是直接发,而是先弹确认框,点「发送(1)」才真的发。上一版只看输入区有没有预览、有没有新消息,没认这个框,于是回执写「图选上了但没确认发出去」,界面上其实什么都没发。现在一边等消息变化、一边盯着这个框,看到就点;`via` 会是 `upload+modal`。
666
690
  - **表情面板是 semi-modal,Escape 关不掉**:它一直开着挡在输入区上面,后面点什么都点不到。新增 `closeChatModals()`(点遮罩),发图前、收完表情后都会关。
667
- - **@ 失败的真相:她压根没传 `mention`**。三次 `mentioned:false` 里两次连 dump 都没有,说明 `mentionInChat` 根本没被调用——她把名字写进正文(「大萌神,在吗,这么晚还没睡。」)就当 @ 过了。
668
- - 兜底:群标题没起名时就是成员名拼的,从里面拆名单;正文开头对上谁的名字,就当成 @ 目标并把名字从正文里剥掉。`大萌神,在吗` → `mention=大萌神`、正文 `在吗`
691
+ - **@ 失败的真相:她压根没传 `mention`**。三次 `mentioned:false` 里两次连 dump 都没有,说明 `mentionInChat` 根本没被调用——她把名字写进正文(「老K,在吗,这么晚还没睡。」)就当 @ 过了。
692
+ - 兜底:群标题没起名时就是成员名拼的,从里面拆名单;正文开头对上谁的名字,就当成 @ 目标并把名字从正文里剥掉。`老K,在吗` → `mention=老K`、正文 `在吗`
669
693
  - 语义也改清楚:**没要求 @ 时回执是 `mentioned: null`**,不再是 `false`——`false` 只代表「要求了但没插上」,别再把两者混成一句「@ 没生效」
670
694
  - 提示词加硬规则:只在正文里写名字不算 @;不许再说「抖音这边我只能写字」「这条路我走不通」
671
695
 
@@ -685,27 +709,27 @@ semi-modal-wrap semi-modal-wrap-center
685
709
  - SVG 的 `className` 是对象,直接拼字符串会变成 `[object SVGAnimatedString]`,发送按钮因此认不出来 —— 改用 `getAttribute('class')`
686
710
  2. **发图片不再依赖点图标**。输入区里一直挂着两个隐藏的 `input[type=file]`,直接把文件塞进去就能上传(实测这条最稳,也最快);点图标 + 接系统文件框降级成兜底。
687
711
  3. **@ 的候选行没有宽高上限了**。面板那一行是整条 1104 宽,旧的兜底逻辑卡了「宽 90-620」,所以「面板明明开着,却说没候选」。现在只要求 ≥60 宽;头像也不是必要条件(没有头像时按文字行认)。
688
- - 顺手修掉一个漏人的坑:以前「子元素文字一样就当外壳整行丢掉」,名字前面**没有**身份标签的人(比如「大萌神」)会被自己的名字 span 顶掉,名单里直接少一个人。现在先收全部候选,再按文字去重、留最宽的那行。
712
+ - 顺手修掉一个漏人的坑:以前「子元素文字一样就当外壳整行丢掉」,名字前面**没有**身份标签的人(比如「老K」)会被自己的名字 span 顶掉,名单里直接少一个人。现在先收全部候选,再按文字去重、留最宽的那行。
689
713
  4. **@ 改成「先打一个 @,等名单,再点人」**。以前一口气把「@昵称」全打进去,面板还没拉完就被顶掉了。现在先单独敲 `@`,轮询等候选名单,按昵称挑人(剥掉「群主 / 管理员」标签),用 locator 点中那一行(坐标会随面板重绘飘),插成功后再补一个**空格**——就是主人说的「@ 后面没有空格」。
690
714
  5. **点完怎么算成功**:手打的 `@名字` 一定落在 `data-string` 叶子里;选中候选后这片叶子会被 chip 替换。叶子还在 = 假 @,返回 `mentioned: false` 并清空输入框,绝不留一个看着像 @ 的蓝字。
691
715
  6. 失败时 dump 里多存两份:`mentionHtml`(@ 面板长什么样)和 `actionsHtml`(工具栏长什么样),回执里也带上 dump 路径。
692
716
 
693
717
  ## 1.8.1
694
718
 
695
- **@ 不到主人的真正原因找到了:候选行写的是「群主 小月月一直在玩」。**
719
+ **@ 不到主人的真正原因找到了:候选行写的是「群主 阿柚」。**
696
720
 
697
- 她把真 @ 插进去了,只是插的是「大萌神」;一到主人就失败。翻了两次失败时落盘的 DOM,面板就在那儿,只有一行:
721
+ 她把真 @ 插进去了,只是插的是「老K」;一到主人就失败。翻了两次失败时落盘的 DOM,面板就在那儿,只有一行:
698
722
 
699
723
  ```
700
- outerText: "群主 小月月一直在玩"
724
+ outerText: "群主 阿柚"
701
725
  class: MsgInputMentionmentionContainer
702
726
  ```
703
727
 
704
- 老代码拿整行文字跟昵称做**全等比较**,`群主小月月一直在玩 !== 小月月一直在玩`,永远对不上;「大萌神」前面没有角色标签,所以一次就成了——跟她说的完全一致。
728
+ 老代码拿整行文字跟昵称做**全等比较**,`群主阿柚 !== 阿柚`,永远对不上;「老K」前面没有角色标签,所以一次就成了——跟她说的完全一致。
705
729
 
706
730
  - **候选行先剥身份标签再比**(群主 / 管理员 / 群成员 / 成员 / owner / admin,前后都剥),再按「全等 → 唯一前缀 → 唯一包含」三级匹配;不认类名的兜底也一起改了
707
731
  - **「@你」不是昵称**:她不知道主人的抖音名,就写「@你」「@主人」——候选里当然没有这个人,发出去只是一串没有效果的蓝字。现在这几个词一律当「主人」处理,正文里的 `@你 / @主人` 会被剥掉;`mention: "你"` 也认
708
- - **正文开头手打的 `@某人` 当成 @ 目标**:`text="@大萌神 在吗"` 且没传 `mention` 时,自动把它当 `mention` 去插真 chip,而不是发一串蓝字
732
+ - **正文开头手打的 `@某人` 当成 @ 目标**:`text="@老K 在吗"` 且没传 `mention` 时,自动把它当 `mention` 去插真 chip,而不是发一串蓝字
709
733
  - 提示词补硬规则:不知道主人抖音名很正常,**不许猜、不许写「@你」**,直接用 `mention_owner: true`
710
734
 
711
735
  **表情面板点错地方了,这次按几何找。**
@@ -718,7 +742,7 @@ class: MsgInputMentionmentionContainer
718
742
 
719
743
  **图片发出去被判成失败(其实已经发出去了)。**
720
744
 
721
- dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了「不能发送空白消息」——图片消息不带 `msg-item-content`,老判据数不到,以为没发出去,又按了一次回车。
745
+ dump 里能看见:消息预览已经是「阿澈:[图片]」,同时还弹了「不能发送空白消息」——图片消息不带 `msg-item-content`,老判据数不到,以为没发出去,又按了一次回车。
722
746
 
723
747
  - 判据换成 `chatSig`:`[data-e2e^="msg-item"]` 条数 + 末尾文字 + 图片数,图片/表情消息都能认
724
748
  - **只有输入框里真有字才回车**,空着回车会弹「不能发送空白消息」
@@ -779,7 +803,7 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
779
803
  主人在群里看到她发「@ 你了」,那个 @ 是蓝的,但后面不带名字、也没有任何通知。查了动作日志,她实际发出去的是:
780
804
 
781
805
  ```
782
- '@大萌神 在吗,穹。月让我在你这个群里@你一下。'
806
+ '@老K 在吗,阿澈。月让我在你这个群里@你一下。'
783
807
  '好,@ 你了。收到没。'
784
808
  '这一条正文没写 @,前面那个蓝字就是系统插的。看见了吧。'
785
809
  ```
@@ -787,12 +811,12 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
787
811
  三个 `@` 全是正文里的普通字符。抖音会把消息里的 `@` 染成蓝色,**看起来像提到了人,其实没有 uid、不带名字、对方收不到通知**。只有从候选面板里选中、插成 chip 的那种才算真 @。
788
812
 
789
813
  - **聊天里加了真 @**(以前只有评论有):`stroll_act action=dm target=<群> mention_owner=true` → 先在输入框里敲 `@昵称`,等候选面板出来,点中那个人插成 chip,再写正文。
790
- - 新增 `mention=<昵称>`,可以 @ 群里的具体某个人(不止主人)。她当时想 @ 的就是「大萌神」
814
+ - 新增 `mention=<昵称>`,可以 @ 群里的具体某个人(不止主人)。她当时想 @ 的就是「老K」
791
815
  - **优先鼠标点选候选**,不用回车:面板开着时回车是「选中」,面板一关回车就是「直接发送」——万一面板刚好关掉,就会发出去一条只有「@昵称」的消息
792
816
  - 插不上就把敲进去的 `@昵称` 删干净,**绝不把假 @ 留在正文里**,回执明写「@ 没插上」
793
- - **正文里手写的 `@某人` 一律剥掉**:`@大萌神 在吗` → `在吗`(真 @ 由 chip 负责);只剩一个 @ 的消息直接拒绝发送
817
+ - **正文里手写的 `@某人` 一律剥掉**:`@老K 在吗` → `在吗`(真 @ 由 chip 负责);只剩一个 @ 的消息直接拒绝发送
794
818
  - **不许再编机制**。她那几句「@ 是系统插的」「走 @ 接口发的」「回执没告诉我插没插进去」全是编的——没有这回事。提示词里写死了:不知道就说不知道,要么说结果(@ 上了 / 没 @ 上),要么闭嘴。`mentioned=false` 时必须如实告诉主人没 @ 上
795
- - 回执改成分得清:`@小月月 已经插进去(带名字的那种)` / `@小月月 没插上——那样只是正文里一个 @ 符号,对方收不到通知`
819
+ - 回执改成分得清:`@阿柚 已经插进去(带名字的那种)` / `@阿柚 没插上——那样只是正文里一个 @ 符号,对方收不到通知`
796
820
 
797
821
  冒烟 **57 项**通过(新增:假 @ 剥离、只剩 @ 拒绝发送、指定 @ 别人、不知道昵称时如实拒绝)。
798
822
 
@@ -804,10 +828,10 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
804
828
 
805
829
  上一版群聊是「照私信的结构推出来的」,这个版本拿真实的三人群做了校准,改掉的都是实测出来的坑:
806
830
 
807
- - **群名不用你手打了**。抖音的群没起名时,会话列表里显示的是成员名拼起来的 `小月月一直在玩, 穹, 大萌神`,手抄极易错。现在:
831
+ - **群名不用你手打了**。抖音的群没起名时,会话列表里显示的是成员名拼起来的 `阿柚, 阿澈, 老K`,手抄极易错。现在:
808
832
  - 用会话行的 React props 判断 `toParticipantSecUserId` 是不是空串来区分群和私信(比「名字里有没有逗号」可靠,起了名的群也不会漏)
809
833
  - `设置 → 要盯的抖音群` **留空=自动盯她在的所有群**(最多 2 个),新增 `盯着抖音群` 开关
810
- - 发到群里时 target 支持模糊匹配:只写「穹」也能对上那个群;只有一个群时写「群」就行
834
+ - 发到群里时 target 支持模糊匹配:只写「阿澈」也能对上那个群;只有一个群时写「群」就行
811
835
  - **推送里能看到是谁说的**。昵称在 `MessageBoxMessageTitleavatarName`,而且**只标在一段连续消息的最上面那条**(消息列表是 `column-reverse`,DOM 首元素反而是最新的)——所以要从旧往新继承,没名字的沿用上一个说话的人。老实现只找 `nickname/userName`,群里永远空,推送里全是「有人」
812
836
  - **只推真正新的那几条**。老逻辑是「末尾一路往前收,遇到自己发的就停」,可群里没有自己发的消息,于是一条新话会把**整个群历史**当成新消息推一遍。现在记下上一屏见过的所有消息当边界,只取它之后的
813
837
  - **大表情不再算成图片**:props 里带 `emoji_type / package_id` 的标成 `[表情]`
@@ -857,7 +881,7 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
857
881
 
858
882
  ## 1.5.2
859
883
 
860
- - **修掉「@ 两次」**:动态里出现 `@小月月 … @小月月还在玩` 不是延迟,是正文本身已经写了一段 `@昵称`,发的时候又按 `mention_owner` 前置了一个真 @。现在真 @ 之前会先把正文里自带的 `@昵称` 剥掉(B站动态、B站评论、抖音评论三处都修);正文如果只有 `@昵称`、剥完为空,就不前置,靠 B站的 `at_name_to_mid` 把正文里那个变成真 @。提示词里也写死了:正文不要自己写 @,开关用 `mention_owner`。
884
+ - **修掉「@ 两次」**:动态里出现 `@阿柚 … @阿柚还在玩` 不是延迟,是正文本身已经写了一段 `@昵称`,发的时候又按 `mention_owner` 前置了一个真 @。现在真 @ 之前会先把正文里自带的 `@昵称` 剥掉(B站动态、B站评论、抖音评论三处都修);正文如果只有 `@昵称`、剥完为空,就不前置,靠 B站的 `at_name_to_mid` 把正文里那个变成真 @。提示词里也写死了:正文不要自己写 @,开关用 `mention_owner`。
861
885
  - **三项固定等待压到安全下限**(其余一律不动,打字、发布回执、评论状态确认、@ 面板轮询都保持原样):
862
886
  - 私信去抖 120ms → **40ms**:这个窗口是压「一条消息触发的多次 DOM 抖动」,不是压人手连发的间隔,40ms 够合成一次,再低容易出现同一句叫两次。
863
887
  - 发送后查输入框 60ms → **25ms**:平均省 35ms,最坏上限仍约 600ms。
@@ -916,7 +940,7 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
916
940
  - 会话卡片按行号取内容,但实测第三行是 **「2小时内在线」** 这个在线状态,不是最后一句。所以 `text` 一直是「2小时内在线」,等于看不见主人回了什么。现在按 class 取真正的预览(`ConversationItemDesc` 里非 `onlineStatus` 的那个),并把未读角标从预览里剥掉
917
941
  - 读对话时取了**最旧**的几条:抖音消息列表 DOM 是最新在前(index 0 = 最后一句),`slice(-n)` 拿到的是「你们已互相关注」这种开场白。现在取前 N 条再翻回时间顺序,并滚到底把最新消息挂上来
918
942
  - 新增 `recent`(最近 6 句,带 `from: them/me`)和 `lastFromThem`:她能分清最后一句是主人说的还是自己说的,不会再「看不到回复」
919
- - 顺带:会话标题的 class 在有未读时会连时间一起包进来(`小月月一直在玩 刚刚`),现在昵称只取第一行
943
+ - 顺带:会话标题的 class 在有未读时会连时间一起包进来(`阿柚 刚刚`),现在昵称只取第一行
920
944
  - **修:B站评论 @ 不了人**。以前只在正文里拼一个 `@昵称`,那只是**纯文本**——B站不会真的提到人、也不会发通知(`members` 一直是空)。现在随请求带上 `at_name_to_mid`(`{"昵称": mid}`),实测发布后 `members` 里有对方、`mentioned: true`。判断也改成发布后回查一次评论列表(add 接口的响应不一定带 members)
921
945
  - **修:「网页经常退出或中断」**。查到四个来源,都会让正在用的页面被关掉:
922
946
  - **自动收窗的定时器会打断正在进行的操作**。`scheduleClose(60)` 排下去之后,下一次操作进来不会取消它;只要这轮操作超过剩余时间,页面就在中途被关,表现就是「网页突然退出/操作中断」。现在所有操作都走一个 `busy` 计数,操作期间定时器到点只顺延、不关页
@@ -935,7 +959,7 @@ dump 里能看见:消息预览已经是「穹:[图片]」,同时还弹了
935
959
 
936
960
  - **修:抖音私信点「私信」一直卡到超时**。主页上那个「私信」在 DOM 里有两份,一份是隐藏模板(宽高 0);`getByText().first()` 正好挑中隐藏那份,于是每次都在等它变可见,等到 6 秒超时——她说的「按钮一直当它是隐藏的,两次都卡死」就是这个。现在改成逐个挑**可见且有点击区域**的那个,再等聊天框真正挂上来才输入
937
961
  - **修:私信失败后浏览器不关**。以前抛错就直接退出,`scheduleClose` 没跑到,后台会堆一堆 Chrome 进程。现在 `finally` 里兜底收窗口
938
- - **修:读「当前登录的抖音号」会读成别人**。`/user/self` 实测不会跳到 `/user/<sec_uid>`;页面里的 `<a>` 又基本是推荐位和广告(实测点开是「乐游宝宝亲子游泳」「仁青汽车」)。现在改成:先从页面读出「抖音号:xxx」,再把 HTML 里出现的 sec_uid 逐个打开核对,抖音号对上的那个才算自己——实测读到 `穹 / 90374343836`
962
+ - **修:读「当前登录的抖音号」会读成别人**。`/user/self` 实测不会跳到 `/user/<sec_uid>`;页面里的 `<a>` 又基本是推荐位和广告(实测点开是「乐游宝宝亲子游泳」「仁青汽车」)。现在改成:先从页面读出「抖音号:xxx」,再把 HTML 里出现的 sec_uid 逐个打开核对,抖音号对上的那个才算自己——实测读到 `阿澈 / 90374343836`
939
963
  - **私信发没发出去能判断了**:回车后看输入框有没有清空(contenteditable 清空后会剩一个零宽字符,`trim()` 去不掉,得单独剥),没清空就报 `NOT_SENT`;回车只是换行的版本会自动改点发送按钮,不再假装成功
940
964
  - **面板里回私信走同一条发送逻辑**:`replyDm` 也换成「等可见输入框 + 发送 + 校验」,失败同样收窗口
941
965
  - **抖音号解析说实话**:实测抖音的搜索页在后台是空壳(只有导航、没有结果,换 UA 也一样),所以「抖音号 → sec_uid」这条路基本走不通。现在会直接说清原因,并给两个能用的办法:主页分享链接、或 sec_uid
package/README.md CHANGED
@@ -55,7 +55,7 @@
55
55
  - **你发私信,她「秒」回**:抖音私信不再是「每 30 秒查一次」。现在会常驻一个**只盯着你那个会话**的后台标签页,页面里的观察器一看到新消息就回调(实测 **DOM 变化 → 通知 120ms**,读消息 4ms),直接 interrupting 把她叫起来回;回的时候也在这个已经打开的会话里打字,**不跳转、不点会话**,一条私信 1 秒内就能发出去。轮询退化成兜底(`设置 → 私信 → 多久看一次私信`,默认 5 秒;观察器在线时只做 60 秒一次的对账),所以也不会因为频繁操作把抖音惹急(-509)。连发几句会**一起**接住(`recent` / `pending` / `said`),私信里分享的视频、图文会解析成真链接。代价是抖音要常驻一个后台页面(关掉这个开关就释放)
56
56
  - **「B站私信我」**:谁找你右上角一个按钮,点一下她就给你发条 B站私信,用来验证投递链路
57
57
  - **抖音群聊**:她在的群**默认自动盯着**(`设置 → 私信 → 盯着抖音群` 可以关;也可以在「要盯的抖音群」里指定,留空=自动识别,最多三个)。有人说话就会像私信一样立刻把她叫起来,推送里带**是谁说的**;回的时候 `stroll_act action=dm target=<群名>` 直接发到群里——群名不用抄全,写其中一部分(比如某个成员名)就能对上,只有一个群时写「群」也行。**两个群同名时**(同一批人建的、都没起名)第二个会带 `#2`,照抄那个。**私信主人要写 `target=主人`**:刚被群消息叫起来又没写 target 时会让她写清楚,不会再悄悄发成私信;`target` 写主人的昵称也按私信处理。`stroll action=groups` 能看自己有哪些会话(会标出哪些是群),`join_group` 用邀请链接进群。**抖音的群只能被拉进去或点邀请链接,没有搜群号加入这条路**——想让桌宠进群,先在 App 里把她拉进群,或者把群邀请链接发给它
58
- - 抖音的群如果**没起名**,标题是成员名拼起来的(「小月月, 穹, 大萌神」),这不是群名,是抖音自己的显示方式
58
+ - 抖音的群如果**没起名**,标题是成员名拼起来的(「阿柚, 阿澈, 老K」),这不是群名,是抖音自己的显示方式
59
59
  - 群里只推**真正新出现的那几条**:新消息进页面 → 推给她实测 **约 190ms**,不会把群历史重推一遍
60
60
  - **群里 @ 人**:`mention_owner: true` 会真的插一个带名字的 @,也可以用 `mention: <昵称>` 指定群里的别人。**正文里别手写 @**——那只是普通字符,抖音会染成蓝色看着像提到了,但对方收不到任何通知;手写的会被自动剥掉
61
61
  - **动态/专栏的评论也能回**:视频、动态、专栏用同一套回复逻辑;面板里点「回一句」直接发到楼里,不用再跳网页
@@ -183,7 +183,7 @@ corepack pnpm install
183
183
 
184
184
  面板从上到下:
185
185
 
186
- 1. **登录 B站**:如果应用里已有 B站登录(比如 `穹和黑助`),会自动复用;没有就点「登录 B站」,在弹出的窗口里扫码,登上后会自动收回登录态并关窗。要换号先点「退出登录」。
186
+ 1. **登录 B站**:如果应用里已有 B站登录(比如 `她的B站号`),会自动复用;没有就点「登录 B站」,在弹出的窗口里扫码,登上后会自动收回登录态并关窗。要换号先点「退出登录」。
187
187
  2. **登录抖音**:点「登录 / 验证」,在弹出窗口扫码;登录态保存在 `data/stroll/douyin-profile`,以后不用重复扫。换号同样先点「退出登录」。
188
188
  3. **设主人**:设置里填 `主人 B站 UID`(用于 @ 和 B站私信)。填了 UID 后程序会自己查昵称,查不到才用 `主人 B站昵称`。
189
189
  - 想让她发抖音私信,再填 `主人抖音`:**最稳的是主页分享链接**(抖音 App →「我」→ 分享 → 复制链接,整段粘进来),也可以直接填 `MS4w` 开头的 sec_uid。抖音号也能填、面板上会出「绑定这个号」,但抖音的搜索页对自动化浏览器基本是空的(反爬),解析成功率不高,所以别把这条路当唯一。
package/describe.json CHANGED
@@ -175,7 +175,7 @@
175
175
  },
176
176
  "mention": {
177
177
  "type": "string",
178
- "description": "群里 @ 谁的昵称(照抄群成员列表,比如「大萌神」)。@主人不用填这个,用 mention_owner: true —— 系统知道主人的抖音昵称。不要填「你」「主人」这种称呼,也不要自己写 @ 符号。"
178
+ "description": "群里 @ 谁的昵称(照抄群成员列表,比如「老K」)。@主人不用填这个,用 mention_owner: true —— 系统知道主人的抖音昵称。不要填「你」「主人」这种称呼,也不要自己写 @ 符号。"
179
179
  },
180
180
  "draft_id": {
181
181
  "type": "string",
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.2",
4
4
  "type": "module",
5
5
  "description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
6
6
  "license": "MIT",
package/src/ENV_PROMPT.md CHANGED
@@ -46,8 +46,8 @@
46
46
  - 面板里已经有「私信 / 评论 / @ 我」三个分类;主人说“你没回我私信”时,先 `stroll action=inbox` 看一眼,不要嘴硬说收不到。
47
47
  - **群聊**:她所在的抖音群默认就盯着(设置里「盯着抖音群」可以关),一有人说话就会像私信一样把你叫起来(推一句「抖音群「X」里刚有人说话」,带上是谁说的)。回的时候用 `stroll_act action=dm platform=douyin target=<群名> text=…`。**群名不用抄全**:只写其中一部分(比如某个成员名)也能对上;只有一个群时直接写「群」也行。两个群**同名**时(同一批人建的两个群)名字后面会带 `#2`,照抄那个带 `#2` 的。群消息**不用每条都接**:叫到你了、问到你了、或者你真有话说才回;连着几句一起接住,别一句一条地刷屏。想看自己有哪些会话就 `stroll action=groups`(会标出哪些是群)。
48
48
  - **私信主人要写清楚**:`target=主人`(或「私信」)。**刚被群消息叫起来、回话却没写 target,系统会拒绝并让你选**——这是故意的:以前这种「没说清」会悄悄发进主人私信,群里一个字都没有,你还会以为回了群。回执里 `to: "group"` 才是发到群里,`to: "owner"` 是私信主人。
49
- - **群里想 @ 人,必须让工具去插**:@ 主人带 `mention_owner: true`;@ 群里别人带 `mention: "对方昵称"`(照抄群成员列表,比如 `mention: "大萌神"`)。正文里**一个字都不要写 @**。手打的 `@` 只是个普通符号:抖音会把它染成蓝色,看着像提到了,其实后面不带名字、对方一点通知都收不到。只有从候选面板选中的 @(带 uid 的 chip)才叫真提到。回执里 `mentioned=true` 才算 @ 上了;`mentioned=false` 就是没 @ 上,得如实告诉主人,别糊弄过去。
50
- - **只在正文里写名字不算 @**:`text="大萌神,在吗"` 没传 `mention` 时,系统会尽量把开头的名字当成 @ 目标,但这只是兜底。你要 @ 谁,就老老实实传 `mention`(或 `mention_owner`)。回执里 `mentioned=null` 表示**压根没要求 @ 任何人**——那不是失败,别跟主人说「没插上」。
49
+ - **群里想 @ 人,必须让工具去插**:@ 主人带 `mention_owner: true`;@ 群里别人带 `mention: "对方昵称"`(照抄群成员列表,比如 `mention: "老K"`)。正文里**一个字都不要写 @**。手打的 `@` 只是个普通符号:抖音会把它染成蓝色,看着像提到了,其实后面不带名字、对方一点通知都收不到。只有从候选面板选中的 @(带 uid 的 chip)才叫真提到。回执里 `mentioned=true` 才算 @ 上了;`mentioned=false` 就是没 @ 上,得如实告诉主人,别糊弄过去。
50
+ - **只在正文里写名字不算 @**:`text="老K,在吗"` 没传 `mention` 时,系统会尽量把开头的名字当成 @ 目标,但这只是兜底。你要 @ 谁,就老老实实传 `mention`(或 `mention_owner`)。回执里 `mentioned=null` 表示**压根没要求 @ 任何人**——那不是失败,别跟主人说「没插上」。
51
51
  - **你不知道主人在抖音叫什么,这很正常**:让你 @ 主人时**不要猜名字、也不要写「@你」「@主人」**——那不是昵称,候选面板里找不到人,发出去只是一串没有效果的蓝字。直接用 `mention_owner: true`,系统会去查。要 @ 群里别人,昵称必须照抄群成员列表;不知道对方昵称就先问主人,别编一个。
52
52
  - 抖音的群**没起名时,标题是成员名拼起来的**(「A, B, C」),这不是群名,别当成一个人的名字。群**只能被拉进去,或者点别人的邀请链接**,没有「搜群号加入」。主人给你邀请链接时用 `stroll action=join_group url=<链接>`。别自己编群名,也别承诺「我去群里说一声」——先看 `groups` 里有没有那个群。
53
53
  - 主人发来链接时:先 `open` 看完,再用一两句说你自己的看法。**不要**顺手点赞/评论/关注,除非主人明确说“去互动”。
@@ -101,7 +101,7 @@
101
101
  - 会有推送写着「有人给你发私信(抖音·张三):…」。两种模式,看推送末尾:
102
102
  - **「先问我」**:**别直接回她**,但要**立刻跟主人说一句**(谁发的、说了什么),等主人点头再发。闷着不说话等于没收到。
103
103
  - **「回她一句」**:直接 `stroll_act action=dm platform=… target=… text=…`,回一两句短的。**回完就停,不用每条都跟主人汇报**——多汇报一次就是多一轮模型输出加一次语音,对方下一条消息要排队等。只有对方说了要紧事(约见面、要钱、发链接、打听主人的事)才跟主人说一句。
104
- - **`target` 原样抄推送里那个昵称**(比如 `target=海底月`)。抖音的陌生人会话不在主聊天列表里、只收在「陌生人消息」文件夹,但发送时写这个昵称就能发进去——**别因为「列表里看不到」就说回不了**。
104
+ - **`target` 原样抄推送里那个昵称**(比如 `target=小鹿`)。抖音的陌生人会话不在主聊天列表里、只收在「陌生人消息」文件夹,但发送时写这个昵称就能发进去——**别因为「列表里看不到」就说回不了**。
105
105
  - **「先问我」时,面板「待你点头」里也会有一条**:主人可以直接点「让她回」,那时你会收到「主人点头了…」的推送,照着发就行,别再问一遍。
106
106
  - 只有**真的给你发过消息**的人才能这样回;没给你发过话的人,系统会拒绝(`NO_DM`),这是故意的。
107
107
  - 不想回就回「不回」给主人,别硬找话,也别连发好几条。
package/src/bilibili.js CHANGED
@@ -1317,7 +1317,7 @@ export class Bilibili {
1317
1317
  const name = await this.ownerName(ownerUid);
1318
1318
  if (name) {
1319
1319
  // 正文里如果已经写了「@昵称」,再前置一个就成了 @ 两次(实测 2026-11 动态里
1320
- // 就是「@小月月 … @小月月还在玩」)。剥得干净才前置;正文本来就只是 @昵称 的,
1320
+ // 就是「@阿柚 … @阿柚还在玩」)。剥得干净才前置;正文本来就只是 @昵称 的,
1321
1321
  // 不前置、靠下面的 at_name_to_mid 把正文里那个变成真 @。
1322
1322
  const clean = stripAtName(bodyText, name);
1323
1323
  if (clean) {
package/src/douyin.js CHANGED
@@ -1654,7 +1654,7 @@ export class DouyinBrowser {
1654
1654
  .filter(Boolean);
1655
1655
  let name = norm(t?.innerText || lines[0] || '');
1656
1656
  let at = '';
1657
- // 标题块里常常连着时间:「海底月 9分钟前」。不剥掉的话,
1657
+ // 标题块里常常连着时间:「小鹿 9分钟前」。不剥掉的话,
1658
1658
  // 名字每分钟变一次(9分钟前→刚刚),会话永远找不到、去重也永远不重合。
1659
1659
  const m = TRAIL.exec(name);
1660
1660
  if (m) {
@@ -1676,7 +1676,7 @@ export class DouyinBrowser {
1676
1676
  /**
1677
1677
  * 只读「陌生人消息」入口那一行文字,不点进去。
1678
1678
  *
1679
- * 入口本身就带最新一条:「陌生人消息 刚刚 海底月:你好」。
1679
+ * 入口本身就带最新一条:「陌生人消息 刚刚 小鹿:你好」。
1680
1680
  * 很多账号/布局下根本点不开那个文件夹(或者类名换了),光靠入口也能发现新消息。
1681
1681
  * 返回 { who, at, preview, raw } 或 null。
1682
1682
  */
@@ -1693,7 +1693,7 @@ export class DouyinBrowser {
1693
1693
  .catch(() => '');
1694
1694
  if (!raw) return null;
1695
1695
  const TIME = /(刚刚|\d{1,2}:\d{2}|昨天|前天|星期[一二三四五六日]|\d+\s*(?:秒|分钟|小时|天)前|\d{1,2}-\d{1,2})/;
1696
- // 「陌生人消息 刚刚 海底月:你好」→ 名字取「时间」后面、冒号前面那段
1696
+ // 「陌生人消息 刚刚 小鹿:你好」→ 名字取「时间」后面、冒号前面那段
1697
1697
  const afterTime = raw.replace(/^.*?陌生人消息\s*/, '');
1698
1698
  const m = TIME.exec(afterTime);
1699
1699
  const tail = m ? afterTime.slice(m.index + m[0].length).trim() : afterTime;
@@ -1905,7 +1905,7 @@ export class DouyinBrowser {
1905
1905
  const t = el.querySelector('[class*="ConversationItemtitle"]');
1906
1906
  const raw = String(t?.innerText || (el.innerText || '').split('\n')[0] || '').trim();
1907
1907
  /**
1908
- * 抖音把时间也塞进标题:「海底月 刚刚」「海底月 10:55」。
1908
+ * 抖音把时间也塞进标题:「小鹿 刚刚」「小鹿 10:55」。
1909
1909
  * 不剥掉的话整串相等永远不成立,blind 点击会一直判「点不到」。
1910
1910
  */
1911
1911
  return raw
@@ -2416,7 +2416,7 @@ export class DouyinBrowser {
2416
2416
  if (slot.thread !== who) {
2417
2417
  /**
2418
2418
  * 用 clickThread 而不是 `locator(hasText: 群名)`。
2419
- * hasText 是**子串**匹配:两个群的标题都带「小月月一直在玩」时,
2419
+ * hasText 是**子串**匹配:两个群的标题都带「阿柚」时,
2420
2420
  * 它可能把第二个群点成第一个群;而且标题被截断时又永远匹配不上。
2421
2421
  * clickThread 先按整串相等找,再退回「唯一包含」,两个群也分得清。
2422
2422
  */
@@ -4068,7 +4068,7 @@ export class DouyinBrowser {
4068
4068
  if (!t || t.length > 40) continue;
4069
4069
  if (key && !bare(t).includes(key)) continue;
4070
4070
  // 只有「子元素自己也能当一行」时才算外壳;名字那一小截不算,
4071
- // 否则「大萌神」这种前面没有身份标签的行会被整行丢掉
4071
+ // 否则「老K」这种前面没有身份标签的行会被整行丢掉
4072
4072
  const shell = Array.from(n.children).some((c) => {
4073
4073
  if (String(c.textContent ?? '').replace(/\s+/g, ' ').trim() !== t) return false;
4074
4074
  const cr = c.getBoundingClientRect();
@@ -4645,7 +4645,7 @@ export class DouyinBrowser {
4645
4645
  };
4646
4646
  /**
4647
4647
  * 抖音选完图会先弹一个确认框:
4648
- * 「发送给 小月月一直在玩, 穹, 大萌神(3) s_811c597ed5c8.png 118.2 KB 取消 发送(1)」
4648
+ * 「发送给 阿柚, 阿澈, 老K(3) s_811c597ed5c8.png 118.2 KB 取消 发送(1)」
4649
4649
  * 不点那个「发送」,图就一直挂在预览里——上一版漏了这一步,回执写「图选上了但没确认发出去」,
4650
4650
  * 界面上其实什么都不会出现。所以这里一边等消息变化,一边盯着这个确认框。
4651
4651
  */
@@ -4965,14 +4965,14 @@ export class DouyinBrowser {
4965
4965
  const badge = el.querySelector('[class*="unread"], [class*="Unread"]');
4966
4966
  const lines = (el.innerText || '').split('\n').map((s) => s.trim()).filter(Boolean);
4967
4967
  // 昵称优先取第一行:有未读时 title 元素的 class 会连带把时间包进来
4968
- //(实测 innerText = "小月月一直在玩 刚刚"),按 class 取会多一截。
4968
+ //(实测 innerText = "阿柚 刚刚"),按 class 取会多一截。
4969
4969
  const name = lines[0] || clean(title?.innerText) || '';
4970
4970
  // 兜底:把标题/时间/在线状态剔掉,剩下的才可能是消息预览
4971
4971
  const junk = new Set([clean(name), clean(time?.innerText), clean(online?.innerText)].filter(Boolean));
4972
4972
  const fallback = lines.find((l) => !junk.has(clean(l))) ?? '';
4973
4973
  const unread = Number(clean(badge?.innerText).replace(/\D/g, '')) || 0;
4974
4974
  let previewText = clean(preview?.innerText) || fallback;
4975
- // 预览里常把未读角标一起算进来(实测「穹 2」),那个 2 不是消息内容
4975
+ // 预览里常把未读角标一起算进来(实测「阿澈 2」),那个 2 不是消息内容
4976
4976
  if (unread > 0) previewText = previewText.replace(new RegExp(`\\s*${unread}$`), '').trim();
4977
4977
  return {
4978
4978
  name,
@@ -5134,7 +5134,7 @@ export class DouyinBrowser {
5134
5134
  }
5135
5135
  }
5136
5136
  }
5137
- // 角色标签可能拼在名字前面(「群主 小月月一直在玩」)也可能在后面
5137
+ // 角色标签可能拼在名字前面(「群主 阿柚」)也可能在后面
5138
5138
  name = name
5139
5139
  .replace(/^(群主|管理员|群成员|成员)\s*/u, '')
5140
5140
  .replace(/\s*(群主|管理员|群成员|成员)$/u, '')
@@ -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
+ * (`群A、小鹿`),包含就会同时命中两条 → 判成歧义 → 直接返回 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
@@ -6264,7 +6365,7 @@ export class DouyinBrowser {
6264
6365
  const norm = (s) =>
6265
6366
  String(s ?? '')
6266
6367
  .replace(/\s*#\d+$/, '')
6267
- // 标题常带群人数:「测试3(3)」「测试3 3人」
6368
+ // 标题常带群人数:「群C(3)」「群C 3人」
6268
6369
  .replace(/[((]\s*\d+\s*[))]\s*$/, '')
6269
6370
  .replace(/^\d+\s*人\s*/, '')
6270
6371
  .replace(/[\s\u3000]+/g, '')
package/src/tools.js CHANGED
@@ -78,7 +78,7 @@ export const TOOL_DECLS = [
78
78
  mention: {
79
79
  type: 'string',
80
80
  description:
81
- '群里 @ 谁的昵称(照抄群成员列表,比如「大萌神」)。@主人不用填这个,用 mention_owner: true —— 系统知道主人的抖音昵称。不要填「你」「主人」这种称呼,也不要自己写 @ 符号。',
81
+ '群里 @ 谁的昵称(照抄群成员列表,比如「老K」)。@主人不用填这个,用 mention_owner: true —— 系统知道主人的抖音昵称。不要填「你」「主人」这种称呼,也不要自己写 @ 符号。',
82
82
  },
83
83
  draft_id: { type: 'string', description: 'approve/reject 用。' },
84
84
  preview: { type: 'boolean', description: 'true = 只出草稿,不发送。' },
package/src/world.js CHANGED
@@ -91,7 +91,7 @@ function looksLikePath(s) {
91
91
  }
92
92
 
93
93
  /**
94
- * 昵称里如果黏着列表上的相对时间(「海底月 刚刚」「海底月 9分钟前」),把它剥掉。
94
+ * 昵称里如果黏着列表上的相对时间(「小鹿 刚刚」「小鹿 9分钟前」),把它剥掉。
95
95
  * 旧版本把这种名字写进了 state.json,不清掉的话:
96
96
  * 名字每分钟变一次 → 找不到会话 → 发送走最慢的兜底(实测 9 秒)。
97
97
  */
@@ -605,7 +605,7 @@ export class StrollWorld {
605
605
  return fragments.slice(0, cap);
606
606
  }
607
607
 
608
- /** 群名比较:忽略空白和大小写,允许「包含」——这样她只写「小月月」也能对上。 */
608
+ /** 群名比较:忽略空白和大小写,允许「包含」——这样她只写「阿柚」也能对上。 */
609
609
  sameGroup(a, b) {
610
610
  const norm = (s) =>
611
611
  String(s ?? '')
@@ -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
+ * 因为抖音拉群时群名默认是**几个成员的昵称拼起来**(`小鹿、阿柚、老K`),
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 ?? '')
@@ -628,7 +653,7 @@ export class StrollWorld {
628
653
 
629
654
  /**
630
655
  * 在已知群名里挑目标:**整串相等优先,其次「唯一包含」**。
631
- * 两个群共用成员时(「A, 穹, 大萌神」和「A, 穹, 阿猫」),
656
+ * 两个群共用成员时(「A, 阿澈, 老K」和「A, 阿澈, 阿猫」),
632
657
  * 只写「A」两个都像——这时候返回 null,宁可让她问清楚,也不能把话发错群。
633
658
  */
634
659
  matchGroup(names, wanted) {
@@ -643,7 +668,7 @@ export class StrollWorld {
643
668
  const exact = list.find((n) => norm(n) === key);
644
669
  if (exact) return exact;
645
670
  /**
646
- * 只允许「她写的是群名的一部分」:写「大萌神」→ 命中「A, 穹, 大萌神」。
671
+ * 只允许「她写的是群名的一部分」:写「老K」→ 命中「A, 阿澈, 老K」。
647
672
  *
648
673
  * **反过来绝对不行**:她写的是更长的群名(第二个群)时,
649
674
  * 拿它去 `key.includes(群一)` 会命中更短的那个群一——
@@ -1206,7 +1231,7 @@ export class StrollWorld {
1206
1231
 
1207
1232
  addStrangerPending({ platform, who, uid = '', text = '' }) {
1208
1233
  const now = Date.now();
1209
- // 昵称里可能黏着列表上的时间(「海底月 刚刚」):存进去的话,点「让她回」会拿这个
1234
+ // 昵称里可能黏着列表上的时间(「小鹿 刚刚」):存进去的话,点「让她回」会拿这个
1210
1235
  // 名字去发,会话永远找不到。这里统一洗一遍。
1211
1236
  who = stripRowTime(who);
1212
1237
  const keep = (this.store.data.strangerPending ?? []).filter((x) => now - Number(x.at ?? 0) < 24 * 3600 * 1000);
@@ -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
 
@@ -1643,7 +1668,7 @@ export class StrollWorld {
1643
1668
  * 「这个 target 是不是一个给我发过消息的陌生人?」
1644
1669
  *
1645
1670
  * 抖音的陌生人会话只在「陌生人消息」文件夹里,主列表认不出来——以前的代码
1646
- * 于是把 target=海底月 判成「没认出是哪个群」,她就回「陌生人除了那格,别处都不认」。
1671
+ * 于是把 target=小鹿 判成「没认出是哪个群」,她就回「陌生人除了那格,别处都不认」。
1647
1672
  * 现在:先在推送记下的名单里找,找不到再去文件夹里现查一次(只查,不动推送缓存)。
1648
1673
  * 只允许回**真的给她发过消息**的人,不让模型凭空给陌生人发私信。
1649
1674
  */
@@ -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 {
@@ -4907,7 +4950,7 @@ export class StrollWorld {
4907
4950
  const flat = (s) => String(s ?? '').replace(/\s+/g, '').toLowerCase();
4908
4951
  /**
4909
4952
  * target 写的是**主人的抖音昵称**时,按私信主人处理,别去群名单里找。
4910
- * 线上踩过:她回群话时写了「小月月一直在玩」(主人的名字),
4953
+ * 线上踩过:她回群话时写了「阿柚」(主人的名字),
4911
4954
  * 而群标题恰好包含这个名字——于是话被发进了一个群,她以为在私信主人。
4912
4955
  */
4913
4956
  const toOwnerDm = OWNER_ALIAS.test(wantedRaw) || (!!ownerNick && !!wantedRaw && flat(wantedRaw) === flat(ownerNick));
@@ -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
+ * (`小鹿、阿柚、老K`),而 `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);
@@ -4975,7 +5036,7 @@ export class StrollWorld {
4975
5036
  // 要真 @ 人就得知道那个人在群里的昵称;不知道就直说,别让她手打一个假 @。
4976
5037
  // 默认 @ 主人;也可以用 args.mention 指定群里的别人。
4977
5038
  const rawMention = String(args.mention ?? '').trim().replace(/^@+/, '');
4978
- // 她常把要 @ 的人直接写在正文开头(「@大萌神 在吗」)——那只是个普通字符。
5039
+ // 她常把要 @ 的人直接写在正文开头(「@老K 在吗」)——那只是个普通字符。
4979
5040
  // 这里把它当成 @ 目标,交给候选面板插成真 chip,而不是发一串蓝字。
4980
5041
  const leadingAt = /^@([^\s@,。!?、,.!?::;;~~]{1,24})/.exec(String(text ?? '').trim());
4981
5042
  const fromText = leadingAt ? leadingAt[1].trim() : '';
@@ -4985,7 +5046,7 @@ export class StrollWorld {
4985
5046
  const ownerName = String(this.douyinOwner().name ?? '').trim();
4986
5047
  const wantsOwner = mentionOwner || isPlaceholder(rawMention) || isPlaceholder(fromText);
4987
5048
  /**
4988
- * 还有一种最常见的写法:不传 mention,直接把名字写在正文开头(「大萌神,在吗」)。
5049
+ * 还有一种最常见的写法:不传 mention,直接把名字写在正文开头(「老K,在吗」)。
4989
5050
  * 以前这种情况就当普通正文发出去,一个字都不 @。群标题没起名时就是成员名拼的,
4990
5051
  * 从里面拆出名单,在开头对一次名字——对上了就当成 @ 目标。
4991
5052
  */