|
|
5 days ago | |
|---|---|---|
| .. | ||
| dist | 5 days ago | |
| src | 5 days ago | |
| tools | 5 days ago | |
| 8px | 2 weeks ago | |
| PLAN_floatball_polish.md | 2 weeks ago | |
| README.md | 1 week ago | |
按 Ardot 设计稿(file 734917825641912)实现的 TikTok 自动化运营 App 界面,
AutoX.js UI 模式,纯代码建 View,含完整的设备数据获取、配置持久化与运行态。
与同目录下的 tiktok-auto/ 是两件事、互相解耦:
tiktok-auto/ |
tiktok-app/(本工程) |
|
|---|---|---|
| 职责 | 驱动 TikTok 干活(搜索→观看→评论) | 显示界面、收集配置、落盘运行配置 |
| 运行方式 | PC 侧经 MCP 下发,跑完即退 | 在 AutoX 里打开,常驻界面 |
| 交付物 | dist/tiktok-auto.js |
dist/tiktok-app.js |
两者通过 run-config.json 解耦:界面只负责收集 + 落盘,
真正点 TikTok 的动作由 tiktok-auto 读取该文件后执行。谁的实现换了都不影响对方。
前置:手机装 AutoX(org.autojs.autoxjs.v7)并开启无障碍服务。
它现在是整个项目的启动入口:
project.json的"main"→ 根目录main.js, 而main.js只做一件事 ——require('./tiktok-app/dist/tiktok-app.js')。 所以在 AutoX 里点「运行」、或打包 APK 后启动,进来的就是这个界面。 ★"ui";写在入口文件main.js的第一行;被 require 的模块里那句是无效的 (见项目根README.md的说明)。
# 构建 + 离线自检 + 取值审计
node tiktok-app/tools/build.js
node tiktok-app/tools/smoke.js
node tiktok-app/tools/audit-values.js
# 推到手机
adb push tiktok-app/dist/tiktok-app.js /sdcard/Download/tiktok-app.js
# 直接拉起运行(也可在 AutoX 里手动打开该文件)
adb shell "am start -n org.autojs.autoxjs.v7/org.autojs.autojs.external.open.RunIntentActivity \
-a android.intent.action.VIEW -d file:///sdcard/Download/tiktok-app.js -t text/javascript"
⚠️ 这是常驻界面脚本,不要用 MCP 下发(
command:run有 120s 上限, 界面不会__END__,必然超时)。它应该由人在 AutoX 里打开。上面的
adb push是开发期单文件调试用的。走项目入口时不需要单独推 —— AutoX 同步项目后,tiktok-app/dist/tiktok-app.js会随项目一起同步到设备上。
tiktok-app/
├── src/
│ ├── theme.js # 设计令牌(颜色/尺寸)+ 绘制工具(渐变/描边/光晕/dp/argb)
│ ├── store.js # 数据模型 + 持久化 + 校验 + 迁移(字段声明表)
│ ├── records.js # 运行记录:不定长列表,走同一份 storages 的另一个 key
│ ├── device.js # 设备数据获取(权限/TikTok 版本/屏幕/文件选择)
│ ├── service.js # 卡密校验、检查更新(远端优先 + 本地降级)
│ ├── session.js # 运行会话(跑没跑 / 跑哪个 / 跑了多久)——纯内存态
│ ├── widgets.js # 通用组件(卡片/设置行/单选框/勾选框/下拉/胶囊/页签/运行状态条…)
│ ├── dialog.js # 自绘弹窗体系(输入 / 选择 / 确认 / 权限 / 拦截)
│ ├── floatball.js # 全局悬浮球(H1–H7):跨 App 的控制入口与状态可视处
│ ├── sources.js # ID提取的取材清单(不定长列表,独立 storage key)
│ ├── page-id.js # ID提取页(三段式:取材 / 过滤 / 输出)
│ ├── page-dm.js # UID私信页 + 养号页
│ ├── page-video.js # 视频功能页
│ ├── page-tasks.js # 任务列表(B1/B2,含四张任务卡与任务状态)
│ ├── page-records.js # 运行记录(D1/D2 列表与空态 + D3 运行详情)
│ ├── page-login.js # 卡密登录页(未激活时的整屏)
│ ├── page-mine.js # 我的页(卡密/应用/版本)
│ └── main.js # 入口:骨架、路由(tab + 二级页)、运行态、启动流程
├── tools/
│ ├── build.js # src/ → dist/ 单文件(含三道检查)
│ ├── android-stub.js # 最小 Android/AutoX 环境模拟
│ ├── smoke.js # 产物离线自检(387 项断言)
│ └── audit-values.js # 界面取值审计(六个场景)
└── dist/tiktok-app.js # ⚠️ 构建产物,不要直接编辑
为什么要有 tools/build.js:人维护时希望「一个文件只做一件事」(src/ 拆 18 个模块),
但 AutoX 拿到的必须是一整段脚本,带不了一堆 require。
中间加一步 concat,两边都拿到自己最舒服的形态。
本次改版的本质是"入口层级变了":底栏从 5 个页签收敛成 3 个 tab (任务 / 记录 / 我的),原来的 ID提取 / UID私信 / 养号 / 视频功能 降级成「任务」tab 里的四张任务卡,各自的页面成为二级页(带返回头)。 依据是《实施规格书_飞舟脚本_2026-10-13》§1/§2 —— 规格书写明 「有冲突时以本文档为准」,所以画布只作视觉参考。
两层路由(App.TAB_PAGES / App.DETAIL_PAGES,都落盘):
登录页(A1/A2/A3,无底栏)
└─ 主界面
一级 tab:任务(B1/B2) · 记录(D1/D2) · 我的(E1) ← 底栏 3 个图标
二级页 :任务详情 C1/C6/C8/C9 · 运行详情 D3 ← 返回头 + 可滚动
| 编号 | 屏 | 设计稿节点 | 实现 | 状态 |
|---|---|---|---|---|
| A1/A2/A3 | 卡密登录(含校验中 / 失败) | 16:257 / 16:1491 / 16:288 |
Pages.login 三态 |
✅ 真机 |
| A4 | 权限引导弹窗 | 30:738 |
Dialog.showPermissions |
✅ 真机 |
| B1/B2 | 任务列表(含运行态) | 30:102 / 30:203 |
Pages.tasks + W.taskCard |
✅ 真机 |
| C1 | ID提取(取材清单) | 3:1 |
Pages.id + W.backHeader |
✅ 真机 |
| C2/C3 | 16:483 / 16:589 |
v8 已合并进 C1(不再有脚本卡) | — | |
| C4/C5 | ID提取 · 过滤与输出两段 | 16:706 / 16:965 |
同上(v8 起独立成段) | ✅ 真机 |
| C1r/F5 | 运行中 / 脚本报错 | 16:138 / 30:1119 |
全局运行条 + Dialog.scriptError |
✅ 真机(运行条)/ PC 自检(F5) |
| C6/C7 | UID私信(含脚本一展开) | 3:534 / 7:11 |
Pages.dm |
✅ 真机 |
| C8 | 养号 | 3:469 |
Pages.nurture |
✅ 真机 |
| C9 | 视频功能 | 3:629 |
Pages.video |
✅ 真机 |
| D1/D2 | 运行记录(列表 / 空态) | 30:871 / 30:958 |
Pages.records / recordsEmpty |
✅ 真机 |
| D3 | 运行详情(摘要 + 执行日志) | 30:1241 |
Pages.recordDetail |
✅ 真机(真实日志) |
| E1 | 我的(可滚动) | 7:289 |
Pages.mine + 底部渐隐 |
✅ 真机 |
| E2 | 解绑确认 | 16:850 |
Dialog.confirmUnbind |
✅ 真机 |
| F1 | 未选脚本拦截 | 16:23 |
Dialog.runBlocked |
✅ 真机 |
| F2 | 权限拦截 | 16:319 |
Dialog.permissionBlocked |
✅ 真机 |
| F3 | 切换任务确认(全局单跑) | 30:1005 |
Dialog.switchTask |
✅ PC 自检 |
| F4 | 运行中卡密到期 | 16:1360 |
Dialog.licenseExpired |
✅ PC 自检 |
| H1/H2 | 悬浮球(折叠 / 展开) | 34:* |
src/floatball.js |
✅ 真机(几何逐像素核对) |
| H3/H4 | 扇形四按钮 + 标签 | 34:* |
FB.openFan |
✅ 真机(四个按钮逐个点过) |
| H5/H6 | 停止 / 退出二次确认 | 34:* |
Dialog.stopScript / Dialog.exitApp |
✅ 真机(弹窗文字已 dump 核对) |
| H7 | 隐藏悬浮球 → 从「我的」恢复 | — | FB.hide / FB.setHidden |
✅ 真机(隐藏 + 恢复都验过) |
| 新增 | 说明 |
|---|---|
| 任务列表(B1/B2) | 四张任务卡:图标框 40 · 任务名 + 状态徽标(空闲/运行中/未配置)· 当前脚本 · 上次运行(读真实记录) · 「运行」按钮 68×44。点卡片主体进详情、点按钮直接起跑,两个命中区 |
| 全局运行条(§2.4) | 顶部 x=20 y=70 w=335 h=44,仅运行中出现;● {任务} · {脚本} · 阶段 + 等宽计时;点整条跳回运行中的那个任务;失败时整条玫瑰红「执行出错已停止 / 已终止」 |
| 运行记录(D1/D2/D3) | 每次运行结束写一条(任务/脚本/结果分类/时间/耗时/结果文案/结果文件/真实日志)。D1 有条数说明与结果标签,D2 是空态,D3 = 摘要卡 + 日志卡(INFO 灰 / WARN 琥珀 / ERROR 玫瑰,带「复制」) |
| 日志回传通道 | 引擎侧 logger.setSink → files.append(run-log.jsonl),界面 Engine.readLog() 逐行读回(坏行只丢自己)。以前 D3 那种"执行日志"根本没有数据源 |
全局悬浮球(H1–H7,src/floatball.js) |
浮在所有应用之上,跨 App 唯一的控制入口与运行状态可视处。见下节 |
存在的理由很具体:引擎必须待在 TikTok 前台(见 §6),所以 App 内那条"全局运行条"在真正干活时 是看不见的。球浮在最上层,于是"现在跑没跑 / 点一下就能停"这两件事在任何界面都成立。
| 事项 | 取值 / 做法 |
|---|---|
| 尺寸 | 球 Ø48(视觉)/ 热区 Ø64(触控)· 扇形半径 96 · 子按钮 Ø48 · 标签高 24 |
| 夹角 | [60, 20, -20, -60](以球心为原点向右展开;球在右侧时整体镜像、标签移到按钮左侧) |
| 四个按钮 | 运行·停止 / 回到 App / 隐藏悬浮球 / 退出应用 |
| 三态环 | 空闲无环 · 运行中青绿 #13DDC4@22% + 呼吸 · 报错玫瑰 #FF5470@20%。球内恒定是品牌图标(避免"球里是播放键 → 以为点了就运行") |
| 手势 | 单击展开/收起 · 位移 > 8px 算拖动 · 松手吸附最近边缘并记忆(存 ball.side + ball.yRatio) |
| 与引擎的通道 | FB.publishRect → /sdcard/tiktok_app/ball-rect.json;引擎侧 ui.js 的 ballRect()(500ms 缓存)+ shiftOffBall() 有 4 个内缩角可躲,躲不开就返回 blocked-by-floatball 而不乱点 |
| 窗口 | 尺寸 | 触摸 | 说明 |
|---|---|---|---|
| 球窗口 | 88×88dp | 永远可触摸 | 环 + 球 + 透明热区;只在用户拖动时移动 |
| 扇形窗口 | 308×264dp | 折叠时 NOT_TOUCHABLE,展开时可触摸 | 4 个按钮 + 4 个标签 |
为什么不能是"一个窗口、展开时改尺寸"(这条是本模块最贵的教训):
改窗口尺寸/位置由系统(WindowManager,另一个进程)立刻生效,而视图重排要等 App 下一帧重绘;
中间那几帧,合成器显示的是上一帧的旧画面被摆到新的窗口矩形里 —— 用户看到的就是"点击后先跳一下"。
用录屏(screenrecord + ffmpeg -vsync 0 抽帧 + 逐帧量球心)确认过:展开那一刻球会从下往上"爬"几级才停。
两次补救都在真机上失败了,别再试:
onPreDraw)→ 再改尺寸" —— 合成器有 2~3 帧缓冲队列,
它照样能把"藏之前"那一帧摆到新位置上去。⇒ 唯一干净的办法是根本不改窗口几何:两个窗口各自建好就永不动窝,展开/收起只改 显隐 + 一个 flag。附带好处:折叠态的"死区"从 176×176px 缩到球窗口那么大, 引擎要绕开的范围也小了(折叠时只发布球窗口)。
context.getSystemService("window") + addView / updateViewLayout /
removeView),不用 AutoX 的 floaty.window:
· floaty.rawWindow(代码建的 View) 直接不可用(缺 toXMLString());
· floaty 会在内容外再套一圈 12dp 内边距(去不掉),几何全靠反推;
· floaty 同时只能有一个窗口真正绘制(第二个建得出来但画不出来)—— 而这正是本方案的地基;
· floaty 默认还带一个调整手柄小点。裸窗口没有这些问题。
两个裸窗口能同时绘制已实测(_probe/fb_two_windows.js:红块与绿块都出现)。
那圈 12dp 内边距在本实现里用视图自己的 setPadding 复现,所以几何算法一个字没改。FLAG_LAYOUT_IN_SCREEN —— 否则窗口会被状态栏内缩,
整套坐标整体下移一个状态栏高度(实测球比锚点低了 48px,正好是状态栏高;
AutoX 的 floaty 带这个 flag,所以以前没暴露)。FLAG_NOT_TOUCHABLE,让触摸穿透到下面的 App ——
否则那 616×528px 就是一片死区。FLAG_NOT_FOCUSABLE(§10.9④),否则会抢 TikTok 的键盘焦点。WindowManager.LayoutParams 的 x/y/w/h 都是像素,dp 换算只在 dp() 里做。sleep() —— 本模块所有时序一律 setTimeout / setInterval。images.read(path).bitmap + setImageBitmap(bmp);
XML 的 src="…" 走的是 drawable 查找,不吃文件路径(会 InflateException)。setFanVisible(true) 之后做透明度渐变,且透明度的写者必须只有这一处,
否则会出现"两个动画互相顶掉"的鬼影(不报错、只有肉眼看得见)。排障开关(默认关闭,不产生任何 IO):adb shell touch /sdcard/tiktok_app/ball-debug 打开,
删掉即关。打开后落三个文件:
| 文件 | 内容 |
|---|---|
ball-touch.log |
球的每次触摸(action/坐标/展开态/锚点)、每次扇形按钮点击(带标签)、confirmThenAct 的三步 |
ball-layout.txt |
当前真实布局:窗口矩形、根视图、每个子视图的位置尺寸与文字 |
ball-error.txt |
展开 / 定位 / 按钮动作抛出的异常(UI 回调里的异常不写下来就彻底消失了) |
ball-layout.txt 是有来历的:球的坐标算错过两次(dp/px 混用一次、根视图中心漏算内边距一次),
两次都只是"看起来有点歪",靠肉眼量截图量不准。有了它,下一次"位置不对"一眼就能看出是
窗口、根视图还是某个子视图算错了。现在每次就地刷新按钮后也会再 dump 一次
(否则快照永远停在"展开那一刻",事后回看会以为按钮一直是「运行」)。
底色 #030014、底栏 #0A0A17@96%、弹窗底 #17142B、主色 #2EA7FF → #9381FF、
成功/运行中 #13DDC4、警告 #F5C24B、危险 #FF5470、未激活页签 #8E8BA8(旧 #7A7794 对比度 4.4:1 不达 AA)。
尺寸:底栏 83 = 页签行 49 + 安全区 33、返回头 44(热区 44×44)、主 CTA 48、
卡片圆角 16/14、区间输入框单框 56 且数字居中、文件路径右对齐、底部渐隐 80。
| 规格书 §9 | 本项目的处理 |
|---|---|
| ① C6 与 C7 的「功能设置」字段集不同 | 取并集:AI 性别 + 进行关注/关注数量 + 进行私信/私信数量 + 休息时间 + 去重方式。两条都成立,不丢功能 |
| ③ 触控目标建议 44 | 新组件(任务卡运行按钮 68×44、返回热区 44×44)按 44 做;既有设置行/胶囊保持 26 的紧凑视觉(规格书自己也建议"视觉紧凑 + 透明 padding 撑热区") |
| ④ E1 / 二级页内容余量小 → 可滚动 | 已可滚动,并加了底部渐隐(透明→底色,内容空时不可见) |
| ⑤ 未激活页签对比度 | 已改 #8E8BA8 ✅ |
| ⑥ B1「上次运行/结果量」的数据来源 | 用本项目的运行记录(src/records.js);没有记录显示「还未运行过」 |
| ⑦ 多脚本并行不支持 | 保持全局单跑:运行中点别的任务的「运行」不禁用按钮,而是弹 F3「停止并开始」 |
| ⑧ D3 日志可能很长需虚拟滚动 | 未做虚拟滚动:改为只带最近 200 行(记录要落盘,不能让日志把它撑爆)。已在 D3 用等宽字体 + 紧凑行距缓解 |
— 右侧滚动指示条(x=367 w=3) |
未实现:它需要滚动监听,收益小、风险高(AutoX 里构造 OnScrollChangeListener 的兼容性没验过);底部渐隐已经把"还能滚"表达出来了 |
| — §7 技术栈表 | 运行形态 = AutoX.js ui 模块(纯代码建 View);卡密/权限/存储/结果文件/更新检查全部沿用本项目既有实现(本地卡密、storages、files) |
v8(2026-10-17)把四张脚本卡换掉了。 那四张卡是「起点(人从哪来)」×「深度(要不要再展开 一层)」的 4 个不完整组合:各缺一块、各有别人没有的一块,合起来才完整。于是 「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 —— 想同时抓"关键词视频的作者" 和"文本里这批 uid 的粉丝"时,它直接不允许。定位过程与 38 个字段的逐条处置见
REQ_id_extract_v2.md。v8 之后执行引擎已接入(
task: "id"):点「开始运行」会真的起因材与过滤去 TikTok 里抓 ID, 产物落在/sdcard/Download/tiktok_app/uid_日期_时分.txt。引擎侧的实现与实测锚点见tiktok-auto/README.md第 3.5 节。
页面就是三段:
| 段 | 内容 |
|---|---|
| ① 取材 | 可累加的清单(最多 5 条):每条 = 起点 + 该起点需要的输入 + 深度 |
| ② 只要这样的账号 | 所有取材共用:账号特征(私密/无头像/语言)+ 四组数值门槛 |
| ③ 输出 | 提取上限 + 本地/跨轮/云端去重 + 结果文件名 |
五个起点(选了什么才出现什么,不会再有"勾上却无所指"的开关):
| 起点 | 要填什么 | 取到谁 |
|---|---|---|
| 指定账号 | 账号(# 分隔)+ 取哪些列表(粉丝 / 关注 / 主页展示,至少留一个) |
这些列表里的人 |
| 搜索结果 | 用户 / 视频作者 + 关键词 | 搜到的人 / 视频作者 |
| 某个视频的评论区 | 视频从哪来(当前屏幕 / 关键词搜索) | 评论者 |
| 文本文件 | 路径 + 选择文件 | 每行一个 UID |
| 当前屏幕 | 无 | 此刻这个列表里的人 |
深度:不展开 / 再取粉丝 / 再取关注,最多 2 层。 100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开,真机不现实、账号也会异常 —— 所以第 3 层 干脆不给选项。「当前屏幕」没有深度:它已经是最底层。
实现要点(都写在 page-id.js / sources.js 的注释里):
Sources 模块自己的 storage key 里(feizhou_app 的 sources),
不进 Store.FIELDS:它是不定长列表,塞进那张标量表等于让 validate() 去校验数组,
还会把「路径 → 值控件」那套审计规则带歪。照 Records 的先例。W.chooser:不绑 Store 路径的通用单选胶囊 —— 取材的"起点 / 深度"只能走回调。W.inlineField 新增 read / write:用来绑"不是 Store 字段"的值(账号 / 关键词 / 路径)。
这类输入框注册到 W.pendingExtra 而不是 pendingFields ——
后者同时被取值审计当作"这个字段有控件"的证据,混进去会报"绑定了表外路径"。Sources.migrateLegacy() 把旧的"四张卡选一张"翻译成清单,三条原则:
① 已有清单就不动(不覆盖用户自己加的,重装/降级都安全);
② 能展开成多条就展开(脚本四的三个开关 → 三条独立取材,旧结构塞不下的能力);
③ 超出上限要截断并说出来。【踩坑】模块级函数重名会静默互相覆盖:build.js 是单文件拼接,所有模块共享
一个顶层作用域。v8 加 sources.js 时踩到了 —— 里面的 keysOf 与 store.js 同名,
把 getEnum 打坏了(卡密状态读不出 → 所有运行都被送回登录页,没有任何报错)。
新增模块前先查一遍:
for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done | sort | uniq -d
App.screen 只有两个值:login / app。卡密未激活时启动直接进登录页——
不让用户先进一个什么都点不动的主界面。Session 是运行态的唯一持有者(跑没跑 / 跑哪个 / 起于何时),
纯内存、不落盘。写进配置会有两个后果:上次没点停止就杀进程,下次启动会显示成
「运行中」;计时起点被迫落盘,重启后算出来的时长是错的。setInterval(回调跑在主线程 Looper 上,可以安全改 View),
不重建界面——每秒重建整页会让滚动位置归零。[100] ~ [200] 两个可点击控件,点哪个改哪个。
这是对稿子的增强而非偏离:稿子那样只能整体展示,改一个值要重填一对。这一条区分很容易被含糊过去,所以单独写出来:
| 形态 | 判据 | 做法 | 例子 |
|---|---|---|---|
| 复合项 | 两个数各自独立成立,拆开也说得通 | 拆成两行,各自一个输入控件 | 进行关注 / 进行私信(一个开关管两件事) |
| 区间 | 两个数是同一个量的下界与上界,拆开就变成两个不相干的东西 | 一行一个区间:标签 [最小值] ~ [最大值],两个数各自一个胶囊、各自弹窗输入 |
关注数量 100 ~ 200;休息时间(秒)100 ~ 500;停止个数 1 ~ 5 |
区间用 W.rangeRow(pathMin, pathMax, label, separator) 渲染,
两个胶囊互不干扰(点最小值不会顺带改最大值),~ 只是把「这是一对数」表达出来。
注意区间不等于「两个值挤在一个控件里」:
[100] ~ [500]是两个独立控件, 而100 ~ 500一个胶囊才是被塞在一起(本项目已经不存在这种写法)。
上下限联动(syncCrossRules)在区间里只刷新这两个胶囊的文字,不重建整页——
走整页重建会把滚动位置清零,用户刚滚到设置区就被弹回顶部。
Store.CROSS_RULES 里每一对都对应界面上一个 W.rangeRow;
自检里有一条断言专门核对这个对应关系(漏一条就会出现「最小值 500 / 最大值 100」)。
全部收敛在 src/device.js 与 src/service.js,页面只负责展示。
| 界面上的元素 | 来源 | 实现位置 |
|---|---|---|
| 副标题「· 已连接 / 未连接」 | auto.service !== null |
Device.a11yEnabled() |
| 权限弹窗三行状态 | 无障碍服务 / 悬浮窗权限 / 通知权限(可选项) | Device.permissionRows() |
| 「已安装 v47.1.4」 | PackageManager.getPackageInfo 多包名探测 |
Device.tiktok() |
| 卡密状态 / 到期时间 | Service.licenseStatus() + 过期自动改写状态 |
service.js |
| 脚本版本 | 构建时注入(--version / 默认日期) |
Service.SCRIPT_VERSION |
| 「检查更新」 | 远端优先,无端点时本地自比并如实标注来源 | Service.checkUpdate() |
| 「选择文件」 | 扫指定目录的 txt/json/csv,弹列表选择 | Device.listTextFiles() |
| 状态栏 / 导航栏高度 | 系统 dimen 资源,取值失败有兜底 |
Device.statusBarHeight() |
关键约定:所有探测都必须能在能力缺失时返回兜底值,绝不抛出去。
device.js 里每个探测都包了 probe()——AutoX 没有 linter 也没有崩溃隔离,
一次未捕获异常就让整个界面起不来。
设计稿 02 与 12 两个弹窗的顺序与措辞必须完全一致,所以数据只在 Device.permissionRows()
里声明一次:
无障碍服务 已开启 / 未开启 ← 阻断项
悬浮窗权限 已开启 / 未开启 ← 阻断项
通知权限 已开启 / 未开启(可选) ← 不阻断,排最后
blockingPermissionsGranted() 与 allPermissionsGranted() 是两个函数:
前者只管无障碍 + 悬浮窗(决定「能不能跑」),后者含通知(决定「进 App 时要不要提示」)。
用后者去拦人,会让用户被一个不影响执行的权限挡在门外。service.js 默认是本地模式:只校验格式(16 位字母数字),
不具防伪能力,界面上会如实提示「本地模式,未联网核验」。
要真防伪,把 Service.CONFIG.licenseEndpoint 指向自己的接口:
请求 POST {endpoint} {"code":"ABCD1234EFGH5678"}
响应 {"ok":true,"expireAt":"2026-11-09"}
{"ok":false,"reason":"卡密不存在"}
结论先说:界面上每一个可输入 / 可选择的控件,改动都会立刻落盘,启动时自动读回。
不是「大致都存了」——这条有自动化覆盖(第 9 节场景 F):渲染所有页面、把每个控件
真操作一遍、断言「配置跟着变」,再模拟重启(丢掉内存 state、重新从 storages 读)
断言「界面显示的还是新值」。字段表 79 个字段里 78 个有对应控件,
唯一没有控件的是 activeTab(底部页签栏的当前页,由点页签写入)。
| 控件 | 存什么 | 时机 |
|---|---|---|
| 输入框(文本 / 数字 / 区间两端 / 文件路径) | 该字段的值 | 输入即落盘;退格期间不写、失焦一次提交 |
| 勾选框(脚本卡、过滤账户、云端去重、AI 识别…) | 布尔 | 点一下即落盘 |
| 开关(进行关注 / 进行私信 / 执行操作四选) | 布尔,各自独立 | 点一下即落盘 |
| 下拉框(AI 性别、去重方式、概率档位…) | 枚举 key | 选中即落盘 |
| 脚本卡的选中 / 展开态 | 布尔 | 点一下即落盘 |
| 底部页签 | activeTab |
切页签即落盘 → 重开就停在上次的页 |
| 登录页卡密框(还没点激活的半截输入) | license.draftCode |
输入即落盘 → 被系统回收进程也不用重打 16 位 |
| 卡密激活状态 / 到期时间 / 卡密本体 | license.* |
激活成功时写入 |
刻意不落盘的只有三类,都是「重启后必须从头开始」的东西:
src/session.js)—— 落盘会导致「没点停止就杀进程,
下次启动显示运行中」+ 计时起点错误其余机制:
storages.create("feizhou_app") 的 config 键,值是 JSON 字符串Store.set(path, value)——改字段即落盘,且落盘前按字段表归一化
(夹越界 / 回退非法枚举 / 纠正类型),所以盘上永远不会出现越界值App.main() → Store.init() → Store.load() → validate(),
一次读全量;页面只从内存里的 Store.current() 取值Store.FIELDS 把每个字段声明一次
(路径 / 类型 / 范围 / 默认值),defaults() 与 validate() 都由它推导| 接口 | 保证 |
|---|---|
| Store.getEnum(path, group) | 返回合法枚举 key——选择框不会空 |
| Store.getNumber(path) | 返回有限数——不会显示 NaN |
| Store.getText(path) | 返回字符串(非字符串回退默认值) |
| Store.getBool(path) | 返回布尔 |
Store.validate() 逐字段归一化——越界数字夹到区间、非法枚举回退默认、
非字符串回退默认(不做 String() 转换)、min > max 自动抬齐,
并丢弃字段表之外的键;「修正了什么」记进 problems 打印出来optional: true:声明「这个字段的空串是合理状态」(例如云端去重的表名)。
取值审计靠它把「有意留空」和「漏了默认值」区分开Store.MIGRATIONS 按版本逐级升级。新增默认值只能影响没有该字段
的配置;老存档里存着 keyword: "" 时,空串是合法字符串、类型归一化补不上——
这种「空串其实等于未设置」的语义差异只有迁移能处理。
改语义时同理:v4→v5 把五处「一个值」变成「一个区间」,也只能靠迁移{"id":"ID 模式"}),改文案不作废老数据配置结构(节选,SCHEMA_VERSION = 5):
{
version: 5,
activeTab: "id",
license: { code, draftCode, status, expireAt },
tabs: {
id: { scripts: { s1: { checked: false, expanded: true, dedupe: "id",
enterPage: "fans", useTextId, textIdPath }, s2, s3, s4 },
features: { filterPrivate, filterNoAvatar, regionCodes: "JP#TW#US",
langRegion, nicknameCheck, cloudDedupe, cloudTable,
aiAvatar, aiGender, aiAgeMax,
followMin, followMax, fansMin, fansMax,
likesMin, likesMax, videosMin, videosMax, extractLimit } },
dm: { scripts: { s1: { checked, expanded, doDm, filterDate, firstAction,
uidTextPath }, s2 },
aiGender, doFollow, followMin, followMax,
doDm, dmMin, dmMax, restMin, restMax,
dedupeMode, dmTextPath, commentTextPath },
nurture: { scripts: { s1: { checked, expanded } },
browseMin, browseMax, likeRate, commentRate, followRate,
actionMin, actionMax },
video: { scripts: { s1 }, keyword, region, stopMin, stopMax,
intervalMin, intervalMax,
actions: { follow, dm, like, comment }, dmTextPath, commentTextPath }
}
}
按设计稿,关注数量 / 私信数量 / 休息时间 / 浏览时长 / 单次操作 / 停止个数 / 间隔时间
都成了 最小值 ~ 最大值 的区间。迁移原则是不改变原有行为,分三类:
| 旧字段 | 类型 | 迁移方式 | 例 |
|---|---|---|---|
followLimit / dmLimit / actionLimit |
上限 | min = 0(不设下界)、max = 旧值 |
关注上限 300 → 0 ~ 300 |
stopCount |
精确个数 | min = max = 旧值(与稿子「20 ~ 20」同表达) |
停止个数 7 → 7 ~ 7 |
browseDuration(档位枚举) |
档位 | 取该档的上下界 | 档位 5 ~ 10 → 5 ~ 10 |
旧字段非数字时不迁移,直接用设计稿给的默认区间(例如 关注数量 100 ~ 200)。
新装用户拿到的也都是设计稿默认值。
运行配置快照出口:点「开始运行」会写 /sdcard/tiktok_app/run-config.json
(含 tasks + 完整界面配置 + 本次生效的引擎配置 + 不会生效的项)。
它是给人看、给事后追溯用的;引擎真正读的是下面第 6 节里的 engine-config.json。
App.currentTask() 只认当前页签,不把 5 个页签里勾上的都收上来。
设计稿写得很明确——「单选:同一时间仅可运行一个脚本」。
跨页收集会出现「ID提取页什么都没选,按钮却能把养号脚本跑起来」,
用户根本不知道自己到底在跑什么。
它还会带上该脚本的 engine 标记(来自 Pages.SCRIPTS_OF 的脚本定义)。
没有这个标记的脚本,点「开始运行」会被 Dialog.engineMissing 拦下 —— 见第 6 节最后。
点「开始运行」
├─ 定位引擎产物(从入口脚本同级推 → 扫项目目录 → 回退 Download)
├─ 界面配置 → 引擎配置(engine-config.json)
├─ 更新 run-config.json(含本次 runId)
├─ engines.execScriptFile(产物, ["--config",…,"--run-id",…]) ← 起另一个 ScriptEngine
├─ Session.start(...) + 状态条 + 每秒轮询
│
│ 引擎侧:读 --config → 跑流程 → 每步写 run-state.json
│ 界面侧:每秒读 run-state.json → 状态条显示 phase
│
└─ 终态(finished / stopped / failed)→ 复位 + 切回前台 + toast
界面与引擎是两个独立的 ScriptEngine:各有各的全局作用域,对象传不过去 (实测:父脚本读不到子引擎里的任何东西)。所以通道只能是文件。
文件(都在 /sdcard/tiktok_app/) |
方向 | 内容 |
|---|---|---|
engine-config.json |
界面 → 引擎 | 映射后的引擎配置(由 --config 交给 config.resolve) |
run-state.json |
引擎 → 界面 | status / phase / done / total / message / summary |
stop.flag |
界面 → 引擎 | 停止请求,内容是本次运行的 runId |
run-config.json |
界面 → 人 | 完整快照,供追溯 |
全是纯文本 JSON,出问题 cat 一下就能复盘。
点「停止运行」不是"一按就当停了":
stop.flag(内容 = runId),状态条立刻变 「正在收尾」(琥珀色);wait.tick() 里轮询到它 → 抛 StopSignal →
退出当前等待 → 收尾 → 写 status: "stopped";为什么不做成点一下就复位:引擎收到请求后还要退出当前等待、把 TikTok 带回信息流, 它确实还在动。这时界面说「已停止」,用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。
两个细节:
wait.tick() 里,那是全流程唯一的等待出口(等待应用启动、等待播放、
等待结果卡片……几十处都经过它)。于是"能被打断"这件事只实现了一次,
业务代码一行都没改。bridge 未启用时是空操作,独立运行零开销。stop.flag 里存 runId 而不是空文件:于是不必删除它(删除既有竞态,
files.remove 又命中 MCP 危险调用黑名单),上一轮的残留标志也不会误杀本轮。引擎目前实现的是「搜索 → 逐个看 → 评论」。Engine.buildConfig() 会把
"用户表达了意图、但引擎这次做不到"的事逐条记进 notes:
点赞动作/关注动作/私信动作 本次不会生效),
完整内容写进 run-config.json 的 engine.notes;comments.enabled = false,引擎只浏览不发评论。
这是给引擎新加的能力:以前 comments.pool 必须非空,等价于"必然发评论"。
用户的意图优先于引擎的默认行为。notes 的汇总只取每条中文冒号前的短名 —— 三条完整说明拼起来四十多个字,
那种长度的 toast 用户来不及读。
Pages.SCRIPTS_OF 里的脚本可以声明 engine。没声明的(ID提取 / UID私信 / 养号)
点「开始运行」会弹 Dialog.engineMissing,说清是哪个脚本、目前哪一页能用。
绝不静默跑一个不相关的东西——用户以为在跑 ID 提取,实际在跑视频评论,是最糟的一类错误。 这条也是自检里的一条断言:被拦下时不能起任何引擎。
引擎被系统杀掉、被人手工停掉时不会写终态,界面就会永远显示"运行中"。
所以有一个兜底:Session.running 且 Engine.childAlive() 为假 → 判为「引擎已退出」。
childAlive() 靠 engines.all()。但它在不同运行时里的可见性不完全一致,
所以加了一道保险 Engine.sawChild:只有确实见过子引擎,才有资格后来说"它不见了"。
从没见过就当作"查不出来",宁可多显示一会儿运行中,也不误判成已结束把运行态清掉。
引擎是靠"看到 TikTok 在前台"来定位页面的。用户切回界面看一眼进度是很自然的动作, 但它会让 TikTok 退到后台,引擎立刻"瞎掉"(页面状态全是 unknown),甚至误判成"启动应用失败"。
这不是引擎的 bug,也不该靠文档里写"请勿切走"(没人会记得)。所以:
App.backToTarget());ui.emitter.on("resume") 提醒一次
(本次运行只提醒一次,来回切窗口不会反复弹)。这条是真机联调时被自己的操作绊出来的:我为了在界面上点「停止」而把界面切回前台, 结果那一次运行报「搜索流程失败」。查日志才明白是 TikTok 被挤到后台了。
tools/fake-engine.js:读同一套 --config/--state/--stop-flag/--run-id 协议,
只写状态、循环等停止信号,完全不碰 TikTok。
为什么需要它:用真引擎验证"状态条/停止"这些 UI 交互时,它会把 TikTok 拉到前台, 我就没法同时操作界面上的按钮了。换成假引擎,界面始终留在前台,UI 交互能一次测干净。
用法:把设备上的引擎产物临时替换掉(记得备份),验完换回来。
关键词、国家简码、云表名、文件路径、以及所有数字(含区间的两端)都是页面上的
常驻输入框(W.inlineField)。点一下就能改,没有「弹出输入框 → 填 → 确定」这一步。
| 组件 | 用途 |
|---|---|
W.inlineField({ path, ... }) |
绑定配置路径的常驻输入框:读初值 / 输入即落盘 / 失焦正规化 |
W.textField(path, hint, w) |
文本(关键词、主播区域、国家简码、云表名、文件路径) |
W.numberField(path, hint, w, onLinked) |
数字(AI 年龄、提取符合、区间两端) |
W.rangeRow(...) |
一行两项:标签 [最小] ~ [最大],两个数各自独立可改 |
四条实现要点,每条都对应一个真机上验证过的坑:
TextWatcher / OnFocusChangeListener 在这台 AutoX 上可用。
早先绕开常驻 EditText 是担心「Java 接口构造不出来」——
实测 new android.text.TextWatcher({...}) 与 new View.OnFocusChangeListener({...})
都能构造,真机逐字输入每键都触发回调。监听写在本组件内部,
全项目只有这一个输入入口,不存在「某一处漏绑监听」的余地
(漏绑的表现是「能打字但永远不保存」,用户会报成「没有默认值」)。setText 会同步触发 TextWatcher
(before → on → after 全部跑完才返回),所以 paint() 用一个 writing 标志挡住监听;
并且用 try/finally 复位这个标志 —— 一旦异常把标志留在 true,
这个输入框就永远不会再写配置了(静默哑掉,极难查)。2000 清成 2 会经过 200、20
这些恰好合法的中间值,用户清空后走开,配置就停在 2 上。
退格期间不写、失焦时一次性提交;空串则回退原值。
文本字段不受这条限制 —— 半截文本本身就是「用户正在输入的内容」。EditText 失焦,所以「按开始运行」「切页签」时栏位内容可能还没落盘。
W.commitPendingInputs() 在这两个入口各调一次;提交之后 W.beginPage()
清空登记表(留成全局累积的话,切走那一页的旧文字会把别处刚改的值顶掉)。「配置里永远没有越界值」由写入端保证:Store.set() 落盘前先按字段表归一化
(夹紧越界、回退非法枚举、纠正类型)。少了这一步,界面直接输入的 99999 会原样进配置,
要等下一次重启加载才被纠正 —— 用户看到的是「我填的数它照收了,可跑起来又不是那么回事」。
这条改动顺带把一处旧漏配打回来了:activeTab 的候选值里少了 "mine",
导致「切到我的页后重启会落回 ID提取」。
src/dialog.js 是自绘弹窗,输入类弹窗(askText / askNumber)已全部删除 ——
输入不再需要弹窗,留下的都是「选一项 / 确认一次 / 被告知一次」。
| 接口 | 用途 | 对应设计稿 |
|---|---|---|
Dialog.choose |
单选列表(所有下拉框、选择文件) | — |
Dialog.confirm / alert |
确认 / 提示 | 区域条款说明、更新日志 |
Dialog.showPermissions |
权限检测(软引导) | 7:260 |
Dialog.permissionBlocked |
权限拦截(「无法开始运行」) | 16:436 |
Dialog.runBlocked |
未选脚本拦截 | 16:1065 |
Dialog.confirmUnbind |
解绑确认(红色确认键) | 16:951 |
Dialog.licenseExpired |
卡密到期 | 16:1480 |
为什么下拉框仍然用弹窗:从一组候选里选一个,本质上需要一个选择器;
而系统 Spinner 的弹出列表是浅色主题,在深色界面里就是一块刺眼的白板。
(也不能用 dialogs.select:它在 "ui"; 模式下拿不到返回值 —— 阻塞即死锁。)
另外三条约定:
present() 会先关掉上一个。
两层弹窗叠着关不掉,是自绘 UI 最容易出的岔子。onConfirm 自己决定。
早先写成「先 close() 再 onConfirm()」,直接毁掉了输入类弹窗的关键行为。runBlocked / permissionBlocked 的标题统一是
「无法开始运行」,调用点读起来就是「被拦了,因为……」,不用去猜 title 该写什么。Store.CROSS_RULES 声明了成对的上下限(休息时间、间隔时间、养号的两个参数、
ID提取的四个数量门槛)。每敲一个数字都会跑一遍 syncCrossRules():若上界小于下界就抬齐。
★ 只刷新「兄弟」那一格,绝不刷用户正在输入的那一格 —— 回写会抢掉刚敲进去的字符、还会把光标弹到末尾。 联动也不重建整页:重建会把滚动位置清零,用户刚滚到设置区就被弹回顶部。
设备:Pixel 5a(1080×1920,Android 14),AutoX org.autojs.autoxjs.v7。
19 屏相关的界面逐屏截图核对通过(含登录页三态、两个拦截弹窗、解绑弹窗、运行态计时), 三个含区间的页(UID私信 / 养号 / 视频功能)也逐行核对过新形态, 并在真机上实际改过值(点最小值胶囊 → 输入 → 确定 → 界面文字就地刷新)。 过程中修掉的问题按「为什么检查体系没抓到」分两类:
A. 只有真机才暴露
| 现象 | 根因 | 修法 |
|---|---|---|
| 卡片里所有文字看不见,只剩勾选框 | 用 U.spacer()(宽度 MATCH_PARENT)做横向间距,把同行的标题挤成 0 宽 |
新增 U.hgap() 固定宽度版,横向一律用它 |
| 「TikTok 客户端」标签与「更新」按钮消失 | 左侧标签用 weight=1 撑开,而右侧控件本身是 MATCH_PARENT 时,标签被压成 0 宽 |
改成「内容宽度 + flexible() 弹性占位」 |
切到「我的」页直接崩溃,报 Cannot find function setAndNotify |
「检查更新」在 threads.start 起的后台线程里改 View |
默认路径改同步;只有真发 HTTP 时才走线程 |
| 整排「非活动」页签消失 | 改配色时漏掉了 Theme.hex.tabIdle,argb(undefined) 算出 0(全透明)。uiautomator 里它们的 bounds 一切正常,只有肉眼看得出 |
① 补回 tabIdle;② argb() 遇到非法色值改为返回洋红并打日志,不再静默返回透明;③ 自检新增两条断言:所有色值合法、且界面用到的键都存在 |
| 关掉「进行关注」顺带把私信也关了 | 设计稿两版的功能设置卡内容不一致,代码取并集时把两个独立动作挤进了一个开关 | 见 B 类第 2 条 |
B. 只有「有历史数据」或「跑到具体流程」才暴露
| 现象 | 根因 | 修法 |
|---|---|---|
| 关键词 / 主播区域 / 文件路径只显示灰色占位符 | ① v1 的默认值本身就是空串,老存档里 "" 是合法字符串,类型归一化补不上;② W.textField 用常驻 EditText 却没绑 TextWatcher |
加版本迁移(v1→v2);输入框改弹窗取值 |
数字框可能显示 NaN ~ NaN |
Number(Store.get(path, 0)) 遇非数字串得 NaN |
改用 Store.getNumber() |
| 两个数值的输入框只能输一个 | 连弹两次阻塞式 dialogs.rawInput,第一次就拿不到返回值 |
自绘弹窗体系 |
| 「关注/私信」这类复合项没法单独控制 | 两个独立动作/两个独立上限挤在一个开关、一个胶囊里 | 拆成一项一控件 + 版本迁移 |
| 五个数量仍是「单一上限/单一值」,与稿子的区间不一致 | 关注上限 200 / 私信上限 30 / 单次操作上限 30 次 / 浏览时长档位 / 停止个数 5,都是「一个值」;稿子已全部改成 最小值 ~ 最大值 |
改成区间行 + v4→v5 迁移(上限→0 ~ 旧值;精确值→旧值 ~ 旧值;档位→取该档上下界),保证升级后行为不变 |
| 我的页显示「已登录」却挂着「去登录」 | Service.licenseDisplay() 没返回 status,页面写 display.status === "active" 永远为 false |
让 licenseDisplay() 返回 status;自检加断言(已登录给「解绑」、未登录给「去登录」) |
| 非法输入时弹窗被关掉,只能重新点开重填 | cancelConfirmRow 先 close() 再 onConfirm() |
「确定」不预先关窗,由 onConfirm 决定 |
| 输入框改成「直接在栏位里改」后,会有四种「改了不保存 / 存了怪值」 | ① 常驻输入框要靠 TextWatcher 才能同步回配置;② 程序化回写的 setText 又会同步触发同一个监听;③ 数字框退格清空会把 2000 一路写成 2;④ 点「不可聚焦」的按钮不会让 EditText 失焦,切页签时编辑随视图消失 |
四条一起做:防自触发(try/finally 复位 writing)+ 退格期间不写 + 失焦提交并正规化 + 离开前 commitPendingInputs() |
| 「切到我的页后重启会落回 ID提取」 | activeTab 的候选值漏了 "mine"(字段表 values 与 Store.enum.tab 两处都漏)。写入端归一化上线后才把这种漏配打回来 |
两处都补 mine;自检加断言 Store.set('activeTab','mine') 后能读回 mine |
| 清空数字框再走开,配置停在半截数字上 | 退格经过的 200、20 恰好都是合法值,逐个落盘 |
数字框退格期间不写;失焦时若为空则回退原值 |
| 「激活成功后清空卡密草稿」清了等于没清 | App.enterApp() → renderContent() 会先跑 commitPendingInputs(),而登录页那个输入框此刻还在待提交表里,于是把刚清掉的草稿又写了回去(顺序反了) |
干脆不清草稿:解绑后回登录页可一键重新激活,而它和 license.code 本来就在同一个 JSON 里,多留一份不增加暴露面 |
edit.getText().length() 在 PC 桩上每次必抛 |
真机上 getText() 返回 android.text.Editable(有 length() 方法),桩返回的是 JS 字符串(length 是属性不是函数)→ paint() 永远走 catch 分支,「回写之后光标在哪」这类问题在 PC 侧根本测不到 |
改成引擎无关的 String(edit.getText()).length |
这一轮的教训:上面 A 类里「页签消失」那条,PC 桩测与真机首启都是绿的—— 因为它是"画出来了但看不见"。它提醒我:自检只能验证"结构对不对", 颜色的取值必须单独断言(所有色值合法 + 用到的键都存在), 否则一个拼错的键就等价于一次静默的视觉删除。
页签图标也踩过一次:\u2709(信封)在 Android 上即使追加 \uFE0E 文本变体选择符
仍被渲染成彩色 emoji,已全换成 U+25xx 几何块字符。
弹窗顶部的警示图标同理,用字符 ! 而不是 ⚠。
验证「直接输入」时踩到的操作坑(不是代码问题,但会得出错误结论):
点一下输入框,软键盘就弹起来了,界面被顶起、页签栏从 y=1801 移到 y=1225。
此后按原来的固定坐标点「切页签」,实际点中的是键盘上的退格键和数字键 ——
配置里就会凭空出现一个没人输入过的数字(我就这样「造」出了一个 9,
一度以为是代码写错了)。
→ 每次点完输入框,都要重新 uiautomator dump 取当前真实坐标,
并先确认 dumpsys input_method | grep mInputShown 的状态,不要复用上一轮的坐标。
按真实的项目目录结构部署到 /sdcard/Scripts/tiktok-demo/
(main.js + tiktok-app/dist/ + tiktok-auto/dist/),从 main.js 启动:
| 验的东西 | 结果 |
|---|---|
| 从入口启动即进界面 | [TTA] 项目启动入口 main.js → 加载界面 → [app] 界面已就绪 |
| 界面唤起引擎 | [app] 已唤起引擎 …/tiktok-auto/dist/tiktok-auto.js runId=… |
| 参数送达 | 引擎打印 已连接到界面宿主,runId=… |
| 配置映射 | 引擎打印生效配置 {"keyword":"fashion","maxVideos":1,"watchMs":[2000,2000],"commentEnabled":false} |
| 状态上报 | run-state.json 的 phase 随流程变化(启动应用 → 进入信息流 → 搜索 fashion) |
| 真引擎端到端 | 启动 TikTok → 搜索 fashion → 切 Top → 打开视频 → 观看 2095ms → 返回;终态 finished,界面接住并提示 |
| 纯浏览模式 | comment: {ok:true, skipped:true, reason:"本次未启用评论动作"}——没勾评论就真的没发 |
| 跑完自动切回前台 | 界面自己回到前台,顶部显示「上次运行:完成:处理 1 个视频 / 发出 0 条评论」 |
| 两步停止 | 状态条「正在收尾」(琥珀色)→ 约 1 秒后「已停止(假引擎)」;引擎日志 收到停止请求(runId 匹配) |
| 未接入的脚本被拦下 | 在 UID私信页点「开始运行」→ 弹「…的执行引擎尚未接入」,引擎调用数为 0(ID提取已接入,改用它验这条闸) |
| ID提取端到端 | 界面点运行 → 引擎读到 task=id + 完整取材清单 → 抓 3 个 uid → 产物文件 3 行 3 唯一 → 记录页「完成:提取 3 个 ID → 文件路径」 |
联调时自己绊自己两次,都记下来免得重演:
am start -f 0x20000000 把界面切回前台 ——
结果 TikTok 被挤到后台,引擎立刻"瞎掉",那一次运行报「搜索流程失败」。
这不是 bug,而是"引擎必须待在 TikTok 前台"的直接证据。后来改用假引擎
(tools/fake-engine.js,不碰 TikTok)才把 UI 交互测干净,并顺手加了
「点状态条切回 TikTok」与「回到前台时提醒一次」。[TTA] 前缀就能看出来)。所以换引擎前后都要核对 md5sum。| 验的东西 | 结果 |
|---|---|
| 四张卡各自展开 | 脚本一 4 项 / 脚本二 2 项 / 脚本三 4 项 / 脚本四 5 项,与稿子逐项一致 |
| 脚本一的「主播 ID」 | 直接输入 anchor001#anchor002 → 切页签来回 → 杀进程重启后仍在 |
| 开关与右侧控件联动 | 关掉「主播去重」→ 右侧「ID 模式」下拉消失;再打开→回来 |
| 脚本三的「进入页面」 | 显示「关注页」(脚本一是「粉丝页」,同一控件不同默认值) |
| 脚本四的「关键词主播」 | 显示默认值 fashion,不是空框 |
| 滚动指引的位置 | 「↓ 下方还有「功能设置」,向下滚动查看」在脚本四之后、功能设置标题之前(2026-10-13 按要求从页尾移上来) |
这几条里最容易写错的是最后两条:同一类控件在不同卡片上默认值不同 (进入页面:脚本一粉丝页 / 脚本三关注页),以及开关关掉后要移除右侧控件。 两者都已进审计(
脚本三的「进入页面」默认「关注页」/关掉「主播去重」后不再显示「去重方式」)。
| 验的东西 | 结果 |
|---|---|
| v5 老存档迁移 | 老配置 activeTab="id" → 启动直接停在 ID提取 详情页(返回头 + 三 tab 底栏),配置一条没丢 |
| 任务列表 B1 | 四张卡(图标框/任务名/状态徽标/当前脚本/上次运行/运行按钮)✅ 截图 28-v6-tasks |
| 未配置 / 空闲徽标 | 选了脚本的任务显示「空闲」,ID提取一个都没选时显示「未配置」✅ |
| 任务卡「上次…」是真实数据 | 跑过一轮后视频功能卡显示「上次 05:26 · 完成:处理 1 个视频 / 发出 0 条评论」✅ |
| 点卡片主体进详情 | 卡片 → 二级页(返回头「‹ 视频功能」+ 参数 + 底部 CTA)✅ |
| 点运行按钮直接起跑 | 任务卡「运行」→ 引擎起来(run-state 变 running)→ 自动停在那个任务的详情页 ✅ |
| 全局运行条 | 顶部 ● 视频功能 · 正在处理第 1/1 个视频 00:00:21(切回界面截图 38-v6-running-foreground)✅ |
| 记录列表 D1 | 「共 N 条 · 点任一条查看执行日志」+ 条目(任务·脚本 / 已完成 / 今天 05:26 · 耗时 27 秒 / 结果行)✅ |
| 记录空态 D2 | 无记录时「还没有运行记录 / 去「任务」标签选一个脚本跑起来」✅ 截图 29-v6-records-empty |
| 运行详情 D3 + 真实日志 | 摘要卡 + 日志卡,日志行就是引擎 run-log.jsonl 的内容([bridge] 已连接到界面宿主,runId=… 等 28 行)✅ 截图 35-v6-record-detail |
| 我的页 E1 | 卡密/应用/关于三段,版本显示真实构建号 2026.10.13、TikTok 版本读系统(v47.1.4)✅ |
| 二次运行落第二条记录 | 两次运行 → D1 显示 2 条,各自的耗时不同 ✅ |
现象:任务列表上点卡片的「运行」按钮有反应,但点卡片其它任何地方(包括脚本名所在的文本组) 都进不去参数设置页。
根因:W.taskCard 里写过一句 line.setOnClickListener(null),本意是"行内不挂监听、
让点击气泡到卡片"(card.setOnClickListener(onOpen))。但 AutoX 的桥对 null 并不保证是
"清掉监听" —— 它很可能装上一个吃掉点击却什么都不做的空监听。
于是触摸被那一层吞掉,永远到不了 card:
| 点哪里 | 结果 | 为什么 |
|---|---|---|
| 「运行」按钮 | ✅ 起跑 | 按钮是它自己的子视图,装着真监听 |
| 卡片其它地方 | ❌ 毫无反应 | 触摸被中间那层的空监听吃掉 |
这两件事实拼起来才看得出问题 —— 单看"按钮能用"会以为整条点击链路是好的。
修法:删掉那句 null 监听,并让 card / 图标框 / 文本组 各挂一次同一个回调:
谁接到都做同一件事,既不依赖气泡顺序,也不怕中间某层被点成 clickable。
smoke.js 新增 5 条断言逐个点这三个区域(外加守住"中间那层不许有空监听")。
顺便纠正上次的一个误判:这不是
uiautomator dump滞后造成的假象 —— dump 滞后确实存在,但卡片点击当时是真的坏的。⚠️ 另一个假象,记下来:验证过程中一度以为「内容区点不动、只有底栏能点」, 排查了半天(甚至怀疑新增的底部渐隐层挡触摸)。真相是—— 这台设备上
uiautomator dump在自绘界面上会滞后一步,我拿 dump 当"点击后的事实", 于是每次看到的都是点击之前的界面。改用screencap截图核对后一切正常 (点「解绑」→ 弹窗确实弹出)。结论:核对交互结果永远以截图为准,dump 只用来取坐标。 顺带做的对照实验证明了这一点:把 2026-10-11 的旧产物推到设备上,同样"点不动"。
ball-debug 打开后逐项验,全部有可读取的证据(不是"看着像"):
| 验的东西 | 证据 |
|---|---|
| 折叠态几何 | ball-layout.txt:window 616x528 @ -16,696 / root 568x480 / ball center 反算 = 锚点 ✅ |
| 展开扇形 | 8 个子视图:4 按钮 + 4 标签,位置与 R=96 / 角度 60,20,-20,-60 逐像素吻合 ✅ |
| 尺寸变化引发 CANCEL | ball-touch.log 里 UP 之后紧跟一条 action=3 (0,0) —— 被正确忽略(open 仍为 true)✅ |
| 扇形第 1 个按钮(运行) | 点下去 → run-state.json 变成新 runId、status:"running"、phase:"进入信息流" —— 引擎真的被这个按钮跑起来了 ✅ |
| 扇形第 1 个按钮(停止) | 运行中再点 → 轨迹三步 click fan#0 primary → confirmThenAct bringToFront=true → 弹窗已弹出;dumpsys window 显示前台是 org.autojs.autoxjs.v7/…ScriptExecuteActivity ✅ |
| 「回到 App」 | 先 KEYCODE_HOME 回桌面,再点它 → 前台焦点变回 ScriptExecuteActivity ✅ |
| 「隐藏悬浮球」 | 点它 → ball-rect.json = {"hidden":true},之后再点球位一行新事件都没有(窗口真没了)✅ |
| 「退出应用」 | 点它 → uiautomator dump 里出现「退出飞舟脚本? / 将停止正在运行的脚本,并关闭悬浮球 / 取消 / 退出应用」✅ |
| 隐藏后能恢复 | 「我的」页悬浮球开关 → ball-rect.json 回到可见,点击重新有事件 ✅ |
| 引擎避让 | 引擎跑完一整轮搜索→打开→观看,没有一次点到球位 ✅ |
现象:球的展开、拖动、吸附、三态环全对,其余三个按钮都能用,
只有「运行 / 停止」点了毫无反应。触摸事件收到了、按钮也在、bounds 也对。
根因:那个按钮的动作存在一个可变 holder(FB.firstAction.fn)里,
由 applyFirstButton() 在三态发生变化时写入。而"展开扇形"这一步通常是 idle → idle ——
状态根本没变,FB.sync() 判定 changed === false,于是对新按钮实例从不调用
applyFirstButton(),holder 里始终是最初那个空函数 function () { }。
展开 → openFan 建新按钮(holder 还是空的)
→ sync() → tone 没变 → applyFirstButton() 不执行 → 回调是空壳
→ 点下去:事件到了、handler 跑了、什么也没发生
修法:动作在点击那一刻现算 —— action: function () { primaryState().fn(); },
holder 整个删掉。"忘记接线"这种可能就不存在了。
另外把扇形按钮的 handler 包上 try/catch 并写进 ball-error.txt:UI 回调里的异常在 AutoX 上
不报错、不弹窗、不结束脚本,不写下来就是彻底消失。
这一条是被离线自检钉死的:tools/smoke.js 现在有一条
「空闲态点扇形第 1 个按钮 → 真的发起运行」(点完断言 App.requestRun 被调用、参数是当前任务)。
写完立刻做了反向验证:把动作临时改回空函数 → 7 条断言立刻失败;改回来 → 全绿。
(这正是之前漏掉它的原因:floaty 在那之前根本不在离线桩里,悬浮球整块功能零覆盖。
现在桩补上了 floaty.window / ImageView / MotionEvent / images,悬浮球有 40+ 条断言。)
顺手改掉的两个小问题:① 选完按钮扇形不收起,会挡住用户此刻要看的 TikTok → 现在选完即收; ② 就地刷新按钮后不再 dump 布局 → 快照永远停在"展开那一刻",事后回看会误判按钮状态 → 现在补 dump。
现象:点球,球先"跳"一下,之后才展开或收起。
定位手法(可复用):给球挂布局轨迹,每次真正发生布局就把「窗口尺寸 + 球当时画在哪」
记一行到 ball-touch.log(ball-debug 门控)。第一次点击就露出了因果链:
+37ms 窗口已是 616×528 但画面还是旧视图 → +49ms 系统按新窗口重排(发 ACTION_CANCEL,这是证据)
→ +94ms 新视图才建好。光看这条还不够 —— 后来用 screenrecord 录屏 + ffmpeg -vsync 0
抽帧 + 逐帧量球心,才看清球在切换瞬间从下往上"爬"了几级才停(旧画面被摆到新窗口位置上)。
两次"聪明的补救"都失败了(真机实测,别再试):
| 尝试 | 结果 |
|---|---|
| 视图只建一次(不再每次重建 8 个视图 + 重解码品牌图,省掉 57ms) | 57ms 的窗口确实消失了,但切换瞬间照样有 1~2 帧错位 |
| 切换时"先藏球、再改尺寸" | 藏只是标记,改尺寸已经发给系统了 —— 旧画面照样被摆到新位置 |
"藏起来 → 等这一帧画完(onPreDraw)→ 再改尺寸" |
合成器有 2~3 帧缓冲队列,它照样能把"藏之前"那一帧摆到新位置 |
根因(一句话):改窗口尺寸/位置由系统立刻生效,而 App 的视图重排要等下一帧; 只要窗口几何变了,中间那几帧就必然是"旧画面 + 新窗口"。
真正的修法:让窗口几何根本不变 —— 拆成两个 overlay 窗口:
FLAG_NOT_TOUCHABLE(触摸自动穿透)。展开/收起 = 改显隐 + 改一个 flag。没有任何几何变化 ⇒ 不可能有位移。
代价是要自己走裸 WindowManager(addView / updateViewLayout / removeView)而不是
AutoX 的 floaty.window —— 而 floaty 恰恰做不到"两个窗口同时绘制"(第二个建得出来但画不出来),
这也是之前一直卡在"一个窗口"上的原因。两个裸窗口能同时绘制已实测
(_probe/fb_two_windows.js:红块与绿块都出现)。
实测(修复后):
| 验的 | 结果 |
|---|---|
| 连续 6 次"点击 + 立刻截图" | 球心每次都是 1170(= 锚点),无位移、也不消失 |
| 切换时的触摸事件 | 一次 ACTION_CANCEL 都没有(不再改窗口尺寸) |
| 展开/收起前后的窗口几何 | 两个窗口的 x/y/w/h 完全不变(自检里有专门的断言钉住) |
| 折叠态点扇形窗口的透明区 | 触摸穿透到 App(用滚动验证:列表真的滚了)→ 没有死区 |
| 展开态点扇形第 2 个按钮 | click fan#1 回到 App ✓,且自动收起 |
| 拖动 | 锚点平滑跟随,松手吸附右侧;扇形镜像到球的左侧 ✓ |
| 「隐藏悬浮球」 | 两个窗口都摘掉(截图确认球真的没了、没有孤儿窗口),从「我的」能恢复 ✓ |
⚠️ 顺带踩到并修掉的两个坑: ① 覆盖窗口必须带
FLAG_LAYOUT_IN_SCREEN—— 否则窗口被状态栏内缩,整套坐标整体下移 48px(正好一个状态栏高)。AutoX 的 floaty 带这个 flag,所以换成裸窗口后才暴露。 ② 同类型窗口按加入顺序叠放:球窗口必须后加(在最上层)。反过来的话,扇形展开时 球的矩形整个落在扇形窗口里,点球会被扇形窗口吃掉 → 再点一次收不起来(实测踩到)。⚠️ 验证手法上的一个坑(很重要):跑动中执行
uiautomator dump会把 AutoX 的无障碍服务撞掉 ⚠️ 验证手法上的一个坑(很重要):跑动中执行uiautomator dump会把 AutoX 的无障碍服务撞掉 —— logcat 里能看到 AutoX 进程随后连续 7 次发android.settings.ACCESSIBILITY_SETTINGS(子引擎卡在auto.waitFor()循环里)。这会把 App 和它的弹窗一起盖住, 让人误判成"确认弹窗没出来"。运行时只能看文件与 logcat,uiautomator dump只在没有运行任务时用。
tools/build.js 四道检查,任一不过直接阻断构建:
"ui"; 指令必须在第一行——被注释放到后面,脚本会以非 UI 模式执行new Function)——设备端没有 linterassertProjectEntry,写盘之前跑)——project.json 的 main 指向的文件
必须存在、第一行是 "ui";、且确实加载了界面产物。这类错误在设备上的表现是
「以非 UI 模式执行 + 一堆不着边际的报错」,从日志里看不出根因,所以必须在 PC 侧拦住。tools/smoke.js 配合 tools/android-stub.js 在 PC 上把产物真实跑一遍(387 项断言)——
v6 新增的部分:任务卡直跑 / F3 全局单跑确认(取消与「停止并开始」两条路径)/ 校验顺序
(卡密 → 权限 → 已选脚本 → 引擎 → 是否在跑)/ 运行记录落盘与上限 / F5 报错态与红色运行条 /
D3 日志读取(坏行只丢自己)/ Records 文案纯函数 / 悬浮球 80+ 项
(显示前置三条件、几何、单击/拖动/CANCEL、四个按钮的动作、隐藏与恢复、球位发布给引擎、
视图只建一次的回归防线、切换后不留下看不见的球)。
tools/android-stub.js里悬浮球相关的桩是为了消灭"零覆盖"才加的:floaty.window/ImageView/MotionEvent/images以前一个都没有,于是产物把建窗包在try里静默失败,悬浮球整块功能在离线检查里一条断言都跑不到 —— 那个 "界面全对、点了没反应"的 bug 就是这么溜过去的。 桩里另外两条纪律没变:setInterval只登记不自动跑(自动跑就是死循环,用env.flushIntervals()推进);MockTextView.setText同步触发 TextWatcher(真机就是这个顺序)。
加载与挂载 · 模块依赖顺序正确、界面树建得出来
启动屏路由 · 卡密未激活时进登录页,且登录页没有页签栏
主题计算 · argb / alphaOf / 渐变 / 描边的数值正确
颜色令牌 · 所有色值合法、不会解析成全透明、界面用到的键都存在
数据层 · 字段表默认值类型、set→读回→落盘闭环、深合并、校验收敛、枚举可逆
服务层 · 卡密格式与分组、licenseDisplay 带 status、过期判定、更新检查降级
设备层 · 多包名探测、权限顺序与「可选」标记、阻断权限判定
界面构建 · 5 个页签 + 5 个弹窗全部可构建,且弹窗文案与设计稿逐字一致
登录流程 · 空卡密不切屏;合法卡密 → 主界面 + 状态落盘 + 三态复位
交互闭环 · 单选互斥 / 取消回到「未选」、切页签后按钮的有无、运行配置落盘
运行态闭环 · 未选脚本拦截 → 缺权限拦截 → **未接入引擎的脚本被拦下(且不起引擎)** →
**ID提取已接入:点运行真的起引擎且配置里带全取材清单** →
开始运行 → 定位引擎产物 → **唤起引擎 + 参数与映射配置逐项核对** →
状态条 + 计时推进 → 引擎上报进度就地在状态条刷新 →
**点停止发请求 → 「正在收尾」→ 引擎确认后才复位** →
finished 终态接住 / **引擎消失且无终态的兜底判定** →
卡密失效自动停跑 + 向引擎发停止请求 + 弹窗 → 点确认回登录页
悬浮球 · 三态前置(卡密/悬浮窗权限/未隐藏)→ 建窗与幂等 → 折叠态几何与密度换算 →
**球位发布给引擎** → 单击展开 / 拖动吸附记忆 / **CANCEL 不当点击** →
**四个扇形按钮的动作都真的触发**(运行 / 停止确认 / 回到 App / 退出确认)→
选完自动收起 → 隐藏落盘 + 通知引擎「球没了」→ 开关恢复后能重建
$ node tools/smoke.js
通过 387 项
SMOKE_ALL_PASS
tools/audit-values.js 专门盯「界面取值与交互」,六个场景(250+ 项断言):
| 场景 | 验什么 |
|---|---|
| A 全新配置 | 5 个页签 + 登录页渲染后,没有任何控件显示 undefined / null / NaN |
| B 脏数据 | 灌入 v1 老存档(缺字段 + 类型错 + 枚举非法 + 越界 + 空串 + 多余字段 + v4 的旧单值字段),验证归一化与迁移,并重新渲染确认界面干净 |
| C 字段表完整性 | 每个字段的默认值都与声明类型相符;除卡密与 optional 外文本默认值非空;默认配置通过校验且幂等;序列化往返不变样 |
| D 输入与弹窗 | 直接在输入框里逐字敲(桩里的 type / backspace 与输入法走同一条通路)→ 断言「打字即落盘」、点输入框不弹窗、越界值失焦后被夹紧并写回栏位、清空是中间态不把配置冲成 0、退格期间不落盘但离开前整体提交不丢、区间行两个数各自独立可改、联动只刷兄弟且不抢当前输入、非法输入不改配置、单选写回、弹窗唯一性 |
| E 登录页三态 + 运行状态条 | idle / verifying / failed 三态的文案与保留的卡密;状态条可就地刷新时长;时长格式化(超过 24 小时不回绕) |
| F 持久化覆盖 | ① 界面上绑定的每个路径都必须在字段表里——表外路径会被下次启动的 validate() 丢掉,是「改了但存不住」最隐蔽的一种;② 逐个控件真操作一遍 + 模拟重启(丢内存 state、重新从 storages 读)后界面显示新值:勾选框 / 开关 / 下拉 / 输入框 / 区间行 / 多选开关 / 脚本卡勾选与展开 / 文件路径 / 登录页卡密草稿;③ 落盘 JSON 是「全量」而不是子集,且带版本号 |
场景 F 的 ① 是对着代码看不出来的那类问题:路径拼错时
Store.set照样写进内存、 全程不报错,但下次启动按字段表重建配置就把它丢了。做法是渲染一遍所有页面、 记录所有读过的路径(所有取值函数最终都走Store.get),再逐个对照字段表 —— 顺带把「条件渲染」的控件也覆盖到了(把所有布尔开关都打开再扫一遍, 否则「云端去重」揭开后才出现的表名输入框会被误判为「没有界面的字段」)。这一条同时保证了 ID提取四张脚本卡的展开态被覆盖到:
expanded本身也是 bool, 全开一遍就等于把四张卡都展开再扫。场景 A 里另有一条断言直接检查 「这 25 个字段都有界面控件」—— 只要哪张卡展开后其实什么都没渲染,它就会报出来。
$ node tools/audit-values.js
AUDIT_CLEAN
场景 B / D / F 是这套检查里最有价值的三条。 「关键词只显示占位符」只有拿老存档跑才暴露; 「双值只能输一个」「弹窗被提前关掉」连真机手点都要点好几次才发现; 「25 小时回绕成 01 小时」这种只在长跑时才出现。
场景 B 里还专门放了一个 v4 存档,用来看五处旧单值是否被正确搬进新区间 (含「旧值非数字 → 不迁移、直接用默认区间」这条分支)。
另有反向断言守住两条底线: ① 界面上不再出现
200 / 30、100 ~ 500、20 ~ 20这类把两个值挤进一个胶囊的写法; ② 上一版的旧标签(关注上限/私信上限/休息间隔/单次操作上限) 不再出现 —— 改名时漏掉一处就会在这里暴露。还有一条结构断言:每个区间行必须真的是「标签 + 两个数字胶囊 + 一个
~」, 数一数那一行里纯数字的胶囊个数即可(多一个少一个都会报)。 这类检查之所以必要,是因为「改了标签但忘了改控件」在肉眼看来几乎一样。
license.draftCode):手打 16 位卡密时被系统回收进程,
重开还能接着填;激活成功后也不清(解绑后回登录页,上次的卡密已经填好,一键重新激活)。
不想要这个便利的话,把 W.inlineField 的 path 去掉即可(它就退回一次性取值)。关注量 [0] ~ [500000]):两个数各自一个输入框、
各自可改,但排在同一行。这样功能设置卡短了一半,四个门槛不用滚很久才看完;
也不至于像「关注量下限 / 关注量上限」两行那样,看着像两个互不相干的设置。adjustPan)。真机验证过不影响操作。W.TABS 的 icon 字段。/sdcard/Download 等目录里的 APK,交给系统安装器。run-config.json + 显示状态条,
真正驱动 TikTok 的动作由 tiktok-auto/ 负责。App.bringToFront() + App 内已验证过的弹窗)真机验证过、稳定,
而换成浮窗形态要重做 H5/H6 的样式与触摸,收益只是"不用跳一下 App"。
真要改的话,更稳的做法是把确认内联进扇形(按钮就地变成"确认停止?"),不需要第三个窗口。setAdjustEnabled(false):floaty.window 默认带一个 AutoX 的调整手柄。
实测球的几何与手势都不受影响(手柄与球不重叠),所以没有动它 ——
这项设置在真实项目里没有验证过,改它属于"顺手改一个没验证的开关",容易引入新问题。slack = 12dp,即根视图那圈透明内边距):
这是刻意的 —— 夹窗口会让球多内缩 8dp、看起来不贴边。发布给引擎的
ball-rect.json 用的是窗口矩形(比球大一圈),因为透明区同样会吃掉触摸。