| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- /* ============================== src/engine.js ==============================
- * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
- *
- * ## 为什么走文件,不走内存
- * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
- * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
- *
- * run-config.json 界面 → 人/追溯:完整配置快照(人可读)
- * engine-config.json 界面 → 引擎:映射后的引擎配置
- * run-state.json 引擎 → 界面:进度与终态
- * stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId)
- *
- * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
- * 而不是"我点了就当我做了"。点「停止运行」后界面显示"正在收尾"、
- * 等到引擎真的上报 stopped 才切回未运行 —— 否则界面说"已停止"而 TikTok 还在自己动,
- * 用户不知道该信谁。
- *
- * ## 为什么不删 stop.flag
- * 停止靠**比内容**而不是靠"文件在不在":只有内容等于本次 runId 才算数。
- * 于是不必删除它(删除既有竞态,`files.remove` 又命中 MCP 危险调用黑名单),
- * 上一轮残留的标志也不会误杀下一轮。
- *
- * ## 能力边界要如实说
- * tiktok-auto 目前实现的是「搜索 → 逐个看 → 评论」。界面「执行操作」里的
- * 关注 / 私信 / 点赞**尚未接入**,`buildConfig()` 会把它们逐条记进 notes,
- * 由调用方决定怎么告知用户 —— **绝不静默忽略**:
- * 用户勾了点赞却什么都没发生,比明确告知"尚未支持"糟得多。
- */
- var Engine = {};
- Engine.DIR = "/sdcard/tiktok_app";
- Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
- Engine.STATE_PATH = Engine.DIR + "/run-state.json";
- Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
- /**
- * 引擎的日志文件(jsonl,一行一条)。
- *
- * 2026-10-13 新增:D3 运行详情里的「执行日志」要看的就是它。
- * 为什么走文件而不是读 console:引擎是**另一个 ScriptEngine**,
- * 它的 stdout 界面根本读不到(见本文件头的通道说明)。
- * 引擎侧由 `logger.setSink` → `files.append` 落盘(tiktok-auto/src/bridge.js)。
- */
- Engine.LOG_PATH = Engine.DIR + "/run-log.jsonl";
- /** 一条记录最多带走多少行日志(记录要落盘,不能让日志把它撑爆)。 */
- Engine.LOG_MAX_LINES = 200;
- /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
- Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
- /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
- Engine.RUN_ID = "";
- /** 上次定位到的引擎产物路径;null 表示还没找过 */
- Engine.scriptPath = null;
- /**
- * 是否**确实在引擎列表里见过**子引擎。
- *
- * 用途唯一:给"引擎没写终态就没了"的兜底判定加一道保险。
- * `engines.all()` 在不同运行时里的可见性不完全一致 —— 如果它从来就不报告子引擎,
- * 那"看不到子引擎"这件事毫无信息量,此时宁可不判定,也不要误把正在跑的运行结束掉。
- */
- Engine.sawChild = false;
- /* ------------------------------------------------------------ 运行标识 */
- /**
- * 生成本次运行的标识。
- * 用「时间戳 + 随机串」:同一秒内连点两次也不会撞,
- * 而且人在日志里一眼能看出这是哪一次运行。
- */
- Engine.newRunId = function () {
- return "run-" + Date.now() + "-" + Math.floor(Math.random() * 100000);
- };
- /* ------------------------------------------------------------ 定位引擎 */
- /** 探测用的候选路径,按「最可能正确」排序。 */
- Engine.candidates = function () {
- var list = [];
- /* ① 入口脚本同级 —— 项目模式下最准。
- * 用 myEngine().source 而不是 files.cwd():后者依赖运行方式,
- * 前者永远是入口脚本的真实路径。 */
- try {
- var source = String(engines.myEngine().source || "");
- var cut = source.lastIndexOf("/");
- if (cut > 0) {
- list.push(source.substring(0, cut) + "/" + Engine.SCRIPT_RELATIVE);
- }
- } catch (e) {
- /* 取不到就靠后面的候选 */
- }
- /* ② 扫一层项目目录:产物可能来自另一个项目文件夹 */
- try {
- var names = files.listDir("/sdcard/Scripts");
- for (var i = 0; i < names.length; i += 1) {
- var dir = "/sdcard/Scripts/" + names[i];
- if (files.isDir(dir)) {
- list.push(dir + "/" + Engine.SCRIPT_RELATIVE);
- }
- }
- } catch (e) {
- /* 没这个目录就跳过 */
- }
- /* ③ 扁平部署:产物被单独拷到 Download(手工推送时的常见形态) */
- list.push("/sdcard/Download/tiktok-auto.js");
- list.push(Engine.DIR + "/tiktok-auto.js");
- return list;
- };
- /**
- * 找到引擎产物。
- * @returns 路径;找不到返回空串(调用方负责给出可操作的提示)
- */
- Engine.locate = function () {
- var list = Engine.candidates();
- for (var i = 0; i < list.length; i += 1) {
- try {
- if (files.exists(list[i])) {
- Engine.scriptPath = list[i];
- return list[i];
- }
- } catch (e) {
- /* 单个候选探测失败不该中断整轮查找 */
- }
- }
- Engine.scriptPath = null;
- return "";
- };
- /* ------------------------------------------------------------ 配置映射 */
- function trimAll(text) {
- return String(text).replace(/^\s+|\s+$/g, "");
- }
- /**
- * 读文本文件的行,跳过空行与 `#` 开头的注释行。
- * 读不到(文件不存在 / 权限问题)时返回空数组 —— 调用方按"没配"处理。
- */
- function readLines(path) {
- var out = [];
- try {
- if (!path || !files.exists(path)) { return out; }
- var lines = String(files.read(path)).split(/\r?\n/);
- for (var i = 0; i < lines.length; i += 1) {
- var text = trimAll(lines[i]);
- if (text !== "" && text.charAt(0) !== "#") { out.push(text); }
- }
- } catch (e) {
- console.log("[engine] 读取文本失败 " + path + ": " + e);
- }
- return out;
- }
- function randomInt(min, max) {
- var low = Math.floor(min);
- var high = Math.floor(max);
- if (high < low) { high = low; }
- return low + Math.floor(Math.random() * (high - low + 1));
- }
- /** 秒 → 毫秒,并给一个下限:0 秒会让"观看"立刻返回,等于没看 */
- function seconds(ms) {
- return Math.max(1000, Math.floor(ms) * 1000);
- }
- /**
- * 界面配置 → 引擎配置。
- *
- * @returns { config, notes }
- * notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
- */
- Engine.buildConfig = function () {
- var notes = [];
- var keyword = Store.getText("tabs.video.keyword");
- var stopMin = Store.getNumber("tabs.video.stopMin");
- var stopMax = Store.getNumber("tabs.video.stopMax");
- var intervalMin = Store.getNumber("tabs.video.intervalMin");
- var intervalMax = Store.getNumber("tabs.video.intervalMax");
- var doComment = Store.getBool("tabs.video.actions.comment");
- var pool = readLines(Store.getText("tabs.video.commentTextPath"));
- if (doComment && pool.length === 0) {
- notes.push("\u8BC4\u8BBA\u6587\u672C\u91CC\u6CA1\u6709\u53EF\u7528\u884C\uFF0C"
- + "\u672C\u6B21\u4F1A\u7528\u5185\u7F6E\u9ED8\u8BA4\u8BC4\u8BBA");
- }
- var config = {
- target: {
- keyword: keyword,
- /* 「停止个数 最小值 ~ 最大值」= 每次运行在这个区间里随机停 ——
- * 固定不变会让每次运行的行为完全一样,反而更容易被看出是脚本。 */
- maxVideos: randomInt(stopMin, stopMax),
- resultTab: "Top",
- pickStrategy: "random"
- },
- /* 界面的「间隔时间(秒)」映射为**每个视频的观看时长**。
- * 引擎目前没有单独的"两个视频之间的停顿"配置,而"看 20 秒再操作"
- * 与"每 20 秒处理一个"在行为上是一回事。 */
- watch: {
- minMs: seconds(intervalMin),
- maxMs: seconds(intervalMax)
- },
- comments: {
- /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
- * 引擎侧据此跳过评论动作,而不是照默认值照发。
- * 用户的意图优先于引擎的默认行为。 */
- enabled: doComment,
- avoidRepeatWithinRun: true,
- verifyInList: true
- },
- runtime: {
- configPath: Engine.CONFIG_PATH,
- dryRun: false,
- maxRuntimeMs: 0
- }
- };
- if (pool.length > 0) {
- config.comments.pool = pool;
- }
- notes = notes.concat(Engine.unsupportedNotes());
- return { config: config, notes: notes };
- };
- /**
- * 引擎这次做不到、但用户可能勾了的项。
- *
- * 单独抽出来是因为它只跟「已实现的动作集合」有关,
- * 将来引擎接入了点赞,**只改这一个函数**就够。
- */
- Engine.unsupportedNotes = function () {
- var notes = [];
- if (Store.getBool("tabs.video.actions.like")) {
- notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
- }
- if (Store.getBool("tabs.video.actions.follow")) {
- notes.push("\u5173\u6CE8\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
- }
- if (Store.getBool("tabs.video.actions.dm")) {
- notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
- }
- return notes;
- };
- /**
- * notes → 一句能放在 toast 里的话(没有就返回空串)。
- *
- * 只取每条的**短名**(中文冒号前的部分)拼起来:三条完整说明拼起来有四十多个字,
- * 那种长度的 toast 用户根本来不及读。完整内容在 run-config.json 里,随时可查。
- */
- Engine.summarizeNotes = function (notes) {
- if (!notes || notes.length === 0) { return ""; }
- var names = [];
- for (var i = 0; i < notes.length; i += 1) {
- names.push(String(notes[i]).split("\uFF1A")[0]);
- }
- return names.join("/") + " \u672C\u6B21\u4E0D\u4F1A\u751F\u6548";
- };
- /* ------------------------------------------------------------ 启动 */
- /**
- * 准备一次运行:定位产物 + 构建配置。
- *
- * @returns { ok, scriptPath, config, notes, error }
- * error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
- */
- Engine.prepare = function () {
- var path = Engine.locate();
- if (!path) {
- return {
- ok: false,
- error: "\u627E\u4E0D\u5230\u6267\u884C\u5F15\u64CE\u4EA7\u7269\uFF08" + Engine.SCRIPT_RELATIVE + "\uFF09\u3002\n\n"
- + "\u8BF7\u5728 PC \u4E0A\u6267\u884C node tiktok-auto/tools/build.js \u540E\uFF0C"
- + "\u5C06 tiktok-auto/dist/tiktok-auto.js \u653E\u5230 \u9879\u76EE\u6839\u76EE\u5F55\u4E0B,\n"
- + "\u6216\u76F4\u63A5\u63A8\u5230 /sdcard/Download/tiktok-auto.js\u3002"
- };
- }
- var built = Engine.buildConfig();
- /* 关键词是引擎的硬前提:空关键词会导致搜索失败,与其让它跑一半报错,
- * 不如在这里挡住并告诉用户填哪里。 */
- if (trimAll(built.config.target.keyword) === "") {
- return {
- ok: false,
- scriptPath: path,
- 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"
- };
- }
- return {
- ok: true,
- scriptPath: path,
- config: built.config,
- notes: built.notes
- };
- };
- /** 把引擎配置落盘。返回是否成功。 */
- Engine.writeConfig = function (config) {
- try {
- files.ensureDir(Engine.DIR + "/");
- files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
- return true;
- } catch (e) {
- console.log("[engine] 引擎配置落盘失败: " + e);
- return false;
- }
- };
- /**
- * 唤起引擎(另一个 ScriptEngine,不是当前脚本的线程)。
- *
- * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
- * 桥那边按数字下标取,两边约定越简单越不容易出错。
- */
- Engine.launch = function (runId, scriptPath) {
- try {
- var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
- arguments: [
- "--config", Engine.CONFIG_PATH,
- "--state", Engine.STATE_PATH,
- "--stop-flag", Engine.STOP_FLAG_PATH,
- "--log", Engine.LOG_PATH,
- "--run-id", runId
- ]
- });
- if (!handle) {
- return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
- }
- return { ok: true };
- } catch (e) {
- console.log("[engine] 唤起失败: " + e);
- return { ok: false, error: String(e) };
- }
- };
- /* ------------------------------------------------------------ 日志回读 */
- /**
- * 读引擎这次运行写下的日志(D3 的「执行日志」)。
- *
- * 三条原则:
- * · **一行坏了只丢一行**:jsonl 逐行 parse,坏行跳过 —— 进程被杀时最后一行
- * 常常是半截的,整份 JSON.parse 会直接全丢;
- * · 只取**最近** LOG_MAX_LINES 行:记录要落盘,日志不能把它撑到几百 KB;
- * · 读不到就返回空数组,由页面如实说明「本次没有回传日志」,
- * 绝不编一行"看起来像日志"的东西。
- */
- Engine.readLog = function () {
- var out = [];
- try {
- if (!files.exists(Engine.LOG_PATH)) { return out; }
- var lines = String(files.read(Engine.LOG_PATH)).split("\n");
- var start = Math.max(0, lines.length - 1 - Engine.LOG_MAX_LINES);
- for (var i = start; i < lines.length; i++) {
- var text = String(lines[i]).replace(/^\s+|\s+$/g, "");
- if (text === "") { continue; }
- try {
- var one = JSON.parse(text);
- if (one && one.text) { out.push(one); }
- } catch (e) {
- /* 半截行:跳过这一行,其余照收 */
- }
- }
- } catch (e2) {
- console.log("[engine] 读取运行日志失败: " + e2);
- }
- return out;
- };
- /* ------------------------------------------------------------ 状态回读 */
- /** 读引擎上报的状态。没有 / 读坏了都返回 null。 */
- Engine.readState = function () {
- try {
- if (!files.exists(Engine.STATE_PATH)) { return null; }
- var parsed = JSON.parse(String(files.read(Engine.STATE_PATH)));
- return parsed && typeof parsed === "object" ? parsed : null;
- } catch (e) {
- return null;
- }
- };
- /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
- Engine.isActiveStatus = function (status) {
- return status === "starting" || status === "running" || status === "stopping";
- };
- /** 终态 —— 界面据此结束运行态并告知结果 */
- Engine.isTerminalStatus = function (status) {
- return status === "finished" || status === "stopped" || status === "failed";
- };
- /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
- Engine.requestStop = function (runId) {
- try {
- files.ensureDir(Engine.DIR + "/");
- files.write(Engine.STOP_FLAG_PATH, String(runId));
- return true;
- } catch (e) {
- console.log("[engine] 写停止标志失败: " + e);
- return false;
- }
- };
- /**
- * 引擎是否还活着。
- *
- * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
- * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
- * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
- */
- Engine.childAlive = function () {
- try {
- var mine = engines.myEngine();
- var all = engines.all() || [];
- for (var i = 0; i < all.length; i += 1) {
- var one = all[i];
- if (one && String(one.id) !== String(mine.id)) {
- return true;
- }
- }
- return false;
- } catch (e) {
- return true;
- }
- };
- /** 复位内存态。**不删文件**:状态靠 runId 比对,残留自然失效。 */
- Engine.reset = function () {
- Engine.RUN_ID = "";
- };
|