tiktok-uid和视频短链解析.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. findWidget = function (object) {
  2. try {
  3. const {
  4. text: textValue,
  5. textMatches: textMatchesValue,
  6. id: idValue,
  7. className: classValue,
  8. depth: depthValue,
  9. indexInParent: indexValue,
  10. boundsInside: boundsValue,
  11. } = object;
  12. // 归一化正则:本运行时(Rhino)把 RegExp 对象直接传给 Java 的 textMatches/descMatches 时
  13. // 会走 toString() 变成 "/pattern/flags" 字面量(首尾斜杠和标志位成为待匹配内容),
  14. // 导致永远匹配不到控件。这里统一转成 Java Pattern 字符串,i/m/s 标志转内联 (?i)(?m)(?s)
  15. let matchesPattern = textMatchesValue;
  16. if (matchesPattern instanceof RegExp) {
  17. let inlineFlags = "";
  18. if (matchesPattern.ignoreCase) inlineFlags += "(?i)";
  19. if (matchesPattern.multiline) inlineFlags += "(?m)";
  20. if (matchesPattern.dotAll) inlineFlags += "(?s)";
  21. matchesPattern = inlineFlags + matchesPattern.source;
  22. }
  23. // 根据传入的字段自动判断查找方式
  24. const hasText = textValue !== undefined;
  25. const hasTextMatches = textMatchesValue !== undefined;
  26. const hasId = idValue !== undefined;
  27. const hasClass = classValue !== undefined;
  28. const hasDepth = depthValue !== undefined;
  29. const hasIndex = indexValue !== undefined;
  30. const hasBounds = boundsValue !== undefined;
  31. // 创建基础选择器
  32. let selector = null;
  33. // 构建选择器链
  34. if (hasText) {
  35. // 从文本或描述开始(探测需带 visibleToUser(true),与最终查询一致,否则不可见控件命中会导致最终查不到)
  36. selector = text(textValue).visibleToUser(true).findOnce() ? text(textValue) : desc(textValue);
  37. } else if (hasTextMatches) {
  38. // 从文本匹配开始(同上,探测带可见性过滤)
  39. selector = textMatches(matchesPattern).visibleToUser(true).findOnce() ? textMatches(matchesPattern) : descMatches(matchesPattern);
  40. } else if (hasId) {
  41. // 从ID开始
  42. selector = id(idValue);
  43. } else if (hasClass) {
  44. // 从类名开始
  45. selector = className(classValue);
  46. } else if (hasDepth) {
  47. // 从深度开始
  48. selector = depth(depthValue);
  49. } else if (hasIndex) {
  50. // 从索引开始
  51. selector = indexInParent(indexValue);
  52. } else if (hasBounds) {
  53. // 从边界开始
  54. if (Array.isArray(boundsValue) && boundsValue.length === 4) {
  55. selector = boundsInside(
  56. boundsValue[0],
  57. boundsValue[1],
  58. boundsValue[2],
  59. boundsValue[3]
  60. );
  61. }
  62. }
  63. // 如果没有匹配到任何条件,返回null
  64. if (!selector) return null;
  65. // 添加其他条件
  66. if (hasText &&
  67. !(
  68. selector.toString().includes("text") ||
  69. selector.toString().includes("desc")
  70. )
  71. ) {
  72. selector =
  73. selector.textContains(textValue) || selector.descContains(textValue);
  74. }
  75. if (hasTextMatches &&
  76. !(
  77. selector.toString().includes("textMatches") ||
  78. selector.toString().includes("descMatches")
  79. )
  80. ) {
  81. selector =
  82. selector.textMatches(matchesPattern) || selector.descMatches(matchesPattern);
  83. }
  84. if (hasId && !selector.toString().includes("id")) {
  85. selector = selector.id(idValue);
  86. }
  87. if (hasClass && !selector.toString().includes("className")) {
  88. selector = selector.className(classValue);
  89. }
  90. if (hasDepth && !selector.toString().includes("depth")) {
  91. selector = selector.depth(depthValue);
  92. }
  93. if (hasIndex && !selector.toString().includes("indexInParent")) {
  94. selector = selector.indexInParent(indexValue);
  95. }
  96. if (hasBounds && !selector.toString().includes("boundsInside")) {
  97. if (Array.isArray(boundsValue) && boundsValue.length === 4) {
  98. selector = selector.boundsInside(
  99. boundsValue[0],
  100. boundsValue[1],
  101. boundsValue[2],
  102. boundsValue[3]
  103. );
  104. }
  105. }
  106. return selector.visibleToUser(true).findOnce();
  107. } catch (error) {
  108. console.log("findWidget error: ", error);
  109. console.log("findWidget failed on:", object);
  110. return null;
  111. }
  112. };
  113. widgetBounds = function (widget, returnType) {
  114. try {
  115. if (!widget) {
  116. return false;
  117. }
  118. const bool =
  119. widget.bounds().left < widget.bounds().right &&
  120. widget.bounds().left >= 0 &&
  121. widget.bounds().top < widget.bounds().bottom &&
  122. widget.bounds().top >= 0;
  123. if (bool && returnType == "object") {
  124. return {
  125. left: widget.bounds().left,
  126. top: widget.bounds().top,
  127. right: widget.bounds().right,
  128. bottom: widget.bounds().bottom,
  129. };
  130. }
  131. return bool;
  132. } catch (error) {
  133. console.log("widgetBounds error: ", error);
  134. }
  135. return false;
  136. };
  137. clickWidget = function (widget, ranNum) {
  138. try {
  139. if (!widget) {
  140. return false;
  141. }
  142. ranNum = ranNum || 1;
  143. const x = random(
  144. widget.bounds().left + ranNum,
  145. widget.bounds().right - ranNum
  146. );
  147. const y = random(
  148. widget.bounds().top + ranNum,
  149. widget.bounds().bottom - ranNum
  150. );
  151. const time = random(150, 280);
  152. sleep(random(500, 1000));
  153. return press(x, y, time);
  154. // const result = shell("input tap ".concat(x, " ", y), true);
  155. // return result.code === 0;
  156. } catch (error) {
  157. console.log("clickWidget coordinate error:", error);
  158. }
  159. console.log("Failed widget:", widget);
  160. return false;
  161. };
  162. // 从 0~10(含)中随机取 1~3 个不重复的数
  163. function randomPick(len) {
  164. len = len || 10
  165. const count = Math.floor(Math.random() * 3) + 1; // 1~3 个
  166. const pool = Array.from({ length: len }, (_, i) => i); // [0..10]
  167. // 洗牌
  168. for (let i = pool.length - 1; i > 0; i--) {
  169. const j = Math.floor(Math.random() * (i + 1));
  170. [pool[i], pool[j]] = [pool[j], pool[i]];
  171. }
  172. return pool.slice(0, count);
  173. }
  174. /*
  175. let interestsArr = []
  176. let interests = id("com.zhiliaoapp.musically:id/kj5").find()
  177. if (interests.nonEmpty()) {
  178. for (let i = 0; i < interests.length; i++) {
  179. let element = interests[i];
  180. if (widgetBounds(element)) {
  181. interestsArr.push(element)
  182. }
  183. }
  184. console.log("interestsArr:", interestsArr.length)
  185. if (interestsArr.length > 0) {
  186. let chooseInterestsArr = randomPick(interestsArr.length)
  187. console.log("chooseInterestsArr:", chooseInterestsArr.length)
  188. for (let index = 0; index < chooseInterestsArr.length; index++) {
  189. console.log("choose interests:" + index)
  190. let currentIndex = chooseInterestsArr[index]
  191. clickWidget(interestsArr[currentIndex])
  192. sleep(1000)
  193. }
  194. clickWidget(findWidget({ text: "Next" }))
  195. }
  196. }
  197. console.log(randomPick(10)) */
  198. 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";
  199. /**
  200. * 正则特殊字符转义
  201. */
  202. function escapeRegex(s) {
  203. return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  204. }
  205. /**
  206. * 读取 HTTP 响应体(UTF-8),读完自动断开连接。
  207. * 4xx/5xx 时改读错误流,避免 getInputStream 抛 FileNotFoundException。
  208. * @returns {string} 响应内容(可能为空字符串)
  209. */
  210. function readHttpBody(conn) {
  211. try {
  212. let code = conn.getResponseCode();
  213. let stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
  214. if (!stream) {
  215. conn.disconnect();
  216. return "";
  217. }
  218. let reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8"));
  219. let sb = new java.lang.StringBuilder();
  220. let line;
  221. while ((line = reader.readLine()) !== null) {
  222. sb.append(line);
  223. }
  224. reader.close();
  225. conn.disconnect();
  226. return sb.toString();
  227. } catch (e) {
  228. try {
  229. conn.disconnect();
  230. } catch (e2) {}
  231. throw e;
  232. }
  233. }
  234. let TIKTOK_TTWID = null;
  235. /**
  236. * 获取 ttwid Cookie(TikTok 接口风控校验用,首页 Set-Cookie 下发)。
  237. * 获取一次后缓存复用;失败不影响主流程(降级走 HTML 解析)。
  238. */
  239. function fetchTtwidCookie() {
  240. if (TIKTOK_TTWID) return TIKTOK_TTWID;
  241. try {
  242. let conn = new java.net.URL("https://www.tiktok.com/").openConnection();
  243. conn.setConnectTimeout(8000);
  244. conn.setReadTimeout(8000);
  245. conn.setRequestProperty("User-Agent", TIKTOK_UA);
  246. conn.getResponseCode();
  247. let map = conn.getHeaderFields();
  248. let list = map ? map.get("Set-Cookie") : null;
  249. if (list) {
  250. for (let i = 0; i < list.size(); i++) {
  251. let c = String(list.get(i));
  252. let idx = c.indexOf("ttwid=");
  253. if (idx !== -1) {
  254. TIKTOK_TTWID = c.substring(idx).split(";")[0]; // 形如 ttwid=xxx
  255. console.log("fetchTtwidCookie: " + TIKTOK_TTWID);
  256. break;
  257. }
  258. }
  259. }
  260. conn.disconnect();
  261. } catch (e) {
  262. console.log("fetchTtwidCookie: 获取失败 " + e);
  263. }
  264. return TIKTOK_TTWID;
  265. }
  266. /**
  267. * GET 请求并返回响应体字符串;网络异常 / 非 200 / 空响应统一返回 null(只留一行日志)。
  268. */
  269. function httpGetRaw(url, headers, tag) {
  270. try {
  271. let conn = new java.net.URL(url).openConnection();
  272. conn.setConnectTimeout(8000);
  273. conn.setReadTimeout(8000);
  274. conn.setRequestProperty("User-Agent", TIKTOK_UA);
  275. let extra = headers || {};
  276. for (let k in extra) {
  277. if (extra.hasOwnProperty(k)) conn.setRequestProperty(k, extra[k]);
  278. }
  279. let code = conn.getResponseCode();
  280. let body = readHttpBody(conn);
  281. if (code !== 200 || !body || body.trim() === "") {
  282. console.log((tag || "httpGetRaw") + ": HTTP " + code + " 响应为空,跳过");
  283. return null;
  284. }
  285. return body;
  286. } catch (e) {
  287. console.log((tag || "httpGetRaw") + ": 请求失败 " + e);
  288. return null;
  289. }
  290. }
  291. /**
  292. * GET 请求并解析 JSON;失败返回 null
  293. */
  294. function httpGetJson(url, headers, tag) {
  295. let body = httpGetRaw(url, headers, tag);
  296. if (body === null) return null;
  297. try {
  298. return JSON.parse(body);
  299. } catch (e) {
  300. console.log((tag || "httpGetJson") + ": JSON 解析失败");
  301. return null;
  302. }
  303. }
  304. /**
  305. * 解析 TikTok 短链为最终完整链接(含用户名与视频ID)。
  306. * 手动跟随 301/302 重定向(最多 8 跳),命中 /video/ 链接提前结束。
  307. * @param {string} url 短链(如 https://www.tiktok.com/t/ZTy8r7T2a/)
  308. * @returns {string} 最终链接,解析失败时返回原链接
  309. */
  310. function resolveTikTokUrl(url) {
  311. try {
  312. let current = url;
  313. for (let i = 0; i < 8; i++) {
  314. let conn = new java.net.URL(current).openConnection();
  315. conn.setInstanceFollowRedirects(false);
  316. conn.setConnectTimeout(8000);
  317. conn.setReadTimeout(8000);
  318. conn.setRequestProperty("User-Agent", TIKTOK_UA);
  319. conn.setRequestMethod("GET");
  320. conn.connect();
  321. let loc = conn.getHeaderField("Location");
  322. conn.disconnect();
  323. if (loc) {
  324. current = new java.net.URL(new java.net.URL(current), loc).toString();
  325. if (current.indexOf("/video/") !== -1) break;
  326. } else {
  327. break;
  328. }
  329. }
  330. return current;
  331. } catch (e) {
  332. console.log("resolveTikTokUrl error:", e);
  333. return url;
  334. }
  335. }
  336. /**
  337. * 从完整链接提取 @用户名 与 视频ID
  338. */
  339. function parseTikTokUrl(url) {
  340. let u = String(url || "");
  341. let usernameMatch = u.match(/@([A-Za-z0-9_.-]+)/);
  342. let videoMatch = u.match(/\/video\/(\d+)/);
  343. return {
  344. url: u,
  345. username: usernameMatch ? usernameMatch[1] : null,
  346. videoId: videoMatch ? videoMatch[1] : null
  347. };
  348. }
  349. /**
  350. * 通过用户名(句柄)获取 TikTok 用户 UID。
  351. * 依次尝试:官方用户详情 API(带 ttwid)→ node/share 接口 → 解析主页 HTML 兜底。
  352. * @param {string} username 用户名(可带 @ 前缀)
  353. * @returns {string|null} uid,获取失败返回 null
  354. */
  355. function getTikTokUidByUsername(username) {
  356. if (!username) return null;
  357. let handle = String(username).replace(/^@/, "").trim();
  358. if (!handle) return null;
  359. // ===== 方式1:官方用户详情 API(带 ttwid Cookie)=====
  360. let ttwid = fetchTtwidCookie();
  361. let headers = {
  362. "Accept": "application/json, text/plain, */*",
  363. "Referer": "https://www.tiktok.com/@" + encodeURIComponent(handle)
  364. };
  365. if (ttwid) headers["Cookie"] = ttwid;
  366. let obj = httpGetJson(
  367. "https://www.tiktok.com/api/user/detail/?uniqueId=" + encodeURIComponent(handle),
  368. headers,
  369. "getTikTokUidByUsername(api)"
  370. );
  371. let uid = obj && obj.userInfo && obj.userInfo.user && obj.userInfo.user.id;
  372. if (uid) {
  373. console.log("getTikTokUidByUsername(api): " + handle + " -> " + uid);
  374. return String(uid);
  375. }
  376. // ===== 方式2:node/share 用户接口 =====
  377. obj = httpGetJson(
  378. "https://www.tiktok.com/node/share/user/@" + encodeURIComponent(handle)
  379. + "?uniqueId=" + encodeURIComponent(handle),
  380. { "Accept": "application/json, text/plain, */*" },
  381. "getTikTokUidByUsername(node/share)"
  382. );
  383. uid = obj && obj.userInfo && obj.userInfo.user && obj.userInfo.user.id;
  384. if (uid) {
  385. console.log("getTikTokUidByUsername(node/share): " + handle + " -> " + uid);
  386. return String(uid);
  387. }
  388. // ===== 方式3:解析主页 HTML(__UNIVERSAL_DATA_FOR_REHYDRATION__)=====
  389. let html = httpGetRaw(
  390. "https://www.tiktok.com/@" + encodeURIComponent(handle),
  391. { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
  392. "getTikTokUidByUsername(html)"
  393. );
  394. if (html) {
  395. // 主页 JSON 典型结构: {"id":"123456789","shortId":"...","uniqueId":"handle",...}
  396. let m = html.match(new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escapeRegex(handle) + '"'));
  397. if (m) {
  398. console.log("getTikTokUidByUsername(html): " + handle + " -> " + m[1]);
  399. return String(m[1]);
  400. }
  401. // 兜底:定位 uniqueId 字段,向前找最近的 "id"
  402. let idx = html.indexOf('"uniqueId":"' + handle + '"');
  403. if (idx !== -1) {
  404. let seg = html.slice(Math.max(0, idx - 500), idx);
  405. let m2 = seg.match(/"id":"(\d+)"/);
  406. if (m2) {
  407. console.log("getTikTokUidByUsername(html): " + handle + " -> " + m2[1]);
  408. return String(m2[1]);
  409. }
  410. }
  411. }
  412. console.log("getTikTokUidByUsername: 获取 " + handle + " 的 UID 失败");
  413. return null;
  414. }
  415. /**
  416. * 交叉验证 UID 是否真的属于该用户名。
  417. * 来源1(优先):视频详情页 HTML 中的 author 对象(独立数据源,双重比对 id+uniqueId);
  418. * 来源2(兜底):主页 HTML 中 uniqueId=handle 的用户对象提取 id 比对,顺带拿 secUid。
  419. * @param {string} handle 用户名(句柄)
  420. * @param {string} uid 待验证的 UID
  421. * @param {string} [videoUrl] 视频链接,用于独立交叉验证
  422. * @returns {Object} { ok, byProfile, byVideo, secUid }
  423. * ok=true 表示至少一个来源确认匹配;byProfile/byVideo 取值 true/false/null(无法获取)
  424. */
  425. function verifyTikTokUid(handle, uid, videoUrl) {
  426. let result = { ok: false, byProfile: null, byVideo: null, secUid: null };
  427. let target = String(uid);
  428. // ===== 来源1:视频详情页 author 对象(独立交叉)=====
  429. if (videoUrl) {
  430. let resolved = resolveTikTokUrl(videoUrl);
  431. let vHtml = httpGetRaw(resolved, { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, "verifyTikTokUid(video)");
  432. if (vHtml) {
  433. let aidx = vHtml.indexOf('"author":{');
  434. if (aidx !== -1) {
  435. let seg = vHtml.slice(aidx, aidx + 2500);
  436. let mAId = seg.match(/"id":"(\d+)"/);
  437. let mAUid = seg.match(/"uniqueId":"([^"]+)"/);
  438. if (mAId) {
  439. result.byVideo = String(mAId[1]) === target
  440. && (!mAUid || mAUid[1].toLowerCase() === handle.toLowerCase());
  441. }
  442. } else {
  443. let mAId2 = vHtml.match(/"authorId":"(\d+)"/);
  444. if (mAId2) result.byVideo = String(mAId2[1]) === target;
  445. }
  446. }
  447. }
  448. // ===== 来源2:主页 HTML 兜底/补充(顺带拿 secUid)=====
  449. if (result.byVideo === null) {
  450. let html = httpGetRaw(
  451. "https://www.tiktok.com/@" + encodeURIComponent(handle),
  452. { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
  453. "verifyTikTokUid(profile)"
  454. );
  455. if (html) {
  456. let idx = html.indexOf('"uniqueId":"' + handle + '"');
  457. if (idx !== -1) {
  458. let mId = html.slice(Math.max(0, idx - 500), idx).match(/"id":"(\d+)"/);
  459. if (mId) {
  460. result.byProfile = String(mId[1]) === target;
  461. let mSec = html.slice(idx, idx + 3000).match(/"secUid":"([^"]+)"/);
  462. if (mSec) result.secUid = mSec[1];
  463. }
  464. }
  465. }
  466. }
  467. result.ok = result.byProfile === true || result.byVideo === true;
  468. console.log("verifyTikTokUid: " + handle + " uid=" + target
  469. + " 主页比对=" + result.byProfile + " 视频页交叉=" + result.byVideo
  470. + " -> " + (result.ok ? "校验通过" : "校验未通过"));
  471. return result;
  472. }
  473. /**
  474. * 从视频链接获取作者 UID(支持短链,内部先解析出用户名并交叉验证)
  475. * @param {string} tiktokUrl 视频链接或短链
  476. * @returns {string|null} uid;校验不通过或获取失败返回 null
  477. */
  478. function getTikTokUidByUrl(tiktokUrl) {
  479. let resolved = resolveTikTokUrl(tiktokUrl);
  480. let info = parseTikTokUrl(resolved);
  481. if (!info.username) {
  482. console.log("getTikTokUidByUrl: 链接中未解析到用户名: " + resolved);
  483. return null;
  484. }
  485. let uid = getTikTokUidByUsername(info.username);
  486. if (!uid) return null;
  487. let check = verifyTikTokUid(info.username, uid, resolved);
  488. if (check.ok) return uid;
  489. if (check.byProfile === false || check.byVideo === false) {
  490. console.log("getTikTokUidByUrl: UID 校验不通过,丢弃结果");
  491. return null;
  492. }
  493. // 两个来源都无法获取时无法交叉验证,仍返回(仅告警)
  494. console.log("getTikTokUidByUrl: 无法交叉验证,按当前结果返回 uid=" + uid);
  495. return uid;
  496. }
  497. /**
  498. * 通过 oEmbed 接口获取视频标题与作者昵称。
  499. * 注意:链接里的 @handle 是账号句柄,页面展示的是昵称,二者可能不同,
  500. * 所以用 oEmbed 的 author_name 作为页面比对基准最可靠。
  501. */
  502. function fetchTikTokOembed(videoUrl) {
  503. let obj = httpGetJson(
  504. "https://www.tiktok.com/oembed?url=" + encodeURIComponent(videoUrl),
  505. { "Accept": "application/json, text/plain, */*" },
  506. "fetchTikTokOembed"
  507. );
  508. if (!obj) return null;
  509. return {
  510. title: (obj.title || "").trim(),
  511. authorName: obj.author_name || "",
  512. authorUrl: obj.author_url || ""
  513. };
  514. }
  515. /**
  516. * 在当前页面校验视频是否为目标视频:
  517. * 1) 作者昵称精确匹配 → 2) 昵称包含匹配 → 3) 视频文案前缀匹配
  518. * @param {Object} expected { authorName, captionPrefix }
  519. * @param {number} timeout 轮询超时(ms),默认 8000
  520. * @returns {boolean} 命中任一特征返回 true
  521. */
  522. function verifyTikTokVideoPage(expected, timeout) {
  523. timeout = timeout || 8000;
  524. let checks = [];
  525. if (expected.authorName) {
  526. checks.push({ key: "作者昵称(精确)", widget: { text: expected.authorName } });
  527. checks.push({ key: "作者昵称(包含)", widget: { textMatches: "(?s).*" + escapeRegex(expected.authorName) + ".*" } });
  528. }
  529. if (expected.captionPrefix) {
  530. let prefix = String(expected.captionPrefix).slice(0, 12);
  531. if (prefix) {
  532. checks.push({ key: "视频文案前缀", widget: { textMatches: "(?s).*" + escapeRegex(prefix) + ".*" } });
  533. }
  534. }
  535. if (checks.length === 0) {
  536. console.log("verifyTikTokVideoPage: 无可比对的视频特征");
  537. return false;
  538. }
  539. let deadline = new Date().getTime() + timeout;
  540. while (new Date().getTime() < deadline) {
  541. for (let i = 0; i < checks.length; i++) {
  542. if (findWidget(checks[i].widget)) {
  543. console.log("verifyTikTokVideoPage: 命中【" + checks[i].key + "】");
  544. return true;
  545. }
  546. }
  547. sleep(800);
  548. }
  549. console.log("verifyTikTokVideoPage: 页面未匹配到目标视频特征");
  550. return false;
  551. }
  552. /**
  553. * 兜底校验(需 root):从 dumpsys activity top 的 Intent 数据中找当前视频ID。
  554. * @returns {boolean|null} 命中=true / 未命中=false / 无法读取=null
  555. */
  556. function verifyByDumpsys(videoId) {
  557. try {
  558. let r = shell("dumpsys activity top", true);
  559. let dump = "";
  560. if (r && typeof r.result === "string") dump = r.result;
  561. else if (typeof r === "string") dump = r;
  562. if (!dump) return null;
  563. let ids = [];
  564. let m;
  565. let re = /\/video\/(\d+)/g;
  566. while ((m = re.exec(dump)) !== null) {
  567. ids.push(m[1]);
  568. }
  569. if (ids.length === 0) return null;
  570. let found = ids.indexOf(String(videoId)) !== -1;
  571. console.log("verifyByDumpsys: 当前页面视频ID=" + ids.join(",") + " 目标=" + videoId + " 命中=" + found);
  572. return found;
  573. } catch (e) {
  574. console.log("verifyByDumpsys error:", e);
  575. return null;
  576. }
  577. }
  578. /**
  579. * 打开 TikTok 视频链接并校验是否到达目标视频页
  580. * @param {string} tiktokUrl TikTok 视频链接(支持短链)
  581. * @returns {boolean} 确认到达目标视频返回 true,否则 false
  582. */
  583. function openTikTokVideo(tiktokUrl) {
  584. try {
  585. if (!tiktokUrl) {
  586. console.log("openTikTokVideo: 缺少视频链接");
  587. return false;
  588. }
  589. // ===== 0. 打开前解析短链,拿到目标视频特征(用户名/视频ID/文案) =====
  590. let resolvedUrl = resolveTikTokUrl(tiktokUrl);
  591. let urlInfo = parseTikTokUrl(resolvedUrl);
  592. let oembed = fetchTikTokOembed(resolvedUrl);
  593. console.log("目标视频: " + JSON.stringify(urlInfo) + " oembed: " + JSON.stringify(oembed));
  594. let expected = {
  595. authorName: (oembed && oembed.authorName) || urlInfo.username || null,
  596. captionPrefix: (oembed && oembed.title) || null,
  597. videoId: urlInfo.videoId
  598. };
  599. importClass(android.content.Intent);
  600. importClass(android.net.Uri);
  601. // 通过 Intent 唤起 TikTok 并打开视频
  602. let intent = new Intent(Intent.ACTION_VIEW);
  603. intent.setData(Uri.parse(tiktokUrl));
  604. intent.setPackage("com.zhiliaoapp.musically");
  605. app.startActivity(intent);
  606. sleep(random(5000, 6000))
  607. // 等待页面加载:Follow 按钮与播放按钮
  608. let resultFollow = text("Follow").id("com.zhiliaoapp.musically:id/sku").findOne(10000)
  609. let resultPlay = id("com.zhiliaoapp.musically:id/ujp").findOne(2000)
  610. console.log("resultFollow:", resultFollow)
  611. console.log("resultPlay:", resultPlay)
  612. if (resultFollow && resultPlay) {
  613. clickWidget(resultPlay)
  614. sleep(5000)
  615. console.log("到达新视频")
  616. // ===== 校验当前视频是否为目标视频 =====
  617. let verified = verifyTikTokVideoPage(expected, 8000);
  618. if (!verified && expected.videoId) {
  619. if (verifyByDumpsys(expected.videoId) === true) verified = true;
  620. }
  621. console.log("当前视频校验: " + (verified ? "✓ 是目标视频" : "✗ 无法确认是目标视频"));
  622. return verified;
  623. }
  624. console.log("openTikTokVideo: 未找到 Follow 按钮或播放按钮");
  625. return false;
  626. } catch (error) {
  627. console.log("openTikTokVideo error:", error);
  628. return false;
  629. }
  630. }
  631. // 调用示例
  632. let tiktokUrl = "https://www.tiktok.com/t/ZTy8r7T2a/";
  633. console.log("作者UID:", getTikTokUidByUrl(tiktokUrl));
  634. // openTikTokVideo(tiktokUrl);