cortico-world-mosey 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.5.0
4
+
5
+ **分享抖音视频:现在能发原生卡片了。**
6
+
7
+ 以前她只能把链接当文字发过来,一条光秃秃的 URL。现在「设置 → 私信与群 → 分享视频怎么发」有三档:
8
+
9
+ - **真分享卡**(默认):走抖音自己的「分享 → 分享给朋友」,发原生视频卡片,就是 App 里那种带封面和标题的样子。后台运行时会在屏幕外开一个你看不见的窗口来做,做完立刻关掉,不影响你正在用的浏览器。
10
+ - **画面 + 标题**:先发一张视频首帧,再发「《标题》· 作者 · 时长 + 链接」。
11
+ - **只发链接**:一段纯文字,等于旧行为。
12
+
13
+ 她不用记新动作——正常 `stroll_act action=dm` 里带抖音链接就行,系统自己转。
14
+
15
+ **顺手修掉一个会把东西发错地方的 bug**
16
+
17
+ - 发图片、发表情走的那条路里有个「现在开着谁」的缓存:在群里回完一句之后它可能还是主人,于是**跳过点击会话**,图片直接打进当时开着的群。现在每次都重新点一次(200ms),并且只往**核实过**的主人会话发;核不到就不发图,退回纯文字。
18
+ - 发私信本身也一样:只在页面确实切到目标会话之后才发,核实失败就清缓存、不发送。
19
+
20
+ > 卡片这条路依赖抖音的界面,改版就可能失效;失效时自动退回「画面 + 标题」,不会丢消息,日志里会留 `stroll share card fallback { step }`。
21
+
22
+
23
+ ## 2.4.1
24
+
25
+ **修抖音登录:不再隔几小时掉一次,扫完码也会立刻收窗。**
26
+
27
+ ### 掉线:两个原因,都修了
28
+
29
+ - **登录态备份**:每 10 分钟把 cookie 存一份到 `stroll/douyin-cookies.json`,启动时如果 profile 里没有 `sessionid` 就补回去。以前进程被强杀(清残留进程用的是 `Stop-Process -Force`)时 Chromium 来不及把 cookie 落盘,轮换过的 `sessionid` 一丢就得重新扫码。
30
+ - **风控不再被当成掉线**:只有接口返回 `code 8`(真未登录)才判定掉线;`2154 / 2483` 这些「要验证 / 风控」的返回以前也被当成未登录,于是面板显示「未登录」,白扫一次码,过一会儿它自己又好了。
31
+
32
+ ### 登录后窗口挂很久
33
+
34
+ 两处都改了:
35
+
36
+ - 以前登录成功后会先**另开一页读昵称**(几秒到十几秒),读完才关窗。现在昵称直接从登录校验里带出来,扫完就关。
37
+ - 更关键的是 `sessionAlive` 有 5 分钟缓存:扫码**之前**面板查过一次「未登录」,这个结论被缓存住了,扫完码仍然返回未登录,非要等缓存过期才认。现在登录轮询每次都强制刷新,扫完 1-2 秒内收窗。
38
+
3
39
  ## 2.4.0
4
40
 
5
41
  **有人在视频评论区 @ 她,现在她会知道了。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-mosey",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
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
@@ -41,6 +41,7 @@
41
41
  - 抖音 `recent` 每条还有 `kind`:`text` 是文字,`share` 是分享的视频/图文(带 `url` 和 `title`),`card` 是名片,`image` 是图片。**主人发来分享卡时 `url` 就是那条视频/图集的链接**,直接拿去 `stroll action=open`,别再说「链接取不出来」。`/note/` 开头的是图文,也能 open。
42
42
  - 抖音私信能读到**最近几句对话**:`recent` 里每条有 `from`(`them`=主人说的,`me`=你自己发的);`lastFromThem=false` 表示最后一句是你发的、对方还没回,`true` 才是主人回了你。别再说「看不到回复」——先看 `recent`,读不到才说读不到。
43
43
  - **她会在主人发私信时被直接叫起来**(推一条「主人刚给你发私信了」)。收到就马上回,不用等下次出门;回完这件事就结束,别顺手再刷一轮。
44
+ - **把抖音视频分享给主人**:直接 `stroll_act action=dm platform=douyin target=主人 text=想说的话 https://www.douyin.com/video/xxxx`。系统会按主人设置的形式发出去(原生分享卡,或者「画面 + 《标题》· 作者 · 时长 + 链接」),不用你自己拼链接那一套;正常说你想说的就行。这条路要多花 5-10 秒(要开一次视频页),发出去之前别重复发。
44
45
  - **私信要「秒回」**:这条推送到达时,主人就坐在手机那头等。所以第一条回得越短越快越好——一两句、十几个字,直接 `stroll_act action=dm`(抖音带 `target=主人`)发出去。**不要**先 `status` / `feed` / `inbox` 查一遍(那要多花十几秒),也不要先说明「我看到了,我来回你」。想多说,等这条发出去之后再说。
