cortico-world-mosey 2.4.1 → 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 +20 -2
- package/package.json +1 -1
- package/src/ENV_PROMPT.md +1 -0
- package/src/config.js +14 -0
- package/src/definition.js +7 -0
- package/src/douyin.js +261 -1
- package/src/world.js +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 2.4.1
|
|
4
24
|
|
|
5
25
|
**修抖音登录:不再隔几小时掉一次,扫完码也会立刻收窗。**
|
|
@@ -38,8 +58,6 @@
|
|
|
38
58
|
|
|
39
59
|
## 2.3.0
|
|
40
60
|
|
|
41
|
-
> 这一版被 npm 暂存(staged),没能上架;内容已随 2.4.0 一起发布。
|
|
42
|
-
|
|
43
61
|
**B站也能抽帧了——用播放器进度条的预览图,不用登录、不开播放页、不抢你的浏览器。**
|
|
44
62
|
|
|
45
63
|
以前设置里的「看视频抽几帧」只对抖音有效:抖音没有字幕,只能靠截图看画面;B站是纯接口读取(字幕/弹幕/评论),从头到尾没打开过播放页,所以那个数字对 B站一直是空的。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-mosey",
|
|
3
|
-
"version": "2.
|
|
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
|
@@ -6618,7 +6618,13 @@ export class DouyinBrowser {
|
|
|
6618
6618
|
if (!(await this.isLoggedIn())) throw new DouyinError('LOGIN_REQUIRED', '抖音还没登录');
|
|
6619
6619
|
const opened = await (async () => {
|
|
6620
6620
|
if (!/\/chat/.test(page.url())) await this.gotoChat(page);
|
|
6621
|
-
if (name
|
|
6621
|
+
if (name) {
|
|
6622
|
+
/**
|
|
6623
|
+
* 以前这里写的是 `if (name && this.openThread !== name)`:openThread 是个全局缓存,
|
|
6624
|
+
* 在群里回完一句它就被写成群名/人名,下一次给主人发图时如果恰好「等于」就跳过点击,
|
|
6625
|
+
* 于是图片直接打进当时开着的那个会话(2026-11-11 分享视频发到群聊就是这么来的)。
|
|
6626
|
+
* 点击只要 200ms,别为省这点时间赌对。
|
|
6627
|
+
*/
|
|
6622
6628
|
let ok = await this.clickThread(page, name);
|
|
6623
6629
|
// 陌生人会话不在主列表里
|
|
6624
6630
|
if (!ok) ok = await this.clickStrangerThread(page, name).catch(() => false);
|
|
@@ -6634,6 +6640,260 @@ export class DouyinBrowser {
|
|
|
6634
6640
|
});
|
|
6635
6641
|
}
|
|
6636
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
|
+
|
|
6637
6897
|
/** 发一张表情到某个会话(群名 / 昵称 / 留空 = 当前会话)。 */
|
|
6638
6898
|
async sendStickerTo(who, { file = '', src = '', label = '' } = {}) {
|
|
6639
6899
|
const t = await this.chatPageOf(who);
|
package/src/world.js
CHANGED
|
@@ -4510,9 +4510,82 @@ export class StrollWorld {
|
|
|
4510
4510
|
}
|
|
4511
4511
|
|
|
4512
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
|
+
|
|
4513
4583
|
async douyinDmFast(secUid, text) {
|
|
4514
4584
|
const name = this.douyinOwner().name || (await this.douyinOwnerName().catch(() => ''));
|
|
4515
4585
|
if (name) {
|
|
4586
|
+
// 正文里有抖音视频链接:先试着发成「画面 + 标题 + 链接」
|
|
4587
|
+
const shared = await this.shareDouyinLink(name, text).catch(() => null);
|
|
4588
|
+
if (shared) return shared;
|
|
4516
4589
|
/**
|
|
4517
4590
|
* 主人私信也要求「先核实会话再发」:
|
|
4518
4591
|
* strict + requireTitle —— 标题读不出来就当作没切过去,走下面按 secUid 的慢路径。
|