resolve.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /**
  2. * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid
  3. *
  4. * ## 为什么需要它
  5. * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。
  6. * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。
  7. * 两者完全不同:
  8. *
  9. * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见
  10. * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改
  11. * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到**
  12. *
  13. * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。
  14. *
  15. * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路)
  16. *
  17. * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节)
  18. * ② `www.tiktok.com/node/share/user/@<handle>` 老的分享接口
  19. * ③ `www.tiktok.com/@<handle>` 的整页 SSR 最重(约 388KB),但**最可靠**
  20. *
  21. * 2026-10-20 真机实测:**①② 都已经不可用** ——
  22. * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在")
  23. * ② 返回 `403`
  24. * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms
  25. *
  26. * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后,
  27. * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。
  28. * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。
  29. *
  30. * ## 试过但走不通的(别再试)
  31. * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...")
  32. * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/<videoId>.html`)
  33. * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id**
  34. * · AutoX 的 `http.get` —— 本机这个版本是坏的:
  35. * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection`
  36. *
  37. * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse
  38. */
  39. TikTokAuto.resolve = (function () {
  40. 'use strict';
  41. var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
  42. '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
  43. /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */
  44. var API_TIMEOUT_MS = 8000;
  45. var PAGE_TIMEOUT_MS = 20000;
  46. /**
  47. * 两次请求之间的间隔区间。
  48. *
  49. * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码;
  50. * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。
  51. */
  52. var GAP_MIN_MS = 400;
  53. var GAP_MAX_MS = 900;
  54. /**
  55. * 本次运行里各条路径是否已被证明不可用。
  56. *
  57. * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求;
  58. * 记住之后只有第一个账号付这个代价。
  59. */
  60. var deadPaths = { api: false, share: false };
  61. /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */
  62. var ttwidCache = null;
  63. var ttwidTried = false;
  64. function logger() { return TikTokAuto.logger; }
  65. /**
  66. * 读一个连接的响应体。
  67. *
  68. * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常,
  69. * 于是「404 账号不存在」会被误判成「网络错误」。
  70. * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。
  71. */
  72. function readBody(conn) {
  73. var stream = null;
  74. try {
  75. var code = conn.getResponseCode();
  76. stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
  77. if (!stream) {
  78. return { code: code, body: '' };
  79. }
  80. var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8'));
  81. var buffer = new java.lang.StringBuilder();
  82. var line;
  83. while ((line = reader.readLine()) !== null) {
  84. buffer.append(line).append('\n');
  85. }
  86. reader.close();
  87. return { code: code, body: String(buffer.toString()) };
  88. } finally {
  89. try { if (stream) { stream.close(); } } catch (ignored) { }
  90. }
  91. }
  92. /**
  93. * 发一个 GET。
  94. * @returns { ok, status, body, error }
  95. */
  96. function get(url, headers, timeoutMs) {
  97. var conn = null;
  98. try {
  99. conn = new java.net.URL(url).openConnection();
  100. conn.setConnectTimeout(timeoutMs);
  101. conn.setReadTimeout(timeoutMs);
  102. conn.setInstanceFollowRedirects(true);
  103. conn.setRequestProperty('User-Agent', UA);
  104. conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9');
  105. if (headers) {
  106. for (var key in headers) {
  107. if (headers.hasOwnProperty(key)) {
  108. conn.setRequestProperty(key, headers[key]);
  109. }
  110. }
  111. }
  112. var result = readBody(conn);
  113. return { ok: true, status: result.code, body: result.body };
  114. } catch (error) {
  115. return { ok: false, status: 0, body: '', error: String(error) };
  116. } finally {
  117. /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */
  118. try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { }
  119. }
  120. }
  121. /**
  122. * 取 ttwid(TikTok 首页下发,接口风控用)。
  123. * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。
  124. */
  125. function ttwid() {
  126. if (ttwidTried) {
  127. return ttwidCache;
  128. }
  129. ttwidTried = true;
  130. var conn = null;
  131. try {
  132. conn = new java.net.URL('https://www.tiktok.com/').openConnection();
  133. conn.setConnectTimeout(API_TIMEOUT_MS);
  134. conn.setReadTimeout(API_TIMEOUT_MS);
  135. conn.setRequestProperty('User-Agent', UA);
  136. conn.getResponseCode();
  137. var fields = conn.getHeaderFields();
  138. var cookies = fields ? fields.get('Set-Cookie') : null;
  139. if (cookies) {
  140. for (var i = 0; i < cookies.size(); i += 1) {
  141. var one = String(cookies.get(i));
  142. var idx = one.indexOf('ttwid=');
  143. if (idx !== -1) {
  144. ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */
  145. break;
  146. }
  147. }
  148. }
  149. } catch (error) {
  150. logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error);
  151. } finally {
  152. try { if (conn) { conn.disconnect(); } } catch (ignored) { }
  153. }
  154. return ttwidCache;
  155. }
  156. /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */
  157. function findUser(node, depth) {
  158. if (depth > 8 || node === null || typeof node !== 'object') {
  159. return null;
  160. }
  161. if (node.userInfo && node.userInfo.user && node.userInfo.user.id) {
  162. return node.userInfo.user;
  163. }
  164. for (var key in node) {
  165. if (node.hasOwnProperty(key)) {
  166. var hit = findUser(node[key], depth + 1);
  167. if (hit) {
  168. return hit;
  169. }
  170. }
  171. }
  172. return null;
  173. }
  174. /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */
  175. function findUserByRegex(html, handle) {
  176. var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  177. var matched = html.match(
  178. new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"')
  179. ) || html.match(
  180. new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"')
  181. );
  182. if (!matched) {
  183. return null;
  184. }
  185. return { id: matched[1], uniqueId: handle };
  186. }
  187. /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */
  188. function accept(user, wanted) {
  189. if (!user || !user.id) {
  190. return null;
  191. }
  192. var got = TikTokAuto.parse.username(user.uniqueId || '');
  193. if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) {
  194. logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃');
  195. return null;
  196. }
  197. return {
  198. ok: true,
  199. uid: String(user.id),
  200. nickname: user.nickname || '',
  201. secUid: user.secUid || ''
  202. };
  203. }
  204. /* ---------------------------------------------------------- 三条路径 */
  205. /** ① 官方用户详情接口 */
  206. function viaApi(handle) {
  207. var headers = {
  208. 'Accept': 'application/json, text/plain, */*',
  209. 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle)
  210. };
  211. var cookie = ttwid();
  212. if (cookie) {
  213. headers['Cookie'] = cookie;
  214. }
  215. var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' +
  216. encodeURIComponent(handle), headers, API_TIMEOUT_MS);
  217. if (!res.ok || res.status !== 200 || res.body === '') {
  218. return null;
  219. }
  220. var data = null;
  221. try { data = JSON.parse(res.body); } catch (error) { return null; }
  222. return findUser(data, 0);
  223. }
  224. /** ② 老的分享接口 */
  225. function viaShare(handle) {
  226. var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) +
  227. '?uniqueId=' + encodeURIComponent(handle),
  228. { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS);
  229. if (!res.ok || res.status !== 200 || res.body === '') {
  230. return null;
  231. }
  232. var data = null;
  233. try { data = JSON.parse(res.body); } catch (error) { return null; }
  234. return findUser(data, 0);
  235. }
  236. /** ③ 主页整页 SSR(实测唯一可用的一条) */
  237. function viaProfileHtml(handle) {
  238. var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle),
  239. { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
  240. PAGE_TIMEOUT_MS);
  241. if (!res.ok || res.status !== 200 || res.body === '') {
  242. return null;
  243. }
  244. var matched = res.body.match(
  245. /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application\/json">([\s\S]*?)<\/script>/
  246. );
  247. if (matched) {
  248. var data = null;
  249. try { data = JSON.parse(matched[1]); } catch (error) { data = null; }
  250. if (data) {
  251. var user = findUser(data, 0);
  252. if (user) {
  253. return user;
  254. }
  255. }
  256. }
  257. /* 结构改版 / 解析失败时的兜底 */
  258. return findUserByRegex(res.body, handle);
  259. }
  260. /**
  261. * 一个 username → uid。
  262. *
  263. * @returns { ok, uid, nickname, secUid, via, error }
  264. * `via` 记录这次用的是哪条路径(api / share / html),看日志就知道接口有没有恢复。
  265. */
  266. function fetchUserId(username) {
  267. var name = TikTokAuto.parse.username(username);
  268. if (name === '') {
  269. return { ok: false, error: '空用户名' };
  270. }
  271. var paths = [
  272. { key: 'api', dead: deadPaths.api, run: viaApi,
  273. label: '官方 user/detail' },
  274. { key: 'share', dead: deadPaths.share, run: viaShare,
  275. label: 'node/share' },
  276. { key: 'html', dead: false, run: viaProfileHtml,
  277. label: '主页 SSR' }
  278. ];
  279. for (var i = 0; i < paths.length; i += 1) {
  280. var path = paths[i];
  281. if (path.dead) {
  282. continue;
  283. }
  284. var user = path.run(name);
  285. if (user) {
  286. var accepted = accept(user, name);
  287. if (accepted) {
  288. accepted.via = path.key;
  289. return accepted;
  290. }
  291. }
  292. /* 前两条失败就标记"本次运行不再试";主页是最后一条,没有可退的地方 */
  293. if (path.key !== 'html') {
  294. deadPaths[path.key] = true;
  295. logger().info('resolve', path.label + ' 不可用,本次运行后续直接走主页 SSR');
  296. }
  297. }
  298. return { ok: false, error: '三条路径都拿不到(账号可能不存在或已注销)' };
  299. }
  300. /** 随机间隔,让请求节奏不像机器 */
  301. function pause() {
  302. var span = GAP_MAX_MS - GAP_MIN_MS;
  303. TikTokAuto.wait.tick(GAP_MIN_MS + Math.floor(Math.random() * (span + 1)));
  304. }
  305. /**
  306. * 批量把 username 换成 uid。
  307. *
  308. * 三条原则:
  309. * ① **一个失败不影响其余**(注销的号、网络抖动都只记一行);
  310. * ② 每处理一个就回报进度(这个阶段比抓取慢,没有进度会让人以为卡死);
  311. * ③ 可以被停止打断(`wait.tick` 里会检查停止标志);
  312. * ④ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系,比写一堆空值有用。
  313. *
  314. * @param usernames 用户名数组
  315. * @param options { onProgress(done, total, name), shouldStop() }
  316. * @returns { ok, map: [{username, uid, nickname}], failed: [{username, error}], stopped }
  317. */
  318. function resolveAll(usernames, options) {
  319. var settings = options || {};
  320. var log = logger();
  321. var map = [];
  322. var failed = [];
  323. var total = usernames.length;
  324. for (var i = 0; i < total; i += 1) {
  325. if (settings.shouldStop && settings.shouldStop()) {
  326. /* 注意措辞:这里的 shouldStop 只反映**时长护栏**,
  327. * 不含"提取上限已到"(那个属于抓取阶段,不该阻止收尾)。
  328. * 早先写成"收到停止",把"跑太久"和"用户点了停止"混为一谈,排查时很误导。 */
  329. log.warn('resolve', '到达时长上限,已换 ' + map.length + '/' + total + ' 个');
  330. return { ok: true, map: map, failed: failed, stopped: true };
  331. }
  332. var name = usernames[i];
  333. if (settings.onProgress) {
  334. settings.onProgress(i + 1, total, name);
  335. }
  336. var result = fetchUserId(name);
  337. if (result.ok) {
  338. map.push({ username: name, uid: result.uid, nickname: result.nickname });
  339. /* 带上 via:这样一看日志就知道现在走的是哪条路径,
  340. * 接口哪天恢复了也能立刻发现(不用等别人报告)。 */
  341. log.info('resolve', name + ' → ' + result.uid + '(via ' + result.via + ')');
  342. } else {
  343. failed.push({ username: name, error: result.error });
  344. log.warn('resolve', '换不到 ' + name + ':' + result.error);
  345. }
  346. if (i < total - 1) {
  347. pause();
  348. }
  349. }
  350. return { ok: true, map: map, failed: failed, stopped: false };
  351. }
  352. /** 把映射写成两列文本(TAB 分隔)。写入失败返回 false,不抛。 */
  353. function writeMap(path, map) {
  354. var lines = [];
  355. for (var i = 0; i < map.length; i += 1) {
  356. lines.push(map[i].username + '\t' + map[i].uid);
  357. }
  358. try {
  359. files.write(path, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
  360. return true;
  361. } catch (error) {
  362. logger().error('resolve', '写映射文件失败 ' + path + ':' + error);
  363. return false;
  364. }
  365. }
  366. return {
  367. fetchUserId: fetchUserId,
  368. viaApi: viaApi,
  369. viaShare: viaShare,
  370. viaProfileHtml: viaProfileHtml,
  371. resolveAll: resolveAll,
  372. writeMap: writeMap,
  373. GAP_MIN_MS: GAP_MIN_MS,
  374. GAP_MAX_MS: GAP_MAX_MS
  375. };
  376. }());