45
46
  - **给主人的私信不限量**,也不占每日额度:想说就说,不用攒着,也不用为了省额度把话憋回去。只防连点(5 秒)。
46
47
  - **私信额度管的是「发给别人」**:回陌生人、回私聊对象算「私信」,B站和抖音共用一个池子(`{{stroll.summary}}` 里的「私」)。**抖音群聊不算私信**,走单独的「群」额度。额度到量了就直说「今天的私信额度用完了」,别换平台绕。
package/src/config.js CHANGED
@@ -109,6 +109,8 @@ export const DEFAULTS = {
109
109
  dmWatch: true,
110
110
  /** 有人在评论区 @ 她时,主动叫她一声(B站,5 分钟查一次)。 */
111
111
  mentionWatch: true,
112
+ /** 分享抖音视频的方式:image=画面+标题(稳,默认);card=走分享面板(实验);text=只发链接。 */
113
+ shareStyle: 'card',
112
114
  /**
113
115
  * 多久兜底看一次私信(秒)。
114
116
  * 抖音现在走页面事件通道(新消息一进 DOM 就回调),轮询只是防掉线的兜底,
@@ -753,6 +755,17 @@ const MESSAGES = {
753
755
  'x-suffix': ' 秒',
754
756
  'x-hot': true,
755
757
  },
758
+ [P + 'shareStyle']: {
759
+ type: 'string',
760
+ title: '分享视频怎么发',
761
+ description:
762
+ '她给主人发抖音视频时的样子。' +
763
+ '「画面 + 标题」:先发一张视频画面,再发标题和链接(稳,默认)。' +
764
+ '「真分享卡」:走抖音自己的「分享给朋友」,发原生视频卡片(会多花 5-10 秒;抖音改版时可能失效,失效会自动退回「画面 + 标题」)。' +
765
+ '「只发链接」:就是一段纯文字链接。',
766
+ 'x-options': 'stroll-share-style',
767
+ 'x-hot': true,
768
+ },
756
769
  [P + 'strangerDmMode']: {
757
770
  type: 'string',
758
771
  title: '陌生人的私信',
@@ -900,6 +913,7 @@ export const POLICY_FIELDS = {
900
913
  watchPollSec: { type: 'int', min: 5, max: 120 },
901
914
  dmWatch: { type: 'boolean' },
902
915
  mentionWatch: { type: 'boolean' },
916
+ shareStyle: { type: 'enum', values: ['card', 'image', 'text'] },
903
917
  dmPollSec: { type: 'int', min: 3, max: 300 },
904
918
  booksDir: { type: 'string', max: 400 },
905
919
  groupWatch: { type: 'boolean' },
package/src/definition.js CHANGED
@@ -40,6 +40,13 @@ export const STROLL = {
40
40
  { value: 'normal', label: zh ? '普通窗口 1280×800' : 'Window 1280×800' },
41
41
  ];
42
42
  }
43
+ if (kind === 'stroll-share-style') {
44
+ return [
45
+ { value: 'image', label: zh ? '画面 + 标题(稳,推荐)' : 'Frame + title (stable)' },
46
+ { value: 'card', label: zh ? '真分享卡(原生卡片)' : 'Native share card' },
47
+ { value: 'text', label: zh ? '只发链接' : 'Plain link' },
48
+ ];
49
+ }
43
50
  if (kind === 'stroll-stranger-dm') {
44
51
  return [
45
52
  { value: 'ask', label: zh ? '先问我,我同意再回(默认)' : 'Ask me first (default)' },
package/src/douyin.js CHANGED
@@ -758,6 +758,8 @@ export class DouyinBrowser {
758
758
  });
759
759
  this.context = context;
760
760
  this.headless = headless !== false;
761
+ // 上次进程被强杀、cookie 没来得及落盘时,用备份补一次(缺 sessionid 才补)
762
+ await this.restoreCookies().catch(() => {});
761
763
  return context;
762
764
  })()
763
765
  .catch((error) => {
@@ -1109,6 +1111,60 @@ export class DouyinBrowser {
1109
1111
  return null;
1110
1112
  }
1111
1113
 
1114
+ get cookieFile() {
1115
+ return path.join(this.dataDir, 'stroll', 'douyin-cookies.json');
1116
+ }
1117
+
1118
+ /**
1119
+ * 把当前登录态另存一份到磁盘。
1120
+ * profile 里的 Cookies 由 Chromium 自己管:进程被强杀(reapOrphans 用的是 Stop-Process -Force)、
1121
+ * 或者没来得及 flush 时,最近轮换过的 sessionid 会丢,下次启动就变成「未登录」——
1122
+ * 表现出来就是每隔几个小时要重新扫码。有这份备份就能在启动时补回去。
1123
+ */
1124
+ async saveCookies({ force = false } = {}) {
1125
+ if (!this.context) return 0;
1126
+ const now = Date.now();
1127
+ if (!force && this.cookiesSavedAt && now - this.cookiesSavedAt < 10 * 60 * 1000) return 0;
1128
+ this.cookiesSavedAt = now;
1129
+ const list = await this.context.cookies().catch(() => []);
1130
+ const useful = list.filter((c) => /(^|\.)(douyin|bytedance|amemv|snssdk)\.(com|cn)$/i.test(String(c.domain ?? '')));
1131
+ if (!useful.length) return 0;
1132
+ try {
1133
+ fs.mkdirSync(path.dirname(this.cookieFile), { recursive: true });
1134
+ fs.writeFileSync(this.cookieFile, JSON.stringify({ at: new Date(now).toISOString(), cookies: useful }), { mode: 0o600 });
1135
+ } catch {
1136
+ return 0;
1137
+ }
1138
+ return useful.length;
1139
+ }
1140
+
1141
+ /** 启动时把备份补回 profile;只在缺 sessionid 时补,别覆盖页面刚写进去的新 cookie。 */
1142
+ async restoreCookies() {
1143
+ if (!this.context) return 0;
1144
+ let saved = null;
1145
+ try {
1146
+ saved = JSON.parse(fs.readFileSync(this.cookieFile, 'utf8'));
1147
+ } catch {
1148
+ return 0;
1149
+ }
1150
+ const cookies = Array.isArray(saved?.cookies) ? saved.cookies : [];
1151
+ if (!cookies.length) return 0;
1152
+ const now = Date.now();
1153
+ const live = await this.context.cookies().catch(() => []);
1154
+ const alive = live.some(
1155
+ (c) => (c.name === 'sessionid' || c.name === 'sid_tt') && (!c.expires || c.expires < 0 || c.expires * 1000 > now),
1156
+ );
1157
+ if (alive) return 0;
1158
+ // 过期的丢掉;session cookie(expires<=0)补一个 30 天,免得下次又被当成会话 cookie 丢掉
1159
+ const fixed = cookies
1160
+ .filter((c) => !c.expires || c.expires <= 0 || c.expires * 1000 > now)
1161
+ .map((c) => ({ ...c, expires: c.expires && c.expires > 0 ? c.expires : Math.floor(now / 1000) + 30 * 86400 }));
1162
+ if (!fixed.length) return 0;
1163
+ await this.context.addCookies(fixed).catch(() => {});
1164
+ this.log?.info?.('stroll douyin cookies restored', { n: fixed.length, from: saved.at });
1165
+ return fixed.length;
1166
+ }
1167
+
1112
1168
  async login() {
1113
1169
  return this.serialize(async () => {
1114
1170
  await this.launch({ headless: false, force: true });
@@ -1136,15 +1192,28 @@ export class DouyinBrowser {
1136
1192
  this.loginPoller = setInterval(async () => {
1137
1193
  try {
1138
1194
  if (!this.context) return;
1195
+ /**
1196
+ * 每次都要重新问一次服务端。
1197
+ * sessionAlive 有 5 分钟缓存:扫码之前面板查过一次「未登录」,这个 false 会被缓存住,
1198
+ * 扫完码 isLoggedIn 仍然返回 false,于是白白等到缓存过期才收窗——
1199
+ * 这就是「登录完了那个网页还挂着好久」的真正原因。
1200
+ */
1201
+ this.sessionOkAt = 0;
1139
1202
  if (await this.isLoggedIn()) {
1140
1203
  this.markLoggedIn();
1141
1204
  clearInterval(this.loginPoller);
1142
1205
  this.loginPoller = null;
1143
- // 顺手记一下「现在登的是哪个号」,面板就能显示昵称、也能发现和主人是同一个号
1144
- const self = await this.selfProfile().catch(() => null);
1145
- if (self?.secUid) this.onSelf?.(self);
1206
+ /**
1207
+ * 收窗要快:以前这里先 `selfProfile()`(另开一页读昵称,几秒到十几秒),
1208
+ * 读完才 close,主人在桌面上看着一个登录窗挂在那一动不动。
1209
+ * 昵称/uid 其实 sessionAlive() 已经顺手填好了,不用再跑一趟。
1210
+ */
1211
+ await this.saveCookies({ force: true }).catch(() => {});
1146
1212
  await this.close();
1147
- this.log?.info?.('stroll: 抖音登录成功,已切回后台');
1213
+ // 收窗后别留着「未登录」的旧缓存,不然面板还要再等 5 分钟才变绿
1214
+ this.sessionOk = true;
1215
+ this.sessionOkAt = Date.now();
1216
+ this.log?.info?.('stroll: 抖音登录成功,已切回后台', { who: this.selfName || '' });
1148
1217
  } else if (Date.now() - started > 5 * 60 * 1000) {
1149
1218
  clearInterval(this.loginPoller);
1150
1219
  this.loginPoller = null;
@@ -1155,7 +1224,7 @@ export class DouyinBrowser {
1155
1224
  } catch {
1156
1225
  // 下一轮再看
1157
1226
  }
1158
- }, 2500);
1227
+ }, 1200);
1159
1228
  }
1160
1229
 
1161
1230
  /**
@@ -1182,13 +1251,27 @@ export class DouyinBrowser {
1182
1251
  })
1183
1252
  .catch(() => ({ code: -1, name: '', uid: '' }));
1184
1253
  const code = res?.code ?? -1;
1185
- // 8 = 用户未登录;-1 是没问到,别拿它当结论
1186
- if (code === -1) return null;
1187
1254
  if (res.name) this.selfName = res.name;
1188
1255
  if (res.uid) this.selfUid = res.uid;
1189
- this.sessionOk = code === 0;
1190
- this.sessionOkAt = now;
1191
- return this.sessionOk;
1256
+ /**
1257
+ * 只有下面三种结论:
1258
+ * 0 → 活着
1259
+ * 8 → 真的未登录(要重新扫码)
1260
+ * 其它 → 不知道(-1 没问到;2154/2483 是风控/需要验证)
1261
+ * 以前把「其它」一律当成掉线,结果风控一来面板就显示未登录,
1262
+ * 主人白扫一次码,过一会儿又好了——「每隔几小时掉线」有一半是这个误报。
1263
+ */
1264
+ if (code === 0) {
1265
+ this.sessionOk = true;
1266
+ this.sessionOkAt = now;
1267
+ return true;
1268
+ }
1269
+ if (code === 8) {
1270
+ this.sessionOk = false;
1271
+ this.sessionOkAt = now;
1272
+ return false;
1273
+ }
1274
+ return null;
1192
1275
  }
1193
1276
 
1194
1277
  /** 她自己这个号的昵称(群里用来区分「谁说的」,拿不到就空串)。 */
@@ -6535,7 +6618,13 @@ export class DouyinBrowser {
6535
6618
  if (!(await this.isLoggedIn())) throw new DouyinError('LOGIN_REQUIRED', '抖音还没登录');
6536
6619
  const opened = await (async () => {
6537
6620
  if (!/\/chat/.test(page.url())) await this.gotoChat(page);
6538
- if (name && this.openThread !== name) {
6621
+ if (name) {
6622
+ /**
6623
+ * 以前这里写的是 `if (name && this.openThread !== name)`:openThread 是个全局缓存,
6624
+ * 在群里回完一句它就被写成群名/人名,下一次给主人发图时如果恰好「等于」就跳过点击,
6625
+ * 于是图片直接打进当时开着的那个会话(2026-11-11 分享视频发到群聊就是这么来的)。
6626
+ * 点击只要 200ms,别为省这点时间赌对。
6627
+ */
6539
6628
  let ok = await this.clickThread(page, name);
6540
6629
  // 陌生人会话不在主列表里
6541
6630
  if (!ok) ok = await this.clickStrangerThread(page, name).catch(() => false);
@@ -6551,6 +6640,260 @@ export class DouyinBrowser {
6551
6640
  });
6552
6641
  }
6553
6642
 
6643
+ /**
6644
+ * 只给「主人私信」用的发送页:必须是核实过的那一页。
6645
+ * 宁可不发、回退成纯文字,也绝不把图打到当时开着的群/别人会话里。
6646
+ */
6647
+ async ownerChat(name) {
6648
+ const who = String(name ?? '').trim();
6649
+ if (!who) return null;
6650
+ // 1) 监听器专门停在主人会话上的那一页(最稳)
6651
+ if (this.dmPage && !this.dmPage.isClosed() && this.dmThread === who) {
6652
+ const input = await this.waitMsgInput(this.dmPage, 2000).catch(() => null);
6653
+ if (input) return { page: this.dmPage, input, via: 'dm-page' };
6654
+ }
6655
+ // 2) 主页面:点会话 + 用「会话内容有没有换」核实(这套页面读不到标题)
6656
+ const page = await this.getPage().catch(() => null);
6657
+ if (!page) return null;
6658
+ if (!/\/chat/.test(page.url())) await this.gotoChat(page).catch(() => {});
6659
+ const before = await this.threadFingerprint(page).catch(() => null);
6660
+ const clicked = await this.clickThread(page, who).catch(() => false);
6661
+ if (!clicked) return null;
6662
+ await page.waitForTimeout(250);
6663
+ if (before && !(await this.waitThreadChanged(page, before, 1800).catch(() => false))) return null;
6664
+ const input = await this.waitMsgInput(page, 3000).catch(() => null);
6665
+ if (!input) return null;
6666
+ this.openThread = who;
6667
+ return { page, input, via: 'chat' };
6668
+ }
6669
+
6670
+ /**
6671
+ * 走抖音网页自己的「分享 → 分享给朋友」发一张原生卡片。
6672
+ *
6673
+ * 三条实测结论(2026-11-11,别再踩):
6674
+ * 1. 必须用 `/?modal_id=<id>` 这种「视频浮层」打开。直接开 /video/<id> 是宽版详情页,
6675
+ * 竖排动作列不渲染(`video-player-share` 恒为 0×0),点了没反应。
6676
+ * 2. **无头模式渲染不出浮层**(等 40 秒也没有那个按钮)。所以后台运行时另开一个
6677
+ * 「离屏有头窗口」来做,窗口在屏幕外,用户看不见,但渲染和点击都正常。
6678
+ * 3. 首次进浮层会弹「滚轮/键盘上下键…我知道了」引导,它盖住页面,不关掉就点不动——
6679
+ * 之前 no-trigger 有一半是它干的。
6680
+ */
6681
+ async shareCardTo(name, url) {
6682
+ const who = String(name ?? '').trim();
6683
+ const id = String(url ?? '').match(/(\d{8,})/)?.[1] ?? '';
6684
+ if (!who || !id) return { ok: false, step: 'args' };
6685
+ // 主上下文本来就有头(一起看/扫码登录时):直接开一页,省一次浏览器启动
6686
+ if (this.context && this.headless !== true) {
6687
+ let page = null;
6688
+ try {
6689
+ page = await this.context.newPage();
6690
+ return await this.shareOnPage(page, who, id);
6691
+ } catch (error) {
6692
+ return { ok: false, step: 'error', reason: String(error?.message ?? error).slice(0, 80) };
6693
+ } finally {
6694
+ await page?.close().catch(() => {});
6695
+ }
6696
+ }
6697
+ return await this.shareInTempWindow(who, id);
6698
+ }
6699
+
6700
+ /**
6701
+ * 后台(无头)时用的离屏有头窗口:独立资料目录 + 从当前上下文/备份注入登录态,做完就关。
6702
+ * 用独立目录是为了不跟主 profile 抢锁,也不会把主人正在用的页面带跑。
6703
+ */
6704
+ async shareInTempWindow(who, id) {
6705
+ const mod = await this.loadPlaywright();
6706
+ const chromium = mod.chromium ?? mod.default?.chromium;
6707
+ const dir = path.join(this.dataDir, 'stroll', 'share-profile');
6708
+ let ctx = null;
6709
+ try {
6710
+ fs.mkdirSync(dir, { recursive: true });
6711
+ ctx = await chromium.launchPersistentContext(dir, {
6712
+ headless: false,
6713
+ channel: this.channel ?? 'msedge',
6714
+ viewport: { width: 1400, height: 900 },
6715
+ locale: 'zh-CN',
6716
+ timezoneId: 'Asia/Shanghai',
6717
+ args: [
6718
+ '--disable-blink-features=AutomationControlled',
6719
+ '--autoplay-policy=no-user-gesture-required',
6720
+ // 挪到屏幕外:看得见就打扰人了,但必须真渲染,所以不能 headless
6721
+ '--window-position=-2400,-2400',
6722
+ '--window-size=1420,940',
6723
+ '--disable-features=CalculateNativeWinOcclusion',
6724
+ '--disable-backgrounding-occluded-windows',
6725
+ '--disable-renderer-backgrounding',
6726
+ ],
6727
+ ignoreDefaultArgs: ['--enable-automation'],
6728
+ });
6729
+ const cookies = await this.shareCookies();
6730
+ if (cookies.length) await ctx.addCookies(cookies).catch(() => {});
6731
+ const page = await ctx.newPage();
6732
+ const result = await this.shareOnPage(page, who, id);
6733
+ this.log?.info?.('stroll share temp window', { ok: result.ok, step: result.step ?? '' });
6734
+ return result;
6735
+ } catch (error) {
6736
+ return { ok: false, step: 'temp-browser', reason: String(error?.message ?? error).slice(0, 80) };
6737
+ } finally {
6738
+ await ctx?.close().catch(() => {});
6739
+ }
6740
+ }
6741
+
6742
+ /** 分享窗口要用的 cookie:优先当前上下文的实时值,没有再用落盘备份。 */
6743
+ async shareCookies() {
6744
+ const pick = (list) =>
6745
+ (Array.isArray(list) ? list : [])
6746
+ .filter((c) => /(^|\.)(douyin|bytedance|amemv|snssdk)\.(com|cn)$/i.test(String(c?.domain ?? '')))
6747
+ .map((c) => ({
6748
+ name: c.name,
6749
+ value: c.value,
6750
+ domain: c.domain,
6751
+ path: c.path ?? '/',
6752
+ expires: c.expires && c.expires > 0 ? c.expires : undefined,
6753
+ httpOnly: !!c.httpOnly,
6754
+ secure: !!c.secure,
6755
+ sameSite: c.sameSite ?? 'Lax',
6756
+ }));
6757
+ const live = this.context ? await this.context.cookies().catch(() => []) : [];
6758
+ const fromLive = pick(live);
6759
+ if (fromLive.length) return fromLive;
6760
+ try {
6761
+ const saved = JSON.parse(fs.readFileSync(this.cookieFile, 'utf8'));
6762
+ return pick(saved?.cookies ?? []);
6763
+ } catch {
6764
+ return [];
6765
+ }
6766
+ }
6767
+
6768
+ /** 在一个页面上把整套分享流程走完。 */
6769
+ async shareOnPage(page, who, id) {
6770
+ page.setDefaultTimeout(8000);
6771
+ await page
6772
+ .goto(`https://www.douyin.com/?modal_id=${id}`, { waitUntil: 'domcontentloaded', timeout: 30000 })
6773
+ .catch(() => {});
6774
+ const box = await this.waitShareTrigger(page, 20000);
6775
+ if (!box) return { ok: false, step: 'no-trigger' };
6776
+ await page.bringToFront().catch(() => {});
6777
+ let opened = false;
6778
+ for (let i = 0; i < 2 && !opened; i += 1) {
6779
+ const cx = box.x + box.width / 2;
6780
+ const cy = box.y + box.height / 2;
6781
+ await page.mouse.move(cx, cy, { steps: 12 }).catch(() => {});
6782
+ await page.waitForTimeout(600);
6783
+ await page.mouse.click(cx, cy).catch(() => {});
6784
+ opened = await this.waitSharePanel(page, 5000);
6785
+ }
6786
+ if (!opened) return { ok: false, step: 'no-panel' };
6787
+ const clicked = await this.shareRowClick(page, who);
6788
+ if (!clicked.ok) return { ok: false, step: clicked.step };
6789
+ if (!(await this.waitShareDone(page, who, 5000))) return { ok: false, step: 'no-confirm' };
6790
+ this.log?.info?.('stroll share card ok', { who, id });
6791
+ return { ok: true, via: 'card', id };
6792
+ }
6793
+
6794
+ /**
6795
+ * 等竖排里那个分享按钮变得可点。顺便把「我知道了」引导关掉——它盖在页面上,
6796
+ * 不关的话点哪都没反应。
6797
+ */
6798
+ async waitShareTrigger(page, timeout = 20000) {
6799
+ const deadline = Date.now() + timeout;
6800
+ while (Date.now() < deadline) {
6801
+ const guide = page.getByText('我知道了', { exact: true }).first();
6802
+ if (await guide.count().catch(() => 0)) {
6803
+ await guide.click({ timeout: 1200 }).catch(() => {});
6804
+ await page.waitForTimeout(500);
6805
+ }
6806
+ const box = await page
6807
+ .evaluate(() => {
6808
+ const boxes = Array.from(document.querySelectorAll('[data-e2e="video-player-share"]'))
6809
+ .map((el) => {
6810
+ const r = el.getBoundingClientRect();
6811
+ return { x: r.left, y: r.top, width: r.width, height: r.height };
6812
+ })
6813
+ .filter((b) => b.width > 10 && b.height > 30 && b.x > 0 && b.y > 0 && b.y < window.innerHeight);
6814
+ // 信息流里可能有多条视频的按钮,取最靠上的那条(当前播放的)
6815
+ boxes.sort((a, b) => a.y - b.y);
6816
+ return boxes[0] ?? null;
6817
+ })
6818
+ .catch(() => null);
6819
+ if (box) return box;
6820
+ await page.waitForTimeout(400).catch(() => {});
6821
+ }
6822
+ return null;
6823
+ }
6824
+
6825
+ /** 分享面板有没有弹出来(「分享给朋友」是它的标题)。 */
6826
+ async waitSharePanel(page, timeout = 6000) {
6827
+ const deadline = Date.now() + timeout;
6828
+ while (Date.now() < deadline) {
6829
+ const ok = await page
6830
+ .evaluate(() => String(document.body?.innerText ?? '').includes('分享给朋友'))
6831
+ .catch(() => false);
6832
+ if (ok) return true;
6833
+ await page.waitForTimeout(300).catch(() => {});
6834
+ }
6835
+ return false;
6836
+ }
6837
+
6838
+ /**
6839
+ * 在「分享给朋友」列表里找到昵称正好是 who 的那一行,点它自己那个「分享」按钮。
6840
+ * 只认名字完全一致的行(去掉「在线/分享/捎句话」这些状态词),差一个字都不点——
6841
+ * 点错就是把视频发给别人。
6842
+ */
6843
+ async shareRowClick(page, who) {
6844
+ return await page
6845
+ .evaluate((name) => {
6846
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
6847
+ const bare = (t) =>
6848
+ t
6849
+ .split(' ')
6850
+ .filter((x) => x && !/^(在线|分享|捎句话|已分享)$/.test(x))
6851
+ .join(' ');
6852
+ const rows = [];
6853
+ document.querySelectorAll('div').forEach((el) => {
6854
+ const t = clean(el.innerText);
6855
+ if (!t || !/(分享|捎句话|已分享)$/.test(t)) return;
6856
+ const r = el.getBoundingClientRect();
6857
+ if (r.width < 240 || r.width > 440 || r.height < 36 || r.height > 100) return;
6858
+ if (bare(t) !== name) return;
6859
+ rows.push(el);
6860
+ });
6861
+ if (!rows.length) return { ok: false, step: 'no-row' };
6862
+ const row = rows[rows.length - 1];
6863
+ const btn = Array.from(row.querySelectorAll('div,span,button'))
6864
+ .filter((b) => clean(b.innerText) === '分享')
6865
+ .pop();
6866
+ if (!btn) return { ok: false, step: 'no-btn' };
6867
+ const r = btn.getBoundingClientRect();
6868
+ btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
6869
+ return { ok: true, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
6870
+ }, who)
6871
+ .catch((error) => ({ ok: false, step: 'eval', reason: String(error?.message ?? '').slice(0, 60) }));
6872
+ }
6873
+
6874
+ /** 发成功的信号:那一行变成「捎句话」,或者出现「分享成功」。 */
6875
+ async waitShareDone(page, who, timeout = 5000) {
6876
+ const deadline = Date.now() + timeout;
6877
+ while (Date.now() < deadline) {
6878
+ const done = await page
6879
+ .evaluate((name) => {
6880
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
6881
+ const t = clean(document.body?.innerText ?? '');
6882
+ if (/分享成功|已分享/.test(t)) return true;
6883
+ return Array.from(document.querySelectorAll('div')).some((el) => {
6884
+ const s = clean(el.innerText);
6885
+ if (!s || s.length > 40) return false;
6886
+ if (!/捎句话/.test(s)) return false;
6887
+ return s.startsWith(name);
6888
+ });
6889
+ }, who)
6890
+ .catch(() => false);
6891
+ if (done) return true;
6892
+ await page.waitForTimeout(400).catch(() => {});
6893
+ }
6894
+ return false;
6895
+ }
6896
+
6554
6897
  /** 发一张表情到某个会话(群名 / 昵称 / 留空 = 当前会话)。 */
6555
6898
  async sendStickerTo(who, { file = '', src = '', label = '' } = {}) {
6556
6899
  const t = await this.chatPageOf(who);
@@ -6871,6 +7214,8 @@ export class DouyinBrowser {
6871
7214
  if (this.closeTimer) clearTimeout(this.closeTimer);
6872
7215
  this.closeTimer = null;
6873
7216
  this.holdOpen = false;
7217
+ // 关窗之前先备份登录态:进程被强杀时 Chromium 不一定来得及把 cookie 落盘
7218
+ await this.saveCookies({ force: true }).catch(() => {});
6874
7219
  await this.closeWatch().catch(() => {});
6875
7220
  await this.page?.close().catch(() => {});
6876
7221
  this.page = null;
package/src/world.js CHANGED
@@ -1239,6 +1239,9 @@ export class StrollWorld {
1239
1239
  const health = await this.douyin.watchHealth?.().catch(() => null);
1240
1240
  if (health) await this.repairWatch(health).catch(() => {});
1241
1241
  }
1242
+ // 每 10 分钟把抖音登录态备份一次(内部自己节流):
1243
+ // 进程被强杀时 Chromium 不一定来得及把 cookie 落盘,丢了就得重新扫码
1244
+ if (useDy) this.douyin.saveCookies?.().catch(() => {});
1242
1245
  // 群聊:观察器掉了就补挂(30 秒最多试一次)。
1243
1246
  // 群名留空时靠自动识别;每 3 分钟滚动会话列表做一次深度读取,
1244
1247
  // 这样刚被拉进的新群不用等旧名单过期就能被发现。
@@ -4507,9 +4510,82 @@ export class StrollWorld {
4507
4510
  }
4508
4511
 
4509
4512
  /** 抖音私信:有会话就直接在聊天页回(比去主页点「私信」快十几秒),没有才回退到主页。 */
4513
+ /**
4514
+ * 正文里带抖音视频链接时,改发「画面 + 标题 + 链接」。
4515
+ * 抖音私信里的纯 URL 就是一段文字,不会自动变卡片;我们又点不开分享面板(实测拿不到
4516
+ * 播放器那块的 DOM,页面还挂着验证 iframe),所以用首帧截图 + 标题凑一个「像分享」的效果。
4517
+ * 失败就返回 null,上层照旧发纯文字。
4518
+ */
4519
+ async shareDouyinLink(name, text) {
4520
+ const raw = String(text ?? '');
4521
+ const m = raw.match(/https?:\/\/(?:www\.)?(?:douyin\.com\/(?:video|note)\/\d+|v\.douyin\.com\/[\w-]+)/i);
4522
+ if (!m) return null;
4523
+ const style = String(this.cfg.shareStyle ?? 'image');
4524
+ if (style === 'text') return null;
4525
+ if (style === 'card') {
4526
+ /**
4527
+ * 真卡片:用 `?modal_id=` 打开视频浮层 → 点竖排里的分享 → 在「分享给朋友」里点主人那一行。
4528
+ * 这条路依赖抖音的界面,任何一步不通就落回「画面 + 标题」,不丢消息。
4529
+ */
4530
+ const card = await this.douyin.shareCardTo(name, m[0]).catch(() => ({ ok: false, step: 'throw' }));
4531
+ if (card?.ok) {
4532
+ const said = String(text).replace(m[0], '').trim();
4533
+ if (said) await this.douyin.replyDm(name, said.slice(0, 300), { strict: true, requireTitle: true }).catch(() => {});
4534
+ this.log?.info?.('stroll share card sent', { url: m[0], via: card.via ?? '' });
4535
+ return { ok: true, sent: true, via: 'card' };
4536
+ }
4537
+ /**
4538
+ * 已经点过「分享」但没等到确认:卡片很可能已经发出去了。
4539
+ * 这时**绝不能**再补一条「画面 + 标题」——主人在 12:37 收到两份就是这么来的。
4540
+ * 只把想说的话补上。
4541
+ */
4542
+ if (card?.step === 'no-confirm') {
4543
+ const said = String(text).replace(m[0], '').trim();
4544
+ if (said) await this.douyin.replyDm(name, said.slice(0, 300), { strict: true, requireTitle: true }).catch(() => {});
4545
+ this.log?.info?.('stroll share card unconfirmed', { url: m[0] });
4546
+ return { ok: true, sent: true, via: 'card-unconfirmed' };
4547
+ }
4548
+ this.log?.info?.('stroll share card fallback', { url: m[0], step: card?.step ?? 'unknown' });
4549
+ }
4550
+ const info = await this.douyin.open(m[0], { frames: 1, keep: 120 }).catch(() => null);
4551
+ if (!info?.frames?.length) return null;
4552
+ try {
4553
+ const dir = path.join(this.dataDir, 'media');
4554
+ fs.mkdirSync(dir, { recursive: true });
4555
+ const file = path.join(dir, `share-${info.id}.jpg`);
4556
+ fs.writeFileSync(file, info.frames[0].bytes);
4557
+ /**
4558
+ * 只往「核实过的主人会话」发。
4559
+ * 以前走 sendStickerTo → chatPageOf,那里面有个全局 openThread 缓存,
4560
+ * 在群里回完一句之后它的值可能正好对得上,于是跳过点击、图直接打进群里
4561
+ * (2026-11-11 分享视频发到群聊就是这么来的)。没核实到就不发图,让上层退回纯文字。
4562
+ */
4563
+ const chat = await this.douyin.ownerChat(name).catch(() => null);
4564
+ if (!chat) {
4565
+ this.log?.info?.('stroll share card skip image', { reason: 'owner chat not verified' });
4566
+ return null;
4567
+ }
4568
+ const shot = await this.douyin
4569
+ .sendStickerInChat(chat.page, chat.input, { file, name: '视频画面' })
4570
+ .catch((error) => ({ ok: false, error: error?.message ?? '' }));
4571
+ if (!shot?.ok) return null;
4572
+ const title = cutChars(String(info.title ?? '').trim(), 40);
4573
+ const meta = [title ? `《${title}》` : '', info.author, info.dur].filter(Boolean).join(' · ');
4574
+ const body = `${raw.replace(m[0], '').trim()}\n${meta}\n${m[0]}`.trim().slice(0, 300);
4575
+ await this.douyin.replyDm(name, body, { strict: true, requireTitle: true }).catch(() => {});
4576
+ this.log?.info?.('stroll share card', { id: info.id, to: name, title });
4577
+ return { ok: true, sent: true, via: 'share', title, file };
4578
+ } catch {
4579
+ return null;
4580
+ }
4581
+ }
4582
+
4510
4583
  async douyinDmFast(secUid, text) {
4511
4584
  const name = this.douyinOwner().name || (await this.douyinOwnerName().catch(() => ''));
4512
4585
  if (name) {
4586
+ // 正文里有抖音视频链接:先试着发成「画面 + 标题 + 链接」
4587
+ const shared = await this.shareDouyinLink(name, text).catch(() => null);
4588
+ if (shared) return shared;
4513
4589
  /**
4514
4590
  * 主人私信也要求「先核实会话再发」:
4515
4591
  * strict + requireTitle —— 标题读不出来就当作没切过去,走下面按 secUid 的慢路径。