| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- /**
- * 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/@<handle>` 老的分享接口
- * ③ `www.tiktok.com/@<handle>` 的整页 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/<videoId>.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(
- /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application\/json">([\s\S]*?)<\/script>/
- );
- if (matched) {
- var data = null;
- try { data = JSON.parse(matched[1]); } catch (error) { data = null; }
- if (data) {
- var user = findUser(data, 0);
- if (user) {
- return user;
- }
- }
- }
- /* 结构改版 / 解析失败时的兜底 */
- return findUserByRegex(res.body, handle);
- }
- /**
- * 一个 username → uid。
- *
- * @returns { ok, uid, nickname, secUid, via, error }
- * `via` 记录这次用的是哪条路径(api / share / html),看日志就知道接口有没有恢复。
- */
- function fetchUserId(username) {
- var name = TikTokAuto.parse.username(username);
- if (name === '') {
- return { ok: false, error: '空用户名' };
- }
- var paths = [
- { key: 'api', dead: deadPaths.api, run: viaApi,
- label: '官方 user/detail' },
- { key: 'share', dead: deadPaths.share, run: viaShare,
- label: 'node/share' },
- { key: 'html', dead: false, run: viaProfileHtml,
- label: '主页 SSR' }
- ];
- for (var i = 0; i < paths.length; i += 1) {
- var path = paths[i];
- if (path.dead) {
- continue;
- }
- var user = path.run(name);
- if (user) {
- var accepted = accept(user, name);
- if (accepted) {
- accepted.via = path.key;
- return accepted;
- }
- }
- /* 前两条失败就标记"本次运行不再试";主页是最后一条,没有可退的地方 */
- if (path.key !== 'html') {
- deadPaths[path.key] = true;
- logger().info('resolve', path.label + ' 不可用,本次运行后续直接走主页 SSR');
- }
- }
- return { ok: false, error: '三条路径都拿不到(账号可能不存在或已注销)' };
- }
- /** 随机间隔,让请求节奏不像机器 */
- function pause() {
- var span = GAP_MAX_MS - GAP_MIN_MS;
- TikTokAuto.wait.tick(GAP_MIN_MS + Math.floor(Math.random() * (span + 1)));
- }
- /**
- * 批量把 username 换成 uid。
- *
- * 三条原则:
- * ① **一个失败不影响其余**(注销的号、网络抖动都只记一行);
- * ② 每处理一个就回报进度(这个阶段比抓取慢,没有进度会让人以为卡死);
- * ③ 可以被停止打断(`wait.tick` 里会检查停止标志);
- * ④ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系,比写一堆空值有用。
- *
- * @param usernames 用户名数组
- * @param options { onProgress(done, total, name), shouldStop() }
- * @returns { ok, map: [{username, uid, nickname}], failed: [{username, error}], stopped }
- */
- function resolveAll(usernames, options) {
- var settings = options || {};
- var log = logger();
- var map = [];
- var failed = [];
- var total = usernames.length;
- for (var i = 0; i < total; i += 1) {
- if (settings.shouldStop && settings.shouldStop()) {
- /* 注意措辞:这里的 shouldStop 只反映**时长护栏**,
- * 不含"提取上限已到"(那个属于抓取阶段,不该阻止收尾)。
- * 早先写成"收到停止",把"跑太久"和"用户点了停止"混为一谈,排查时很误导。 */
- log.warn('resolve', '到达时长上限,已换 ' + map.length + '/' + total + ' 个');
- return { ok: true, map: map, failed: failed, stopped: true };
- }
- var name = usernames[i];
- if (settings.onProgress) {
- settings.onProgress(i + 1, total, name);
- }
- var result = fetchUserId(name);
- if (result.ok) {
- map.push({ username: name, uid: result.uid, nickname: result.nickname });
- /* 带上 via:这样一看日志就知道现在走的是哪条路径,
- * 接口哪天恢复了也能立刻发现(不用等别人报告)。 */
- log.info('resolve', name + ' → ' + result.uid + '(via ' + result.via + ')');
- } else {
- failed.push({ username: name, error: result.error });
- log.warn('resolve', '换不到 ' + name + ':' + result.error);
- }
- if (i < total - 1) {
- pause();
- }
- }
- return { ok: true, map: map, failed: failed, stopped: false };
- }
- /** 把映射写成两列文本(TAB 分隔)。写入失败返回 false,不抛。 */
- function writeMap(path, map) {
- var lines = [];
- for (var i = 0; i < map.length; i += 1) {
- lines.push(map[i].username + '\t' + map[i].uid);
- }
- try {
- files.write(path, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
- return true;
- } catch (error) {
- logger().error('resolve', '写映射文件失败 ' + path + ':' + error);
- return false;
- }
- }
- return {
- fetchUserId: fetchUserId,
- viaApi: viaApi,
- viaShare: viaShare,
- viaProfileHtml: viaProfileHtml,
- resolveAll: resolveAll,
- writeMap: writeMap,
- GAP_MIN_MS: GAP_MIN_MS,
- GAP_MAX_MS: GAP_MAX_MS
- };
- }());
|