# tiktok-auto — TikTok 真机自动互动(AutoX + MCP) 自动完成这条链路: > 打开 TikTok → 搜索关键词 → 从结果里挑一个视频 → **随机观看 10–35 秒** → 发一条评论 面向"要长期跑、网络会抖、界面会弹东西"的场景写的,所以重点不在"能跑一次", 而在**网络慢的时候也判得准、失败时说得清、改文案不用改代码**。 --- ## 1. 快速开始 前置:手机装 AutoX 并开启无障碍;VS Code 里 AutoX 扩展已激活(它会起 MCP 桥 `127.0.0.1:18789`)。 ```bash # 0) 把配置推到手机(改文案/关键词只改 JSON,不用改代码) node tiktok-auto/tools/run.js --push-config # 1) 设备端纯函数自检(不碰界面,约 1 秒) node tiktok-auto/tools/run.js --self-test # 2) 走完整流程但不真的发评论(安全验证) node tiktok-auto/tools/run.js --dry-run --keyword fashion # 3) 正式跑(会真的发评论) node tiktok-auto/tools/run.js --keyword fashion --videos 1 ``` 常用参数: | 参数 | 作用 | |---|---| | `--keyword <词>` | 覆盖搜索关键词 | | `--videos ` | 本次处理几个视频 | | `--dry-run` | 输入评论但不发送 | | `--config ` | 用 JSON 文件覆盖配置(见 `config/smoke-2videos-dryrun.json`) | | `--push-config` | 把 `config/tiktok-auto.config.json` 推到手机 | | `--self-test` | 只跑设备端纯函数自检 | | `--eval ` | **调试用**:加载全部模块但入口换成该文件的代码 | | `--raw` | 打印设备端原始日志(不汇总) | 退出码:`0` 成功,`1` 流程未达成,`2` MCP 桥不可达,`3` 脚本命中危险调用黑名单需人工确认。 > ⚠️ MCP 单次下发有 **120s 上限**。`--videos` 大时请分批跑,别指望一次下发里连看十条视频。 --- ## 2. 目录结构 ``` tiktok-auto/ ├── config/ │ ├── tiktok-auto.config.json # 业务配置(关键词/评论池/时长/超时/开关) │ └── smoke-2videos-dryrun.json # 冒烟用覆盖项 ├── src/ # 源码:一个文件只做一件事 │ ├── logger.js # 结构化日志 │ ├── bridge.js # ★ 与界面宿主的桥(状态上报 / 停止请求),未启用时零开销 │ ├── config.js # 默认值 / 加载 / 深合并 / 校验 │ ├── wait.js # ★ 唯一的等待原语(waitFor / poll / waitQuiet) │ ├── layout.js # ★ AutoX 无障碍 API 适配层(所有平台怪癖都关在这里) │ ├── ui.js # 点击四级降级 / 输入+回读校验 │ ├── popups.js # 干扰弹窗:检测与执行分离 │ ├── tiktok.js # ★ TikTok 选择器与页面状态机 │ ├── player.js # ★ 播放存活探测 + 有界观看 │ ├── comment.js # ★ 评论发布与**分级证据校验** │ ├── flow.js # 业务流程编排 │ ├── main.js # 入口与收尾(保证 __END__ / 关线程 / 输出 RESULT / 上报终态) │ └── __selftest.js # 设备端纯函数自检 ├── tools/ │ ├── build.js # 构建:src/ 多模块 → dist/ 单文件(含两道自检) │ ├── mcp.js # MCP 桥最小客户端 │ ├── run.js # PC 侧执行器 │ └── diag-*.js # 排障探针(见第 7 节) └── dist/tiktok-auto.js # 构建产物:可直接在 AutoX 里跑的单文件 ``` **为什么要有 `tools/build.js`**:人维护代码时希望"一个文件只做一件事"(`src/` 拆成 12 个模块), 但 AutoX 通过 MCP 下发的是**一整段脚本**,没法带一堆 `require`。 中间的 concat 一步同时满足两边:开发按模块读,部署是一个自包含文件。 --- ## 2.1 两种运行方式 同一个产物既能**独立跑**,也能被 **`tiktok-app` 界面唤起**: | | 独立运行 | 被界面唤起 | |---|---|---| | 触发 | MCP 下发 / 打开产物 / `tools/run.js` | 界面点「开始运行」→ `engines.execScriptFile(产物, 参数)` | | 配置从哪来 | `config.resolve(injected)` 读 `/sdcard/Download/tiktok-auto/config.json` | 界面写好的 `engine-config.json`,路径由 `--config` 传入 | | 界面能看到进度吗 | 不能(也没界面) | 能:`bridge.report()` 写 `run-state.json`,界面每秒读 | | 能叫停吗 | 靠 PC 侧断连 / 手动停 | 能:界面写 `stop.flag`(内容 = runId),`wait.tick()` 轮询到就抛 `StopSignal` | | 收尾 | 打印 `__END__` + `RESULT` | 额外写一次终态(`finished`/`stopped`/`failed`) | 桥**未启用时全部是空操作**(`bridge.isEnabled()` 为假),所以独立运行的行为与以前完全一致。 三个设计要点,都写在 `src/bridge.js` 的注释里: 1. **停止用异常抛出**(`StopSignal`),并且在**唯一的等待出口** `wait.tick()` 里检查。 停止可能发生在任意深度的等待里,逐层返回码要改几十处调用点、漏一处就变成"点了停止但不停"。 放在 `tick()` 里则一次实现、全流程生效。 2. **`stop.flag` 用内容比对(runId)而不是"文件在不在"**:不必删除它 (删除有竞态,`files.remove` 又命中 MCP 危险调用黑名单), 上一轮的残留标志也不会误杀本轮。 3. **注释 `comments.enabled`**:关掉就是"只浏览不发评论"。界面没勾「评论」时走这条, 用户的意图优先于引擎的默认行为。 **能力边界(如实标注)**:评论动作已接入;**关注 / 私信 / 点赞尚未实现** —— 界面勾了会在启动时提示一次。收到 `StopSignal` 时守护线程也会一并收工 (否则它每一轮巡检都会记一条 WARN 把日志刷满)。 --- ## 3. 配置 改 `config/tiktok-auto.config.json` → `--push-config`,**不需要重新构建、不需要改代码**。 | 段 | 关键字段 | 说明 | |---|---|---| | `target` | `keyword` | 搜索关键词 | | | `maxVideos` | 本次处理几个视频 | | | `resultTab` | 用哪个结果页签(`Top` / `Videos` / …) | | | `pickStrategy` | `random`(默认)或 `first` | | `watch` | `minMs` / `maxMs` | 观看时长区间,默认 10000–35000 | | `comments` | `pool` | 评论文案池,随机挑,同轮不重复 | | | `strictPostCheck` | `true` 时只认强证据,否则弱证据也算成功 | | | `verifyInList` | 是否打开评论列表做地面真相校验(默认开) | | `timeouts` | 各阶段上限 | 全部是**上限**,不是固定等待 | | `probe` | `useAudio` / `usePixels` | 播放存活证据源开关 | | | `livenessIntervalMs` / `stallTolerance` | 卡顿判定节拍与容忍度 | | `guard` | `enabled` / `intervalMs` / `dismissLabels` | 干扰弹窗守护线程 | | `runtime` | `dryRun` | 只输入不发送 | | | `configPath` | 设备上配置文件的路径 | | `selectors` | `sendButtonId` | 发送按钮的 id 逃生舱(见下) | **逃生舱**:评论发送按钮在 TikTok 里没有可用文案(其 `content-desc` 是个无关账号串), 所以默认靠**结构**定位(工具行同高、最右、且不属于右侧竖排动作栏)。 若某次 TikTok 改版导致结构定位失效,把它的资源 id 填进 `selectors.sendButtonId` 即可,不用改代码。 (实测两条路径都指向 `Rect(952,1264 - 1056,1328)`,即同一个控件。) --- ## 4. 需求 → 设计对照 ### 需求 1:网络延迟下也要稳定,不能只靠 sleep **做法:脚本里不存在"拿 sleep 当等待"的代码。** 区分两件事: - **轮询节拍**(`probe.pollTickMs`,默认 150ms)= 多久检查一次,允许存在; - **盲等**(`sleep(N)` 赌它 N 秒后会好)= 禁止。 所有"等"都走 `wait.js` 的 `waitFor(条件, 上限)`:快的时候立刻返回,慢的时候一直等到条件成立或到上限。 于是"网络有多慢"和"脚本能不能跑通"被彻底解耦。 `waitQuiet()` 更进一步:判据不是时间,而是**可见文案签名连续 N 次不变**, 天然适配"加载中 → 不断有新节点 → 稳定"这个过程。 `postMs` / `pageReadyMs` 这类参数**都是上限**,不是固定等待。 ### 需求 2:稳定性 | 机制 | 解决什么 | |---|---| | **播放存活探测** | 观看期间按节拍采样证据(`AudioManager.isMusicActive()`,可选像素差),连续 `stallTolerance` 次无证据即判定卡顿,轻点画面尝试恢复一次 | | **页面身份守卫** | 观看全程校验"还在播放页",连续漂移即提前结束并如实上报 `aborted` | | **多证据状态判定** | 播放页要 `返回箭头 + 无底部导航 + 点赞 + 评论` 四条同时成立(只看点赞+评论会把首页也判成播放页——实测踩过) | | **发布分级校验** | 见下方"证据分级",不靠单一信号 | | **弹窗守卫线程** | 独立线程清已知弹窗;主流程只用只读 `popupPresent()` 去**等**它清完,不抢它的活;**绝不 back() 正常页面** | | **未知弹窗只记录不猜** | 命中名单才动手;看起来像弹窗但不在名单里 → 只记 WARN,由人决定要不要加 | | **页面状态机** | `feed / search / results / player / comment-panel / unknown`,`ensureFeed` 先看再动、`unknown` 连续多轮才按返回,绝不无脑 back | | **坐标取"可见交集中心"** | 被状态栏切掉一半的节点,几何中心会点到状态栏 | | **去掉不可见格式字符** | 评论列表头部的实际文本是 `"‎150 comments"`(U+200E),带锚点正则永远匹配不上——这是适配层统一吸收的平台噪音 | | **构建期两道自检** | 语法检查 + **MCP 危险调用黑名单预检**(命中会让每次下发都要人工确认,必须在构建期挡住) | | **收尾必达** | 无论成败都打印 `__END__`、关线程、输出一行机器可读 `RESULT` | ### 需求 3:代码规范、可维护 按《代码整洁之道》与《编写可维护的JavaScript》落地: - **单一职责 + 分层**:`ui.js` 只管"怎么点",`tiktok.js` 只管"TikTok 长什么样",`flow.js` 只管"先做什么"。 换 App 或 TikTok 改版,只动 `tiktok.js`;`flow.js` 一行不用改。 - **边界适配层**:`auto.root` 是属性不是函数、`UiObject.text()` 是快照、离屏节点占四成…… 所有平台怪癖关在 `layout.js` 一个文件里。 - **配置对象(Zakas ch09)**:会变的业务数据全部外置成 JSON。 - **自定义错误(ch10)**:配置非法抛 `ConfigError`,带字段与非法值,而不是在几十行之后奇怪地炸掉。 - **单全局/零全局(ch06)**:整个产物是一个 IIFE,不污染任何全局。 - **注释只写"为什么"**:每一个反直觉的写法旁边都写了它是被哪次真机故障逼出来的。 - **自动化测试**:`__selftest.js` 覆盖配置合并、`poll` 的假值/异常/超时语义、评论挑选、 选片策略、随机区间等纯逻辑,一条命令在真机上跑完。 #### 评论发布的证据分级 | 级别 | 判据 | 说明 | |---|---|---| | 高 | 评论列表里出现该文案 | 地面真相 | | 中 | 评论数自增 | **必须轮询着等**:角标有 1–2s 延迟,看一眼就下结论会把成功误判成失败 | | 低 | 面板收起 + 输入框复位为占位符 | 必要条件,单独不足以证明 | 最终置信度会如实写进 `RESULT`(`confidence: high/medium/low`), `--dry-run` 时不会误报失败,`strictPostCheck` 可要求只认强证据。 --- ## 5. 真机实测记录 设备:Pixel 5a(1080×1920,Android 14,容器化远程机),`com.zhiliaoapp.musically`。 已验证通过: - 搜索 → 结果页 → 进视频 → 观看 → 评论 全链路 - 观看时长实测 `26974/26889ms`、`35053/34983ms`(均在 10–35s 随机区间内),证据源 `audio` - 评论计数 `190→191`(`via=comment-count`,置信度 medium);评论列表地面真相曾实测 `149→150 comments` 且新评论显示 `1s ago`(置信度 high) - 多视频循环:2 个不同卡片、2 条不同文案、两次观看均完成(dry-run 冒烟) - 从"异常态"(评论面板开着、`pageState=unknown`)自动恢复到首页并跑完 - 发送按钮两条定位路径(结构化 / 配置 id)指向同一控件 **踩过并修掉的坑**(都在源码注释里):首页被误判成播放页、评论面板开着导致状态机迷路、 `ensureFeed` 先盲等 15 秒、`hasOwnProperty.call(` 触发 MCP 确认、评论头部的 U+200E、 `visibleTexts(25)` 数量上限把评论条目挡在外面、发布后立刻点角标被收起动画吞掉。 --- ## 6. 已知限制 - **像素级播放校验默认关闭**:Android 14 的 MediaProjection 是**按会话**授权的, 脚本进程拿不到(`captureScreen()` 报 `No screen capture permission`)。 想开启:给 AutoX 授予截屏/录屏权限,并设 `probe.usePixels: true`。 默认用音频活跃度(`probe.useAudio`),静音视频会退化为"页面守卫"模式, 此时 `RESULT` 里会明确标 `probeMode=page-guard-only` / `degraded=true`,**不会假装成功**。 - **单次下发 120s 上限**:MCP 桥的硬限制,`maxVideos` 大时请分批。 - **评论发布在本机验证为"最终一致"**:角标与列表头部刷新时机不一致(实测出现角标 516 / 列表头 515)。 所以判定采用"轮询 + 分级证据",而不是单点读数。 - **资源 id 会随 TikTok 改版重排**:代码刻意优先使用无障碍文案锚点,混淆 id 只作逃生舱。 --- ## 7. 排障 先不要猜着改选择器,按顺序来: 1. **看症状定位到哪一层** - `未捕获异常` → `src/` 逻辑问题,先 `--self-test` - `ensureFeed 超时` / `pageState=unknown` → 页面判据问题,跑 `tools/diag-page-state.js` - `评论列表校验未通过` → 发布或校验问题,跑 `tools/diag-list-open.js` - `发送按钮` 相关 → 跑 `tools/diag-send-button.js` 2. **跑对应探针**(只读,不改设备状态): ```bash node tiktok-auto/tools/run.js --eval tiktok-auto/tools/diag-page-state.js --raw node tiktok-auto/tools/run.js --eval tiktok-auto/tools/diag-list-open.js --raw node tiktok-auto/tools/run.js --eval tiktok-auto/tools/diag-send-button.js --raw ``` 3. **要地面真相**(会真的发一条评论,发完前后对比评论列表): ```bash node tiktok-auto/tools/run.js --eval tiktok-auto/tools/diag-post-verify.js --raw ``` 4. **看完整时序**:加 `--raw`,日志带 `+Nms` 相对时间戳,直接能看出"卡在哪一步、卡了多久"。 | 症状 | 常见原因 | |---|---| | 日志里只有 `Start operation` 没有自己的输出 | 下发脚本的 `name` 少了 `.js`(由工具链保证,一般不会) | | `status=needs_confirmation` | 代码里出现了 MCP 黑名单里的调用;构建期本应挡住,检查是否绕过了 `build.js` | | 明明发了评论却报低置信度 | 角标延迟;看 `verifyInList` 是否被关掉 | | 一连串 `clickableAncestor` 点击 | 文案节点本身不可点,属正常降级路径 |