engine.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. /* ============================== src/engine.js ==============================
  2. * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
  3. *
  4. * ## 为什么走文件,不走内存
  5. * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
  6. * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
  7. *
  8. * run-config.json 界面 → 人/追溯:完整配置快照(人可读)
  9. * engine-config.json 界面 → 引擎:映射后的引擎配置
  10. * run-state.json 引擎 → 界面:进度与终态
  11. * stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId)
  12. *
  13. * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
  14. * 而不是"我点了就当我做了"。点「停止运行」后界面显示"正在收尾"、
  15. * 等到引擎真的上报 stopped 才切回未运行 —— 否则界面说"已停止"而 TikTok 还在自己动,
  16. * 用户不知道该信谁。
  17. *
  18. * ## 为什么不删 stop.flag
  19. * 停止靠**比内容**而不是靠"文件在不在":只有内容等于本次 runId 才算数。
  20. * 于是不必删除它(删除既有竞态,`files.remove` 又命中 MCP 危险调用黑名单),
  21. * 上一轮残留的标志也不会误杀下一轮。
  22. *
  23. * ## 能力边界要如实说
  24. * tiktok-auto 目前实现的是「搜索 → 逐个看 → 评论」。界面「执行操作」里的
  25. * 关注 / 私信 / 点赞**尚未接入**,`buildConfig()` 会把它们逐条记进 notes,
  26. * 由调用方决定怎么告知用户 —— **绝不静默忽略**:
  27. * 用户勾了点赞却什么都没发生,比明确告知"尚未支持"糟得多。
  28. */
  29. var Engine = {};
  30. Engine.DIR = "/sdcard/tiktok_app";
  31. Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
  32. Engine.STATE_PATH = Engine.DIR + "/run-state.json";
  33. Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
  34. /**
  35. * 引擎的日志文件(jsonl,一行一条)。
  36. *
  37. * 2026-10-13 新增:D3 运行详情里的「执行日志」要看的就是它。
  38. * 为什么走文件而不是读 console:引擎是**另一个 ScriptEngine**,
  39. * 它的 stdout 界面根本读不到(见本文件头的通道说明)。
  40. * 引擎侧由 `logger.setSink` → `files.append` 落盘(tiktok-auto/src/bridge.js)。
  41. */
  42. Engine.LOG_PATH = Engine.DIR + "/run-log.jsonl";
  43. /** 一条记录最多带走多少行日志(记录要落盘,不能让日志把它撑爆)。 */
  44. Engine.LOG_MAX_LINES = 200;
  45. /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
  46. Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
  47. /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
  48. Engine.RUN_ID = "";
  49. /** 上次定位到的引擎产物路径;null 表示还没找过 */
  50. Engine.scriptPath = null;
  51. /**
  52. * 是否**确实在引擎列表里见过**子引擎。
  53. *
  54. * 用途唯一:给"引擎没写终态就没了"的兜底判定加一道保险。
  55. * `engines.all()` 在不同运行时里的可见性不完全一致 —— 如果它从来就不报告子引擎,
  56. * 那"看不到子引擎"这件事毫无信息量,此时宁可不判定,也不要误把正在跑的运行结束掉。
  57. */
  58. Engine.sawChild = false;
  59. /* ------------------------------------------------------------ 运行标识 */
  60. /**
  61. * 生成本次运行的标识。
  62. * 用「时间戳 + 随机串」:同一秒内连点两次也不会撞,
  63. * 而且人在日志里一眼能看出这是哪一次运行。
  64. */
  65. Engine.newRunId = function () {
  66. return "run-" + Date.now() + "-" + Math.floor(Math.random() * 100000);
  67. };
  68. /* ------------------------------------------------------------ 定位引擎 */
  69. /** 探测用的候选路径,按「最可能正确」排序。 */
  70. Engine.candidates = function () {
  71. var list = [];
  72. /* ① 入口脚本同级 —— 项目模式下最准。
  73. * 用 myEngine().source 而不是 files.cwd():后者依赖运行方式,
  74. * 前者永远是入口脚本的真实路径。 */
  75. try {
  76. var source = String(engines.myEngine().source || "");
  77. var cut = source.lastIndexOf("/");
  78. if (cut > 0) {
  79. list.push(source.substring(0, cut) + "/" + Engine.SCRIPT_RELATIVE);
  80. }
  81. } catch (e) {
  82. /* 取不到就靠后面的候选 */
  83. }
  84. /* ② 扫一层项目目录:产物可能来自另一个项目文件夹 */
  85. try {
  86. var names = files.listDir("/sdcard/Scripts");
  87. for (var i = 0; i < names.length; i += 1) {
  88. var dir = "/sdcard/Scripts/" + names[i];
  89. if (files.isDir(dir)) {
  90. list.push(dir + "/" + Engine.SCRIPT_RELATIVE);
  91. }
  92. }
  93. } catch (e) {
  94. /* 没这个目录就跳过 */
  95. }
  96. /* ③ 扁平部署:产物被单独拷到 Download(手工推送时的常见形态) */
  97. list.push("/sdcard/Download/tiktok-auto.js");
  98. list.push(Engine.DIR + "/tiktok-auto.js");
  99. return list;
  100. };
  101. /**
  102. * 找到引擎产物。
  103. * @returns 路径;找不到返回空串(调用方负责给出可操作的提示)
  104. */
  105. Engine.locate = function () {
  106. var list = Engine.candidates();
  107. for (var i = 0; i < list.length; i += 1) {
  108. try {
  109. if (files.exists(list[i])) {
  110. Engine.scriptPath = list[i];
  111. return list[i];
  112. }
  113. } catch (e) {
  114. /* 单个候选探测失败不该中断整轮查找 */
  115. }
  116. }
  117. Engine.scriptPath = null;
  118. return "";
  119. };
  120. /* ------------------------------------------------------------ 配置映射 */
  121. function trimAll(text) {
  122. return String(text).replace(/^\s+|\s+$/g, "");
  123. }
  124. /**
  125. * 读文本文件的行,跳过空行与 `#` 开头的注释行。
  126. * 读不到(文件不存在 / 权限问题)时返回空数组 —— 调用方按"没配"处理。
  127. */
  128. function readLines(path) {
  129. var out = [];
  130. try {
  131. if (!path || !files.exists(path)) { return out; }
  132. var lines = String(files.read(path)).split(/\r?\n/);
  133. for (var i = 0; i < lines.length; i += 1) {
  134. var text = trimAll(lines[i]);
  135. if (text !== "" && text.charAt(0) !== "#") { out.push(text); }
  136. }
  137. } catch (e) {
  138. console.log("[engine] 读取文本失败 " + path + ": " + e);
  139. }
  140. return out;
  141. }
  142. function randomInt(min, max) {
  143. var low = Math.floor(min);
  144. var high = Math.floor(max);
  145. if (high < low) { high = low; }
  146. return low + Math.floor(Math.random() * (high - low + 1));
  147. }
  148. /** 秒 → 毫秒,并给一个下限:0 秒会让"观看"立刻返回,等于没看 */
  149. function seconds(ms) {
  150. return Math.max(1000, Math.floor(ms) * 1000);
  151. }
  152. /**
  153. * 界面配置 → 引擎配置。
  154. *
  155. * @param task 当前要跑的任务(`App.startRun` 传进来的那个对象,含 `task` 字段)
  156. * @returns { config, notes }
  157. * notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  158. */
  159. Engine.buildConfig = function (task, opts) {
  160. /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
  161. * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
  162. if (task && task.task === "id") {
  163. return Engine.buildIdConfig(opts);
  164. }
  165. return Engine.buildVideoConfig();
  166. };
  167. /* ------------------------------------------------------------ ID 提取 */
  168. /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
  169. Engine.ID_DIR = "/sdcard/Download/tiktok_app";
  170. /**
  171. * 断点文件(ID提取的"跑到哪了")。
  172. *
  173. * 与产物同目录:用户清产物时看得见它;而且它引用的是 runFile 的绝对路径,
  174. * 放在一起语义最直白。引擎侧同一份路径由 `id.resume.path` 传下去。
  175. */
  176. Engine.ID_PROGRESS_PATH = Engine.ID_DIR + "/id_progress.json";
  177. /**
  178. * 界面 → 引擎(task = 'id')。
  179. *
  180. * ## 取材清单几乎是原样透传
  181. * `Sources` 模块当初就是**照着引擎侧的契约**设计的(`kind` + 每种起点的参数 + `expand`),
  182. * 所以这里不需要再翻译一遍字段名。少一层翻译就少一处"路径拼错=静默丢值"的机会。
  183. *
  184. * ## 做不到的项一律进 notes
  185. * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
  186. */
  187. Engine.buildIdConfig = function (opts) {
  188. var notes = [];
  189. var base = "tabs.id.features.";
  190. var sources = Sources.list();
  191. var engineSources = [];
  192. for (var i = 0; i < sources.length; i++) {
  193. var one = sources[i];
  194. /* `key` 就是界面的 `id`:断点按它认"哪条取材做完了"。
  195. * 以前刻意不带(引擎不需要),但续跑必须带 —— 否则断点里的条目和配置里的
  196. * 条目只能靠下标对齐,用户删一条就全错位。 */
  197. engineSources.push({
  198. key: one.id || "",
  199. kind: one.kind,
  200. anchors: one.anchors || "",
  201. lists: one.lists || [],
  202. searchType: one.searchType || "users",
  203. keyword: one.keyword || "",
  204. videoSource: one.videoSource || "screen",
  205. videoKeyword: one.videoKeyword || "",
  206. path: one.path || "",
  207. expand: one.expand || ""
  208. });
  209. }
  210. var config = {
  211. task: "id",
  212. id: {
  213. sources: engineSources,
  214. filter: {
  215. private: Store.getBool(base + "filterPrivate"),
  216. noAvatar: Store.getBool(base + "filterNoAvatar"),
  217. langRegion: Store.getEnum(base + "langRegion", "langRegion"),
  218. nicknameCheck: Store.getEnum(base + "nicknameCheck", "nicknameCheck"),
  219. /* 四组区间:界面是"最小值 / 最大值"两格,0 表示该侧不设限 */
  220. follow: { min: Store.getNumber(base + "followMin"), max: Store.getNumber(base + "followMax") },
  221. fans: { min: Store.getNumber(base + "fansMin"), max: Store.getNumber(base + "fansMax") },
  222. likes: { min: Store.getNumber(base + "likesMin"), max: Store.getNumber(base + "likesMax") },
  223. videos: { min: Store.getNumber(base + "videosMin"), max: Store.getNumber(base + "videosMax") }
  224. },
  225. output: {
  226. dir: Engine.ID_DIR,
  227. historyPath: Engine.ID_DIR + "/uid_extracted.txt",
  228. dedupeHistory: Store.getBool(base + "dedupeHistory"),
  229. dryRun: false,
  230. /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
  231. limit: Store.getNumber(base + "extractLimit"),
  232. /* 抓完之后换数字 uid。默认开(数字 uid 在界面上读不到,不换就得事后另跑),
  233. * 每个约多花 2.5 秒 —— 「输出」卡片上给了开关,用户可关。 */
  234. resolveUid: Store.getBool(base + "resolveUid")
  235. },
  236. guard: {
  237. /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
  238. maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
  239. },
  240. /**
  241. * 断点续跑。**只有点「继续」时才为 true**。
  242. *
  243. * 引擎侧看到它才会去读断点文件;普通「开始运行」传 false,
  244. * 于是"想从头跑一次"永远是从头跑(不会被上一轮的断点悄悄接管)。
  245. */
  246. resume: {
  247. enabled: !!(opts && opts.resume),
  248. path: Engine.ID_PROGRESS_PATH
  249. }
  250. },
  251. runtime: {
  252. configPath: Engine.CONFIG_PATH,
  253. dryRun: false,
  254. maxRuntimeMs: 0
  255. }
  256. };
  257. notes = notes.concat(Engine.unsupportedNotes({ task: "id" }));
  258. /* 「本地去重」关掉时要说清楚后果:它不只是"少一道过滤",
  259. * 而是同一个 ID 会在产物文件里重复出现,下游私信会重复发。 */
  260. if (!Store.getBool(base + "dedupeLocal")) {
  261. notes.push("\u672C\u5730\u53BB\u91CD\uFF1A\u5DF2\u5173\u95ED\uFF0C\u540C\u4E00\u4E2A ID \u53EF\u80FD\u91CD\u590D\u5199\u5165");
  262. }
  263. return { config: config, notes: notes };
  264. };
  265. /* ------------------------------------------------------------ 视频链路 */
  266. Engine.buildVideoConfig = function () {
  267. var notes = [];
  268. var keyword = Store.getText("tabs.video.keyword");
  269. var stopMin = Store.getNumber("tabs.video.stopMin");
  270. var stopMax = Store.getNumber("tabs.video.stopMax");
  271. var intervalMin = Store.getNumber("tabs.video.intervalMin");
  272. var intervalMax = Store.getNumber("tabs.video.intervalMax");
  273. var doComment = Store.getBool("tabs.video.actions.comment");
  274. var pool = readLines(Store.getText("tabs.video.commentTextPath"));
  275. if (doComment && pool.length === 0) {
  276. notes.push("\u8BC4\u8BBA\u6587\u672C\u91CC\u6CA1\u6709\u53EF\u7528\u884C\uFF0C"
  277. + "\u672C\u6B21\u4F1A\u7528\u5185\u7F6E\u9ED8\u8BA4\u8BC4\u8BBA");
  278. }
  279. var config = {
  280. target: {
  281. keyword: keyword,
  282. /* 「停止个数 最小值 ~ 最大值」= 每次运行在这个区间里随机停 ——
  283. * 固定不变会让每次运行的行为完全一样,反而更容易被看出是脚本。 */
  284. maxVideos: randomInt(stopMin, stopMax),
  285. resultTab: "Top",
  286. pickStrategy: "random"
  287. },
  288. /* 界面的「间隔时间(秒)」映射为**每个视频的观看时长**。
  289. * 引擎目前没有单独的"两个视频之间的停顿"配置,而"看 20 秒再操作"
  290. * 与"每 20 秒处理一个"在行为上是一回事。 */
  291. watch: {
  292. minMs: seconds(intervalMin),
  293. maxMs: seconds(intervalMax)
  294. },
  295. /* 点赞 / 关注:与评论同级的两个动作开关。
  296. * 界面上「执行操作」是四项多选,用户勾了才做 —— 没勾就一个都不点。 */
  297. likes: {
  298. enabled: Store.getBool("tabs.video.actions.like")
  299. },
  300. follows: {
  301. enabled: Store.getBool("tabs.video.actions.follow")
  302. },
  303. comments: {
  304. /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
  305. * 引擎侧据此跳过评论动作,而不是照默认值照发。
  306. * 用户的意图优先于引擎的默认行为。 */
  307. enabled: doComment,
  308. avoidRepeatWithinRun: true,
  309. verifyInList: true
  310. },
  311. runtime: {
  312. configPath: Engine.CONFIG_PATH,
  313. dryRun: false,
  314. maxRuntimeMs: 0
  315. }
  316. };
  317. if (pool.length > 0) {
  318. config.comments.pool = pool;
  319. }
  320. notes = notes.concat(Engine.unsupportedNotes({ task: "video" }));
  321. return { config: config, notes: notes };
  322. };
  323. /**
  324. * 引擎这次做不到、但用户可能勾了的项。
  325. *
  326. * 单独抽出来是因为它只跟「已实现的动作集合」有关,
  327. * 将来引擎接入了点赞,**只改这一个函数**就够。
  328. */
  329. Engine.unsupportedNotes = function (task) {
  330. var notes = [];
  331. if (task && task.task === "id") {
  332. return Engine.unsupportedIdNotes();
  333. }
  334. /* 点赞与关注已由引擎实现(src/interact.js),不再报"尚未实现"。
  335. * 只剩私信一项还在等(见 UID私信 任务)。 */
  336. if (Store.getBool("tabs.video.actions.dm")) {
  337. notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
  338. }
  339. return notes;
  340. };
  341. /**
  342. * ID 提取里"用户可能配了、但这次不生效"的项。
  343. *
  344. * 判据是**偏离默认值**,而不是"有值" —— 这一条很关键:
  345. * 功能设置里有好几项的默认值本身就是非空的(`regionCodes = "JP#TW#US"`、
  346. * `aiAgeMax = 20`、`videosMax = 500000`)。若按"有值就报",
  347. * 每次运行都会冒出一串"未生效",把真正需要注意的那条淹没 ——
  348. * 而 note 的本意是"你表达了意图,这次做不到",默认值并不代表任何意图。
  349. */
  350. Engine.unsupportedIdNotes = function () {
  351. var notes = [];
  352. var base = "tabs.id.features.";
  353. /** 这一项被用户改过吗(与默认值不同) */
  354. function changed(path) {
  355. return String(Store.get(path)) !== String(Store.defaultOf(path));
  356. }
  357. /* AI 三项:需要模型,本次不做。界面上是灰行,理论上改不动;
  358. * 老配置里可能残留过值,所以仍然按"改过才报"来处理。 */
  359. if (changed(base + "aiAvatar") && Store.getBool(base + "aiAvatar")) {
  360. notes.push("AI \u5934\u50CF\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  361. }
  362. if (changed(base + "aiGender") && Store.getBool(base + "aiGender")) {
  363. notes.push("AI \u6027\u522B\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  364. }
  365. if (changed(base + "aiAgeMax") && Store.getNumber(base + "aiAgeMax") > 0) {
  366. notes.push("AI \u5E74\u9F84\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  367. }
  368. /* 云端去重:契约已定但服务端没接。降级为本地去重并**说出来**,绝不静默。 */
  369. if (Store.getBool(base + "cloudDedupe")) {
  370. notes.push("\u4E91\u7AEF\u53BB\u91CD\uFF1A\u670D\u52A1\u672A\u63A5\u5165\uFF0C\u672C\u6B21\u53EA\u7528\u672C\u5730\u53BB\u91CD");
  371. }
  372. /* 视频量门槛:真机实测用户页**只显示 关注/粉丝/获赞 三组数字,没有视频数**。
  373. * 引擎侧对这条的处理是"放行 + 告知"(见 tiktok-auto/src/filters.js 文件头)。
  374. * 默认上限 500000 在语义上等于"不限",所以只有用户真的收窄了才报。 */
  375. if (changed(base + "videosMin") || changed(base + "videosMax")) {
  376. notes.push("\u89C6\u9891\u91CF\u95E8\u69DB\uFF1A\u7528\u6237\u9875\u4E0D\u663E\u793A\u8BE5\u6570\u5B57\uFF0C\u672C\u6B21\u4E0D\u53C2\u4E0E\u8FC7\u6EE4");
  377. }
  378. /* 无头像靠像素判定,纯黑白照片会被误判 —— 用户勾了就得先知道这个局限。 */
  379. if (Store.getBool(base + "filterNoAvatar")) {
  380. notes.push("\u65E0\u5934\u50CF\u68C0\u6D4B\uFF1A\u6309\u50CF\u7D20\u8272\u5F69\u5224\u5B9A\uFF0C\u7EAF\u9ED1\u767D\u7167\u7247\u4F1A\u88AB\u8BEF\u5224");
  381. }
  382. /* 「提取区域」刻意**不进 notes**:它在界面上是一行灰字 + 「暂不支持」胶囊,
  383. * 用户已经看得到;每次运行再报一次只会变成噪音。它的值仍保留在配置里,
  384. * 将来接上数据源时直接生效。 */
  385. return notes;
  386. };
  387. /**
  388. * notes → 一句能放在 toast 里的话(没有就返回空串)。
  389. *
  390. * 只取每条的**短名**(中文冒号前的部分)拼起来:三条完整说明拼起来有四十多个字,
  391. * 那种长度的 toast 用户根本来不及读。完整内容在 run-config.json 里,随时可查。
  392. */
  393. Engine.summarizeNotes = function (notes) {
  394. if (!notes || notes.length === 0) { return ""; }
  395. var names = [];
  396. for (var i = 0; i < notes.length; i += 1) {
  397. names.push(String(notes[i]).split("\uFF1A")[0]);
  398. }
  399. return names.join("/") + " \u672C\u6B21\u4E0D\u4F1A\u751F\u6548";
  400. };
  401. /* ------------------------------------------------------------ 启动 */
  402. /**
  403. * 准备一次运行:定位产物 + 构建配置。
  404. *
  405. * @param task 当前要跑的任务(含 `task` 字段);不传按视频链路处理(向后兼容)
  406. * @returns { ok, scriptPath, config, notes, error }
  407. * error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  408. */
  409. Engine.prepare = function (task, opts) {
  410. var path = Engine.locate();
  411. if (!path) {
  412. return {
  413. ok: false,
  414. error: "\u627E\u4E0D\u5230\u6267\u884C\u5F15\u64CE\u4EA7\u7269\uFF08" + Engine.SCRIPT_RELATIVE + "\uFF09\u3002\n\n"
  415. + "\u8BF7\u5728 PC \u4E0A\u6267\u884C node tiktok-auto/tools/build.js \u540E\uFF0C"
  416. + "\u5C06 tiktok-auto/dist/tiktok-auto.js \u653E\u5230 \u9879\u76EE\u6839\u76EE\u5F55\u4E0B,\n"
  417. + "\u6216\u76F4\u63A5\u63A8\u5230 /sdcard/Download/tiktok-auto.js\u3002"
  418. };
  419. }
  420. var built = Engine.buildConfig(task, opts);
  421. /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
  422. * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
  423. * 所以这条拦截必须按任务分支,否则 ID 提取第一次点运行就会被无理由挡下。 */
  424. if (task && task.task === "id") {
  425. if (!built.config.id.sources || built.config.id.sources.length === 0) {
  426. return {
  427. ok: false,
  428. scriptPath: path,
  429. error: "\u8BF7\u5148\u5728\u300CID\u63D0\u53D6\u300D\u9875\u6DFB\u52A0\u81F3\u5C11\u4E00\u6761\u53D6\u6750\u3002"
  430. };
  431. }
  432. return {
  433. ok: true,
  434. scriptPath: path,
  435. config: built.config,
  436. notes: built.notes
  437. };
  438. }
  439. if (trimAll(built.config.target.keyword) === "") {
  440. return {
  441. ok: false,
  442. scriptPath: path,
  443. error: "\u5173\u952E\u8BCD\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u5728\u300C\u89C6\u9891\u529F\u80FD\u300D\u9875\u586B\u5199\u8981\u641C\u7D22\u7684\u5173\u952E\u8BCD\u3002"
  444. };
  445. }
  446. return {
  447. ok: true,
  448. scriptPath: path,
  449. config: built.config,
  450. notes: built.notes
  451. };
  452. };
  453. /** 把引擎配置落盘。返回是否成功。 */
  454. Engine.writeConfig = function (config) {
  455. try {
  456. files.ensureDir(Engine.DIR + "/");
  457. files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
  458. return true;
  459. } catch (e) {
  460. console.log("[engine] 引擎配置落盘失败: " + e);
  461. return false;
  462. }
  463. };
  464. /**
  465. * 唤起引擎(另一个 ScriptEngine,不是当前脚本的线程)。
  466. *
  467. * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
  468. * 桥那边按数字下标取,两边约定越简单越不容易出错。
  469. */
  470. Engine.launch = function (runId, scriptPath, opts) {
  471. try {
  472. var argv = [
  473. "--config", Engine.CONFIG_PATH,
  474. "--state", Engine.STATE_PATH,
  475. "--stop-flag", Engine.STOP_FLAG_PATH,
  476. "--log", Engine.LOG_PATH,
  477. "--run-id", runId
  478. ];
  479. /* 只有点「继续」才带这两个参数。普通「开始运行」绝不能带 ——
  480. * 不然用户想从头跑一次,结果它接着上次跑。 */
  481. if (opts && opts.resume) {
  482. argv.push("--resume");
  483. argv.push("--checkpoint");
  484. argv.push(Engine.ID_PROGRESS_PATH);
  485. }
  486. var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
  487. arguments: argv
  488. });
  489. if (!handle) {
  490. return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
  491. }
  492. return { ok: true };
  493. } catch (e) {
  494. console.log("[engine] 唤起失败: " + e);
  495. return { ok: false, error: String(e) };
  496. }
  497. };
  498. /* ------------------------------------------------------------ 日志回读 */
  499. /**
  500. * 读引擎这次运行写下的日志(D3 的「执行日志」)。
  501. *
  502. * 三条原则:
  503. * · **一行坏了只丢一行**:jsonl 逐行 parse,坏行跳过 —— 进程被杀时最后一行
  504. * 常常是半截的,整份 JSON.parse 会直接全丢;
  505. * · 只取**最近** LOG_MAX_LINES 行:记录要落盘,日志不能把它撑到几百 KB;
  506. * · 读不到就返回空数组,由页面如实说明「本次没有回传日志」,
  507. * 绝不编一行"看起来像日志"的东西。
  508. */
  509. Engine.readLog = function () {
  510. var out = [];
  511. try {
  512. if (!files.exists(Engine.LOG_PATH)) { return out; }
  513. var lines = String(files.read(Engine.LOG_PATH)).split("\n");
  514. var start = Math.max(0, lines.length - 1 - Engine.LOG_MAX_LINES);
  515. for (var i = start; i < lines.length; i++) {
  516. var text = String(lines[i]).replace(/^\s+|\s+$/g, "");
  517. if (text === "") { continue; }
  518. try {
  519. var one = JSON.parse(text);
  520. if (one && one.text) { out.push(one); }
  521. } catch (e) {
  522. /* 半截行:跳过这一行,其余照收 */
  523. }
  524. }
  525. } catch (e2) {
  526. console.log("[engine] 读取运行日志失败: " + e2);
  527. }
  528. return out;
  529. };
  530. /* ------------------------------------------------------------ 状态回读 */
  531. /** 读引擎上报的状态。没有 / 读坏了都返回 null。 */
  532. Engine.readState = function () {
  533. try {
  534. if (!files.exists(Engine.STATE_PATH)) { return null; }
  535. var parsed = JSON.parse(String(files.read(Engine.STATE_PATH)));
  536. return parsed && typeof parsed === "object" ? parsed : null;
  537. } catch (e) {
  538. return null;
  539. }
  540. };
  541. /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
  542. Engine.isActiveStatus = function (status) {
  543. return status === "starting" || status === "running" || status === "stopping";
  544. };
  545. /** 终态 —— 界面据此结束运行态并告知结果 */
  546. Engine.isTerminalStatus = function (status) {
  547. return status === "finished" || status === "stopped" || status === "failed";
  548. };
  549. /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
  550. Engine.requestStop = function (runId) {
  551. try {
  552. files.ensureDir(Engine.DIR + "/");
  553. files.write(Engine.STOP_FLAG_PATH, String(runId));
  554. return true;
  555. } catch (e) {
  556. console.log("[engine] 写停止标志失败: " + e);
  557. return false;
  558. }
  559. };
  560. /**
  561. * 引擎是否还活着。
  562. *
  563. * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
  564. * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
  565. * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
  566. */
  567. Engine.childAlive = function () {
  568. try {
  569. var mine = engines.myEngine();
  570. var all = engines.all() || [];
  571. for (var i = 0; i < all.length; i += 1) {
  572. var one = all[i];
  573. if (one && String(one.id) !== String(mine.id)) {
  574. return true;
  575. }
  576. }
  577. return false;
  578. } catch (e) {
  579. return true;
  580. }
  581. };
  582. /** 复位内存态。**不删文件**:状态靠 runId 比对,残留自然失效。 */
  583. Engine.reset = function () {
  584. Engine.RUN_ID = "";
  585. };