|
@@ -1,6 +1,6 @@
|
|
|
/*! tiktok-auto v1.0.0
|
|
/*! tiktok-auto v1.0.0
|
|
|
- * built 2026-10-29T08:52:19.860Z
|
|
|
|
|
- * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, main
|
|
|
|
|
|
|
+ * built 2026-10-29T10:22:57.399Z
|
|
|
|
|
+ * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, main
|
|
|
* 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
|
|
* 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
|
|
|
*/
|
|
*/
|
|
|
(function () {
|
|
(function () {
|
|
@@ -377,6 +377,15 @@ TikTokAuto.bridge = (function () {
|
|
|
/* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
|
|
/* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
|
|
|
* 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
|
|
* 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
|
|
|
var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
|
|
var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
|
|
|
|
|
+ /* 私信:关注与私信是两个不同的动作,要分开说 */
|
|
|
|
|
+ var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number';
|
|
|
|
|
+ var dmTail = function () {
|
|
|
|
|
+ var tail = '';
|
|
|
|
|
+ if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
|
|
|
|
|
+ if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
|
|
|
|
|
+ if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
|
|
|
|
|
+ return tail;
|
|
|
|
|
+ };
|
|
|
/* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
|
|
/* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
|
|
|
* 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
|
|
* 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
|
|
|
var total = summary && typeof summary.extractedTotal === 'number'
|
|
var total = summary && typeof summary.extractedTotal === 'number'
|
|
@@ -387,6 +396,10 @@ TikTokAuto.bridge = (function () {
|
|
|
? ',本轮新增 ' + summary.extracted + ' 个' : '';
|
|
? ',本轮新增 ' + summary.extracted + ' 个' : '';
|
|
|
|
|
|
|
|
if (status === 'stopped') {
|
|
if (status === 'stopped') {
|
|
|
|
|
+ if (isDmTask) {
|
|
|
|
|
+ return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' +
|
|
|
|
|
+ summary.sent + ' 条' + dmTail();
|
|
|
|
|
+ }
|
|
|
if (isNurture) {
|
|
if (isNurture) {
|
|
|
return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
|
|
return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
|
|
|
}
|
|
}
|
|
@@ -396,6 +409,10 @@ TikTokAuto.bridge = (function () {
|
|
|
: '已停止';
|
|
: '已停止';
|
|
|
}
|
|
}
|
|
|
if (status === 'finished') {
|
|
if (status === 'finished') {
|
|
|
|
|
+ if (isDmTask) {
|
|
|
|
|
+ return '完成:关注 ' + summary.followed + ' 个 / 私信 ' +
|
|
|
|
|
+ summary.sent + ' 条' + dmTail();
|
|
|
|
|
+ }
|
|
|
if (isNurture) {
|
|
if (isNurture) {
|
|
|
/* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
|
|
/* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
|
|
|
var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
|
|
var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
|
|
@@ -649,6 +666,41 @@ TikTokAuto.config = (function () {
|
|
|
commentRate: 0.10,
|
|
commentRate: 0.10,
|
|
|
followRate: 0.15
|
|
followRate: 0.15
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * UID 私信(task = 'dm')。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**,
|
|
|
|
|
+ * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来
|
|
|
|
|
+ * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次
|
|
|
|
|
+ * 再传下来),不是区间。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
|
|
|
|
|
+ */
|
|
|
|
|
+ dm: {
|
|
|
|
|
+ /** 's1' = 已提 UID 私信(读文本);'s2' = 自己粉丝私信(暂未实现) */
|
|
|
|
|
+ script: 's1',
|
|
|
|
|
+ /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释 */
|
|
|
|
|
+ uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
|
|
|
|
|
+ follow: { enabled: true, max: 0 },
|
|
|
|
|
+ send: { enabled: true, max: 0 },
|
|
|
|
|
+ /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */
|
|
|
|
|
+ rest: { minMs: 10000, maxMs: 40000 },
|
|
|
|
|
+ /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */
|
|
|
|
|
+ dedupeMode: 'follow_dm',
|
|
|
|
|
+ /** 首视频筛选与首视频动作(脚本一的两个设置) */
|
|
|
|
|
+ filterDate: '7d',
|
|
|
|
|
+ firstAction: 'both',
|
|
|
|
|
+ /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */
|
|
|
|
|
+ pool: [],
|
|
|
|
|
+ /** 评论文案池(首视频评论用,同样从文件读) */
|
|
|
|
|
+ commentPool: [],
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 物理护栏:时长到点就收尾,别指望"跑完自然停"。
|
|
|
|
|
+ * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息),
|
|
|
|
|
+ * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。
|
|
|
|
|
+ */
|
|
|
|
|
+ guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
|
|
|
|
|
+ },
|
|
|
timeouts: {
|
|
timeouts: {
|
|
|
appLaunchMs: 25000,
|
|
appLaunchMs: 25000,
|
|
|
pageReadyMs: 15000,
|
|
pageReadyMs: 15000,
|
|
@@ -825,6 +877,32 @@ TikTokAuto.config = (function () {
|
|
|
return config;
|
|
return config;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */
|
|
|
|
|
+ if (config.task === 'dm') {
|
|
|
|
|
+ var dm = config.dm || {};
|
|
|
|
|
+ var followMax = Number(dm.follow && dm.follow.max);
|
|
|
|
|
+ var sendMax = Number(dm.send && dm.send.max);
|
|
|
|
|
+ var followOn = dm.follow && dm.follow.enabled === true;
|
|
|
|
|
+ var sendOn = dm.send && dm.send.enabled === true;
|
|
|
|
|
+ if (!followOn && !sendOn) {
|
|
|
|
|
+ throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
|
|
|
|
|
+ { follow: followOn, send: sendOn });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (followOn && !(followMax >= 1)) {
|
|
|
|
|
+ throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sendOn && !(sendMax >= 1)) {
|
|
|
|
|
+ throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sendOn && (!dm.pool || dm.pool.length === 0)) {
|
|
|
|
|
+ throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!(Number(dm.rest && dm.rest.maxMs) > 0)) {
|
|
|
|
|
+ throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs);
|
|
|
|
|
+ }
|
|
|
|
|
+ return config;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var target = config.target;
|
|
var target = config.target;
|
|
|
if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
|
|
if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
|
|
|
throw new ConfigError('target.keyword 不能为空', target.keyword);
|
|
throw new ConfigError('target.keyword 不能为空', target.keyword);
|
|
@@ -9015,6 +9093,680 @@ TikTokAuto.idflow = (function () {
|
|
|
}());
|
|
}());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+/* ============================== src/dm.js ============================== */
|
|
|
|
|
+/**
|
|
|
|
|
+ * dm.js — 发私信:打开面板 → 输入 → **点发送** → 验证真的发出去了
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 实测锚点(2026-10-29 / TikTok 47.1.4 / 1080×1920 / @nasa 主页)
|
|
|
|
|
+ *
|
|
|
|
|
+ * | 元素 | 类 | 识别方式 | 备注 |
|
|
|
|
|
+ * |---|---|---|---|
|
|
|
|
|
+ * | Message 入口 | TextView | text/desc === `Message` | 用户主页上,Follow 右边 |
|
|
|
|
|
+ * | 面板输入框 | EditText | `top > 25%H` | 未聚焦时贴底(y=1816) |
|
|
|
|
|
+ * | 表情按钮 | Button | desc 含 `stickers, gifs` | 会挡在发送键左边 |
|
|
|
|
|
+ * | **发送键** | ImageView | 见 `sendButton()` | **无 id、无 desc,只能按位置** |
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么发送键要按位置找
|
|
|
|
|
+ *
|
|
|
|
|
+ * 输入前它根本不存在;输入文字后输入框会**缩短**(960 → 888),
|
|
|
|
|
+ * 空出来的位置才出现那个 72×72 的可点 ImageView(968~1040)。
|
|
|
|
|
+ * 它没有 id、没有 desc、也没有任何文案 —— 除了几何关系没有别的抓手。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 和评论的区别(这是私信能做、评论不能做的原因)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 评论的发送键是**自绘的**,无障碍树里根本没有对应节点;
|
|
|
|
|
+ * 私信这个是标准 ImageView、`clickable=true`,无障碍点得到。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 依赖:TikTokAuto.logger / layout / ui / wait / tiktok
|
|
|
|
|
+ */
|
|
|
|
|
+TikTokAuto.dm = (function () {
|
|
|
|
|
+ 'use strict';
|
|
|
|
|
+
|
|
|
|
|
+ /** 表情按钮的 desc 特征 —— 它紧贴在发送键左边,必须排除 */
|
|
|
|
|
+ var STICKER_HINT = /stickers|gifs|emoji|表情|贴纸/i;
|
|
|
|
|
+
|
|
|
|
|
+ function logger() { return TikTokAuto.logger; }
|
|
|
|
|
+
|
|
|
|
|
+ function screenH() { return TikTokAuto.layout.screen().height; }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 找可见的、text 或 desc 完全匹配的节点。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 必须**分别**测 text 和 desc,不能拼起来再测:
|
|
|
|
|
+ * 拼成 `txt + ' ' + desc` 之后,`desc="Message"` 会变成 " Message"(前导空格),
|
|
|
|
|
+ * `/^Message$/` 就匹配不上了 —— 明明节点在树里却报"找不到"。
|
|
|
|
|
+ */
|
|
|
|
|
+ function findExact(re, classes) {
|
|
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
|
|
+ var height = screenH();
|
|
|
|
|
+ for (var c = 0; c < classes.length; c += 1) {
|
|
|
|
|
+ var found = className(classes[c]).find();
|
|
|
|
|
+ for (var i = 0; i < found.size(); i += 1) {
|
|
|
|
|
+ var n = found.get(i);
|
|
|
|
|
+ var box = layout.visibleRect(n);
|
|
|
|
|
+ if (!box || box.bottom <= 0 || box.top >= height) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (re.test(layout.text(n)) || re.test(layout.desc(n))) {
|
|
|
|
|
+ return n;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 用户主页上的 Message 入口 */
|
|
|
|
|
+ function messageButton() {
|
|
|
|
|
+ return findExact(/^(Message|Send message|消息|私信)$/i,
|
|
|
|
|
+ ['android.widget.Button', 'android.widget.TextView']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 私信面板里的输入框。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 位置判据用「top > 25% 屏高」而不是"底部固定 y":聚焦后键盘弹起,
|
|
|
|
|
+ * 输入框会被顶到 0.65H(实测 1240),贴底判据就失效了。
|
|
|
|
|
+ */
|
|
|
|
|
+ function panelInput() {
|
|
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
|
|
+ var height = screenH();
|
|
|
|
|
+ var found = className('android.widget.EditText').find();
|
|
|
|
|
+ for (var i = 0; i < found.size(); i += 1) {
|
|
|
|
|
+ var n = found.get(i);
|
|
|
|
|
+ var box = layout.visibleRect(n);
|
|
|
|
|
+ if (!box) { continue; }
|
|
|
|
|
+ if (box.top > height * 0.25 && box.top < height * 0.95) {
|
|
|
|
|
+ return n;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 发送键。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 判据(都来自实测,缺一条就会点到表情按钮或点空):
|
|
|
|
|
+ * ① 与输入框**垂直重叠**(同一行)
|
|
|
|
|
+ * ② 左边界在输入框右边界**之外**(`left >= right + 10`)——
|
|
|
|
|
+ * 输入框输入后会缩短,表情按钮紧贴着它,所以必须留出这 10px 的余量
|
|
|
|
|
+ * ③ 尺寸在 40~120 之间(排除整行的容器)
|
|
|
|
|
+ * ④ 可点
|
|
|
|
|
+ * ⑤ desc 里不含 stickers/gifs/emoji(排除表情按钮)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 取**最靠左**的那个:紧挨输入框的是发送键,更右边没有东西。
|
|
|
|
|
+ */
|
|
|
|
|
+ function sendButton() {
|
|
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
|
|
+ var input = panelInput();
|
|
|
|
|
+ if (!input) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ var ib = input.bounds();
|
|
|
|
|
+
|
|
|
|
|
+ var best = null;
|
|
|
|
|
+ var bestLeft = null;
|
|
|
|
|
+ var classes = ['android.widget.ImageView', 'android.widget.Button'];
|
|
|
|
|
+ for (var c = 0; c < classes.length; c += 1) {
|
|
|
|
|
+ var found = className(classes[c]).find();
|
|
|
|
|
+ for (var i = 0; i < found.size(); i += 1) {
|
|
|
|
|
+ var n = found.get(i);
|
|
|
|
|
+ var box = layout.visibleRect(n);
|
|
|
|
|
+ if (!box) { continue; }
|
|
|
|
|
+
|
|
|
|
|
+ var overlap = Math.min(box.bottom, ib.bottom) - Math.max(box.top, ib.top);
|
|
|
|
|
+ if (overlap <= 0) { continue; } // ① 同一行
|
|
|
|
|
+ if (box.left < ib.right + 10) { continue; } // ② 在输入框右边
|
|
|
|
|
+ var w = box.right - box.left;
|
|
|
|
|
+ var h = box.bottom - box.top;
|
|
|
|
|
+ if (w < 40 || h < 40 || w > 120 || h > 120) { continue; } // ③ 尺寸
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (n.clickable() !== true) { continue; } // ④ 可点
|
|
|
|
|
+ } catch (ignored) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (STICKER_HINT.test(layout.desc(n))) { continue; } // ⑤ 不是表情
|
|
|
|
|
+
|
|
|
|
|
+ if (bestLeft === null || box.left < bestLeft) {
|
|
|
|
|
+ bestLeft = box.left;
|
|
|
|
|
+ best = n;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return best;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns {ok, via, reason}
|
|
|
|
|
+ */
|
|
|
|
|
+ function openPanel(config, step) {
|
|
|
|
|
+ var story = step || 'dm:open';
|
|
|
|
|
+ var log = logger();
|
|
|
|
|
+
|
|
|
|
|
+ if (panelInput() !== null) {
|
|
|
|
|
+ log.info(story, '私信面板已经开着');
|
|
|
|
|
+ return { ok: true, via: 'already-open' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var entry = messageButton();
|
|
|
|
|
+ if (!entry) {
|
|
|
|
|
+ /* 两种可能:对方关闭了私信(没有按钮),或页面还没渲染完。
|
|
|
|
|
+ * 分不清时如实说,不要假惺惺地重试到超时。 */
|
|
|
|
|
+ log.warn(story, '主页上没有 Message 按钮(对方可能关闭了私信,或页面还没渲染)');
|
|
|
|
|
+ return { ok: false, via: 'no-message-button', reason: '主页上没有 Message 按钮' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.info(story, '点 Message 入口');
|
|
|
|
|
+ TikTokAuto.ui.clickNode(entry, story);
|
|
|
|
|
+
|
|
|
|
|
+ var opened = TikTokAuto.wait.waitFor(story + ':panel', function () {
|
|
|
|
|
+ return panelInput() !== null ? true : null;
|
|
|
|
|
+ }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
|
|
|
|
|
+
|
|
|
|
|
+ if (opened !== true) {
|
|
|
|
|
+ /* 点一次没开就再点一次(首次点击常被页面动画吞掉) */
|
|
|
|
|
+ log.warn(story, '第一次点击没打开面板,重试一次');
|
|
|
|
|
+ TikTokAuto.wait.waitQuiet(function () {
|
|
|
|
|
+ return TikTokAuto.layout.signature();
|
|
|
|
|
+ }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
|
|
|
|
|
+ var again = messageButton();
|
|
|
|
|
+ if (again) {
|
|
|
|
|
+ TikTokAuto.ui.clickNode(again, story + ':retry');
|
|
|
|
|
+ }
|
|
|
|
|
+ opened = TikTokAuto.wait.waitFor(story + ':panel2', function () {
|
|
|
|
|
+ return panelInput() !== null ? true : null;
|
|
|
|
|
+ }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (opened !== true) {
|
|
|
|
|
+ return { ok: false, via: 'panel-not-opened', reason: '私信面板没打开' };
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: true, via: 'message-button' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 收起面板(只在确认开着时调用,不无脑 back) */
|
|
|
|
|
+ function closePanel(step) {
|
|
|
|
|
+ if (panelInput() === null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ TikTokAuto.logger.info(step || 'dm:close', '收起私信面板');
|
|
|
|
|
+ back();
|
|
|
|
|
+ TikTokAuto.wait.until(function () {
|
|
|
|
|
+ return panelInput() === null ? true : null;
|
|
|
|
|
+ }, 4000, 200);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 发送一条私信。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param config 生效配置
|
|
|
|
|
+ * @param text 要发的内容
|
|
|
|
|
+ * @returns {ok, via, text, reason}
|
|
|
|
|
+ */
|
|
|
|
|
+ function send(config, text, step) {
|
|
|
|
|
+ var story = step || 'dm:send';
|
|
|
|
|
+ var log = logger();
|
|
|
|
|
+ var content = String(text || '');
|
|
|
|
|
+ if (content === '') {
|
|
|
|
|
+ return { ok: false, via: 'empty-text', text: content, reason: '私信内容为空' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var opened = openPanel(config, story);
|
|
|
|
|
+ if (!opened.ok) {
|
|
|
|
|
+ return { ok: false, via: opened.via, text: content, reason: opened.reason };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var input = panelInput();
|
|
|
|
|
+ if (!input) {
|
|
|
|
|
+ return { ok: false, via: 'no-input', text: content, reason: '面板里找不到输入框' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* 先用 typeInto(它会点输入框聚焦、再写入、再回读校验)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ **必须先聚焦**:不聚焦就不会弹键盘,而发送键是"输入框缩短后"
|
|
|
|
|
+ * 才出现的那块区域 —— 两者是同一个布局变化。 */
|
|
|
|
|
+ var typed = TikTokAuto.ui.typeInto(input, content, story);
|
|
|
|
|
+ if (!typed.ok) {
|
|
|
|
|
+ return { ok: false, via: 'type-failed', text: content, reason: '输入框回读不一致' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (config.runtime && config.runtime.dryRun) {
|
|
|
|
|
+ /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
|
|
|
|
|
+ * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
|
|
|
|
|
+ * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
|
|
|
|
|
+ var preview = TikTokAuto.wait.waitFor(story + ':btn-preview', function () {
|
|
|
|
|
+ return sendButton() || null;
|
|
|
|
|
+ }, 4000, 250);
|
|
|
|
|
+ log.warn(story, 'dryRun:已输入但不发送。发送键' +
|
|
|
|
|
+ (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
|
|
|
|
|
+ ',内容=' + JSON.stringify(content));
|
|
|
|
|
+
|
|
|
|
|
+ var restore = panelInput();
|
|
|
|
|
+ if (restore) {
|
|
|
|
|
+ try { restore.setText(''); } catch (e) { }
|
|
|
|
|
+ }
|
|
|
|
|
+ TikTokAuto.wait.tick(300);
|
|
|
|
|
+ closePanel(story);
|
|
|
|
|
+ return { ok: true, via: 'dry-run', text: content, skipped: true,
|
|
|
|
|
+ sendButtonFound: preview !== null };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* 发送键是输入之后才出现的,写完之后等一下再找 */
|
|
|
|
|
+ var button = TikTokAuto.wait.waitFor(story + ':btn', function () {
|
|
|
|
|
+ return sendButton() || null;
|
|
|
|
|
+ }, 4000, 250);
|
|
|
|
|
+ if (!button) {
|
|
|
|
|
+ log.warn(story, '输入后仍找不到发送键');
|
|
|
|
|
+ closePanel(story);
|
|
|
|
|
+ return { ok: false, via: 'send-button-not-found', text: content,
|
|
|
|
|
+ reason: '输入后找不到发送键' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* 点发送键必须用**坐标点击**:它是无 id/desc 的 ImageView,
|
|
|
|
|
+ * 无障碍 ACTION_CLICK 大概率落到父容器上(评论那边就栽在这)。 */
|
|
|
|
|
+ var point = TikTokAuto.layout.center(button);
|
|
|
|
|
+ log.info(story, '点发送键 id=' + TikTokAuto.layout.shortId(button) +
|
|
|
|
|
+ ' b=' + JSON.stringify(button.bounds()));
|
|
|
|
|
+ if (point) {
|
|
|
|
|
+ TikTokAuto.ui.tapPoint(point.x, point.y);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ TikTokAuto.ui.clickNode(button, story);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return awaitSent(config, content, story);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 判定"到底发出去没有"。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 证据两档:
|
|
|
|
|
+ * 强:对话列表里出现了刚发的那条文本
|
|
|
|
|
+ * 中:输入框被清空并恢复占位符("Message...")+ 面板仍然开着
|
|
|
|
|
+ *
|
|
|
|
|
+ * 只有一档"面板关了"时**不算成功** —— 面板也可能被别的原因关掉,
|
|
|
|
|
+ * 这正是评论那边假阳性的来源。
|
|
|
|
|
+ */
|
|
|
|
|
+ function awaitSent(config, text, step) {
|
|
|
|
|
+ var log = logger();
|
|
|
|
|
+
|
|
|
|
|
+ /* 轮询等"输入框被清空"。
|
|
|
|
|
+ * `setText` 清空后 text 是空串;占位符 "Message..." 是 UI 画的、不算 text,
|
|
|
|
|
+ * 但某些版本会把占位符当 text 读回来,所以两个都认。 */
|
|
|
|
|
+ var sent = TikTokAuto.wait.waitFor(step + ':sent', function () {
|
|
|
|
|
+ var input = panelInput();
|
|
|
|
|
+ if (!input) {
|
|
|
|
|
+ return null; // 面板没了 ≠ 发出去了,不能算成功
|
|
|
|
|
+ }
|
|
|
|
|
+ var value = String(input.text() || '');
|
|
|
|
|
+ return (value === '' || value === 'Message...') ? true : null;
|
|
|
|
|
+ }, config.timeouts.postMs, config.probe.pollTickMs);
|
|
|
|
|
+
|
|
|
|
|
+ if (sent === true) {
|
|
|
|
|
+ /* 强证据:对话里能不能看到这条文本 */
|
|
|
|
|
+ if (conversationContains(text)) {
|
|
|
|
|
+ log.info(step, '私信已发送(对话里已出现该内容)' + JSON.stringify(text.slice(0, 20)));
|
|
|
|
|
+ return { ok: true, via: 'text-in-conversation', confidence: 'high', text: text };
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info(step, '私信已发送(输入框已清空)' + JSON.stringify(text.slice(0, 20)));
|
|
|
|
|
+ return { ok: true, via: 'input-cleared', confidence: 'medium', text: text };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.warn(step, '点了发送键但输入框没清空,疑似没发出去');
|
|
|
|
|
+ closePanel(step);
|
|
|
|
|
+ return { ok: false, via: 'not-cleared', text: text, reason: '点发送后输入框没清空' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 对话列表里有没有这条文本(可见的 TextView 里找) */
|
|
|
|
|
+ function conversationContains(text) {
|
|
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
|
|
+ var needle = String(text || '');
|
|
|
|
|
+ if (needle === '') { return false; }
|
|
|
|
|
+ var found = className('android.widget.TextView').find();
|
|
|
|
|
+ for (var i = 0; i < found.size(); i += 1) {
|
|
|
|
|
+ var value = layout.text(found.get(i));
|
|
|
|
|
+ if (value && value.indexOf(needle) >= 0) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ messageButton: messageButton,
|
|
|
|
|
+ panelInput: panelInput,
|
|
|
|
|
+ sendButton: sendButton,
|
|
|
|
|
+ openPanel: openPanel,
|
|
|
|
|
+ closePanel: closePanel,
|
|
|
|
|
+ send: send,
|
|
|
|
|
+ conversationContains: conversationContains
|
|
|
|
|
+ };
|
|
|
|
|
+}());
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+/* ============================== src/dmflow.js ============================== */
|
|
|
|
|
+/**
|
|
|
|
|
+ * dmflow.js — UID 私信的编排:读 UID → 关注一批 → 私信另一批 → 逐个休息
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 两批是**完全不同**的人
|
|
|
|
|
+ *
|
|
|
|
|
+ * 界面上「关注数量 100~200」与「私信数量 20~30」是两组独立额度,作用在不同的人身上:
|
|
|
|
|
+ * · 前 followMax 个 UID → 只关注
|
|
|
|
|
+ * · 接下来 sendMax 个 UID → 只发私信
|
|
|
|
|
+ * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 每处理完一个人就休息 restMin~Max 秒
|
|
|
|
|
+ *
|
|
|
|
|
+ * 这个值 v9 起默认 10~40 秒(原来是 100~500,一轮要跑 5~24 小时,实际跑不完)。
|
|
|
|
|
+ * 休息是**分段等**的:每秒醒一次,用户点停止能立刻响应,而不是干等半分钟。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 失败一律跳过
|
|
|
|
|
+ *
|
|
|
|
|
+ * 打不开主页、对方关闭私信、找不到发送键 —— 都记一条 warning 继续下一个,
|
|
|
|
|
+ * 不中断整轮。但**连续失败**会有上限:连着 5 个都不成,说明是环境问题
|
|
|
|
|
+ * (没登录、被限流、页面改版),继续跑只是白耗时间,停下来并说明原因。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 依赖:TikTokAuto.profile / interact / dm / bridge / wait / logger / parse
|
|
|
|
|
+ */
|
|
|
|
|
+TikTokAuto.dmflow = (function () {
|
|
|
|
|
+ 'use strict';
|
|
|
|
|
+
|
|
|
|
|
+ /** 连续这么多个都失败就认为环境出问题了,提前收尾 */
|
|
|
|
|
+ var CONSECUTIVE_FAIL_LIMIT = 5;
|
|
|
|
|
+
|
|
|
|
|
+ function logger() { return TikTokAuto.logger; }
|
|
|
|
|
+
|
|
|
|
|
+ function progress(phase, done, total) {
|
|
|
|
|
+ TikTokAuto.bridge.report({
|
|
|
|
|
+ status: 'running',
|
|
|
|
|
+ phase: phase,
|
|
|
|
|
+ done: done,
|
|
|
|
|
+ total: total
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 读 UID 文本:每行一个(`@` 可有可无),忽略空行与 `#` 开头的注释行。
|
|
|
|
|
+ * 去重按**规范化后的用户名**,避免 `@abc` 与 `abc` 被当成两个人。
|
|
|
|
|
+ */
|
|
|
|
|
+ function readUids(path) {
|
|
|
|
|
+ var parse = TikTokAuto.parse;
|
|
|
|
|
+ var out = [];
|
|
|
|
|
+ var seen = {};
|
|
|
|
|
+ if (!path) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ var text = '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!files.exists(path)) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ text = String(files.read(path) || '');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var rows = text.split(/\r?\n/);
|
|
|
|
|
+ for (var i = 0; i < rows.length; i += 1) {
|
|
|
|
|
+ var line = String(rows[i] || '').replace(/^\s+|\s+$/g, '');
|
|
|
|
|
+ if (line === '' || line.charAt(0) === '#') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ var uid = parse.username(line);
|
|
|
|
|
+ if (uid === '' || seen[uid]) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ seen[uid] = true;
|
|
|
|
|
+ out.push(uid);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
|
|
|
|
|
+ function rest(config, step) {
|
|
|
|
|
+ var lo = config.dm.rest.minMs;
|
|
|
|
|
+ var hi = config.dm.rest.maxMs;
|
|
|
|
|
+ var total = TikTokAuto.player.randomInt(lo, hi);
|
|
|
|
|
+ var left = total;
|
|
|
|
|
+ var sliceMs = 1000;
|
|
|
|
|
+ while (left > 0) {
|
|
|
|
|
+ var slice = Math.min(sliceMs, left);
|
|
|
|
|
+ TikTokAuto.wait.tick(slice);
|
|
|
|
|
+ left -= slice;
|
|
|
|
|
+ }
|
|
|
|
|
+ return total;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从文案池随机挑一条,尽量不重复(池子用完就允许重复并告警) */
|
|
|
|
|
+ function pickText(pool, used, step) {
|
|
|
|
|
+ if (!pool || pool.length === 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ var candidates = [];
|
|
|
|
|
+ for (var i = 0; i < pool.length; i += 1) {
|
|
|
|
|
+ if (used.indexOf(pool[i]) === -1) {
|
|
|
|
|
+ candidates.push(pool[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (candidates.length === 0) {
|
|
|
|
|
+ logger().warn(step, '私信文案已用完,本轮允许重复');
|
|
|
|
|
+ candidates = pool;
|
|
|
|
|
+ }
|
|
|
|
|
+ return candidates[Math.floor(Math.random() * candidates.length)];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理一个人:打开主页 → 关注 或 发私信。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns {uid, follow?, dm?, skipped?, reason?}
|
|
|
|
|
+ */
|
|
|
|
|
+ function handleOne(config, uid, mode, text, step) {
|
|
|
|
|
+ var log = logger();
|
|
|
|
|
+ var profileApi = TikTokAuto.profile;
|
|
|
|
|
+ var detail = { uid: uid, mode: mode };
|
|
|
|
|
+
|
|
|
|
|
+ var opened = profileApi.openBySearch(uid, config);
|
|
|
|
|
+ if (!opened) {
|
|
|
|
|
+ detail.skipped = true;
|
|
|
|
|
+ detail.reason = '打不开主页';
|
|
|
|
|
+ return detail;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (mode === 'follow') {
|
|
|
|
|
+ detail.follow = TikTokAuto.interact.follow(step + ':follow');
|
|
|
|
|
+ return detail;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* 私信:先确认面板能开,再发。发完把面板收掉,否则下一个人的主页会被它挡住。 */
|
|
|
|
|
+ detail.dm = TikTokAuto.dm.send(config, text, step + ':dm');
|
|
|
|
|
+ TikTokAuto.dm.closePanel(step + ':close');
|
|
|
|
|
+ return detail;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @param config 生效配置(含 `config.dm`)
|
|
|
|
|
+ * @returns summary
|
|
|
|
|
+ */
|
|
|
|
|
+ function runSession(config) {
|
|
|
|
|
+ var log = logger();
|
|
|
|
|
+ var settings = config.dm;
|
|
|
|
|
+ var parse = TikTokAuto.parse;
|
|
|
|
|
+
|
|
|
|
|
+ var summary = {
|
|
|
|
|
+ ok: false,
|
|
|
|
|
+ task: 'dm',
|
|
|
|
|
+ script: settings.script,
|
|
|
|
|
+ /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
|
|
|
|
|
+ dryRun: !!(config.runtime && config.runtime.dryRun),
|
|
|
|
|
+ dryRunCount: 0,
|
|
|
|
|
+ /** UID 文件里读到多少个(去重后) */
|
|
|
|
|
+ totalUids: 0,
|
|
|
|
|
+ followPlanned: 0,
|
|
|
|
|
+ dmPlanned: 0,
|
|
|
|
|
+ followed: 0,
|
|
|
|
|
+ sent: 0,
|
|
|
|
|
+ skipped: 0,
|
|
|
|
|
+ failed: 0,
|
|
|
|
|
+ details: [],
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ errors: []
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /* ① 读 UID —— 这一步不碰手机,先做完再启动 App */
|
|
|
|
|
+ var uids = readUids(settings.uidPath);
|
|
|
|
|
+ summary.totalUids = uids.length;
|
|
|
|
|
+ if (uids.length === 0) {
|
|
|
|
|
+ summary.errors.push('UID 文本里没有可用的账号(' + (settings.uidPath || '(未设置路径)') + ')');
|
|
|
|
|
+ return summary;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
|
|
|
|
|
+ var followMax = settings.follow.enabled ? settings.follow.max : 0;
|
|
|
|
|
+ var sendMax = settings.send.enabled ? settings.send.max : 0;
|
|
|
|
|
+ if (followMax === 0 && sendMax === 0) {
|
|
|
|
|
+ summary.errors.push('「进行关注」和「进行私信」都没开,没有可执行的动作');
|
|
|
|
|
+ return summary;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var followBatch = uids.slice(0, followMax);
|
|
|
|
|
+ var dmBatch = uids.slice(followMax, followMax + sendMax);
|
|
|
|
|
+ summary.followPlanned = followBatch.length;
|
|
|
|
|
+ summary.dmPlanned = dmBatch.length;
|
|
|
|
|
+
|
|
|
|
|
+ if (followBatch.length < followMax || dmBatch.length < sendMax) {
|
|
|
|
|
+ summary.warnings.push('UID 只够 ' + uids.length + ' 个,少于计划(关注 ' +
|
|
|
|
|
+ followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var total = followBatch.length + dmBatch.length;
|
|
|
|
|
+ log.step('dmflow', '私信任务开始:UID ' + uids.length + ' 个;关注 ' +
|
|
|
|
|
+ followBatch.length + ' 个 + 私信 ' + dmBatch.length + ' 个;' +
|
|
|
|
|
+ '每个之后休息 ' + Math.round(settings.rest.minMs / 1000) + '~' +
|
|
|
|
|
+ Math.round(settings.rest.maxMs / 1000) + ' 秒');
|
|
|
|
|
+
|
|
|
|
|
+ progress('启动应用', 0, total);
|
|
|
|
|
+ if (!TikTokAuto.flow.launchApp(config)) {
|
|
|
|
|
+ summary.errors.push('启动应用失败');
|
|
|
|
|
+ return summary;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!TikTokAuto.flow.ensureFeed(config)) {
|
|
|
|
|
+ summary.errors.push('未能进入信息流首页');
|
|
|
|
|
+ return summary;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var usedTexts = [];
|
|
|
|
|
+ var done = 0;
|
|
|
|
|
+ var consecutiveFail = 0;
|
|
|
|
|
+ var i = 0;
|
|
|
|
|
+
|
|
|
|
|
+ /* ③ 关注批次 */
|
|
|
|
|
+ for (i = 0; i < followBatch.length; i += 1) {
|
|
|
|
|
+ var fUid = followBatch[i];
|
|
|
|
|
+ progress('关注 ' + (i + 1) + '/' + followBatch.length + ' @' + fUid, done, total);
|
|
|
|
|
+
|
|
|
|
|
+ var fDetail = handleOne(config, fUid, 'follow', '', 'dmflow:follow');
|
|
|
|
|
+ fDetail.phase = 'follow';
|
|
|
|
|
+ summary.details.push(fDetail);
|
|
|
|
|
+
|
|
|
|
|
+ if (fDetail.skipped) {
|
|
|
|
|
+ summary.skipped += 1;
|
|
|
|
|
+ summary.warnings.push('关注 @' + fUid + ' 跳过:' + fDetail.reason);
|
|
|
|
|
+ consecutiveFail += 1;
|
|
|
|
|
+ } else if (fDetail.follow && fDetail.follow.ok) {
|
|
|
|
|
+ if (!fDetail.follow.skipped) {
|
|
|
|
|
+ summary.followed += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ consecutiveFail = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ summary.failed += 1;
|
|
|
|
|
+ summary.warnings.push('关注 @' + fUid + ' 失败:' +
|
|
|
|
|
+ ((fDetail.follow && fDetail.follow.reason) || '未知'));
|
|
|
|
|
+ consecutiveFail += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ done += 1;
|
|
|
|
|
+ if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
|
|
|
|
|
+ summary.warnings.push('连续 ' + consecutiveFail +
|
|
|
|
|
+ ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 批次之间**不**休息 —— 关注是轻动作,休息留给私信更合理。
|
|
|
|
|
+ * 但用户明确要求"每处理完一个 UID 都休息",所以这里也休。 */
|
|
|
|
|
+ rest(config, 'dmflow:rest');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ④ 私信批次(换另一批人) */
|
|
|
|
|
+ var dmStart = 0;
|
|
|
|
|
+ if (consecutiveFail < CONSECUTIVE_FAIL_LIMIT) {
|
|
|
|
|
+ for (dmStart = 0; dmStart < dmBatch.length; dmStart += 1) {
|
|
|
|
|
+ var dUid = dmBatch[dmStart];
|
|
|
|
|
+ var text = pickText(settings.pool, usedTexts, 'dmflow:pick');
|
|
|
|
|
+ if (text === '') {
|
|
|
|
|
+ summary.warnings.push('私信文案池是空的,私信批次没法执行(检查「随机私信文本」那个文件)');
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ progress('私信 ' + (dmStart + 1) + '/' + dmBatch.length + ' @' + dUid, done, total);
|
|
|
|
|
+
|
|
|
|
|
+ var dDetail = handleOne(config, dUid, 'dm', text, 'dmflow:dm');
|
|
|
|
|
+ dDetail.phase = 'dm';
|
|
|
|
|
+ dDetail.text = text;
|
|
|
|
|
+ summary.details.push(dDetail);
|
|
|
|
|
+
|
|
|
|
|
+ if (dDetail.skipped) {
|
|
|
|
|
+ summary.skipped += 1;
|
|
|
|
|
+ summary.warnings.push('私信 @' + dUid + ' 跳过:' + dDetail.reason);
|
|
|
|
|
+ consecutiveFail += 1;
|
|
|
|
|
+ } else if (dDetail.dm && dDetail.dm.ok) {
|
|
|
|
|
+ if (dDetail.dm.skipped) {
|
|
|
|
|
+ /* dryRun:演练成功(输入到位、发送键也定位到了),但没真发 */
|
|
|
|
|
+ summary.dryRunCount += 1;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ summary.sent += 1;
|
|
|
|
|
+ usedTexts.push(text);
|
|
|
|
|
+ }
|
|
|
|
|
+ consecutiveFail = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ summary.failed += 1;
|
|
|
|
|
+ summary.warnings.push('私信 @' + dUid + ' 失败:' +
|
|
|
|
|
+ ((dDetail.dm && dDetail.dm.reason) || '未知'));
|
|
|
|
|
+ consecutiveFail += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ done += 1;
|
|
|
|
|
+ if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
|
|
|
|
|
+ summary.warnings.push('连续 ' + consecutiveFail +
|
|
|
|
|
+ ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ rest(config, 'dmflow:rest');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ summary.ok = summary.followed > 0 || summary.sent > 0 || summary.dryRunCount > 0;
|
|
|
|
|
+ if (!summary.ok && summary.errors.length === 0) {
|
|
|
|
|
+ summary.errors.push('一个动作都没做成(关注 ' + summary.followed +
|
|
|
|
|
+ ' / 私信 ' + summary.sent + ')');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.step('dmflow', '私信任务结束:关注 ' + summary.followed + ' 个,私信 ' +
|
|
|
|
|
+ summary.sent + ' 条' +
|
|
|
|
|
+ (summary.dryRun ? '(演练 ' + summary.dryRunCount + ' 条,未真发)' : '') +
|
|
|
|
|
+ ',跳过 ' + summary.skipped + ' 个,失败 ' + summary.failed + ' 个');
|
|
|
|
|
+
|
|
|
|
|
+ return summary;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ runSession: runSession,
|
|
|
|
|
+ readUids: readUids,
|
|
|
|
|
+ pickText: pickText,
|
|
|
|
|
+ CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
|
|
|
|
|
+ };
|
|
|
|
|
+}());
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
/* ============================== src/main.js ============================== */
|
|
/* ============================== src/main.js ============================== */
|
|
|
/**
|
|
/**
|
|
|
* main.js — 入口与收尾
|
|
* main.js — 入口与收尾
|
|
@@ -9072,6 +9824,9 @@ TikTokAuto.main = (function () {
|
|
|
if (config.task === 'nurture') {
|
|
if (config.task === 'nurture') {
|
|
|
return TikTokAuto.nurture.runSession(config);
|
|
return TikTokAuto.nurture.runSession(config);
|
|
|
}
|
|
}
|
|
|
|
|
+ if (config.task === 'dm') {
|
|
|
|
|
+ return TikTokAuto.dmflow.runSession(config);
|
|
|
|
|
+ }
|
|
|
return TikTokAuto.flow.runSession(config);
|
|
return TikTokAuto.flow.runSession(config);
|
|
|
}
|
|
}
|
|
|
|
|
|