tiktok-auto.js 114 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103
  1. /*! tiktok-auto v1.0.0
  2. * built 2026-10-13T15:48:23.018Z
  3. * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, main
  4. * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  5. */
  6. (function () {
  7. var TikTokAuto = {};
  8. /* ============================== src/logger.js ============================== */
  9. /**
  10. * logger.js — 结构化日志
  11. *
  12. * 为什么单独成模块:设备脚本出错时,唯一能依赖的就是回传的日志。
  13. * 把日志格式固定下来(`[TTA][步骤名] 内容`),PC 侧就能按前缀过滤、按步骤计时,
  14. * 而不是靠人肉读一堆散乱的 console.log。
  15. *
  16. * 2026-10-13 增加**文件落盘通道**(sink):界面侧的「运行记录 → 详情的执行日志」
  17. * 要看的就是这些行。之所以不做成"界面去抓 console",是因为
  18. * console 是另一个进程的 stdout,界面根本读不到(见 bridge.js 的通道说明)。
  19. * sink 由 bridge 在启用宿主模式时装上,**独立运行时为空 → 零开销**。
  20. *
  21. * 依赖:无。始终通过 TikTokAuto.logger 取用,因此可以被放在 concat 顺序的最前面。
  22. */
  23. TikTokAuto.logger = (function () {
  24. 'use strict';
  25. var PREFIX = '[TTA]';
  26. var startedAt = Date.now();
  27. /** 文件落盘回调,由 bridge 注入:function(level, step, message) */
  28. var sink = null;
  29. function stamp() {
  30. return ('+' + (Date.now() - startedAt) + 'ms');
  31. }
  32. function write(level, step, message) {
  33. var line = PREFIX + stamp() + '[' + level + '][' + step + '] ' + message;
  34. console.log(line);
  35. if (sink) {
  36. /* 落盘失败绝不能影响流程:日志是"顺带记录的",不是"必须成功的"。 */
  37. try {
  38. sink(level, step, message);
  39. } catch (ignored) {
  40. /* 刻意吞掉:sink 抛错会把一次正常的日志调用变成流程中断 */
  41. }
  42. }
  43. }
  44. return {
  45. /** 装上文件落盘通道(传 null 卸载) */
  46. setSink: function (fn) {
  47. sink = typeof fn === 'function' ? fn : null;
  48. },
  49. /** 记录一次进入某步骤 */
  50. step: function (name, message) {
  51. write('STEP', name, message === undefined ? '' : message);
  52. },
  53. info: function (step, message) {
  54. write('INFO', step, message);
  55. },
  56. /** 可恢复的问题:值得记下来,但不中断流程 */
  57. warn: function (step, message) {
  58. write('WARN', step, message);
  59. },
  60. error: function (step, message) {
  61. write('ERROR', step, message);
  62. },
  63. /** 机器可读的结构化结果,PC 侧用它做断言 */
  64. result: function (payload) {
  65. console.log(PREFIX + 'RESULT ' + JSON.stringify(payload));
  66. },
  67. /** 从脚本开始到现在的耗时,用于定位"哪一步慢" */
  68. elapsedMs: function () {
  69. return Date.now() - startedAt;
  70. }
  71. };
  72. }());
  73. /* ============================== src/bridge.js ============================== */
  74. /**
  75. * bridge.js — 与「界面宿主」之间的桥
  76. *
  77. * 为什么需要它:这个脚本有两种运行方式——
  78. * ① 独立运行(PC 侧下发、或直接打开产物):自己读配置,跑完打印 `__END__`;
  79. * ② 被界面宿主唤起(`engines.execScriptFile`,见 tiktok-app):
  80. * 配置由宿主生成,宿主还要能看见**进度**、能**叫停**。
  81. *
  82. * 两种方式共用同一套流程,差别只在「上下文从哪来、状态往哪去」,所以抽成这个模块:
  83. * 流程代码只调 `bridge.report()` / `bridge.checkStop()`,不必知道自己在被谁驱动。
  84. * **未启用时两者都是空操作**,独立运行零开销、零行为变化。
  85. *
  86. * 通道全部走**文件**,不走共享内存 —— 宿主与子引擎是两个独立的 ScriptEngine,
  87. * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
  88. * 文件是这里唯一可靠的通道。
  89. *
  90. * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下):
  91. * run-config.json 宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
  92. * engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
  93. * run-state.json 引擎 → 宿主:进度与终态(本模块写)
  94. * stop.flag 宿主 → 引擎:停止请求(本模块只读)
  95. *
  96. * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
  97. * 好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。
  98. * 好处二:上一轮残留的标志不会误杀本轮 —— runId 不同,自然不匹配。
  99. *
  100. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  101. */
  102. TikTokAuto.bridge = (function () {
  103. 'use strict';
  104. var DEFAULT_DIR = '/sdcard/tiktok_app';
  105. var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
  106. var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
  107. var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
  108. /**
  109. * 检查停止标志的最小间隔。
  110. * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO;
  111. * 500ms 的响应延迟对人来说与"立刻"无异。
  112. */
  113. var STOP_POLL_MIN_MS = 500;
  114. var state = {
  115. enabled: false,
  116. runId: '',
  117. statePath: DEFAULT_STATE,
  118. stopFlagPath: DEFAULT_STOP_FLAG,
  119. logPath: DEFAULT_LOG,
  120. lastStopCheckMs: 0
  121. };
  122. var last = null;
  123. /**
  124. * 「用户要求停止」。**刻意用异常抛出**:
  125. * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
  126. * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。
  127. * 抛出后由 main 统一兜住,语义明确(名字里带 Signal,不是 Error 那种"出错了")。
  128. */
  129. function StopSignal() {
  130. this.name = 'StopSignal';
  131. this.message = '收到停止请求';
  132. this.stack = new Error(this.message).stack;
  133. }
  134. StopSignal.prototype = new Error();
  135. StopSignal.prototype.constructor = StopSignal;
  136. /**
  137. * 判断自有属性。
  138. * 刻意写成 `.apply(...)` 而不是「属性名 + 点 + call」的形式——见 config.js 里的说明:
  139. * MCP 桥的危险调用黑名单会按词边界匹配「call + 左括号」,`.apply` 语义等价却不会被误判。
  140. */
  141. function owns(object, key) {
  142. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  143. }
  144. function isStopSignal(error) {
  145. return !!(error && error.name === 'StopSignal');
  146. }
  147. /**
  148. * 取引擎启动参数。
  149. * Rhino 会把传进来的数组包装成 `{"0":..,"1":..}` 这种对象(实测),
  150. * 所以既接受真数组,也按数字下标收集一份。
  151. */
  152. function readExecArgv() {
  153. var list = [];
  154. try {
  155. var argv = engines.myEngine().execArgv;
  156. if (argv instanceof Array) {
  157. return argv;
  158. }
  159. for (var i = 0; argv && argv[i] !== undefined; i += 1) {
  160. list.push(argv[i]);
  161. }
  162. } catch (ignored) {
  163. // 独立运行时 execArgv 为空/不可用,按"没有参数"处理
  164. }
  165. return list;
  166. }
  167. /** 解析 `--key value` 形式的参数(不支持 `--key=value`,保持单一写法) */
  168. function parseArgs(argv) {
  169. var options = {};
  170. for (var i = 0; i < argv.length; i += 1) {
  171. var token = String(argv[i]);
  172. if (token === '--config') {
  173. options.configPath = argv[i + 1];
  174. i += 1;
  175. } else if (token === '--state') {
  176. options.statePath = argv[i + 1];
  177. i += 1;
  178. } else if (token === '--stop-flag') {
  179. options.stopFlagPath = argv[i + 1];
  180. i += 1;
  181. } else if (token === '--run-id') {
  182. options.runId = argv[i + 1];
  183. i += 1;
  184. } else if (token === '--log') {
  185. options.logPath = argv[i + 1];
  186. i += 1;
  187. }
  188. }
  189. return options;
  190. }
  191. /**
  192. * 入口用:把命令行参数翻译成「配置覆盖项 + 桥的开关」。
  193. *
  194. * @param base 构建时注入的配置(可为 null)
  195. * @returns 应当交给 main.run 的注入项
  196. */
  197. function fromExecArgv(base) {
  198. var injected = base || {};
  199. var options = parseArgs(readExecArgv());
  200. if (options.runId) {
  201. enable(options);
  202. }
  203. if (options.configPath) {
  204. injected.runtime = injected.runtime || {};
  205. injected.runtime.configPath = options.configPath;
  206. }
  207. return injected;
  208. }
  209. /**
  210. * 日志落盘(界面侧「执行日志」的来源)。
  211. *
  212. * 一行一条 JSON(jsonl):界面按行解析,坏行跳过即可 ——
  213. * 比整份 JSON 更抗"写着写着被杀进程"(半行只丢一行,不会让整份文件不可读)。
  214. * 用 append 而不是重写:日志是**流**,每写一次整文件会让长流程变成 O(n²) 的 IO。
  215. */
  216. function appendLog(level, step, message) {
  217. try {
  218. files.append(state.logPath,
  219. JSON.stringify({
  220. t: Date.now(),
  221. level: level,
  222. step: step,
  223. text: (step ? '[' + step + '] ' : '') + message
  224. }) + '\n');
  225. } catch (error) {
  226. /* 落盘失败不该影响流程:logger 那边已经吞了一层,这里再兜一层 */
  227. }
  228. }
  229. /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
  230. function enable(options) {
  231. state.enabled = true;
  232. state.runId = String(options.runId);
  233. state.statePath = options.statePath || DEFAULT_STATE;
  234. state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
  235. state.logPath = options.logPath || DEFAULT_LOG;
  236. state.lastStopCheckMs = 0;
  237. /* ★ 清空上一轮的日志:界面在结束时整份读走,
  238. * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
  239. try {
  240. files.write(state.logPath, '');
  241. } catch (error) {
  242. /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */
  243. }
  244. if (TikTokAuto.logger && TikTokAuto.logger.setSink) {
  245. TikTokAuto.logger.setSink(appendLog);
  246. }
  247. last = {
  248. runId: state.runId,
  249. status: 'starting',
  250. phase: '',
  251. done: 0,
  252. total: 0,
  253. updatedAt: 0
  254. };
  255. report({});
  256. log('info', '已连接到界面宿主,runId=' + state.runId);
  257. }
  258. function isEnabled() {
  259. return state.enabled;
  260. }
  261. /**
  262. * 写一次状态。宿主每秒读一次这个文件。
  263. * 失败只记 WARN:状态上报失败不该让正在跑的流程崩掉。
  264. */
  265. function report(patch) {
  266. if (!state.enabled || !last) {
  267. return;
  268. }
  269. for (var key in patch) {
  270. if (owns(patch, key)) {
  271. last[key] = patch[key];
  272. }
  273. }
  274. last.runId = state.runId;
  275. last.updatedAt = Date.now();
  276. try {
  277. files.write(state.statePath, JSON.stringify(last));
  278. } catch (error) {
  279. log('warn', '状态上报失败:' + error);
  280. }
  281. }
  282. /** 写终态。宿主靠它结束「运行中」并弹出结果。 */
  283. function finish(status, summary) {
  284. if (!state.enabled) {
  285. return;
  286. }
  287. report({
  288. status: status,
  289. phase: '',
  290. summary: summary || null,
  291. message: describe(status, summary)
  292. });
  293. log('info', '终态已上报:' + status);
  294. }
  295. /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
  296. function describe(status, summary) {
  297. if (status === 'stopped') {
  298. return '已停止';
  299. }
  300. if (status === 'finished') {
  301. var watched = summary ? summary.attemptedVideos : 0;
  302. var posted = summary ? summary.postedComments : 0;
  303. return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论';
  304. }
  305. var reason = summary && summary.errors && summary.errors.length > 0
  306. ? summary.errors[0]
  307. : '未知原因';
  308. return '未完成:' + reason;
  309. }
  310. /**
  311. * 停止检查。**由唯一的等待原语 `wait.tick` 调用**,
  312. * 于是所有长等待都能被打断,而流程代码一处都不用改。
  313. * 命中即抛 StopSignal。
  314. */
  315. function checkStop() {
  316. if (!state.enabled) {
  317. return;
  318. }
  319. var now = Date.now();
  320. if (now - state.lastStopCheckMs < STOP_POLL_MIN_MS) {
  321. return;
  322. }
  323. state.lastStopCheckMs = now;
  324. var content = '';
  325. try {
  326. if (!files.exists(state.stopFlagPath)) {
  327. return;
  328. }
  329. content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
  330. } catch (error) {
  331. log('warn', '读取停止标志失败:' + error);
  332. return;
  333. }
  334. if (content !== '' && content === state.runId) {
  335. report({ status: 'stopping', phase: '正在收尾', message: '收到停止请求,正在收尾' });
  336. log('warn', '收到停止请求(runId 匹配),中断当前等待');
  337. throw new StopSignal();
  338. }
  339. }
  340. function log(level, message) {
  341. if (TikTokAuto.logger && TikTokAuto.logger[level]) {
  342. TikTokAuto.logger[level]('bridge', message);
  343. }
  344. }
  345. return {
  346. DEFAULT_DIR: DEFAULT_DIR,
  347. DEFAULT_STATE: DEFAULT_STATE,
  348. DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
  349. DEFAULT_LOG: DEFAULT_LOG,
  350. StopSignal: StopSignal,
  351. isStopSignal: isStopSignal,
  352. readExecArgv: readExecArgv,
  353. parseArgs: parseArgs,
  354. fromExecArgv: fromExecArgv,
  355. isEnabled: isEnabled,
  356. checkStop: checkStop,
  357. report: report,
  358. finish: finish,
  359. describe: describe,
  360. appendLog: appendLog
  361. };
  362. }());
  363. /* ============================== src/config.js ============================== */
  364. /**
  365. * config.js — 配置的默认值、设备端加载、合并与校验
  366. *
  367. * 设计依据(Zakas《编写可维护的JavaScript》第 9 章「使用配置对象」):
  368. * 所有"会变的业务数据"——搜索关键词、评论文案、观看时长、超时阈值、开关——
  369. * 都集中在配置文件里;代码只负责读,不负责写死。
  370. * 于是**改文案/改时长不需要动代码**,也不需要重新构建。
  371. *
  372. * 配置来源优先级(后者覆盖前者):
  373. * 1. DEFAULTS(本文件,保证"没有配置文件也能跑")
  374. * 2. 设备上的 JSON 文件(默认 /sdcard/Download/tiktok-auto/config.json)
  375. * 3. 运行时注入的 payload(PC 侧下发脚本时携带,便于免推送做实验)
  376. *
  377. * 依赖:TikTokAuto.logger
  378. */
  379. TikTokAuto.config = (function () {
  380. 'use strict';
  381. var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json';
  382. var DEFAULTS = {
  383. target: {
  384. packageName: 'com.zhiliaoapp.musically',
  385. keyword: 'fashion',
  386. maxVideos: 1,
  387. resultTab: 'Top',
  388. pickStrategy: 'random'
  389. },
  390. watch: {
  391. minMs: 10000,
  392. maxMs: 35000
  393. },
  394. comments: {
  395. /* 是否执行评论动作。关掉就是「只浏览不发评论」——
  396. * 界面把「执行操作」里的评论开关映射到这里:
  397. * 用户没勾评论就不该发,哪怕评论池配好了。 */
  398. enabled: true,
  399. pool: [
  400. 'Love this!',
  401. 'So pretty',
  402. 'Great style',
  403. 'This is amazing',
  404. 'Need this look'
  405. ],
  406. avoidRepeatWithinRun: true,
  407. strictPostCheck: false,
  408. verifyInList: true
  409. },
  410. timeouts: {
  411. appLaunchMs: 25000,
  412. pageReadyMs: 15000,
  413. searchResultsMs: 20000,
  414. playerReadyMs: 20000,
  415. playbackStartMs: 12000,
  416. panelReadyMs: 8000,
  417. postMs: 15000,
  418. countRefreshMs: 6000
  419. },
  420. probe: {
  421. pollTickMs: 150,
  422. livenessIntervalMs: 2500,
  423. stallTolerance: 3,
  424. useAudio: true,
  425. usePixels: false
  426. },
  427. guard: {
  428. enabled: true,
  429. intervalMs: 1200,
  430. firstScanTimeoutMs: 4000,
  431. dismissLabels: [
  432. 'Not now', 'No thanks', 'Skip', 'Later', 'Cancel', 'Close',
  433. '稍后', '暂不', '取消', '关闭', '以后再说'
  434. ]
  435. },
  436. runtime: {
  437. configPath: DEFAULT_DEVICE_PATH,
  438. maxRuntimeMs: 0,
  439. dryRun: false
  440. }
  441. };
  442. /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */
  443. function ConfigError(message, detail) {
  444. this.name = 'ConfigError';
  445. this.message = message;
  446. this.detail = detail;
  447. this.stack = new Error(message).stack;
  448. }
  449. ConfigError.prototype = new Error();
  450. ConfigError.prototype.constructor = ConfigError;
  451. /**
  452. * 判断自有属性。
  453. *
  454. * 这里刻意写成 `.apply(...)` 而不是更常见的「属性名 + 点 + 名字为 call 的方法」那种形式。
  455. * 原因:MCP 桥会对下发的脚本做危险调用黑名单扫描,其中一条规则按词边界匹配
  456. * 「名字为 call 的方法 + 左括号」的片段。`.apply` 语义完全等价,却不会被误判成
  457. * 危险代码、从而让每次下发都多要一次人工确认。
  458. */
  459. function owns(object, key) {
  460. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  461. }
  462. function isPlainObject(value) {
  463. return value !== null && typeof value === 'object' && !(value instanceof Array);
  464. }
  465. /** 深拷贝(Rhino 没有 Object.assign,也不该依赖它) */
  466. function clone(value) {
  467. if (value instanceof Array) {
  468. var list = [];
  469. for (var i = 0; i < value.length; i += 1) {
  470. list.push(clone(value[i]));
  471. }
  472. return list;
  473. }
  474. if (isPlainObject(value)) {
  475. var copy = {};
  476. for (var key in value) {
  477. if (owns(value, key)) {
  478. copy[key] = clone(value[key]);
  479. }
  480. }
  481. return copy;
  482. }
  483. return value;
  484. }
  485. /**
  486. * 深合并:对象逐键递归,数组整体替换。
  487. * 数组"整体替换"是刻意的——评论池这类配置如果被逐项合并,
  488. * 用户就永远删不掉默认评论了。
  489. */
  490. function merge(base, override) {
  491. if (!isPlainObject(override)) {
  492. return clone(override);
  493. }
  494. var result = clone(base);
  495. for (var key in override) {
  496. if (!owns(override, key)) {
  497. continue;
  498. }
  499. var value = override[key];
  500. if (isPlainObject(value) && isPlainObject(result[key])) {
  501. result[key] = merge(result[key], value);
  502. } else {
  503. result[key] = clone(value);
  504. }
  505. }
  506. return result;
  507. }
  508. function readDeviceJson(path) {
  509. var logger = TikTokAuto.logger;
  510. try {
  511. if (typeof files === 'undefined' || !files.exists(path)) {
  512. logger.info('config', '未找到配置文件,使用内置默认值:' + path);
  513. return null;
  514. }
  515. var raw = files.read(path);
  516. var parsed = JSON.parse(raw);
  517. logger.info('config', '已加载配置文件:' + path);
  518. return parsed;
  519. } catch (error) {
  520. // 配置写坏了必须让用户知道,但不应因此让整脚本无法启动——
  521. // 用默认值继续跑,同时把问题显著地抛到日志里。
  522. logger.error('config', '配置文件解析失败,改用默认值:' + error);
  523. return null;
  524. }
  525. }
  526. function validate(config) {
  527. var target = config.target;
  528. if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
  529. throw new ConfigError('target.keyword 不能为空', target.keyword);
  530. }
  531. /* 「评论池为空」只有在**要发评论**时才算配置错误:
  532. * 只浏览不发评论是合法的一次运行(界面上没勾评论就是这种情形)。 */
  533. if (config.comments.enabled && config.comments.pool.length === 0) {
  534. throw new ConfigError('comments.pool 不能为空,至少需要一条评论文案', 0);
  535. }
  536. if (config.watch.minMs > config.watch.maxMs) {
  537. throw new ConfigError('watch.minMs 不能大于 watch.maxMs', {
  538. minMs: config.watch.minMs,
  539. maxMs: config.watch.maxMs
  540. });
  541. }
  542. if (config.target.maxVideos < 1) {
  543. throw new ConfigError('target.maxVideos 必须 >= 1', config.target.maxVideos);
  544. }
  545. return config;
  546. }
  547. /**
  548. * 解析最终生效的配置。
  549. *
  550. * @param injected PC 侧注入的覆盖项(可为 null)
  551. */
  552. function resolve(injected) {
  553. var logger = TikTokAuto.logger;
  554. var fromDefaults = clone(DEFAULTS);
  555. var path = (injected && injected.runtime && injected.runtime.configPath) || DEFAULTS.runtime.configPath;
  556. var fromDevice = readDeviceJson(path) || {};
  557. var config = merge(merge(fromDefaults, fromDevice), injected || {});
  558. validate(config);
  559. logger.info('config', '生效配置:' + JSON.stringify({
  560. keyword: config.target.keyword,
  561. maxVideos: config.target.maxVideos,
  562. resultTab: config.target.resultTab,
  563. watchMs: [config.watch.minMs, config.watch.maxMs],
  564. commentEnabled: config.comments.enabled,
  565. commentCount: config.comments.pool.length,
  566. dryRun: config.runtime.dryRun
  567. }));
  568. return config;
  569. }
  570. return {
  571. DEFAULTS: DEFAULTS,
  572. DEFAULT_DEVICE_PATH: DEFAULT_DEVICE_PATH,
  573. readDeviceJson: readDeviceJson,
  574. merge: merge,
  575. resolve: resolve,
  576. ConfigError: ConfigError
  577. };
  578. }());
  579. /* ============================== src/wait.js ============================== */
  580. /**
  581. * wait.js — 唯一的等待原语
  582. *
  583. * 核心主张:**脚本里不允许出现裸 sleep 当作"等待"**。
  584. *
  585. * tick(轮询节拍) = "多久检查一次" → 允许存在,默认 150ms
  586. * sleep(盲等) = "赌它 X 秒后会好" → 禁止用作等待手段
  587. *
  588. * 为什么这是稳定性的关键:网络抖动时页面可能 300ms 才渲染,也可能 8s 才渲染。
  589. * 写 `sleep(2000)` 的脚本在快的时候白等 1.7s,在慢的时候直接失败;
  590. * 而 `waitFor(条件, 超时)` 在快的时候立刻返回,慢的时候一直等到条件成立。
  591. * 这把"网络有多慢"从"脚本能不能跑通"里彻底解耦。
  592. *
  593. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  594. */
  595. TikTokAuto.wait = (function () {
  596. 'use strict';
  597. var DEFAULT_TICK_MS = 150;
  598. function now() {
  599. return Date.now();
  600. }
  601. /**
  602. * 轮询节拍。
  603. *
  604. * ★ 这里是**唯一**的等待出口,所以「用户点了停止」的检查放在这一处就够了 ——
  605. * 全流程(等待应用启动、等待播放、等待结果卡片、等待评论区……共几十处等待)
  606. * 都会经过 tick,于是都能被打断,业务代码一行都不用改。
  607. * bridge 未启用时是空操作(独立运行时零开销)。
  608. */
  609. function tick(ms) {
  610. if (TikTokAuto.bridge) {
  611. TikTokAuto.bridge.checkStop();
  612. }
  613. sleep(ms === undefined ? DEFAULT_TICK_MS : ms);
  614. }
  615. /**
  616. * 反复求值 predicate 直到它返回"真值"或超时。
  617. *
  618. * predicate 的返回值语义:
  619. * - null / false → 条件未满足,继续轮询
  620. * - 其它任何值(含 0!) → 条件已满足,原样返回该值
  621. * 之所以不用 `if (value)` 判断,是为了允许 predicate 合法地返回 0 / '' 这类"假值结果"。
  622. *
  623. * @returns 满足时的返回值;超时返回 null
  624. */
  625. function poll(predicate, timeoutMs, tickMs) {
  626. var deadline = now() + timeoutMs;
  627. var step = tickMs === undefined ? DEFAULT_TICK_MS : tickMs;
  628. var last = null;
  629. for (;;) {
  630. try {
  631. var value = predicate();
  632. if (value !== null && value !== false && value !== undefined) {
  633. return value;
  634. }
  635. last = value;
  636. } catch (error) {
  637. last = error;
  638. }
  639. if (now() >= deadline) {
  640. return null;
  641. }
  642. tick(Math.min(step, Math.max(1, deadline - now())));
  643. }
  644. }
  645. /**
  646. * 带日志与语义名字的 poll —— 业务代码应该用这个,而不是裸 poll。
  647. * 超时会记 WARN(而不是静默返回 null),这样"卡在哪一步"永远有据可查。
  648. */
  649. function waitFor(name, predicate, timeoutMs, tickMs) {
  650. var began = now();
  651. var value = poll(predicate, timeoutMs, tickMs);
  652. var cost = now() - began;
  653. if (value === null) {
  654. TikTokAuto.logger.warn('waitFor', name + ' 超时(等了 ' + cost + 'ms / 上限 ' + timeoutMs + 'ms)');
  655. } else {
  656. TikTokAuto.logger.info('waitFor', name + ' 就绪(' + cost + 'ms)');
  657. }
  658. return value;
  659. }
  660. /** 等待条件成立,并把成立时的值原样返回;语义等价于 waitFor */
  661. function until(condition, timeoutMs, tickMs) {
  662. return poll(condition, timeoutMs, tickMs);
  663. }
  664. /** 等待某个东西消失(例如弹窗、加载圈) */
  665. function untilGone(isPresent, timeoutMs, tickMs) {
  666. return poll(function () {
  667. return isPresent() ? null : true;
  668. }, timeoutMs, tickMs);
  669. }
  670. /**
  671. * 等待页面"安静"下来。
  672. *
  673. * 判据不是时间,而是**可见文案的拼接签名连续 N 次不变**。
  674. * 这比 sleep 可靠:加载中会不断有新节点出现,签名就会变;
  675. * 真正稳定后才返回,因此天然适配"网络慢 → 渲染慢"。
  676. *
  677. * @param sample () => string,页面签名
  678. * @param options { maxMs, tickMs, stableTimes }
  679. */
  680. function waitQuiet(sample, options) {
  681. var settings = options || {};
  682. var maxMs = settings.maxMs === undefined ? 8000 : settings.maxMs;
  683. var tickMs = settings.tickMs === undefined ? 250 : settings.tickMs;
  684. var stableTimes = settings.stableTimes === undefined ? 3 : settings.stableTimes;
  685. var deadline = now() + maxMs;
  686. var previous = null;
  687. var sameCount = 0;
  688. while (now() < deadline) {
  689. var current = sample();
  690. if (current === previous) {
  691. sameCount += 1;
  692. if (sameCount >= stableTimes) {
  693. return true;
  694. }
  695. } else {
  696. sameCount = 0;
  697. previous = current;
  698. }
  699. tick(tickMs);
  700. }
  701. return false;
  702. }
  703. return {
  704. DEFAULT_TICK_MS: DEFAULT_TICK_MS,
  705. tick: tick,
  706. poll: poll,
  707. waitFor: waitFor,
  708. until: until,
  709. untilGone: untilGone,
  710. waitQuiet: waitQuiet
  711. };
  712. }());
  713. /* ============================== src/layout.js ============================== */
  714. /**
  715. * layout.js — 对 AutoX 无障碍 API 的适配层(Boundary Adapter)
  716. *
  717. * 依据《代码整洁之道》第 8 章「边界」:
  718. * 第三方/平台 API 一定有自己的脾气(`auto.root` 是属性不是函数、
  719. * `text()` 是快照、离屏节点占了 40%+…)。把所有这些脾气**关在这一个文件里**,
  720. * 业务代码就只面对一组干净、语义明确的函数。
  721. * 平台 API 变了 → 只改这里。
  722. *
  723. * 依赖:TikTokAuto.logger、TikTokAuto.wait
  724. */
  725. TikTokAuto.layout = (function () {
  726. 'use strict';
  727. var SAFE_MARGIN = 8;
  728. function screen() {
  729. return {
  730. width: device.width,
  731. height: device.height
  732. };
  733. }
  734. /** 当前应用窗口的根节点。注意:是属性 `auto.root`,不是函数 `auto().root()` */
  735. function root() {
  736. return auto.root;
  737. }
  738. /** 前台包名。**不能**用 currentPackage()——软键盘弹出时它返回输入法包名 */
  739. function foregroundPackage() {
  740. var node = root();
  741. return node ? node.packageName() : null;
  742. }
  743. function findAll(selector) {
  744. return selector.find();
  745. }
  746. function findOnce(selector) {
  747. var collection = selector.find();
  748. return collection && collection.size() > 0 ? collection.get(0) : null;
  749. }
  750. /**
  751. * 收集整棵树的节点(含离屏)。
  752. * AutoX 的 `find()` 只从根往下找**可见**节点,拿不到离屏节点;
  753. * 而我们需要离屏信息来做"下面还有内容 → 该滚动"的判断。
  754. */
  755. function collectAll(maxNodes) {
  756. var limit = maxNodes || 1200;
  757. var result = [];
  758. var start = root();
  759. if (!start) {
  760. return result;
  761. }
  762. var stack = [start];
  763. while (stack.length > 0 && result.length < limit) {
  764. var node = stack.pop();
  765. if (!node) {
  766. continue; // 遍历过程中节点可能失效,child(i) 会返回 null
  767. }
  768. result.push(node);
  769. var count = node.childCount();
  770. for (var i = 0; i < count; i += 1) {
  771. stack.push(node.child(i));
  772. }
  773. }
  774. return result;
  775. }
  776. /** 可见性判据:优先用原生 isVisibleToUser(),退化为"与屏幕求交" */
  777. function isVisible(node) {
  778. try {
  779. if (typeof node.isVisibleToUser === 'function') {
  780. return node.isVisibleToUser();
  781. }
  782. } catch (ignored) {
  783. // 落到下面的几何判据
  784. }
  785. try {
  786. if (typeof node.visibleToUser === 'function') {
  787. return node.visibleToUser();
  788. }
  789. } catch (ignoredToo) {
  790. // 落到下面的几何判据
  791. }
  792. return visibleRect(node) !== null;
  793. }
  794. /** 与屏幕求交后的可见矩形;完全在屏幕外时返回 null */
  795. function visibleRect(node) {
  796. var box = node.bounds();
  797. var view = screen();
  798. var left = Math.max(box.left, 0);
  799. var top = Math.max(box.top, 0);
  800. var right = Math.min(box.right, view.width);
  801. var bottom = Math.min(box.bottom, view.height);
  802. if (right - left <= 0 || bottom - top <= 0) {
  803. return null;
  804. }
  805. return { left: left, top: top, right: right, bottom: bottom };
  806. }
  807. /**
  808. * 可点击坐标 = **可见交集矩形的中心**,再夹到安全区内。
  809. *
  810. * 为什么要交集而不是几何中心:被状态栏切掉一半的节点,几何中心可能落在 y=0
  811. * (点到了状态栏),交集中心才是真正能摸到的地方。
  812. */
  813. function center(node) {
  814. var rect = visibleRect(node);
  815. var view = screen();
  816. if (!rect) {
  817. return null;
  818. }
  819. var x = Math.round((rect.left + rect.right) / 2);
  820. var y = Math.round((rect.top + rect.bottom) / 2);
  821. return {
  822. x: Math.min(Math.max(x, SAFE_MARGIN), view.width - SAFE_MARGIN),
  823. y: Math.min(Math.max(y, SAFE_MARGIN), view.height - SAFE_MARGIN)
  824. };
  825. }
  826. /**
  827. * 去掉文案里的**不可见格式字符**(零宽空格、LTR/RTL 标记、软连字符等)。
  828. *
  829. * 这是真机踩出来的坑:评论列表头部的计数实际文本是 "‎150 comments",
  830. * 前面挂着一个 U+200E(LEFT-TO-RIGHT MARK)——肉眼完全看不出来,
  831. * 但 `/^\s*\d+ comments$/` 这种带锚点的正则就是匹配不上,
  832. * 于是"评论列表确认"这条最强证据永远拿不到,只能退化到弱证据。
  833. *
  834. * 放在适配层里统一处理:平台这类噪音只在这里吸掉一次,业务代码就干净了。
  835. */
  836. function sanitize(value) {
  837. if (value === null || value === undefined) {
  838. return '';
  839. }
  840. return String(value)
  841. .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad]/g, '')
  842. .replace(/\s+/g, ' ')
  843. .trim();
  844. }
  845. function text(node) {
  846. var value = '';
  847. try {
  848. value = node.text();
  849. } catch (ignored) {
  850. value = '';
  851. }
  852. return sanitize(value);
  853. }
  854. function desc(node) {
  855. var value = '';
  856. try {
  857. value = node.desc();
  858. } catch (ignored) {
  859. value = '';
  860. }
  861. return sanitize(value);
  862. }
  863. /** 节点的"可读标签":文案优先,没有就看内容描述 */
  864. function label(node) {
  865. return text(node) || desc(node);
  866. }
  867. function resourceId(node) {
  868. try {
  869. var id = node.id();
  870. return id ? String(id) : '';
  871. } catch (ignored) {
  872. return '';
  873. }
  874. }
  875. function shortId(node) {
  876. var id = resourceId(node);
  877. var parts = id.split('/');
  878. return parts.length > 1 ? parts[parts.length - 1] : id;
  879. }
  880. function classNameOf(node) {
  881. try {
  882. return String(node.className());
  883. } catch (ignored) {
  884. return '';
  885. }
  886. }
  887. function shortClass(node) {
  888. var parts = classNameOf(node).split('.');
  889. return parts[parts.length - 1];
  890. }
  891. function isClickable(node) {
  892. try {
  893. return node.clickable() === true;
  894. } catch (ignored) {
  895. return false;
  896. }
  897. }
  898. function isScrollable(node) {
  899. try {
  900. return node.scrollable() === true;
  901. } catch (ignored) {
  902. return false;
  903. }
  904. }
  905. function isEditable(node) {
  906. return /EditText/i.test(classNameOf(node));
  907. }
  908. function parent(node) {
  909. try {
  910. return node.parent();
  911. } catch (ignored) {
  912. return null;
  913. }
  914. }
  915. /** 从 node 向上找到第一个可点击的祖先(不含自身) */
  916. function clickableAncestor(node, maxDepth) {
  917. var depth = 0;
  918. var limit = maxDepth === undefined ? 8 : maxDepth;
  919. var cursor = parent(node);
  920. while (cursor && depth < limit) {
  921. if (isClickable(cursor)) {
  922. return cursor;
  923. }
  924. cursor = parent(cursor);
  925. depth += 1;
  926. }
  927. return null;
  928. }
  929. /** 节点在屏幕上的矩形是否与给定纵向区间有明显重叠 */
  930. function overlapsBand(node, top, bottom, minRatio) {
  931. var rect = visibleRect(node);
  932. if (!rect) {
  933. return false;
  934. }
  935. var overlap = Math.min(rect.bottom, bottom) - Math.max(rect.top, top);
  936. if (overlap <= 0) {
  937. return false;
  938. }
  939. var ratio = minRatio === undefined ? 0.6 : minRatio;
  940. return overlap >= (rect.bottom - rect.top) * ratio;
  941. }
  942. /** 扁平化渲染,用于把当前界面一行一个控件打进日志(排障时非常有用) */
  943. function describe(nodes, limit) {
  944. var lines = [];
  945. var max = limit === undefined ? nodes.length : Math.min(limit, nodes.length);
  946. for (var i = 0; i < max; i += 1) {
  947. var node = nodes[i];
  948. var point = center(node);
  949. lines.push([
  950. shortClass(node),
  951. shortId(node),
  952. point ? '(' + point.x + ',' + point.y + ')' : '(offscreen)',
  953. isClickable(node) ? 'clk' : '-',
  954. JSON.stringify(label(node)).slice(0, 48)
  955. ].join(' '));
  956. }
  957. return lines.join(' | ');
  958. }
  959. /**
  960. * 页面签名 = 所有可见节点文案/描述的拼接。
  961. * 用途:判断"页面变没变""是否已经稳定",完全不依赖时间。
  962. */
  963. function signature() {
  964. var nodes = collectAll(1500);
  965. var parts = [];
  966. for (var i = 0; i < nodes.length; i += 1) {
  967. if (!isVisible(nodes[i])) {
  968. continue;
  969. }
  970. var value = label(nodes[i]);
  971. if (value) {
  972. parts.push(value);
  973. }
  974. }
  975. return parts.join('\u0001');
  976. }
  977. return {
  978. SAFE_MARGIN: SAFE_MARGIN,
  979. screen: screen,
  980. root: root,
  981. foregroundPackage: foregroundPackage,
  982. findAll: findAll,
  983. findOnce: findOnce,
  984. collectAll: collectAll,
  985. isVisible: isVisible,
  986. visibleRect: visibleRect,
  987. center: center,
  988. text: text,
  989. desc: desc,
  990. label: label,
  991. sanitize: sanitize,
  992. resourceId: resourceId,
  993. shortId: shortId,
  994. classNameOf: classNameOf,
  995. shortClass: shortClass,
  996. isClickable: isClickable,
  997. isScrollable: isScrollable,
  998. isEditable: isEditable,
  999. parent: parent,
  1000. clickableAncestor: clickableAncestor,
  1001. overlapsBand: overlapsBand,
  1002. describe: describe,
  1003. signature: signature
  1004. };
  1005. }());
  1006. /* ============================== src/ui.js ============================== */
  1007. /**
  1008. * ui.js — 通用交互动作
  1009. *
  1010. * 这一层只回答一个问题:"怎么把一次『点击』做成功"。
  1011. * 它不认识 TikTok,也不认识业务流程(那是 tiktok.js / flow.js 的事)——
  1012. * 这是《代码整洁之道》第 3 章「只做一件事」的落地。
  1013. *
  1014. * 点击的四级降级(都走无障碍,**不需要 Root**):
  1015. * 1. node.click()
  1016. * 2. 最近的可点击祖先 click() ← 大多数"点不动"的真凶:文案节点本身不可点
  1017. * 3. 手势 click(可见交集中心)
  1018. * 4. 手势 Tap(可见交集中心)
  1019. * 并且返回"真正点到了谁",让调用方可以核对,而不是默默点错地方。
  1020. *
  1021. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.wait
  1022. */
  1023. TikTokAuto.ui = (function () {
  1024. 'use strict';
  1025. function tapPoint(x, y) {
  1026. click(x, y);
  1027. }
  1028. /**
  1029. * 点击一个节点,逐级降级直到成功。
  1030. * @returns {ok, via, targetCls, targetLabel, point}
  1031. */
  1032. /* ---- 悬浮球避让(规格书 §10.9③)----
  1033. *
  1034. * 全局悬浮球浮在**所有应用之上**(包括 TikTok),所以坐标手势如果正好落在球的
  1035. * 窗口矩形里,点到的是球、不是 TikTok —— 实测就是这样:搜索建议列表的某一条
  1036. * 正好压在球位,`click(x,y)` 把球的扇形面板点了出来(用户看到的是"面板自己弹出来")。
  1037. *
  1038. * 规格书给的缓解办法是「脚本侧点击区域避开球位」,这里就做这一件事:
  1039. * ① 界面把球的窗口矩形写进 ball-rect.json(球的窗口比球大一圈,见 floatball.js);
  1040. * ② 本模块在**走坐标手势之前**读它,命中就把落点挪到同节点内、球区之外的角落;
  1041. * ③ 整块节点都在球区里(挪不开)→ 放弃这次点击并记 WARN,
  1042. * 让日志能解释"这次为什么没点成",而不是默默点到了球上。
  1043. *
  1044. * ⚠️ 注意 `node.click()` 全程不受影响:那是无障碍点击,不产生屏幕坐标,
  1045. * 压根碰不到悬浮窗 —— 所以避让只挂在手势这条兜底路径上。
  1046. */
  1047. var BALL_RECT_PATH = '/sdcard/tiktok_app/ball-rect.json';
  1048. var ballRectCache = { at: 0, rect: null };
  1049. function ballRect() {
  1050. var now = Date.now();
  1051. if (now - ballRectCache.at < 500) { return ballRectCache.rect; }
  1052. ballRectCache.at = now;
  1053. ballRectCache.rect = null;
  1054. try {
  1055. if (!files.exists(BALL_RECT_PATH)) { return null; }
  1056. var raw = JSON.parse(String(files.read(BALL_RECT_PATH)));
  1057. if (!raw || raw.hidden || raw.open) { return null; } /* 隐藏 / 扇形展开时不挡点击判断 */
  1058. if (typeof raw.x !== 'number' || typeof raw.w !== 'number') { return null; }
  1059. ballRectCache.rect = raw;
  1060. } catch (error) {
  1061. /* 读不到就当没有球:宁可偶尔点到球,也不要因为一个坏文件让整个脚本点不动 */
  1062. ballRectCache.rect = null;
  1063. }
  1064. return ballRectCache.rect;
  1065. }
  1066. function inside(rect, x, y) {
  1067. return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
  1068. }
  1069. /**
  1070. * 把落点挪出球区。挪不动返回 null。
  1071. * 候选点是「节点四角内缩」:同一节点内的任何位置对这次点击都等价,
  1072. * 而角落最可能远离屏幕中部的球位。
  1073. */
  1074. function shiftOffBall(point, node, rect) {
  1075. if (!inside(rect, point.x, point.y)) { return point; }
  1076. var layout = TikTokAuto.layout;
  1077. /* 复用 layout.visibleRect:它已经处理了"可见区域/被遮挡"这些边界情况,
  1078. * 自己再写一份 node.bounds() 会与 center() 的口径不一致。 */
  1079. var b = layout.visibleRect(node);
  1080. if (!b) { return null; }
  1081. var pad = 6;
  1082. var cands = [
  1083. { x: b.left + pad, y: b.top + pad },
  1084. { x: b.right - pad, y: b.top + pad },
  1085. { x: b.left + pad, y: b.bottom - pad },
  1086. { x: b.right - pad, y: b.bottom - pad }
  1087. ];
  1088. for (var i = 0; i < cands.length; i++) {
  1089. if (cands[i].x > 0 && cands[i].y > 0 && !inside(rect, cands[i].x, cands[i].y)) {
  1090. return cands[i];
  1091. }
  1092. }
  1093. return null;
  1094. }
  1095. function clickNode(node, step) {
  1096. var story = step || 'click';
  1097. var layout = TikTokAuto.layout;
  1098. var targetClass = layout.shortClass(node);
  1099. var targetLabel = layout.label(node);
  1100. if (node.click()) {
  1101. return finish(story, true, 'node.click()', targetClass, targetLabel, layout.center(node));
  1102. }
  1103. var ancestor = layout.clickableAncestor(node);
  1104. if (ancestor && ancestor.click()) {
  1105. return finish(story, true, 'clickableAncestor.click()', targetClass, targetLabel, layout.center(ancestor));
  1106. }
  1107. var point = layout.center(node);
  1108. if (point) {
  1109. var rect = ballRect();
  1110. if (rect) {
  1111. var safe = shiftOffBall(point, node, rect);
  1112. if (!safe) {
  1113. return finish(story, false, 'blocked-by-floatball', targetClass, targetLabel, point);
  1114. }
  1115. point = safe;
  1116. }
  1117. click(point.x, point.y);
  1118. return finish(story, true, 'gesture click(visibleCenter)', targetClass, targetLabel, point);
  1119. }
  1120. return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null);
  1121. }
  1122. function finish(step, ok, via, targetClass, targetLabel, point) {
  1123. var logger = TikTokAuto.logger;
  1124. var where = point ? '(' + point.x + ',' + point.y + ')' : '(n/a)';
  1125. logger.info(step, '点击 ' + (ok ? '成功' : '失败') + ' via=' + via +
  1126. ' target=' + targetClass + ' ' + JSON.stringify(targetLabel).slice(0, 40) + ' ' + where);
  1127. return {
  1128. ok: ok,
  1129. via: via,
  1130. targetClass: targetClass,
  1131. targetLabel: targetLabel,
  1132. point: point
  1133. };
  1134. }
  1135. /**
  1136. * 往一个**已知节点**写入文本,并重新查找节点回读校验。
  1137. *
  1138. * 必须回读:AutoX 的 UiObject 是 find 那一刻的快照,
  1139. * setText 之后立刻 .text() 读到的还是旧值,直接判定"写入失败"是经典误判。
  1140. */
  1141. function typeInto(node, value, step) {
  1142. var logger = TikTokAuto.logger;
  1143. var layout = TikTokAuto.layout;
  1144. var story = step || 'typeInto';
  1145. if (!node) {
  1146. logger.error(story, '输入目标为空');
  1147. return { ok: false, via: 'no-target' };
  1148. }
  1149. // 先让它真正获得焦点,否则部分输入法/控件不会提交文本
  1150. var point = layout.center(node);
  1151. if (point) {
  1152. click(point.x, point.y);
  1153. TikTokAuto.wait.tick(200);
  1154. }
  1155. node.setText(value);
  1156. TikTokAuto.wait.tick(300);
  1157. var actual = readEditableText(value);
  1158. var ok = actual === value;
  1159. if (!ok) {
  1160. logger.warn(story, '回读不一致,重试一次。期望=' + JSON.stringify(value) + ' 实际=' + JSON.stringify(actual));
  1161. node.setText(value);
  1162. TikTokAuto.wait.tick(400);
  1163. actual = readEditableText(value);
  1164. ok = actual === value;
  1165. }
  1166. logger.info(story, '写入' + (ok ? '成功' : '失败') + ' 实际=' + JSON.stringify(actual));
  1167. return { ok: ok, via: 'setText+readback', actual: actual };
  1168. }
  1169. /** 便捷版:传选择器,内部自己找节点 */
  1170. function setText(selector, value, step) {
  1171. return typeInto(TikTokAuto.layout.findOnce(selector), value, step);
  1172. }
  1173. /** 回读:取与期望值相等的输入框文本;没有就返回最长的那条(便于排查) */
  1174. function readEditableText(expected) {
  1175. var layout = TikTokAuto.layout;
  1176. var collection = className('android.widget.EditText').find();
  1177. var longest = '';
  1178. for (var i = 0; i < collection.size(); i += 1) {
  1179. var value = layout.text(collection.get(i));
  1180. if (value === expected) {
  1181. return value;
  1182. }
  1183. if (String(value).length > longest.length) {
  1184. longest = value;
  1185. }
  1186. }
  1187. return longest;
  1188. }
  1189. function isEditableText(expected) {
  1190. return readEditableText(expected) === expected;
  1191. }
  1192. /**
  1193. * 滑动。
  1194. * 注意函数名不能叫 `swipe`——那会遮蔽 AutoX 的全局 `swipe()`,导致自我递归。
  1195. */
  1196. function dragScreen(x1, y1, x2, y2, durationMs) {
  1197. swipe(x1, y1, x2, y2, durationMs === undefined ? 320 : durationMs);
  1198. }
  1199. /**
  1200. * 返回上一页。
  1201. * 注意:只在**明确的页面迁移**里调用(如 播放页 → 结果页)。
  1202. * 永远不要把它当成"兜底清理"——那会把正常页面也退掉。
  1203. */
  1204. function goBack(step) {
  1205. TikTokAuto.logger.info(step || 'back', '按下返回');
  1206. back();
  1207. }
  1208. return {
  1209. tapPoint: tapPoint,
  1210. clickNode: clickNode,
  1211. typeInto: typeInto,
  1212. setText: setText,
  1213. readEditableText: readEditableText,
  1214. isEditableText: isEditableText,
  1215. dragScreen: dragScreen,
  1216. goBack: goBack
  1217. };
  1218. }());
  1219. /* ============================== src/popups.js ============================== */
  1220. /**
  1221. * popups.js — 干扰弹窗的「检测 / 执行分离」
  1222. *
  1223. * 依据 AutoX 真机经验(也是《代码整洁之道》第 3 章的"指令查询分离"):
  1224. * - 把「看」和「动」拆成两组函数。guard 线程负责"动",
  1225. * 主流程只用只读的 popupPresent() 去**等**它清干净,绝不抢它的活。
  1226. * - **绝不 back() 正常页面**。登录页/权限页的兜底必须命中"登录方式"这类语境关键词,
  1227. * 否则在「我的」页看到"立即登录"就会把正常页面退掉。
  1228. *
  1229. * 对未知弹窗的策略是**记录而不是猜**:
  1230. * 命中已知签名 → 点掉;看起来像弹窗但不在名单里 → 只记 WARN + dump 界面,
  1231. * 由人来决定要不要加进名单。比"猜着点"安全得多。
  1232. *
  1233. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait
  1234. */
  1235. TikTokAuto.popups = (function () {
  1236. 'use strict';
  1237. /**
  1238. * 已知的阻塞型弹窗签名。
  1239. * contains 里任一命中即认为出现;dismiss 决定点哪个按钮。
  1240. */
  1241. var KNOWN_DIALOGS = [
  1242. {
  1243. name: 'save-login-info',
  1244. contains: ['Save login info', 'Save your login info', '记住登录'],
  1245. dismiss: ['Not now', 'Cancel', '稍后', '取消']
  1246. },
  1247. {
  1248. name: 'notifications-permission',
  1249. contains: ['Turn on notifications', 'get notified', '打开通知'],
  1250. dismiss: ['Not now', 'No thanks', 'Skip', '稍后', '暂不']
  1251. },
  1252. {
  1253. name: 'rate-app',
  1254. contains: ['Enjoying TikTok', 'Rate this app', 'rate your experience'],
  1255. dismiss: ['Not now', 'No thanks', 'Later', '稍后']
  1256. },
  1257. {
  1258. name: 'update-app',
  1259. contains: ['Update TikTok', 'A new version'],
  1260. dismiss: ['Not now', 'Later', '稍后']
  1261. },
  1262. {
  1263. name: 'open-in-app',
  1264. contains: ['Open in app', 'Continue in app'],
  1265. dismiss: ['Cancel', 'Not now', '取消']
  1266. }
  1267. ];
  1268. /** 只读:扫描当前应用窗口,返回命中的已知弹窗名 + 疑似未知弹窗 */
  1269. function scan() {
  1270. var layout = TikTokAuto.layout;
  1271. var visibleLabels = collectVisibleLabels();
  1272. var joined = visibleLabels.join(' \u0001 ');
  1273. var hits = [];
  1274. for (var i = 0; i < KNOWN_DIALOGS.length; i += 1) {
  1275. var dialog = KNOWN_DIALOGS[i];
  1276. if (containsAny(joined, dialog.contains)) {
  1277. hits.push(dialog);
  1278. }
  1279. }
  1280. return {
  1281. known: hits,
  1282. looksBlocking: hits.length > 0 || looksLikeUnknownModal(visibleLabels)
  1283. };
  1284. }
  1285. function containsAny(haystack, needles) {
  1286. for (var i = 0; i < needles.length; i += 1) {
  1287. if (haystack.indexOf(needles[i]) !== -1) {
  1288. return true;
  1289. }
  1290. }
  1291. return false;
  1292. }
  1293. function collectVisibleLabels() {
  1294. var layout = TikTokAuto.layout;
  1295. var nodes = layout.collectAll(1200);
  1296. var labels = [];
  1297. for (var i = 0; i < nodes.length; i += 1) {
  1298. if (!layout.isVisible(nodes[i])) {
  1299. continue;
  1300. }
  1301. var value = layout.label(nodes[i]);
  1302. if (value) {
  1303. labels.push(value);
  1304. }
  1305. }
  1306. return labels;
  1307. }
  1308. /**
  1309. * "看起来像弹窗"的启发式判据(仅用于告警,不驱动动作):
  1310. * 可见文案很少(弹窗通常只有标题 + 一两个按钮),却又出现了否定的动作词。
  1311. * 正常页面(信息流/结果页)动辄几十条文案,不会命中。
  1312. */
  1313. function looksLikeUnknownModal(labels) {
  1314. var dismissLabels = TikTokAuto.config
  1315. ? TikTokAuto.config.DEFAULTS.guard.dismissLabels
  1316. : ['Not now', 'Cancel', '稍后', '取消'];
  1317. if (labels.length > 8) {
  1318. return false;
  1319. }
  1320. return containsAny(labels.join('\u0001'), dismissLabels);
  1321. }
  1322. /** 只读:是否还有阻塞弹窗(主流程用它"等"guard 清场) */
  1323. function popupPresent() {
  1324. return scan().looksBlocking;
  1325. }
  1326. /** 执行:尝试点掉一个已知弹窗。返回是否真的动了手 */
  1327. function dismissKnown(step) {
  1328. var logger = TikTokAuto.logger;
  1329. var layout = TikTokAuto.layout;
  1330. var found = scan();
  1331. if (found.known.length === 0) {
  1332. return false;
  1333. }
  1334. for (var i = 0; i < found.known.length; i += 1) {
  1335. var dialog = found.known[i];
  1336. for (var j = 0; j < dialog.dismiss.length; j += 1) {
  1337. var button = findExactLabel(dialog.dismiss[j]);
  1338. if (button) {
  1339. logger.info(step || 'popup', '关闭已知弹窗 ' + dialog.name + ' → 点击 ' + JSON.stringify(dialog.dismiss[j]));
  1340. TikTokAuto.ui.clickNode(button, step || 'popup');
  1341. TikTokAuto.wait.tick(400);
  1342. return true;
  1343. }
  1344. }
  1345. logger.warn(step || 'popup', '命中弹窗 ' + dialog.name + ' 但找不到关闭按钮,跳过');
  1346. }
  1347. return false;
  1348. }
  1349. /** 按精确文案/描述找可见节点;文案优先于描述 */
  1350. function findExactLabel(value) {
  1351. var layout = TikTokAuto.layout;
  1352. var nodes = layout.collectAll(1200);
  1353. for (var i = 0; i < nodes.length; i += 1) {
  1354. if (layout.text(nodes[i]) === value && layout.isVisible(nodes[i])) {
  1355. return nodes[i];
  1356. }
  1357. }
  1358. for (var j = 0; j < nodes.length; j += 1) {
  1359. if (layout.desc(nodes[j]) === value && layout.isVisible(nodes[j])) {
  1360. return nodes[j];
  1361. }
  1362. }
  1363. return null;
  1364. }
  1365. /**
  1366. * 启动守护线程:周期性清掉已知弹窗。
  1367. *
  1368. * 返回一个句柄,其中 passes 计数让主流程确认"它已经上岗了"——
  1369. * 否则主流程可能在守护线程第一次巡检之前就撞上弹窗。
  1370. */
  1371. function startGuard(config, logger) {
  1372. var state = { passes: 0, dismissed: 0, stopped: false };
  1373. if (!config.guard.enabled) {
  1374. logger.info('guard', '守护线程已通过配置关闭');
  1375. state.passes = 1;
  1376. return state;
  1377. }
  1378. var thread = threads.start(function () {
  1379. while (!state.stopped) {
  1380. try {
  1381. if (dismissKnown('guard')) {
  1382. state.dismissed += 1;
  1383. }
  1384. state.passes += 1;
  1385. } catch (error) {
  1386. /* 用户叫停时会从 wait.tick 抛出 StopSignal。守护线程不该继续巡检,
  1387. * 也不该每一轮都记一条 WARN 把日志刷满 —— 直接收工。 */
  1388. if (TikTokAuto.bridge && TikTokAuto.bridge.isStopSignal(error)) {
  1389. logger.info('guard', '收到停止请求,守护线程收工');
  1390. break;
  1391. }
  1392. logger.warn('guard', '巡检异常:' + error);
  1393. }
  1394. sleep(config.guard.intervalMs);
  1395. }
  1396. });
  1397. state.thread = thread;
  1398. logger.info('guard', '守护线程已启动,间隔 ' + config.guard.intervalMs + 'ms');
  1399. return state;
  1400. }
  1401. function stopGuard(state, logger) {
  1402. if (!state) {
  1403. return;
  1404. }
  1405. state.stopped = true;
  1406. logger.info('guard', '守护线程已停止,累计清场 ' + state.dismissed + ' 次');
  1407. }
  1408. return {
  1409. KNOWN_DIALOGS: KNOWN_DIALOGS,
  1410. scan: scan,
  1411. popupPresent: popupPresent,
  1412. dismissKnown: dismissKnown,
  1413. findExactLabel: findExactLabel,
  1414. startGuard: startGuard,
  1415. stopGuard: stopGuard
  1416. };
  1417. }());
  1418. /* ============================== src/tiktok.js ============================== */
  1419. /**
  1420. * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定)
  1421. *
  1422. * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。
  1423. * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。
  1424. *
  1425. * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是
  1426. * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排;
  1427. * 而 "Read or add comments" 这类无障碍文案相当稳定。
  1428. * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。
  1429. *
  1430. * 实测锚点(1080x1920,com.zhiliaoapp.musically):
  1431. * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162]
  1432. * 搜索页输入框 EditText (top < 12%H)
  1433. * 搜索页提交 Button text="Search" (left > 80%W)
  1434. * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232
  1435. * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H
  1436. * 播放页 "Read or add comments" + "Like video." 同时存在
  1437. * 播放页评论入口 Button desc="Read or add comments. N comments"
  1438. * 播放页内联输入 EditText desc/text="Add comment..."
  1439. * 评论面板输入 EditText top ∈ [50%H,80%H]
  1440. * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone"
  1441. * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6)
  1442. *
  1443. * 依赖:TikTokAuto.logger、TikTokAuto.layout
  1444. */
  1445. TikTokAuto.tiktok = (function () {
  1446. 'use strict';
  1447. var PKG = 'com.zhiliaoapp.musically';
  1448. var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
  1449. var LIKE_DESC_KEYS = ['Like video.', '点赞'];
  1450. var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
  1451. var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
  1452. var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
  1453. var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
  1454. var logger = TikTokAuto.logger;
  1455. function screenWidth() { return device.width; }
  1456. function screenHeight() { return device.height; }
  1457. function startsWithAny(value, keys) {
  1458. for (var i = 0; i < keys.length; i += 1) {
  1459. if (value.indexOf(keys[i]) === 0) {
  1460. return true;
  1461. }
  1462. }
  1463. return false;
  1464. }
  1465. function equalsAny(value, keys) {
  1466. for (var i = 0; i < keys.length; i += 1) {
  1467. if (value === keys[i]) {
  1468. return true;
  1469. }
  1470. }
  1471. return false;
  1472. }
  1473. function widthOf(node) {
  1474. var box = node.bounds();
  1475. return box.right - box.left;
  1476. }
  1477. function topOf(node) {
  1478. return node.bounds().top;
  1479. }
  1480. function bottomOf(node) {
  1481. return node.bounds().bottom;
  1482. }
  1483. /** 按"文案或描述命中"收集可见节点 */
  1484. function findByLabel(selector, predicate) {
  1485. var result = [];
  1486. var collection = selector.find();
  1487. for (var i = 0; i < collection.size(); i += 1) {
  1488. var node = collection.get(i);
  1489. if (predicate(node) && layout.isVisible(node)) {
  1490. result.push(node);
  1491. }
  1492. }
  1493. return result;
  1494. }
  1495. // ---------------------------------------------------------------- 信息流
  1496. /** 信息流右上角的搜索入口 */
  1497. function searchEntry() {
  1498. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  1499. var point = layout.center(node);
  1500. return layout.desc(node) === 'Search' && point !== null &&
  1501. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85;
  1502. });
  1503. return hits.length > 0 ? hits[0] : null;
  1504. }
  1505. /** 信息流就绪判据:底部导航存在 */
  1506. function bottomNavPresent() {
  1507. return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
  1508. }
  1509. // ---------------------------------------------------------------- 搜索页
  1510. /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
  1511. function searchInput() {
  1512. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1513. return topOf(node) < screenHeight() * 0.12;
  1514. });
  1515. return hits.length > 0 ? hits[0] : null;
  1516. }
  1517. function searchSubmit() {
  1518. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1519. var point = layout.center(node);
  1520. return layout.text(node) === 'Search' && point !== null &&
  1521. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8;
  1522. });
  1523. return hits.length > 0 ? hits[0] : null;
  1524. }
  1525. /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */
  1526. function searchHistoryPresent() {
  1527. return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null;
  1528. }
  1529. // ---------------------------------------------------------------- 结果页
  1530. /** 结果页顶部分类页签 */
  1531. function resultTab(name) {
  1532. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1533. return equalsAny(layout.text(node) || layout.desc(node), [name]) &&
  1534. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  1535. });
  1536. if (hits.length > 0) {
  1537. return hits[0];
  1538. }
  1539. // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout
  1540. var frames = findByLabel(className('android.widget.FrameLayout'), function (node) {
  1541. return equalsAny(layout.desc(node) || layout.text(node), [name]) &&
  1542. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  1543. });
  1544. return frames.length > 0 ? frames[0] : null;
  1545. }
  1546. function resultTabStripPresent() {
  1547. var found = 0;
  1548. for (var i = 0; i < RESULT_TABS.length; i += 1) {
  1549. if (resultTab(RESULT_TABS[i])) {
  1550. found += 1;
  1551. }
  1552. }
  1553. return found >= 3;
  1554. }
  1555. /**
  1556. * 从卡片文案锚点向上找"卡片容器"。
  1557. * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**——
  1558. * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。
  1559. * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。
  1560. */
  1561. function cardContainer(anchor) {
  1562. var height = screenHeight();
  1563. var cursor = layout.clickableAncestor(anchor);
  1564. var depth = 0;
  1565. while (cursor && depth < 6) {
  1566. var cardHeight = bottomOf(cursor) - topOf(cursor);
  1567. if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) {
  1568. return cursor;
  1569. }
  1570. cursor = layout.clickableAncestor(cursor);
  1571. depth += 1;
  1572. }
  1573. return anchor;
  1574. }
  1575. /**
  1576. * 结果页的视频卡片列表。
  1577. *
  1578. * 判据(都来自实测踩坑):
  1579. * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
  1580. * - top > 30%H,据此排掉顶部搜索框;
  1581. * - 底部被导航栏切掉半截的先不收,避免点到一半的位置。
  1582. */
  1583. function videoCards() {
  1584. var width = screenWidth();
  1585. var height = screenHeight();
  1586. var cards = [];
  1587. var seen = {};
  1588. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1589. return widthOf(node) >= width * 0.35 &&
  1590. topOf(node) > height * 0.3 &&
  1591. bottomOf(node) <= height - 12;
  1592. });
  1593. for (var i = 0; i < hits.length; i += 1) {
  1594. var caption = layout.text(hits[i]) || layout.desc(hits[i]);
  1595. if (!caption) {
  1596. continue;
  1597. }
  1598. var key = caption + '@' + topOf(hits[i]);
  1599. if (seen[key]) {
  1600. continue;
  1601. }
  1602. seen[key] = true;
  1603. cards.push({
  1604. anchor: hits[i],
  1605. container: cardContainer(hits[i]),
  1606. caption: caption,
  1607. top: topOf(hits[i])
  1608. });
  1609. }
  1610. cards.sort(function (a, b) { return a.top - b.top; });
  1611. return cards;
  1612. }
  1613. // ---------------------------------------------------------------- 播放页
  1614. function commentButton() {
  1615. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1616. return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS);
  1617. });
  1618. return hits.length > 0 ? hits[0] : null;
  1619. }
  1620. function likeButton() {
  1621. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1622. return startsWithAny(layout.desc(node), LIKE_DESC_KEYS);
  1623. });
  1624. return hits.length > 0 ? hits[0] : null;
  1625. }
  1626. /**
  1627. * 播放页判据。
  1628. *
  1629. * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**,
  1630. * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。
  1631. * 真正能区分"从搜索结果点进来的播放页"的是:
  1632. * - 左上角有返回箭头(首页没有)
  1633. * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile)
  1634. * 四条同时成立才算播放页。
  1635. */
  1636. function onPlayerPage() {
  1637. return commentButton() !== null &&
  1638. likeButton() !== null &&
  1639. backButton() !== null &&
  1640. !bottomNavPresent();
  1641. }
  1642. function backButton() {
  1643. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  1644. return layout.desc(node) === 'Back';
  1645. });
  1646. return hits.length > 0 ? hits[0] : null;
  1647. }
  1648. /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */
  1649. function commentCount() {
  1650. var button = commentButton();
  1651. if (!button) {
  1652. return null;
  1653. }
  1654. var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i);
  1655. if (!matched) {
  1656. return null;
  1657. }
  1658. return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10);
  1659. }
  1660. /** 视频页底部的内联评论输入框(面板未打开时就是它) */
  1661. function inlineInput() {
  1662. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1663. return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) &&
  1664. topOf(node) > screenHeight() * 0.8;
  1665. });
  1666. return hits.length > 0 ? hits[0] : null;
  1667. }
  1668. /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
  1669. function panelInput() {
  1670. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1671. var top = topOf(node);
  1672. return top > screenHeight() * 0.45 && top < screenHeight() * 0.85;
  1673. });
  1674. return hits.length > 0 ? hits[0] : null;
  1675. }
  1676. function toolRowAnchor() {
  1677. for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) {
  1678. var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i]));
  1679. if (node && layout.isVisible(node)) {
  1680. return node;
  1681. }
  1682. }
  1683. return null;
  1684. }
  1685. /**
  1686. * 评论发送按钮。
  1687. *
  1688. * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头,
  1689. * 所以只能靠**结构**定位:
  1690. * 面板里"工具行"(表情/提及/语音那一行)的同高区域里,
  1691. * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。
  1692. *
  1693. * 优先用 config.selectors.sendButtonId(若配置里给了 id),
  1694. * 这样 TikTok 改版时改配置即可,不用改代码。
  1695. */
  1696. function sendButton(config) {
  1697. var override = config && config.selectors && config.selectors.sendButtonId;
  1698. if (override) {
  1699. var byId = layout.findOnce(id(override));
  1700. if (byId && layout.isVisible(byId)) {
  1701. logger.info('comment', '发送按钮:使用配置 id ' + override);
  1702. return byId;
  1703. }
  1704. }
  1705. var anchor = toolRowAnchor();
  1706. if (!anchor) {
  1707. logger.warn('comment', '找不到面板工具行,无法结构化定位发送按钮');
  1708. return null;
  1709. }
  1710. var bandTop = topOf(anchor);
  1711. var bandBottom = bottomOf(anchor);
  1712. var width = screenWidth();
  1713. var best = null;
  1714. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1715. var point = layout.center(node);
  1716. if (point === null) {
  1717. return false;
  1718. }
  1719. var box = node.bounds();
  1720. if (box.top < bandTop || box.top > bandBottom) {
  1721. return false; // 不在工具行高度带里
  1722. }
  1723. if (box.right > width - layout.SAFE_MARGIN) {
  1724. return false; // 属于右侧竖排动作栏
  1725. }
  1726. return box.left >= width * 0.5; // 必须是右半边
  1727. });
  1728. for (var i = 0; i < hits.length; i += 1) {
  1729. if (best === null || hits[i].bounds().left > best.bounds().left) {
  1730. best = hits[i];
  1731. }
  1732. }
  1733. if (best) {
  1734. logger.info('comment', '发送按钮:结构化定位 id=' +
  1735. layout.shortId(best) + ' b=' + JSON.stringify(best.bounds()));
  1736. }
  1737. return best;
  1738. }
  1739. // ---------------------------------------------------------------- 评论列表
  1740. /**
  1741. * 评论列表面板顶部的计数,形如 "150 comments"。
  1742. *
  1743. * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新**
  1744. * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
  1745. * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
  1746. */
  1747. function listCommentCount() {
  1748. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1749. return /^[\d,\.]+\s*(comments?|条评论)$/i.test(layout.text(node));
  1750. });
  1751. if (hits.length === 0) {
  1752. return null;
  1753. }
  1754. return parseInt(layout.text(hits[0]).replace(/[^\d]/g, ''), 10);
  1755. }
  1756. /**
  1757. * 评论面板是否处于打开状态。
  1758. * 注意:点内联输入框打开的是"快捷面板"(有中部输入框),
  1759. * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。
  1760. * 两种都要认,否则 back() 会漏按、面板关不掉。
  1761. */
  1762. function commentPanelOpen() {
  1763. return panelInput() !== null || listCommentCount() !== null;
  1764. }
  1765. /**
  1766. * 评论列表里是否出现了某条完全一致的文案。
  1767. *
  1768. * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本",
  1769. * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了,
  1770. * 评论条目根本没轮到,于是最强的那条证据永远拿不到。
  1771. * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。
  1772. */
  1773. function commentListContains(value) {
  1774. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1775. return layout.text(node) === value;
  1776. });
  1777. return hits.length > 0;
  1778. }
  1779. /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */
  1780. function visibleTexts(limit) {
  1781. var hits = findByLabel(className('android.widget.TextView'), function () { return true; });
  1782. var texts = [];
  1783. for (var i = 0; i < hits.length; i += 1) {
  1784. var value = layout.text(hits[i]);
  1785. if (value && value.length >= 2) {
  1786. texts.push(value);
  1787. }
  1788. if (limit && texts.length >= limit) {
  1789. break;
  1790. }
  1791. }
  1792. return texts;
  1793. }
  1794. // ---------------------------------------------------------------- 页面状态
  1795. /**
  1796. * 判定当前处于哪个页面。
  1797. * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。
  1798. */
  1799. function pageState() {
  1800. // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了,
  1801. // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来
  1802. // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。
  1803. if (commentPanelOpen()) {
  1804. return 'comment-panel';
  1805. }
  1806. if (onPlayerPage()) {
  1807. return 'player';
  1808. }
  1809. if (resultTabStripPresent()) {
  1810. return 'results';
  1811. }
  1812. // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。
  1813. // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown,
  1814. // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。
  1815. if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) {
  1816. return 'search';
  1817. }
  1818. if (searchEntry() !== null && bottomNavPresent()) {
  1819. return 'feed';
  1820. }
  1821. return 'unknown';
  1822. }
  1823. /** 排障用:把当前可见的关键节点打出来 */
  1824. function describeScreen(tag) {
  1825. var nodes = [];
  1826. var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView'];
  1827. for (var i = 0; i < classes.length; i += 1) {
  1828. var collection = className(classes[i]).find();
  1829. for (var j = 0; j < collection.size(); j += 1) {
  1830. if (layout.isVisible(collection.get(j))) {
  1831. nodes.push(collection.get(j));
  1832. }
  1833. }
  1834. }
  1835. logger.info(tag || 'screen', layout.describe(nodes, 40));
  1836. }
  1837. return {
  1838. PKG: PKG,
  1839. pageState: pageState,
  1840. searchEntry: searchEntry,
  1841. searchInput: searchInput,
  1842. searchSubmit: searchSubmit,
  1843. searchHistoryPresent: searchHistoryPresent,
  1844. bottomNavPresent: bottomNavPresent,
  1845. resultTab: resultTab,
  1846. resultTabStripPresent: resultTabStripPresent,
  1847. videoCards: videoCards,
  1848. commentButton: commentButton,
  1849. likeButton: likeButton,
  1850. onPlayerPage: onPlayerPage,
  1851. backButton: backButton,
  1852. commentCount: commentCount,
  1853. inlineInput: inlineInput,
  1854. panelInput: panelInput,
  1855. toolRowAnchor: toolRowAnchor,
  1856. sendButton: sendButton,
  1857. listCommentCount: listCommentCount,
  1858. commentPanelOpen: commentPanelOpen,
  1859. commentListContains: commentListContains,
  1860. visibleTexts: visibleTexts,
  1861. describeScreen: describeScreen
  1862. };
  1863. }());
  1864. /* ============================== src/player.js ============================== */
  1865. /**
  1866. * player.js — 「看视频」这件事:播放存活探测 + 有界观看
  1867. *
  1868. * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。
  1869. * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒,
  1870. * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。
  1871. * 网络一抖,sleep 出来的"观看"就是假的。
  1872. *
  1873. * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**——
  1874. * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始);
  1875. * 2. 观看期间持续做两件事:
  1876. * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报);
  1877. * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒;
  1878. * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。
  1879. *
  1880. * 播放存活证据(按可用性排序,可配置):
  1881. * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用
  1882. * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权
  1883. * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里
  1884. * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。
  1885. *
  1886. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui
  1887. */
  1888. TikTokAuto.player = (function () {
  1889. 'use strict';
  1890. var PIXEL_SAMPLE_POINTS = [
  1891. [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45]
  1892. ];
  1893. // ------------------------------------------------------------------ 证据源
  1894. function createAudioProbe() {
  1895. var manager = null;
  1896. try {
  1897. if (typeof context !== 'undefined' && context) {
  1898. manager = context.getSystemService('audio');
  1899. }
  1900. } catch (error) {
  1901. manager = null;
  1902. }
  1903. if (!manager) {
  1904. return null;
  1905. }
  1906. return {
  1907. name: 'audio',
  1908. read: function () {
  1909. try {
  1910. return manager.isMusicActive() === true;
  1911. } catch (error) {
  1912. return false;
  1913. }
  1914. }
  1915. };
  1916. }
  1917. function createPixelProbe() {
  1918. var first = null;
  1919. function grab() {
  1920. var image = captureScreen();
  1921. if (!image) {
  1922. return null;
  1923. }
  1924. var width = image.getWidth();
  1925. var height = image.getHeight();
  1926. var samples = [];
  1927. for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) {
  1928. var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]);
  1929. var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]);
  1930. samples.push(image.pixel(x, y));
  1931. }
  1932. return samples.join(',');
  1933. }
  1934. try {
  1935. first = grab();
  1936. } catch (error) {
  1937. return null; // 没有 MediaProjection 授权,直接放弃这条证据源
  1938. }
  1939. if (first === null) {
  1940. return null;
  1941. }
  1942. return {
  1943. name: 'pixels',
  1944. read: function () {
  1945. try {
  1946. var now = grab();
  1947. return now !== null && now !== first;
  1948. } catch (error) {
  1949. return false;
  1950. }
  1951. }
  1952. };
  1953. }
  1954. /**
  1955. * 组装可用的探测源。
  1956. * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。
  1957. */
  1958. function createProbe(config) {
  1959. var sources = [];
  1960. if (config.probe.useAudio) {
  1961. var audio = createAudioProbe();
  1962. if (audio) {
  1963. sources.push(audio);
  1964. }
  1965. }
  1966. if (config.probe.usePixels) {
  1967. var pixels = createPixelProbe();
  1968. if (pixels) {
  1969. sources.push(pixels);
  1970. }
  1971. }
  1972. return {
  1973. mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'),
  1974. degraded: sources.length === 0,
  1975. /** 任一证据源报"在动"就算活着 */
  1976. alive: function () {
  1977. for (var i = 0; i < sources.length; i += 1) {
  1978. if (sources[i].read()) {
  1979. return true;
  1980. }
  1981. }
  1982. return false;
  1983. }
  1984. };
  1985. }
  1986. // ------------------------------------------------------------------ 等待播放
  1987. /** 等播放真正开始(而不是假定点开就在播) */
  1988. function waitPlaybackStart(config, step) {
  1989. var logger = TikTokAuto.logger;
  1990. var probe = createProbe(config);
  1991. if (probe.degraded) {
  1992. logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式');
  1993. return { started: false, probe: probe };
  1994. }
  1995. var started = TikTokAuto.wait.until(function () {
  1996. return probe.alive() ? true : null;
  1997. }, config.timeouts.playbackStartMs, config.probe.pollTickMs);
  1998. if (started) {
  1999. logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')');
  2000. } else {
  2001. logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)');
  2002. }
  2003. return { started: started === true, probe: probe };
  2004. }
  2005. // ------------------------------------------------------------------ 观看
  2006. function randomInt(min, max) {
  2007. return Math.floor(Math.random() * (max - min + 1)) + min;
  2008. }
  2009. /**
  2010. * 观看视频。
  2011. *
  2012. * @param config 生效配置
  2013. * @param options { step, onInterrupt }
  2014. * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
  2015. */
  2016. function watch(config, options) {
  2017. var settings = options || {};
  2018. var step = settings.step || 'watch';
  2019. var logger = TikTokAuto.logger;
  2020. var tiktok = TikTokAuto.tiktok;
  2021. var targetMs = randomInt(config.watch.minMs, config.watch.maxMs);
  2022. var start = waitPlaybackStart(config, step);
  2023. var probe = start.probe;
  2024. var beganAt = Date.now();
  2025. var deadline = beganAt + targetMs;
  2026. var tickMs = config.probe.pollTickMs;
  2027. var nextLivenessAt = beganAt + config.probe.livenessIntervalMs;
  2028. var stalls = 0;
  2029. var offPage = 0;
  2030. var maxOffPage = 3;
  2031. var nudges = 0;
  2032. var aborted = null;
  2033. logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
  2034. config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode);
  2035. while (Date.now() < deadline) {
  2036. // (a) 页面身份守卫:不在播放页就不是"在看视频"
  2037. if (!tiktok.onPlayerPage()) {
  2038. offPage += 1;
  2039. logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
  2040. if (offPage >= maxOffPage) {
  2041. aborted = 'left-player-page';
  2042. break;
  2043. }
  2044. } else {
  2045. offPage = 0;
  2046. }
  2047. // (b) 播放存活采样
  2048. if (Date.now() >= nextLivenessAt) {
  2049. nextLivenessAt = Date.now() + config.probe.livenessIntervalMs;
  2050. if (!probe.degraded) {
  2051. if (probe.alive()) {
  2052. stalls = 0;
  2053. } else {
  2054. stalls += 1;
  2055. logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')');
  2056. if (stalls >= config.probe.stallTolerance) {
  2057. // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西
  2058. if (nudges < 1) {
  2059. nudge(step);
  2060. nudges += 1;
  2061. stalls = 0;
  2062. } else {
  2063. logger.warn(step, '判定卡顿且唤醒无效,提前结束观看');
  2064. aborted = 'stalled';
  2065. break;
  2066. }
  2067. }
  2068. }
  2069. }
  2070. }
  2071. TikTokAuto.wait.tick(tickMs);
  2072. }
  2073. var actualMs = Date.now() - beganAt;
  2074. var report = {
  2075. requestedMs: targetMs,
  2076. actualMs: actualMs,
  2077. probeMode: probe.mode,
  2078. degraded: probe.degraded,
  2079. nudges: nudges,
  2080. aborted: aborted,
  2081. completed: aborted === null && actualMs >= targetMs - tickMs
  2082. };
  2083. if (report.completed) {
  2084. logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms');
  2085. } else {
  2086. logger.warn(step, '观看未按计划完成:' + JSON.stringify(report));
  2087. }
  2088. return report;
  2089. }
  2090. /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */
  2091. function nudge(step) {
  2092. var layout = TikTokAuto.layout;
  2093. var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) };
  2094. TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')');
  2095. TikTokAuto.ui.tapPoint(point.x, point.y);
  2096. TikTokAuto.wait.tick(300);
  2097. }
  2098. /** 等播放页就绪(点开卡片之后、开始计时之前) */
  2099. function waitPlayerReady(config, step) {
  2100. var logger = TikTokAuto.logger;
  2101. var tiktok = TikTokAuto.tiktok;
  2102. var ready = TikTokAuto.wait.waitFor(step, function () {
  2103. return tiktok.onPlayerPage() ? true : null;
  2104. }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
  2105. if (ready) {
  2106. TikTokAuto.wait.waitQuiet(function () {
  2107. return TikTokAuto.layout.signature();
  2108. }, { maxMs: 3000, tickMs: 250, stableTimes: 3 });
  2109. }
  2110. return ready === true;
  2111. }
  2112. return {
  2113. createProbe: createProbe,
  2114. waitPlaybackStart: waitPlaybackStart,
  2115. waitPlayerReady: waitPlayerReady,
  2116. watch: watch,
  2117. randomInt: randomInt
  2118. };
  2119. }());
  2120. /* ============================== src/comment.js ============================== */
  2121. /**
  2122. * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了**
  2123. *
  2124. * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。
  2125. * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源
  2126. * (面板也可能因为别的原因关掉,而评论被平台拒了)。
  2127. *
  2128. * 实测有效的两条证据:
  2129. * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments"
  2130. * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起
  2131. * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用
  2132. * comments.strictPostCheck 强制只认第 1 条)。
  2133. *
  2134. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok
  2135. */
  2136. TikTokAuto.comment = (function () {
  2137. 'use strict';
  2138. var PLACEHOLDER = 'Add comment...';
  2139. /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */
  2140. function pickText(config, used, step) {
  2141. var pool = config.comments.pool;
  2142. var candidates = [];
  2143. for (var i = 0; i < pool.length; i += 1) {
  2144. if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) {
  2145. candidates.push(pool[i]);
  2146. }
  2147. }
  2148. if (candidates.length === 0) {
  2149. TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复');
  2150. candidates = pool;
  2151. }
  2152. return candidates[Math.floor(Math.random() * candidates.length)];
  2153. }
  2154. /** 打开评论面板:点内联输入框 → 等面板输入框出现 */
  2155. function openPanel(config, step) {
  2156. var story = step || 'openCommentPanel';
  2157. var logger = TikTokAuto.logger;
  2158. var tiktok = TikTokAuto.tiktok;
  2159. if (tiktok.panelInput() !== null) {
  2160. logger.info(story, '评论面板已经是打开状态');
  2161. return true;
  2162. }
  2163. var inline = tiktok.inlineInput();
  2164. if (!inline) {
  2165. logger.error(story, '找不到评论入口(内联输入框)');
  2166. return false;
  2167. }
  2168. TikTokAuto.ui.clickNode(inline, story);
  2169. var ready = TikTokAuto.wait.waitFor(story, function () {
  2170. return tiktok.panelInput() !== null ? true : null;
  2171. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  2172. return ready === true;
  2173. }
  2174. /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
  2175. function closePanel(step) {
  2176. var tiktok = TikTokAuto.tiktok;
  2177. if (!tiktok.commentPanelOpen()) {
  2178. return false;
  2179. }
  2180. TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板');
  2181. back();
  2182. TikTokAuto.wait.until(function () {
  2183. return tiktok.commentPanelOpen() ? null : true;
  2184. }, 4000, 200);
  2185. return true;
  2186. }
  2187. /**
  2188. * 发送一条评论。
  2189. *
  2190. * @returns {ok, via, text, countBefore, countAfter, reason}
  2191. */
  2192. function post(config, text, step) {
  2193. var story = step || 'postComment';
  2194. var logger = TikTokAuto.logger;
  2195. var tiktok = TikTokAuto.tiktok;
  2196. var countBefore = tiktok.commentCount();
  2197. var before = { countBefore: countBefore };
  2198. if (!openPanel(config, story)) {
  2199. return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' };
  2200. }
  2201. var input = tiktok.panelInput();
  2202. var typed = TikTokAuto.ui.typeInto(input, text, story);
  2203. if (!typed.ok) {
  2204. return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' };
  2205. }
  2206. if (config.runtime.dryRun) {
  2207. logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text));
  2208. var restore = tiktok.panelInput();
  2209. if (restore) {
  2210. restore.setText('');
  2211. }
  2212. TikTokAuto.wait.tick(300);
  2213. closePanel(story);
  2214. return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true };
  2215. }
  2216. var button = tiktok.sendButton(config);
  2217. if (!button) {
  2218. return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
  2219. }
  2220. TikTokAuto.ui.clickNode(button, story);
  2221. return awaitOutcome(config, text, before, story);
  2222. }
  2223. /**
  2224. * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。
  2225. *
  2226. * 证据分三级,从强到弱:
  2227. * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相)
  2228. * 中 comment-count —— 列表头部计数自增
  2229. * 低 panel-closed —— 面板收起 + 输入框复位为占位符
  2230. *
  2231. * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新,
  2232. * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。
  2233. * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。
  2234. */
  2235. function awaitOutcome(config, text, before, step) {
  2236. var logger = TikTokAuto.logger;
  2237. var tiktok = TikTokAuto.tiktok;
  2238. // 必要条件:面板收起且输入框复位
  2239. var closed = TikTokAuto.wait.waitFor(step, function () {
  2240. if (tiktok.panelInput() !== null) {
  2241. return null;
  2242. }
  2243. return tiktok.inlineInput() !== null ? true : null;
  2244. }, config.timeouts.postMs, config.probe.pollTickMs);
  2245. if (closed === null) {
  2246. logger.error(step, '发送后面板未收起,疑似未发布成功');
  2247. closePanel(step);
  2248. return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' };
  2249. }
  2250. // 中级证据:评论数自增。
  2251. // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值,
  2252. // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。
  2253. var counted = TikTokAuto.wait.waitFor(step, function () {
  2254. var after = tiktok.commentCount();
  2255. if (after === null || before.countBefore === null) {
  2256. return null;
  2257. }
  2258. return after !== before.countBefore ? after : null;
  2259. }, config.timeouts.countRefreshMs, 400);
  2260. if (counted !== null) {
  2261. logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text));
  2262. return {
  2263. ok: true, via: 'comment-count', confidence: 'medium', text: text,
  2264. countBefore: before.countBefore, countAfter: counted
  2265. };
  2266. }
  2267. // 高级证据:去评论列表里找这条文案(地面真相)
  2268. if (config.comments.verifyInList) {
  2269. var inList = verifyInCommentList(config, text, before, step);
  2270. if (inList) {
  2271. logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' +
  2272. JSON.stringify(text) + ' 计数=' + inList.count);
  2273. return {
  2274. ok: true, via: 'comment-list', confidence: 'high', text: text,
  2275. countBefore: before.countBefore, countAfter: inList.count
  2276. };
  2277. }
  2278. if (config.comments.strictPostCheck) {
  2279. logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败');
  2280. return {
  2281. ok: false, via: 'not-found-in-list', text: text,
  2282. countBefore: before.countBefore, countAfter: tiktok.commentCount(),
  2283. reason: '评论列表里找不到刚发布的文案'
  2284. };
  2285. }
  2286. }
  2287. logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' +
  2288. before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text));
  2289. return {
  2290. ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text,
  2291. countBefore: before.countBefore, countAfter: tiktok.commentCount()
  2292. };
  2293. }
  2294. /**
  2295. * 打开评论列表,找这条评论是否在列表里。
  2296. * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。
  2297. *
  2298. * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里,
  2299. * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。
  2300. * 所以每次点击前先等签名稳定,并且允许最多点三次。
  2301. */
  2302. function verifyInCommentList(config, text, before, step) {
  2303. var tiktok = TikTokAuto.tiktok;
  2304. var logger = TikTokAuto.logger;
  2305. var clickAttempts = 0;
  2306. var maxClicks = 3;
  2307. var deadline = Date.now() + config.timeouts.panelReadyMs;
  2308. while (Date.now() < deadline) {
  2309. var headerCount = tiktok.listCommentCount();
  2310. if (headerCount !== null) {
  2311. // 最强:列表里真的出现了这条文案
  2312. if (tiktok.commentListContains(text)) {
  2313. closePanel(step);
  2314. return { via: 'text-in-list', count: headerCount };
  2315. }
  2316. // 次强:列表头部计数变了(说明确实多了一条)
  2317. if (before.countBefore !== null && headerCount !== before.countBefore) {
  2318. closePanel(step);
  2319. return { via: 'list-count', count: headerCount };
  2320. }
  2321. } else if (clickAttempts < maxClicks) {
  2322. settle();
  2323. var button = tiktok.commentButton();
  2324. if (!button) {
  2325. logger.warn(step, '找不到评论角标,跳过列表校验');
  2326. return null;
  2327. }
  2328. clickAttempts += 1;
  2329. logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表');
  2330. TikTokAuto.ui.clickNode(button, step);
  2331. }
  2332. TikTokAuto.wait.tick(400);
  2333. }
  2334. logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' +
  2335. tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')');
  2336. closePanel(step);
  2337. return null;
  2338. }
  2339. /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */
  2340. function settle() {
  2341. TikTokAuto.wait.waitQuiet(function () {
  2342. return TikTokAuto.layout.signature();
  2343. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  2344. }
  2345. return {
  2346. PLACEHOLDER: PLACEHOLDER,
  2347. pickText: pickText,
  2348. openPanel: openPanel,
  2349. closePanel: closePanel,
  2350. post: post,
  2351. awaitOutcome: awaitOutcome,
  2352. verifyInCommentList: verifyInCommentList
  2353. };
  2354. }());
  2355. /* ============================== src/flow.js ============================== */
  2356. /**
  2357. * flow.js — 业务流程编排
  2358. *
  2359. * 这里只写"先做什么、再做什么、失败了怎么办",不写"怎么点"(ui.js)
  2360. * 也不写"TikTok 长什么样"(tiktok.js)。
  2361. * 每一段都是一次"进入某页面 → 等它真的就绪 → 做事 → 校验"的小闭环,
  2362. * 因此任何一步卡住都能从日志里立刻看出卡在哪。
  2363. *
  2364. * 收敛原则(《代码整洁之道》第 3 章):每个函数只做一件事,且**只在一个抽象层级上**。
  2365. * 所以下面几乎没有嵌套循环和条件,读起来就是需求本身。
  2366. *
  2367. * 依赖:TikTokAuto.logger / tiktok / player / comment / ui / wait / popups
  2368. */
  2369. TikTokAuto.flow = (function () {
  2370. 'use strict';
  2371. function logger() { return TikTokAuto.logger; }
  2372. // ------------------------------------------------------------ 启动与就位
  2373. function launchApp(config) {
  2374. var step = 'launch';
  2375. logger().step(step, '启动 ' + config.target.packageName);
  2376. app.launchPackage(config.target.packageName);
  2377. var ready = TikTokAuto.wait.waitFor(step, function () {
  2378. return TikTokAuto.layout.foregroundPackage() === config.target.packageName ? true : null;
  2379. }, config.timeouts.appLaunchMs, config.probe.pollTickMs);
  2380. return ready === true;
  2381. }
  2382. /**
  2383. * 保证停在信息流首页。
  2384. *
  2385. * 早先的写法有两个坑(实测踩到):
  2386. * 1. 一上来就 `waitFor(feed, 15s)` —— 明明已经在播放页,却先白等 15 秒;
  2387. * 2. 循环里最后按完 back 不回头复查,4 次退完刚好差一步,然后误判"退不回去"去重启。
  2388. * 现在改成:**先看再动**,每退一步都等页面真的换了(按签名变化判断,不是固定 sleep),
  2389. * 并且只在确认当前不是首页时才按返回——避免把正常页面退掉或退出 App。
  2390. */
  2391. function ensureFeed(config) {
  2392. var step = 'ensureFeed';
  2393. var log = logger();
  2394. var tiktok = TikTokAuto.tiktok;
  2395. var tiktokLayout = TikTokAuto.layout;
  2396. var deadline = Date.now() + 25000;
  2397. var unknownRounds = 0;
  2398. while (Date.now() < deadline) {
  2399. var state = tiktok.pageState();
  2400. if (state === 'feed') {
  2401. log.info(step, '已处于信息流首页');
  2402. return true;
  2403. }
  2404. if (state === 'unknown') {
  2405. // 页面可能还在渲染。但连续多轮都认不出来,说明有东西盖在上面
  2406. // (弹窗、半透明遮罩、异常页),这时**必须**试着按返回脱离,
  2407. // 否则就是原地等到超时、然后"重启大法"。
  2408. unknownRounds += 1;
  2409. if (unknownRounds >= 4) {
  2410. log.warn(step, '页面状态连续 ' + unknownRounds + ' 轮未知,按返回尝试脱离');
  2411. back();
  2412. TikTokAuto.wait.waitQuiet(function () {
  2413. return tiktokLayout.signature();
  2414. }, { maxMs: 2000, tickMs: 200, stableTimes: 2 });
  2415. unknownRounds = 0;
  2416. } else {
  2417. TikTokAuto.wait.tick(700);
  2418. }
  2419. continue;
  2420. }
  2421. unknownRounds = 0;
  2422. log.info(step, '当前在 ' + state + ',返回上一页');
  2423. back();
  2424. TikTokAuto.wait.waitQuiet(function () {
  2425. return tiktokLayout.signature();
  2426. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  2427. }
  2428. log.warn(step, '多次返回仍未到首页,重新启动应用');
  2429. launchApp(config);
  2430. return TikTokAuto.wait.waitFor(step, function () {
  2431. return tiktok.pageState() === 'feed' ? true : null;
  2432. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  2433. }
  2434. // ------------------------------------------------------------ 搜索
  2435. function openSearchPage(config) {
  2436. var step = 'openSearch';
  2437. var entry = TikTokAuto.tiktok.searchEntry();
  2438. if (!entry) {
  2439. logger().error(step, '首页找不到搜索入口');
  2440. return false;
  2441. }
  2442. TikTokAuto.ui.clickNode(entry, step);
  2443. return TikTokAuto.wait.waitFor(step, function () {
  2444. if (TikTokAuto.tiktok.pageState() === 'search') {
  2445. return true;
  2446. }
  2447. // 结果页也算"搜索这条链走通了",直接复用
  2448. return TikTokAuto.tiktok.pageState() === 'results' ? true : null;
  2449. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  2450. }
  2451. function submitKeyword(config) {
  2452. var step = 'submitKeyword';
  2453. var tiktok = TikTokAuto.tiktok;
  2454. if (tiktok.pageState() === 'results') {
  2455. logger().info(step, '已经在结果页,跳过输入');
  2456. return true;
  2457. }
  2458. var input = tiktok.searchInput();
  2459. if (!input) {
  2460. logger().error(step, '找不到搜索输入框');
  2461. return false;
  2462. }
  2463. TikTokAuto.ui.clickNode(input, step);
  2464. TikTokAuto.wait.tick(250);
  2465. var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, config.target.keyword, step);
  2466. if (!typed.ok) {
  2467. return false;
  2468. }
  2469. var submit = tiktok.searchSubmit();
  2470. if (!submit) {
  2471. logger().error(step, '找不到搜索提交按钮');
  2472. return false;
  2473. }
  2474. TikTokAuto.ui.clickNode(submit, step);
  2475. return TikTokAuto.wait.waitFor(step, function () {
  2476. return tiktok.pageState() === 'results' ? true : null;
  2477. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
  2478. }
  2479. /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */
  2480. function waitForCards(config) {
  2481. var step = 'waitForCards';
  2482. return TikTokAuto.wait.waitFor(step, function () {
  2483. var cards = TikTokAuto.tiktok.videoCards();
  2484. return cards.length > 0 ? cards : null;
  2485. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  2486. }
  2487. function selectResultTab(config) {
  2488. var step = 'selectResultTab';
  2489. var name = config.target.resultTab;
  2490. if (!name) {
  2491. return false;
  2492. }
  2493. var tab = TikTokAuto.tiktok.resultTab(name);
  2494. if (!tab) {
  2495. logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
  2496. return false;
  2497. }
  2498. logger().info(step, '切换到页签 ' + name);
  2499. TikTokAuto.ui.clickNode(tab, step);
  2500. TikTokAuto.wait.waitQuiet(function () {
  2501. return TikTokAuto.layout.signature();
  2502. }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
  2503. return true;
  2504. }
  2505. /** 结果页滚动加载更多卡片 */
  2506. function scrollResults(config) {
  2507. var layout = TikTokAuto.layout;
  2508. var screen = layout.screen();
  2509. logger().info('scrollResults', '结果页下拉加载更多');
  2510. TikTokAuto.ui.dragScreen(
  2511. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.8),
  2512. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.35),
  2513. 400
  2514. );
  2515. TikTokAuto.wait.waitQuiet(function () {
  2516. return TikTokAuto.layout.signature();
  2517. }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
  2518. }
  2519. // ------------------------------------------------------------ 选片与观看
  2520. function chooseCard(config, cards, visited) {
  2521. var fresh = [];
  2522. for (var i = 0; i < cards.length; i += 1) {
  2523. if (visited.indexOf(cards[i].caption) === -1) {
  2524. fresh.push(cards[i]);
  2525. }
  2526. }
  2527. var pool = fresh.length > 0 ? fresh : cards;
  2528. if (config.target.pickStrategy === 'first') {
  2529. return pool[0];
  2530. }
  2531. return pool[Math.floor(Math.random() * pool.length)];
  2532. }
  2533. function openCard(config, card) {
  2534. var step = 'openCard';
  2535. logger().step(step, '打开视频:' + JSON.stringify(String(card.caption).slice(0, 40)));
  2536. TikTokAuto.ui.clickNode(card.container, step);
  2537. return TikTokAuto.player.waitPlayerReady(config, step);
  2538. }
  2539. /**
  2540. * 从播放页回到结果页。
  2541. *
  2542. * 必须允许连按多次:校验评论时会把评论列表打开,此时状态是 comment-panel,
  2543. * 一次 back 只够收起面板,得再退一次才真正回到结果页。
  2544. */
  2545. function returnToResults(config) {
  2546. var step = 'returnToResults';
  2547. var log = logger();
  2548. var tiktok = TikTokAuto.tiktok;
  2549. var tiktokLayout = TikTokAuto.layout;
  2550. var deadline = Date.now() + config.timeouts.pageReadyMs;
  2551. var backs = 0;
  2552. while (Date.now() < deadline && backs < 5) {
  2553. var state = tiktok.pageState();
  2554. if (state === 'results') {
  2555. return true;
  2556. }
  2557. if (state === 'unknown') {
  2558. TikTokAuto.wait.tick(500);
  2559. continue;
  2560. }
  2561. backs += 1;
  2562. log.info(step, '当前在 ' + state + ',返回上一页');
  2563. back();
  2564. TikTokAuto.wait.waitQuiet(function () {
  2565. return tiktokLayout.signature();
  2566. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  2567. }
  2568. log.warn(step, '未能回到结果页,跳过后续取片');
  2569. return false;
  2570. }
  2571. // ------------------------------------------------------------ 主流程
  2572. /**
  2573. * 上报进度给界面宿主(未接宿主时是空操作,见 bridge.js)。
  2574. * 只在这里包一层是为了让下面的流程读起来仍是"业务语言",
  2575. * 不必到处重复 status:'running' 这种传输细节。
  2576. */
  2577. function progress(phase, done, total) {
  2578. TikTokAuto.bridge.report({
  2579. status: 'running',
  2580. phase: phase,
  2581. done: done,
  2582. total: total
  2583. });
  2584. }
  2585. /**
  2586. * 跑一轮完整会话:启动 → 搜索 → 逐个视频「看 → 评」。
  2587. * @returns 结构化结果,会被 main.js 打印成一行 RESULT 供 PC 侧断言
  2588. */
  2589. function runSession(config) {
  2590. var summary = {
  2591. ok: false,
  2592. keyword: config.target.keyword,
  2593. requestedVideos: config.target.maxVideos,
  2594. attemptedVideos: 0,
  2595. postedComments: 0,
  2596. details: [],
  2597. errors: []
  2598. };
  2599. var total = config.target.maxVideos;
  2600. progress('\u542f\u52a8\u5e94\u7528', 0, total);
  2601. if (!launchApp(config)) {
  2602. summary.errors.push('启动应用失败');
  2603. return summary;
  2604. }
  2605. progress('\u8fdb\u5165\u4fe1\u606f\u6d41', 0, total);
  2606. if (!ensureFeed(config)) {
  2607. summary.errors.push('未能进入信息流首页');
  2608. return summary;
  2609. }
  2610. progress('\u641c\u7d22 ' + config.target.keyword, 0, total);
  2611. if (!openSearchPage(config) || !submitKeyword(config)) {
  2612. summary.errors.push('搜索流程失败');
  2613. return summary;
  2614. }
  2615. if (!waitForCards(config)) {
  2616. summary.errors.push('搜索结果里没有可用卡片');
  2617. return summary;
  2618. }
  2619. selectResultTab(config);
  2620. var visited = [];
  2621. var usedComments = [];
  2622. var guard = 0;
  2623. while (summary.attemptedVideos < config.target.maxVideos && guard < config.target.maxVideos * 3) {
  2624. guard += 1;
  2625. progress('\u6b63\u5728\u5904\u7406\u7b2c ' + (summary.attemptedVideos + 1) + '/' + total + ' \u4e2a\u89c6\u9891',
  2626. summary.attemptedVideos, total);
  2627. var cards = TikTokAuto.tiktok.videoCards();
  2628. if (cards.length === 0) {
  2629. scrollResults(config);
  2630. cards = TikTokAuto.tiktok.videoCards();
  2631. if (cards.length === 0) {
  2632. summary.errors.push('结果页已无更多卡片');
  2633. break;
  2634. }
  2635. }
  2636. var card = chooseCard(config, cards, visited);
  2637. visited.push(card.caption);
  2638. if (!openCard(config, card)) {
  2639. summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30));
  2640. returnToResults(config);
  2641. continue;
  2642. }
  2643. var detail = { caption: String(card.caption).slice(0, 60) };
  2644. detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
  2645. /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
  2646. * (界面上的「执行操作」没勾评论就是这个意思)。 */
  2647. if (config.comments.enabled) {
  2648. var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
  2649. detail.comment = TikTokAuto.comment.post(config, text, 'comment');
  2650. if (detail.comment.ok && !detail.comment.skipped) {
  2651. usedComments.push(text);
  2652. summary.postedComments += 1;
  2653. }
  2654. } else {
  2655. detail.comment = { ok: true, skipped: true, reason: '本次未启用评论动作' };
  2656. }
  2657. summary.attemptedVideos += 1;
  2658. summary.details.push(detail);
  2659. progress('\u5df2\u5b8c\u6210 ' + summary.attemptedVideos + '/' + total,
  2660. summary.attemptedVideos, total);
  2661. returnToResults(config);
  2662. }
  2663. summary.ok = summary.attemptedVideos > 0;
  2664. return summary;
  2665. }
  2666. return {
  2667. launchApp: launchApp,
  2668. ensureFeed: ensureFeed,
  2669. openSearchPage: openSearchPage,
  2670. submitKeyword: submitKeyword,
  2671. waitForCards: waitForCards,
  2672. selectResultTab: selectResultTab,
  2673. scrollResults: scrollResults,
  2674. chooseCard: chooseCard,
  2675. openCard: openCard,
  2676. returnToResults: returnToResults,
  2677. runSession: runSession
  2678. };
  2679. }());
  2680. /* ============================== src/main.js ============================== */
  2681. /**
  2682. * main.js — 入口与收尾
  2683. *
  2684. * 入口只负责三件事:读配置、开守护线程、跑流程。
  2685. * 收尾负责四件**必须保证**的事:
  2686. * 1. 无论成功失败都打印 `__END__`(否则 PC 侧执行器会一直等到超时);
  2687. * 2. 关掉守护线程(`threads.shutDownAll()`,否则进程不退出);
  2688. * 3. 打印一行机器可读的 RESULT,让 PC 侧能直接断言而不是解析自然语言日志;
  2689. * 4. 向界面宿主上报终态(被界面唤起时才有这一步,见 bridge.js)。
  2690. *
  2691. * 依赖:全部模块
  2692. */
  2693. TikTokAuto.main = (function () {
  2694. 'use strict';
  2695. var RESULT_MARKER = '__END__';
  2696. function emptySummary(reason) {
  2697. return {
  2698. ok: false,
  2699. attemptedVideos: 0,
  2700. postedComments: 0,
  2701. details: [],
  2702. errors: [reason]
  2703. };
  2704. }
  2705. /**
  2706. * 去掉占位原因。
  2707. * `emptySummary('未开始')` 的那条只在"一次都没跑起来"时才有意义;
  2708. * 流程已经跑过一段后再追加真实原因,留着它会让界面把"未开始"显示成第一条错误。
  2709. */
  2710. function dropPlaceholder(errors) {
  2711. var kept = [];
  2712. for (var i = 0; i < errors.length; i += 1) {
  2713. if (errors[i] !== '未开始') {
  2714. kept.push(errors[i]);
  2715. }
  2716. }
  2717. return kept;
  2718. }
  2719. /**
  2720. * @param injected PC 侧注入的配置覆盖项
  2721. * @returns 结构化结果
  2722. */
  2723. function run(injected) {
  2724. var log = TikTokAuto.logger;
  2725. var bridge = TikTokAuto.bridge;
  2726. var summary = emptySummary('未开始');
  2727. var guardState = null;
  2728. var config = null;
  2729. /* 是"用户叫停"还是"出错了",收尾时的上报状态不同 */
  2730. var stoppedByUser = false;
  2731. try {
  2732. log.step('main', '=== TikTok 自动互动开始 ===');
  2733. bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  2734. try {
  2735. config = TikTokAuto.config.resolve(injected);
  2736. } catch (configError) {
  2737. log.error('main', '配置无效:' + configError.message + ' / detail=' + JSON.stringify(configError.detail));
  2738. summary = emptySummary('配置无效:' + configError.message);
  2739. return summary;
  2740. }
  2741. bridge.report({ phase: '启动应用', total: config.target.maxVideos });
  2742. guardState = TikTokAuto.popups.startGuard(config, log);
  2743. var guardUp = TikTokAuto.wait.waitFor('guard', function () {
  2744. return guardState.passes >= 1 ? true : null;
  2745. }, config.guard.firstScanTimeoutMs, 200);
  2746. if (!guardUp) {
  2747. log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)');
  2748. }
  2749. summary = TikTokAuto.flow.runSession(config);
  2750. log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ===');
  2751. } catch (error) {
  2752. if (bridge.isStopSignal(error)) {
  2753. /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */
  2754. stoppedByUser = true;
  2755. log.warn('main', '收到停止请求,提前结束本次运行');
  2756. summary.ok = false;
  2757. summary.errors = dropPlaceholder(summary.errors);
  2758. summary.errors.push('用户停止');
  2759. } else {
  2760. var detail = error && error.stack ? String(error.stack) : String(error);
  2761. log.error('main', '未捕获异常:' + detail);
  2762. summary.ok = false;
  2763. summary.errors.push(String(error));
  2764. }
  2765. } finally {
  2766. TikTokAuto.popups.stopGuard(guardState, log);
  2767. log.result(summary);
  2768. /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
  2769. * 状态条会一直显示"运行中"。 */
  2770. bridge.finish(
  2771. stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
  2772. summary
  2773. );
  2774. // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
  2775. try {
  2776. threads.shutDownAll();
  2777. } catch (ignored) {
  2778. // 没有线程在跑时该调用可能抛错,忽略即可
  2779. }
  2780. console.log(RESULT_MARKER);
  2781. }
  2782. return summary;
  2783. }
  2784. return {
  2785. RESULT_MARKER: RESULT_MARKER,
  2786. run: run
  2787. };
  2788. }());
  2789. /* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */
  2790. var INJECTED_CONFIG = /*TTA_INJECTED_CONFIG*/null;
  2791. /* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),
  2792. * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */
  2793. TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));
  2794. }());