# 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 # 入口与收尾(按 task 分派;保证 __END__ / 关线程 / RESULT / 上报终态) │ ├── __selftest.js # 设备端纯函数自检 │ │ │ │ —— ID 提取(task = "id",见第 3.5 节)—— │ ├── parse.js # 纯函数:计数缩写解析 / 语言判定 / 区间判定 / 用户名归一 │ ├── uidset.js # 去重集合 + 结果文件(只追加不删除) │ ├── filters.js # 「只要这样的账号」判定(纯函数) │ ├── profile.js # 用户主页的导航与读取(含头像像素判定) │ ├── idsource.js # 五种取材源 + 深度展开 │ └── idflow.js # ID 提取的编排(取材 → 过滤 → 去重 → 落盘) ├── tools/ │ ├── build.js # 构建:src/ 多模块 → dist/ 单文件(含两道自检) │ ├── mcp.js # MCP 桥最小客户端 │ ├── run.js # PC 侧执行器 │ ├── pc-selftest-id.js # 在 PC 上验 ID 提取的纯函数(68 项,秒级) │ └── diag-*.js # 排障探针(见第 7 节) └── dist/tiktok-auto.js # 构建产物:可直接在 AutoX 里跑的单文件 ``` **为什么要有 `tools/build.js`**:人维护代码时希望"一个文件只做一件事"(`src/` 拆成 18 个模块,两条链路各占一半), 但 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)`,即同一个控件。) --- ## 3.5 ID 提取(`task = "id"`) 第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在 `main.js` 里按 `config.task` 分派到 `idflow.runSession` 或 `flow.runSession`。 ### 3.5.1 五种取材源,三种遍历形态 界面上的"取材清单"传到引擎就是 `id.sources` 数组(可多条,串行执行)。五种起点: | 起点 | 从哪儿拿 uid | 遍历形态 | |---|---|---| | `anchor` 指定账号 | 该账号的粉丝 / 关注 / 主页列表 | **列表型**(逐屏读行) | | `search` + `users` | 用户搜索结果 | **列表型** | | `screen` 当前屏幕 | 眼前这个列表 | **列表型** | | `text` 文本文件 | 文件里每一行 | **文本型**(一次性) | | `search` + `videos` | 搜索结果里的视频作者 | **逐个点开型** | | `video` 评论区 | 评论者 | **逐个点开型** | **列表型**最便宜:粉丝列表的每一行里就有 uid(实测 `TextView.txt_desc`), 一次读一屏 11 行,翻页接着读 —— 不开用户页时约 **1 秒/个**。 **逐个点开型**最贵:视频卡片上只有作者昵称、评论区只有昵称,**都没有 uid**, 只能点进去看(播放页底部有 `@uid`,评论者要点头像进主页),约 **6~8 秒/个**。 这就是为什么"评论者"和"评论者的粉丝"在界面上是两条独立取材,而不是默认全开。 ### 3.5.2 什么时候才打开用户页(成本的主要来源) `filters.needsProfile()` 一个判据决定: - 过滤设置**全都关着** → 直接从列表抄 uid,**一次用户页都不开**(快 6 倍); - 开了私密 / 无头像 / 语言 / 三组数量中任意一项 → 每个候选都要开一次用户页读资料。 真机实测(同一个 594 粉丝的账号): | 模式 | 处理 11 个 | 单个成本 | |---|---|---| | 不过滤 | 24 秒(含 ~15 秒启动与搜索) | ~1 秒 | | 粉丝量 ≥ 1000 | 88 秒 | ~6.5 秒 | ### 3.5.3 深度展开:两阶段,最多两层 "再取这些人的粉丝"如果边遍历边展开,打开 B 列表时 A 的滚动位置就丢了。 所以拆成两段:① 先收完基础层,记下"这些人要展开";② 再逐个打开他们的列表。 两层都线性,没有嵌套。**展开进来的人同样过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 界面上只给两层是产品约束不是实现偷懒:100 粉丝 × 每人 100 粉丝 = 1 万次开页。 ### 3.5.4 过滤:三档,做不到的如实说 | 档 | 项 | 怎么实现 | |---|---|---| | **能做** | 私密账户 | 用户页读到 "private" 提示且视频网格为空 | | | 关注 / 粉丝 / 获赞 区间 | `parse.count` 解析缩写(`1.6M` / `1.2万` / `2,491`)后比区间 | | | 语言区域(通杀/中/英) | 按昵称+个签的**字符构成**判定(CJK ≥30% 判中文,拉丁 ≥70% 判英文) | | | 取样范围(通杀/昵称/个签) | 决定语言判定读哪段文本 | | | 提取上限 | 计数到点即停;0 = 不限,但受时长护栏约束 | | **能做但有局限** | 无头像 | **像素法**:头像区高饱和像素占比 < 0.12 判为默认剪影。实测默认 0.035 / 真头像 0.472。**纯黑白照片头像会被误判**,运行时会告知 | | **不做,如实告知** | 提取区域 | TikTok 用户页**不显示国家**,没有可靠数据源 | | | AI 头像 / 性别 / 年龄 | 需要模型 | | | 视频量门槛 | 用户页**只显示 关注/粉丝/获赞 三组数字**,没有视频数 → 放行 + 告知"未参与过滤" | | | 云端去重 | 服务端未接入 → 降级本地去重 + 告知 | 「数据缺失」与「不符合」严格分开:设了门槛却**读不到**数字 → 拦下; 平台**根本不提供**该数字(视频量)→ 放行 + 告知。两者的责任不同。 ### 3.5.5 实测锚点(2026-10-20 / TikTok 47.1.4 / 1080×1920) **这一片的资源 id 是语义化的**,与信息流那批混淆 id(`e4g`/`cn6`)完全不同: | 页面 | 控件 | 内容 | |---|---|---| | 用户主页 | `Button`,文案 `@xxx` | **uid**(被 FID 隔离符 U+2068/U+2069 包裹,必须剥) | | | `TextView` ×2 组(`t_r` 数字 / `t_q` 标签) | Following / Followers / Likes | | | `Button`(长文本) | 个签 | | 粉丝/关注列表 | `TextView.txt_desc` | **uid** | | | `TextView.txt_user_name` | 昵称 | | | 顶部三页签 `Following N` / `Followers N` / `Suggested` | 可直接切换,不必退回主页 | | 搜索用户列表 | `TextView.tv_username` | **uid** | | | `TextView.tv_aweme_id` | 昵称(**id 名违反直觉**) | | | `TextView.tv_desc` | `594 followers · 2.5K likes`(**行内就有计数**) | | 播放页 | 底部 `Button`,文案 `@xxx` | 作者 uid | | 评论区 | `Button.title` | 只有昵称,**没有 uid** | | | `ImageView.blm` | 评论者头像(可点进主页) | | 用户主页头像 | `ImageView` 192×192 @ `[856,168][1048,360]` | 无 text / 无 desc / 不可点 → 只能像素法 | 三条导航事实(都实测过,直接决定实现): 1. **从列表进用户页再返回,列表位置保持**(首行与坐标都不变)→ 可以"读一屏 → 逐个进 → 退回来接着读"。 2. **粉丝列表是从主页点进来的覆盖页**,顶部三页签可直接切 → "先收粉丝再收关注"不用来回退。 3. **首次打开粉丝列表会弹「给 TikTok 访问你的 Facebook 好友列表」授权窗**(文案 `Don’t allow`,撇号是 U+2019),会盖住整个列表 → `openList` 里专门点掉它再等一次。 ### 3.5.6 产物与去重 | 文件 | 内容 | |---|---| | `/sdcard/Download/tiktok_app/uid_YYYYMMDD_HHmm.txt` | 本次新收的 uid,一行一个 | | `/sdcard/Download/tiktok_app/uid_extracted.txt` | 累计(跨轮去重基准),**只追加不删除** | | `/sdcard/Download/tiktok_app/uid_map_YYYYMMDD_HHmm.txt` | `username数字uid` 两列,见 3.5.6.1 | 产物**不带 `@`、无表头、无注释**(下游 UID 私信直接当输入读)。 文件名加时分是对规格书的有意偏离:规格书只有日期,同一天跑两次会互相覆盖。 去重三源合一:本轮已收 + 历史已收 + 输入文本里已有的。 **每 20 个刷一次盘**(不是结束时一次性写)—— 用户点停或脚本被杀时,已抓的部分不能全丢。 #### 3.5.6.1 username 与 uid 不是一回事 抓取产物里存的是 **username(@handle)**,不是数字 uid。三者的区别: | | 例子 | 用途 | 界面上能看到吗 | |---|---|---|---| | **uniqueId / handle** | `beautifulsoul78869` | 能搜到人、能 @ | ✅ 用户主页顶部 `@xxx` | | nickname | `🌹Beautiful Soul💀` | 显示名,可重名、可随时改 | ✅ | | **id(数字 uid)** | `254801657882882048` | 平台内部主键,对接口/入库要它 | ❌ **任何界面都不显示** | 所以数字 uid 只能靠 HTTP 拿。`src/resolve.js` 在**抓取完成之后**做这一步, 取数策略照搬 `tiktok-uid和视频短链解析.js` 的**三级降级**(轻 → 重): | 级 | 路径 | 2026-10-20 实测 | |---|---|---| | ① | `www.tiktok.com/api/user/detail/?uniqueId=` | **200 但响应体为空**(风控静默拒绝 —— 最容易误以为"接口还在") | | ② | `www.tiktok.com/node/share/user/@` | **403** | | ③ | `www.tiktok.com/@` 整页 SSR | ✅ 200 / 约 388KB / **约 2 秒** / `JSON.parse` 仅 7ms | 三级都实现着,且**每条只探一次**:某个账号证明 ① 不可用之后,本次运行内不再尝试 ①。 于是只有第一个账号付这个代价(约 0.5 秒),之后每个直接走 ③。 将来接口恢复了,第一次就会自动用轻的那条 —— 日志里 `(via api)` 就是证据。 其它实现要点: - 数据源:`