/** * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid * * ## 为什么需要它 * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。 * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。 * 两者完全不同: * * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见 * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改 * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到** * * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。 * * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路) * * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节) * ② `www.tiktok.com/node/share/user/@` 老的分享接口 * ③ `www.tiktok.com/@` 的整页 SSR 最重(约 388KB),但**最可靠** * * 2026-10-20 真机实测:**①② 都已经不可用** —— * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在") * ② 返回 `403` * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms * * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后, * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。 * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。 * * ## 试过但走不通的(别再试) * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...") * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/.html`) * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id** * · AutoX 的 `http.get` —— 本机这个版本是坏的: * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection` * * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse */ TikTokAuto.resolve = (function () { 'use strict'; var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */ var API_TIMEOUT_MS = 8000; var PAGE_TIMEOUT_MS = 20000; /** * 两次请求之间的间隔区间。 * * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码; * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。 */ var GAP_MIN_MS = 400; var GAP_MAX_MS = 900; /** * 本次运行里各条路径是否已被证明不可用。 * * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求; * 记住之后只有第一个账号付这个代价。 */ var deadPaths = { api: false, share: false }; /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */ var ttwidCache = null; var ttwidTried = false; function logger() { return TikTokAuto.logger; } /** * 读一个连接的响应体。 * * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常, * 于是「404 账号不存在」会被误判成「网络错误」。 * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。 */ function readBody(conn) { var stream = null; try { var code = conn.getResponseCode(); stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); if (!stream) { return { code: code, body: '' }; } var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8')); var buffer = new java.lang.StringBuilder(); var line; while ((line = reader.readLine()) !== null) { buffer.append(line).append('\n'); } reader.close(); return { code: code, body: String(buffer.toString()) }; } finally { try { if (stream) { stream.close(); } } catch (ignored) { } } } /** * 发一个 GET。 * @returns { ok, status, body, error } */ function get(url, headers, timeoutMs) { var conn = null; try { conn = new java.net.URL(url).openConnection(); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); conn.setInstanceFollowRedirects(true); conn.setRequestProperty('User-Agent', UA); conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9'); if (headers) { for (var key in headers) { if (headers.hasOwnProperty(key)) { conn.setRequestProperty(key, headers[key]); } } } var result = readBody(conn); return { ok: true, status: result.code, body: result.body }; } catch (error) { return { ok: false, status: 0, body: '', error: String(error) }; } finally { /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */ try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { } } } /** * 取 ttwid(TikTok 首页下发,接口风控用)。 * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。 */ function ttwid() { if (ttwidTried) { return ttwidCache; } ttwidTried = true; var conn = null; try { conn = new java.net.URL('https://www.tiktok.com/').openConnection(); conn.setConnectTimeout(API_TIMEOUT_MS); conn.setReadTimeout(API_TIMEOUT_MS); conn.setRequestProperty('User-Agent', UA); conn.getResponseCode(); var fields = conn.getHeaderFields(); var cookies = fields ? fields.get('Set-Cookie') : null; if (cookies) { for (var i = 0; i < cookies.size(); i += 1) { var one = String(cookies.get(i)); var idx = one.indexOf('ttwid='); if (idx !== -1) { ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */ break; } } } } catch (error) { logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error); } finally { try { if (conn) { conn.disconnect(); } } catch (ignored) { } } return ttwidCache; } /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */ function findUser(node, depth) { if (depth > 8 || node === null || typeof node !== 'object') { return null; } if (node.userInfo && node.userInfo.user && node.userInfo.user.id) { return node.userInfo.user; } for (var key in node) { if (node.hasOwnProperty(key)) { var hit = findUser(node[key], depth + 1); if (hit) { return hit; } } } return null; } /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */ function findUserByRegex(html, handle) { var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var matched = html.match( new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"') ) || html.match( new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"') ); if (!matched) { return null; } return { id: matched[1], uniqueId: handle }; } /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */ function accept(user, wanted) { if (!user || !user.id) { return null; } var got = TikTokAuto.parse.username(user.uniqueId || ''); if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) { logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃'); return null; } return { ok: true, uid: String(user.id), nickname: user.nickname || '', secUid: user.secUid || '' }; } /* ---------------------------------------------------------- 三条路径 */ /** ① 官方用户详情接口 */ function viaApi(handle) { var headers = { 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle) }; var cookie = ttwid(); if (cookie) { headers['Cookie'] = cookie; } var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' + encodeURIComponent(handle), headers, API_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var data = null; try { data = JSON.parse(res.body); } catch (error) { return null; } return findUser(data, 0); } /** ② 老的分享接口 */ function viaShare(handle) { var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) + '?uniqueId=' + encodeURIComponent(handle), { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var data = null; try { data = JSON.parse(res.body); } catch (error) { return null; } return findUser(data, 0); } /** ③ 主页整页 SSR(实测唯一可用的一条) */ function viaProfileHtml(handle) { var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle), { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, PAGE_TIMEOUT_MS); if (!res.ok || res.status !== 200 || res.body === '') { return null; } var matched = res.body.match( /