|
|
1 неделя назад | |
|---|---|---|
| .. | ||
| config | 2 недель назад | |
| dist | 1 неделя назад | |
| src | 1 неделя назад | |
| tools | 1 неделя назад | |
| README.md | 1 неделя назад | |
自动完成这条链路:
打开 TikTok → 搜索关键词 → 从结果里挑一个视频 → 随机观看 10–35 秒 → 发一条评论
面向"要长期跑、网络会抖、界面会弹东西"的场景写的,所以重点不在"能跑一次", 而在网络慢的时候也判得准、失败时说得清、改文案不用改代码。
前置:手机装 AutoX 并开启无障碍;VS Code 里 AutoX 扩展已激活(它会起 MCP 桥 127.0.0.1:18789)。
# 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 <n> |
本次处理几个视频 |
--dry-run |
输入评论但不发送 |
--config <file> |
用 JSON 文件覆盖配置(见 config/smoke-2videos-dryrun.json) |
--push-config |
把 config/tiktok-auto.config.json 推到手机 |
--self-test |
只跑设备端纯函数自检 |
--eval <file> |
调试用:加载全部模块但入口换成该文件的代码 |
--raw |
打印设备端原始日志(不汇总) |
退出码:0 成功,1 流程未达成,2 MCP 桥不可达,3 脚本命中危险调用黑名单需人工确认。
⚠️ MCP 单次下发有 120s 上限。
--videos大时请分批跑,别指望一次下发里连看十条视频。
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 一步同时满足两边:开发按模块读,部署是一个自包含文件。
同一个产物既能独立跑,也能被 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 的注释里:
StopSignal),并且在唯一的等待出口 wait.tick() 里检查。
停止可能发生在任意深度的等待里,逐层返回码要改几十处调用点、漏一处就变成"点了停止但不停"。
放在 tick() 里则一次实现、全流程生效。stop.flag 用内容比对(runId)而不是"文件在不在":不必删除它
(删除有竞态,files.remove 又命中 MCP 危险调用黑名单),
上一轮的残留标志也不会误杀本轮。comments.enabled:关掉就是"只浏览不发评论"。界面没勾「评论」时走这条,
用户的意图优先于引擎的默认行为。能力边界(如实标注):评论动作已接入;关注 / 私信 / 点赞尚未实现 ——
界面勾了会在启动时提示一次。收到 StopSignal 时守护线程也会一并收工
(否则它每一轮巡检都会记一条 WARN 把日志刷满)。
改 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),即同一个控件。)
task = "id")第二条链路,与视频链路并列:同一套启动/守护/停止/上报基础设施,只在
main.js 里按 config.task 分派到 idflow.runSession 或 flow.runSession。
界面上的"取材清单"传到引擎就是 id.sources 数组(可多条,串行执行)。五种起点:
| 起点 | 从哪儿拿 uid | 遍历形态 |
|---|---|---|
anchor 指定账号 |
该账号的粉丝 / 关注 / 主页列表 | 列表型(逐屏读行) |
search + users |
用户搜索结果 | 列表型 |
screen 当前屏幕 |
眼前这个列表 | 列表型 |
text 文本文件 |
文件里每一行 | 文本型(一次性) |
search + videos |
搜索结果里的视频作者 | 逐个点开型 |
video 评论区 |
评论者 | 逐个点开型 |
列表型最便宜:粉丝列表的每一行里就有 uid(实测 TextView.txt_desc),
一次读一屏 11 行,翻页接着读 —— 不开用户页时约 1 秒/个。
逐个点开型最贵:视频卡片上只有作者昵称、评论区只有昵称,都没有 uid,
只能点进去看(播放页底部有 @uid,评论者要点头像进主页),约 6~8 秒/个。
这就是为什么"评论者"和"评论者的粉丝"在界面上是两条独立取材,而不是默认全开。
filters.needsProfile() 一个判据决定:
真机实测(同一个 594 粉丝的账号):
| 模式 | 处理 11 个 | 单个成本 |
|---|---|---|
| 不过滤 | 24 秒(含 ~15 秒启动与搜索) | ~1 秒 |
| 粉丝量 ≥ 1000 | 88 秒 | ~6.5 秒 |
"再取这些人的粉丝"如果边遍历边展开,打开 B 列表时 A 的滚动位置就丢了。 所以拆成两段:① 先收完基础层,记下"这些人要展开";② 再逐个打开他们的列表。 两层都线性,没有嵌套。展开进来的人同样过一遍过滤 —— 否则"粉丝的粉丝"会绕过所有条件。
界面上只给两层是产品约束不是实现偷懒:100 粉丝 × 每人 100 粉丝 = 1 万次开页。
| 档 | 项 | 怎么实现 |
|---|---|---|
| 能做 | 私密账户 | 用户页读到 "private" 提示且视频网格为空 |
| 关注 / 粉丝 / 获赞 区间 | parse.count 解析缩写(1.6M / 1.2万 / 2,491)后比区间 |
|
| 语言区域(通杀/中/英) | 按昵称+个签的字符构成判定(CJK ≥30% 判中文,拉丁 ≥70% 判英文) | |
| 取样范围(通杀/昵称/个签) | 决定语言判定读哪段文本 | |
| 提取上限 | 计数到点即停;0 = 不限,但受时长护栏约束 | |
| 能做但有局限 | 无头像 | 像素法:头像区高饱和像素占比 < 0.12 判为默认剪影。实测默认 0.035 / 真头像 0.472。纯黑白照片头像会被误判,运行时会告知 |
| 不做,如实告知 | 提取区域 | TikTok 用户页不显示国家,没有可靠数据源 |
| AI 头像 / 性别 / 年龄 | 需要模型 | |
| 视频量门槛 | 用户页只显示 关注/粉丝/获赞 三组数字,没有视频数 → 放行 + 告知"未参与过滤" | |
| 云端去重 | 服务端未接入 → 降级本地去重 + 告知 |
「数据缺失」与「不符合」严格分开:设了门槛却读不到数字 → 拦下; 平台根本不提供该数字(视频量)→ 放行 + 告知。两者的责任不同。
这一片的资源 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 / 不可点 → 只能像素法 |
三条导航事实(都实测过,直接决定实现):
Don’t allow,撇号是 U+2019),会盖住整个列表 → openList 里专门点掉它再等一次。| 文件 | 内容 |
|---|---|
/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<TAB>数字uid 两列,见 3.5.6.1 |
产物不带 @、无表头、无注释(下游 UID 私信直接当输入读)。
文件名加时分是对规格书的有意偏离:规格书只有日期,同一天跑两次会互相覆盖。
去重三源合一:本轮已收 + 历史已收 + 输入文本里已有的。 每 20 个刷一次盘(不是结束时一次性写)—— 用户点停或脚本被杀时,已抓的部分不能全丢。
抓取产物里存的是 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/@<handle> |
403 |
| ③ | www.tiktok.com/@<handle> 整页 SSR |
✅ 200 / 约 388KB / 约 2 秒 / JSON.parse 仅 7ms |
三级都实现着,且每条只探一次:某个账号证明 ① 不可用之后,本次运行内不再尝试 ①。
于是只有第一个账号付这个代价(约 0.5 秒),之后每个直接走 ③。
将来接口恢复了,第一次就会自动用轻的那条 —— 日志里 (via api) 就是证据。
其它实现要点:
<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"> 里的 userInfo.user,
同时带 id / uniqueId / secUid / nickname。不写死键路径(层级改过),
走到第一处带 user.id 的 userInfo 就停;JSON 解析失败还有正则兜底uniqueId 必须与请求的 handle 一致,否则丢弃(网页版对大小写/别名会重定向)id.output.resolveUid(默认开;只要 handle 的场景可以关掉省时间)HttpURLConnection 而不是 http.get,并区分正常流与错误流
(getInputStream() 在 4xx 时会抛异常,会把"账号不存在"误判成"网络错误")试过但走不通的路(别再试):
| 路径 | 结果 |
|---|---|
tikwm.com/api/user/info |
被 Cloudflare 挑战页挡住("Just a moment...") |
m.tiktok.com/@user |
404(那条短链只认 /v/<videoId>.html) |
www.tiktok.com/oembed?url=... |
只有昵称与 handle,没有数字 id |
AutoX 的 http.get |
本机这个版本是坏的:Cannot find function newCall(OkHttp 绑定冲突) |
界面里 limit = 0 的语义是"不限制",但真跑起来没有上限等于跑到没电。所以引擎另加:
id.guard.maxRuntimeMs);idsource.MAX_SCREENS);到护栏时如实报告"因达到护栏而结束",绝不装作抓完了。
StopSignal 从唯一的等待原语 wait.tick() 抛出,覆盖"打开用户页 / 翻列表 / 读数字"等所有等待。
writer.close() 放在 finally 里,无论怎么结束都会刷盘。
实测:抓到 26 个时点停 → 状态 stopped、message: "已停止:本次已收 26 个 ID"、
产物文件 26 行 26 唯一(一个不丢)。
统计数字也随之校正:done 在 bridge.finish 里按 summary.extracted 覆盖一次,
否则终态会比实际少一个(最后一次 report 发生在收下一个人之前)。
settle() 会提前返回。点开粉丝列表/搜索结果页签后,页面骨架先渲染、
内容是后加载的,而 waitQuiet 判据是"可见文案连续两次不变" ——
加载中的页面和打开前的签名一样,于是 872ms 就返回,被误判成"打不开列表"。
凡是"点完要等新内容"的地方,判据必须是"新内容真的出现了",不能等"安静"。
数字会先渲染成占位的 0。同一个账号两次运行分别读到粉丝 78 与 0。
修法:连续两次读到一样才采信(readStatsStable)。页面安静 ≠ 数字就位。
点列表行不能用 node.click()。它落在容器中心,那里常常是空白,
有时还是右侧 Follow 按钮的邻域(误触会把用户关注掉)。
改成按坐标点行左侧 30%(用户名文字上),实测既进得去也不会误关注。
两种"用户列表页"的行结构不同,解析器必须分派。
userListRows() 找 txt_desc(粉丝/关注列表),usersResultRows() 找 tv_username
(搜索结果页)。当初 profile.listRows() 写死成前者,于是"取材=搜索结果"时
必然出现这种日志组合:
[waitFor] idsource:userRows 就绪(6877ms) ← 搜索结果确实读到了行
[waitFor] listRows 超时 ← 同一个列表却说没有行
[idsource] 当前屏没有读到用户行,结束列表 ← 结果"处理 0 行"
看起来像"页面没加载",实际是用错了页面类型的解析器。
listRows() 现在按当前页面分派,isListPage() 同时认这两种页。
做法:脚本里不存在"拿 sleep 当等待"的代码。 区分两件事:
probe.pollTickMs,默认 150ms)= 多久检查一次,允许存在;sleep(N) 赌它 N 秒后会好)= 禁止。所有"等"都走 wait.js 的 waitFor(条件, 上限):快的时候立刻返回,慢的时候一直等到条件成立或到上限。
于是"网络有多慢"和"脚本能不能跑通"被彻底解耦。
waitQuiet() 更进一步:判据不是时间,而是可见文案签名连续 N 次不变,
天然适配"加载中 → 不断有新节点 → 稳定"这个过程。
postMs / pageReadyMs 这类参数都是上限,不是固定等待。
| 机制 | 解决什么 |
|---|---|
| 播放存活探测 | 观看期间按节拍采样证据(AudioManager.isMusicActive(),可选像素差),连续 stallTolerance 次无证据即判定卡顿,轻点画面尝试恢复一次 |
| 页面身份守卫 | 观看全程校验"还在播放页",连续漂移即提前结束并如实上报 aborted |
| 多证据状态判定 | 播放页要 返回箭头 + 无底部导航 + 点赞 + 评论 四条同时成立(只看点赞+评论会把首页也判成播放页——实测踩过) |
| 发布分级校验 | 见下方"证据分级",不靠单一信号 |
| 弹窗守卫线程 | 独立线程清已知弹窗;主流程只用只读 popupPresent() 去等它清完,不抢它的活;绝不 back() 正常页面 |
| 未知弹窗只记录不猜 | 命中名单才动手;看起来像弹窗但不在名单里 → 只记 WARN,由人决定要不要加 |
| 页面状态机 | feed / search / results / player / comment-panel / unknown,ensureFeed 先看再动、unknown 连续多轮才按返回,绝不无脑 back |
| 坐标取"可见交集中心" | 被状态栏切掉一半的节点,几何中心会点到状态栏 |
| 去掉不可见格式字符 | 评论列表头部的实际文本是 "150 comments"(U+200E),带锚点正则永远匹配不上——这是适配层统一吸收的平台噪音 |
| 构建期两道自检 | 语法检查 + MCP 危险调用黑名单预检(命中会让每次下发都要人工确认,必须在构建期挡住) |
| 收尾必达 | 无论成败都打印 __END__、关线程、输出一行机器可读 RESULT |
按《代码整洁之道》与《编写可维护的JavaScript》落地:
ui.js 只管"怎么点",tiktok.js 只管"TikTok 长什么样",flow.js 只管"先做什么"。
换 App 或 TikTok 改版,只动 tiktok.js;flow.js 一行不用改。auto.root 是属性不是函数、UiObject.text() 是快照、离屏节点占四成……
所有平台怪癖关在 layout.js 一个文件里。ConfigError,带字段与非法值,而不是在几十行之后奇怪地炸掉。__selftest.js 覆盖配置合并、poll 的假值/异常/超时语义、评论挑选、
选片策略、随机区间等纯逻辑,一条命令在真机上跑完。| 级别 | 判据 | 说明 |
|---|---|---|
| 高 | 评论列表里出现该文案 | 地面真相 |
| 中 | 评论数自增 | 必须轮询着等:角标有 1–2s 延迟,看一眼就下结论会把成功误判成失败 |
| 低 | 面板收起 + 输入框复位为占位符 | 必要条件,单独不足以证明 |
最终置信度会如实写进 RESULT(confidence: high/medium/low),
--dry-run 时不会误报失败,strictPostCheck 可要求只认强证据。
设备:Pixel 5a(1080×1920,Android 14,容器化远程机),com.zhiliaoapp.musically。
已验证通过:
26974/26889ms、35053/34983ms(均在 10–35s 随机区间内),证据源 audio190→191(via=comment-count,置信度 medium);评论列表地面真相曾实测
149→150 comments 且新评论显示 1s ago(置信度 high)pageState=unknown)自动恢复到首页并跑完踩过并修掉的坑(都在源码注释里):首页被误判成播放页、评论面板开着导致状态机迷路、
ensureFeed 先盲等 15 秒、hasOwnProperty.call( 触发 MCP 确认、评论头部的 U+200E、
visibleTexts(25) 数量上限把评论条目挡在外面、发布后立刻点角标被收起动画吞掉。
captureScreen() 报 No screen capture permission)。
想开启:给 AutoX 授予截屏/录屏权限,并设 probe.usePixels: true。
默认用音频活跃度(probe.useAudio),静音视频会退化为"页面守卫"模式,
此时 RESULT 里会明确标 probeMode=page-guard-only / degraded=true,不会假装成功。maxVideos 大时请分批。先不要猜着改选择器,按顺序来:
未捕获异常 → src/ 逻辑问题,先 --self-testensureFeed 超时 / pageState=unknown → 页面判据问题,跑 tools/diag-page-state.js评论列表校验未通过 → 发布或校验问题,跑 tools/diag-list-open.js发送按钮 相关 → 跑 tools/diag-send-button.js跑对应探针(只读,不改设备状态):
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
要地面真相(会真的发一条评论,发完前后对比评论列表):
node tiktok-auto/tools/run.js --eval tiktok-auto/tools/diag-post-verify.js --raw
看完整时序:加 --raw,日志带 +Nms 相对时间戳,直接能看出"卡在哪一步、卡了多久"。
| 症状 | 常见原因 |
|---|---|
日志里只有 Start operation 没有自己的输出 |
下发脚本的 name 少了 .js(由工具链保证,一般不会) |
status=needs_confirmation |
代码里出现了 MCP 黑名单里的调用;构建期本应挡住,检查是否绕过了 build.js |
| 明明发了评论却报低置信度 | 角标延迟;看 verifyInList 是否被关掉 |
一连串 clickableAncestor 点击 |
文案节点本身不可点,属正常降级路径 |