findWidget = function (object) { try { const { text: textValue, textMatches: textMatchesValue, id: idValue, className: classValue, depth: depthValue, indexInParent: indexValue, boundsInside: boundsValue, } = object; // 归一化正则:本运行时(Rhino)把 RegExp 对象直接传给 Java 的 textMatches/descMatches 时 // 会走 toString() 变成 "/pattern/flags" 字面量(首尾斜杠和标志位成为待匹配内容), // 导致永远匹配不到控件。这里统一转成 Java Pattern 字符串,i/m/s 标志转内联 (?i)(?m)(?s) let matchesPattern = textMatchesValue; if (matchesPattern instanceof RegExp) { let inlineFlags = ""; if (matchesPattern.ignoreCase) inlineFlags += "(?i)"; if (matchesPattern.multiline) inlineFlags += "(?m)"; if (matchesPattern.dotAll) inlineFlags += "(?s)"; matchesPattern = inlineFlags + matchesPattern.source; } // 根据传入的字段自动判断查找方式 const hasText = textValue !== undefined; const hasTextMatches = textMatchesValue !== undefined; const hasId = idValue !== undefined; const hasClass = classValue !== undefined; const hasDepth = depthValue !== undefined; const hasIndex = indexValue !== undefined; const hasBounds = boundsValue !== undefined; // 创建基础选择器 let selector = null; // 构建选择器链 if (hasText) { // 从文本或描述开始(探测需带 visibleToUser(true),与最终查询一致,否则不可见控件命中会导致最终查不到) selector = text(textValue).visibleToUser(true).findOnce() ? text(textValue) : desc(textValue); } else if (hasTextMatches) { // 从文本匹配开始(同上,探测带可见性过滤) selector = textMatches(matchesPattern).visibleToUser(true).findOnce() ? textMatches(matchesPattern) : descMatches(matchesPattern); } else if (hasId) { // 从ID开始 selector = id(idValue); } else if (hasClass) { // 从类名开始 selector = className(classValue); } else if (hasDepth) { // 从深度开始 selector = depth(depthValue); } else if (hasIndex) { // 从索引开始 selector = indexInParent(indexValue); } else if (hasBounds) { // 从边界开始 if (Array.isArray(boundsValue) && boundsValue.length === 4) { selector = boundsInside( boundsValue[0], boundsValue[1], boundsValue[2], boundsValue[3] ); } } // 如果没有匹配到任何条件,返回null if (!selector) return null; // 添加其他条件 if (hasText && !( selector.toString().includes("text") || selector.toString().includes("desc") ) ) { selector = selector.textContains(textValue) || selector.descContains(textValue); } if (hasTextMatches && !( selector.toString().includes("textMatches") || selector.toString().includes("descMatches") ) ) { selector = selector.textMatches(matchesPattern) || selector.descMatches(matchesPattern); } if (hasId && !selector.toString().includes("id")) { selector = selector.id(idValue); } if (hasClass && !selector.toString().includes("className")) { selector = selector.className(classValue); } if (hasDepth && !selector.toString().includes("depth")) { selector = selector.depth(depthValue); } if (hasIndex && !selector.toString().includes("indexInParent")) { selector = selector.indexInParent(indexValue); } if (hasBounds && !selector.toString().includes("boundsInside")) { if (Array.isArray(boundsValue) && boundsValue.length === 4) { selector = selector.boundsInside( boundsValue[0], boundsValue[1], boundsValue[2], boundsValue[3] ); } } return selector.visibleToUser(true).findOnce(); } catch (error) { console.log("findWidget error: ", error); console.log("findWidget failed on:", object); return null; } }; widgetBounds = function (widget, returnType) { try { if (!widget) { return false; } const bool = widget.bounds().left < widget.bounds().right && widget.bounds().left >= 0 && widget.bounds().top < widget.bounds().bottom && widget.bounds().top >= 0; if (bool && returnType == "object") { return { left: widget.bounds().left, top: widget.bounds().top, right: widget.bounds().right, bottom: widget.bounds().bottom, }; } return bool; } catch (error) { console.log("widgetBounds error: ", error); } return false; }; clickWidget = function (widget, ranNum) { try { if (!widget) { return false; } ranNum = ranNum || 1; const x = random( widget.bounds().left + ranNum, widget.bounds().right - ranNum ); const y = random( widget.bounds().top + ranNum, widget.bounds().bottom - ranNum ); const time = random(150, 280); sleep(random(500, 1000)); return press(x, y, time); // const result = shell("input tap ".concat(x, " ", y), true); // return result.code === 0; } catch (error) { console.log("clickWidget coordinate error:", error); } console.log("Failed widget:", widget); return false; }; // 从 0~10(含)中随机取 1~3 个不重复的数 function randomPick(len) { len = len || 10 const count = Math.floor(Math.random() * 3) + 1; // 1~3 个 const pool = Array.from({ length: len }, (_, i) => i); // [0..10] // 洗牌 for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, count); } /* let interestsArr = [] let interests = id("com.zhiliaoapp.musically:id/kj5").find() if (interests.nonEmpty()) { for (let i = 0; i < interests.length; i++) { let element = interests[i]; if (widgetBounds(element)) { interestsArr.push(element) } } console.log("interestsArr:", interestsArr.length) if (interestsArr.length > 0) { let chooseInterestsArr = randomPick(interestsArr.length) console.log("chooseInterestsArr:", chooseInterestsArr.length) for (let index = 0; index < chooseInterestsArr.length; index++) { console.log("choose interests:" + index) let currentIndex = chooseInterestsArr[index] clickWidget(interestsArr[currentIndex]) sleep(1000) } clickWidget(findWidget({ text: "Next" })) } } console.log(randomPick(10)) */ const TIKTOK_UA = "Mozilla/5.0 (Linux; Android 12; Pixel 5a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"; /** * 正则特殊字符转义 */ function escapeRegex(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * 读取 HTTP 响应体(UTF-8),读完自动断开连接。 * 4xx/5xx 时改读错误流,避免 getInputStream 抛 FileNotFoundException。 * @returns {string} 响应内容(可能为空字符串) */ function readHttpBody(conn) { try { let code = conn.getResponseCode(); let stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); if (!stream) { conn.disconnect(); return ""; } let reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8")); let sb = new java.lang.StringBuilder(); let line; while ((line = reader.readLine()) !== null) { sb.append(line); } reader.close(); conn.disconnect(); return sb.toString(); } catch (e) { try { conn.disconnect(); } catch (e2) {} throw e; } } let TIKTOK_TTWID = null; /** * 获取 ttwid Cookie(TikTok 接口风控校验用,首页 Set-Cookie 下发)。 * 获取一次后缓存复用;失败不影响主流程(降级走 HTML 解析)。 */ function fetchTtwidCookie() { if (TIKTOK_TTWID) return TIKTOK_TTWID; try { let conn = new java.net.URL("https://www.tiktok.com/").openConnection(); conn.setConnectTimeout(8000); conn.setReadTimeout(8000); conn.setRequestProperty("User-Agent", TIKTOK_UA); conn.getResponseCode(); let map = conn.getHeaderFields(); let list = map ? map.get("Set-Cookie") : null; if (list) { for (let i = 0; i < list.size(); i++) { let c = String(list.get(i)); let idx = c.indexOf("ttwid="); if (idx !== -1) { TIKTOK_TTWID = c.substring(idx).split(";")[0]; // 形如 ttwid=xxx console.log("fetchTtwidCookie: " + TIKTOK_TTWID); break; } } } conn.disconnect(); } catch (e) { console.log("fetchTtwidCookie: 获取失败 " + e); } return TIKTOK_TTWID; } /** * GET 请求并返回响应体字符串;网络异常 / 非 200 / 空响应统一返回 null(只留一行日志)。 */ function httpGetRaw(url, headers, tag) { try { let conn = new java.net.URL(url).openConnection(); conn.setConnectTimeout(8000); conn.setReadTimeout(8000); conn.setRequestProperty("User-Agent", TIKTOK_UA); let extra = headers || {}; for (let k in extra) { if (extra.hasOwnProperty(k)) conn.setRequestProperty(k, extra[k]); } let code = conn.getResponseCode(); let body = readHttpBody(conn); if (code !== 200 || !body || body.trim() === "") { console.log((tag || "httpGetRaw") + ": HTTP " + code + " 响应为空,跳过"); return null; } return body; } catch (e) { console.log((tag || "httpGetRaw") + ": 请求失败 " + e); return null; } } /** * GET 请求并解析 JSON;失败返回 null */ function httpGetJson(url, headers, tag) { let body = httpGetRaw(url, headers, tag); if (body === null) return null; try { return JSON.parse(body); } catch (e) { console.log((tag || "httpGetJson") + ": JSON 解析失败"); return null; } } /** * 解析 TikTok 短链为最终完整链接(含用户名与视频ID)。 * 手动跟随 301/302 重定向(最多 8 跳),命中 /video/ 链接提前结束。 * @param {string} url 短链(如 https://www.tiktok.com/t/ZTy8r7T2a/) * @returns {string} 最终链接,解析失败时返回原链接 */ function resolveTikTokUrl(url) { try { let current = url; for (let i = 0; i < 8; i++) { let conn = new java.net.URL(current).openConnection(); conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(8000); conn.setReadTimeout(8000); conn.setRequestProperty("User-Agent", TIKTOK_UA); conn.setRequestMethod("GET"); conn.connect(); let loc = conn.getHeaderField("Location"); conn.disconnect(); if (loc) { current = new java.net.URL(new java.net.URL(current), loc).toString(); if (current.indexOf("/video/") !== -1) break; } else { break; } } return current; } catch (e) { console.log("resolveTikTokUrl error:", e); return url; } } /** * 从完整链接提取 @用户名 与 视频ID */ function parseTikTokUrl(url) { let u = String(url || ""); let usernameMatch = u.match(/@([A-Za-z0-9_.-]+)/); let videoMatch = u.match(/\/video\/(\d+)/); return { url: u, username: usernameMatch ? usernameMatch[1] : null, videoId: videoMatch ? videoMatch[1] : null }; } /** * 通过用户名(句柄)获取 TikTok 用户 UID。 * 依次尝试:官方用户详情 API(带 ttwid)→ node/share 接口 → 解析主页 HTML 兜底。 * @param {string} username 用户名(可带 @ 前缀) * @returns {string|null} uid,获取失败返回 null */ function getTikTokUidByUsername(username) { if (!username) return null; let handle = String(username).replace(/^@/, "").trim(); if (!handle) return null; // ===== 方式1:官方用户详情 API(带 ttwid Cookie)===== let ttwid = fetchTtwidCookie(); let headers = { "Accept": "application/json, text/plain, */*", "Referer": "https://www.tiktok.com/@" + encodeURIComponent(handle) }; if (ttwid) headers["Cookie"] = ttwid; let obj = httpGetJson( "https://www.tiktok.com/api/user/detail/?uniqueId=" + encodeURIComponent(handle), headers, "getTikTokUidByUsername(api)" ); let uid = obj && obj.userInfo && obj.userInfo.user && obj.userInfo.user.id; if (uid) { console.log("getTikTokUidByUsername(api): " + handle + " -> " + uid); return String(uid); } // ===== 方式2:node/share 用户接口 ===== obj = httpGetJson( "https://www.tiktok.com/node/share/user/@" + encodeURIComponent(handle) + "?uniqueId=" + encodeURIComponent(handle), { "Accept": "application/json, text/plain, */*" }, "getTikTokUidByUsername(node/share)" ); uid = obj && obj.userInfo && obj.userInfo.user && obj.userInfo.user.id; if (uid) { console.log("getTikTokUidByUsername(node/share): " + handle + " -> " + uid); return String(uid); } // ===== 方式3:解析主页 HTML(__UNIVERSAL_DATA_FOR_REHYDRATION__)===== let html = httpGetRaw( "https://www.tiktok.com/@" + encodeURIComponent(handle), { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, "getTikTokUidByUsername(html)" ); if (html) { // 主页 JSON 典型结构: {"id":"123456789","shortId":"...","uniqueId":"handle",...} let m = html.match(new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escapeRegex(handle) + '"')); if (m) { console.log("getTikTokUidByUsername(html): " + handle + " -> " + m[1]); return String(m[1]); } // 兜底:定位 uniqueId 字段,向前找最近的 "id" let idx = html.indexOf('"uniqueId":"' + handle + '"'); if (idx !== -1) { let seg = html.slice(Math.max(0, idx - 500), idx); let m2 = seg.match(/"id":"(\d+)"/); if (m2) { console.log("getTikTokUidByUsername(html): " + handle + " -> " + m2[1]); return String(m2[1]); } } } console.log("getTikTokUidByUsername: 获取 " + handle + " 的 UID 失败"); return null; } /** * 交叉验证 UID 是否真的属于该用户名。 * 来源1(优先):视频详情页 HTML 中的 author 对象(独立数据源,双重比对 id+uniqueId); * 来源2(兜底):主页 HTML 中 uniqueId=handle 的用户对象提取 id 比对,顺带拿 secUid。 * @param {string} handle 用户名(句柄) * @param {string} uid 待验证的 UID * @param {string} [videoUrl] 视频链接,用于独立交叉验证 * @returns {Object} { ok, byProfile, byVideo, secUid } * ok=true 表示至少一个来源确认匹配;byProfile/byVideo 取值 true/false/null(无法获取) */ function verifyTikTokUid(handle, uid, videoUrl) { let result = { ok: false, byProfile: null, byVideo: null, secUid: null }; let target = String(uid); // ===== 来源1:视频详情页 author 对象(独立交叉)===== if (videoUrl) { let resolved = resolveTikTokUrl(videoUrl); let vHtml = httpGetRaw(resolved, { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, "verifyTikTokUid(video)"); if (vHtml) { let aidx = vHtml.indexOf('"author":{'); if (aidx !== -1) { let seg = vHtml.slice(aidx, aidx + 2500); let mAId = seg.match(/"id":"(\d+)"/); let mAUid = seg.match(/"uniqueId":"([^"]+)"/); if (mAId) { result.byVideo = String(mAId[1]) === target && (!mAUid || mAUid[1].toLowerCase() === handle.toLowerCase()); } } else { let mAId2 = vHtml.match(/"authorId":"(\d+)"/); if (mAId2) result.byVideo = String(mAId2[1]) === target; } } } // ===== 来源2:主页 HTML 兜底/补充(顺带拿 secUid)===== if (result.byVideo === null) { let html = httpGetRaw( "https://www.tiktok.com/@" + encodeURIComponent(handle), { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, "verifyTikTokUid(profile)" ); if (html) { let idx = html.indexOf('"uniqueId":"' + handle + '"'); if (idx !== -1) { let mId = html.slice(Math.max(0, idx - 500), idx).match(/"id":"(\d+)"/); if (mId) { result.byProfile = String(mId[1]) === target; let mSec = html.slice(idx, idx + 3000).match(/"secUid":"([^"]+)"/); if (mSec) result.secUid = mSec[1]; } } } } result.ok = result.byProfile === true || result.byVideo === true; console.log("verifyTikTokUid: " + handle + " uid=" + target + " 主页比对=" + result.byProfile + " 视频页交叉=" + result.byVideo + " -> " + (result.ok ? "校验通过" : "校验未通过")); return result; } /** * 从视频链接获取作者 UID(支持短链,内部先解析出用户名并交叉验证) * @param {string} tiktokUrl 视频链接或短链 * @returns {string|null} uid;校验不通过或获取失败返回 null */ function getTikTokUidByUrl(tiktokUrl) { let resolved = resolveTikTokUrl(tiktokUrl); let info = parseTikTokUrl(resolved); if (!info.username) { console.log("getTikTokUidByUrl: 链接中未解析到用户名: " + resolved); return null; } let uid = getTikTokUidByUsername(info.username); if (!uid) return null; let check = verifyTikTokUid(info.username, uid, resolved); if (check.ok) return uid; if (check.byProfile === false || check.byVideo === false) { console.log("getTikTokUidByUrl: UID 校验不通过,丢弃结果"); return null; } // 两个来源都无法获取时无法交叉验证,仍返回(仅告警) console.log("getTikTokUidByUrl: 无法交叉验证,按当前结果返回 uid=" + uid); return uid; } /** * 通过 oEmbed 接口获取视频标题与作者昵称。 * 注意:链接里的 @handle 是账号句柄,页面展示的是昵称,二者可能不同, * 所以用 oEmbed 的 author_name 作为页面比对基准最可靠。 */ function fetchTikTokOembed(videoUrl) { let obj = httpGetJson( "https://www.tiktok.com/oembed?url=" + encodeURIComponent(videoUrl), { "Accept": "application/json, text/plain, */*" }, "fetchTikTokOembed" ); if (!obj) return null; return { title: (obj.title || "").trim(), authorName: obj.author_name || "", authorUrl: obj.author_url || "" }; } /** * 在当前页面校验视频是否为目标视频: * 1) 作者昵称精确匹配 → 2) 昵称包含匹配 → 3) 视频文案前缀匹配 * @param {Object} expected { authorName, captionPrefix } * @param {number} timeout 轮询超时(ms),默认 8000 * @returns {boolean} 命中任一特征返回 true */ function verifyTikTokVideoPage(expected, timeout) { timeout = timeout || 8000; let checks = []; if (expected.authorName) { checks.push({ key: "作者昵称(精确)", widget: { text: expected.authorName } }); checks.push({ key: "作者昵称(包含)", widget: { textMatches: "(?s).*" + escapeRegex(expected.authorName) + ".*" } }); } if (expected.captionPrefix) { let prefix = String(expected.captionPrefix).slice(0, 12); if (prefix) { checks.push({ key: "视频文案前缀", widget: { textMatches: "(?s).*" + escapeRegex(prefix) + ".*" } }); } } if (checks.length === 0) { console.log("verifyTikTokVideoPage: 无可比对的视频特征"); return false; } let deadline = new Date().getTime() + timeout; while (new Date().getTime() < deadline) { for (let i = 0; i < checks.length; i++) { if (findWidget(checks[i].widget)) { console.log("verifyTikTokVideoPage: 命中【" + checks[i].key + "】"); return true; } } sleep(800); } console.log("verifyTikTokVideoPage: 页面未匹配到目标视频特征"); return false; } /** * 兜底校验(需 root):从 dumpsys activity top 的 Intent 数据中找当前视频ID。 * @returns {boolean|null} 命中=true / 未命中=false / 无法读取=null */ function verifyByDumpsys(videoId) { try { let r = shell("dumpsys activity top", true); let dump = ""; if (r && typeof r.result === "string") dump = r.result; else if (typeof r === "string") dump = r; if (!dump) return null; let ids = []; let m; let re = /\/video\/(\d+)/g; while ((m = re.exec(dump)) !== null) { ids.push(m[1]); } if (ids.length === 0) return null; let found = ids.indexOf(String(videoId)) !== -1; console.log("verifyByDumpsys: 当前页面视频ID=" + ids.join(",") + " 目标=" + videoId + " 命中=" + found); return found; } catch (e) { console.log("verifyByDumpsys error:", e); return null; } } /** * 打开 TikTok 视频链接并校验是否到达目标视频页 * @param {string} tiktokUrl TikTok 视频链接(支持短链) * @returns {boolean} 确认到达目标视频返回 true,否则 false */ function openTikTokVideo(tiktokUrl) { try { if (!tiktokUrl) { console.log("openTikTokVideo: 缺少视频链接"); return false; } // ===== 0. 打开前解析短链,拿到目标视频特征(用户名/视频ID/文案) ===== let resolvedUrl = resolveTikTokUrl(tiktokUrl); let urlInfo = parseTikTokUrl(resolvedUrl); let oembed = fetchTikTokOembed(resolvedUrl); console.log("目标视频: " + JSON.stringify(urlInfo) + " oembed: " + JSON.stringify(oembed)); let expected = { authorName: (oembed && oembed.authorName) || urlInfo.username || null, captionPrefix: (oembed && oembed.title) || null, videoId: urlInfo.videoId }; importClass(android.content.Intent); importClass(android.net.Uri); // 通过 Intent 唤起 TikTok 并打开视频 let intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(tiktokUrl)); intent.setPackage("com.zhiliaoapp.musically"); app.startActivity(intent); sleep(random(5000, 6000)) // 等待页面加载:Follow 按钮与播放按钮 let resultFollow = text("Follow").id("com.zhiliaoapp.musically:id/sku").findOne(10000) let resultPlay = id("com.zhiliaoapp.musically:id/ujp").findOne(2000) console.log("resultFollow:", resultFollow) console.log("resultPlay:", resultPlay) if (resultFollow && resultPlay) { clickWidget(resultPlay) sleep(5000) console.log("到达新视频") // ===== 校验当前视频是否为目标视频 ===== let verified = verifyTikTokVideoPage(expected, 8000); if (!verified && expected.videoId) { if (verifyByDumpsys(expected.videoId) === true) verified = true; } console.log("当前视频校验: " + (verified ? "✓ 是目标视频" : "✗ 无法确认是目标视频")); return verified; } console.log("openTikTokVideo: 未找到 Follow 按钮或播放按钮"); return false; } catch (error) { console.log("openTikTokVideo error:", error); return false; } } // 调用示例 let tiktokUrl = "https://www.tiktok.com/t/ZTy8r7T2a/"; console.log("作者UID:", getTikTokUidByUrl(tiktokUrl)); // openTikTokVideo(tiktokUrl);