|
|
@@ -1,6 +1,6 @@
|
|
|
/*! tiktok-auto v1.0.0
|
|
|
- * built 2026-10-20T08:37:51.597Z
|
|
|
- * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, idflow, main
|
|
|
+ * built 2026-10-20T09:16:16.854Z
|
|
|
+ * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
|
|
|
* 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
|
|
|
*/
|
|
|
(function () {
|
|
|
@@ -349,7 +349,9 @@ TikTokAuto.bridge = (function () {
|
|
|
if (status === 'finished') {
|
|
|
if (isIdTask) {
|
|
|
var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
|
|
|
- return '完成:提取 ' + summary.extracted + ' 个 ID' + tail;
|
|
|
+ /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
|
|
|
+ var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
|
|
|
+ return '完成:提取 ' + summary.extracted + ' 个 ID' + tail + mapped;
|
|
|
}
|
|
|
var watched = summary ? summary.attemptedVideos : 0;
|
|
|
var posted = summary ? summary.postedComments : 0;
|
|
|
@@ -477,7 +479,14 @@ TikTokAuto.config = (function () {
|
|
|
historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
|
|
|
dedupeHistory: true,
|
|
|
dryRun: false,
|
|
|
- limit: 0
|
|
|
+ limit: 0,
|
|
|
+ /**
|
|
|
+ * 抓完之后是否把 username 换成 TikTok 的**数字 uid**,另存一份映射文件。
|
|
|
+ *
|
|
|
+ * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
|
|
|
+ * 每个约 2.5 秒(要下整页 SSR 约 388KB),所以只要 handle 的场景可以关掉省时间。
|
|
|
+ */
|
|
|
+ resolveUid: true
|
|
|
},
|
|
|
/**
|
|
|
* 物理护栏。界面里 `limit = 0` 的语义是"不限",
|
|
|
@@ -3965,6 +3974,7 @@ TikTokAuto.uidset = (function () {
|
|
|
*/
|
|
|
function createWriter(options) {
|
|
|
var pending = [];
|
|
|
+ var collected = [];
|
|
|
var written = 0;
|
|
|
var flushFailed = false;
|
|
|
|
|
|
@@ -3996,11 +4006,19 @@ TikTokAuto.uidset = (function () {
|
|
|
/** 收一个 uid。调用方保证已经过去重。 */
|
|
|
write: function (uid) {
|
|
|
pending.push(uid);
|
|
|
+ /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。
|
|
|
+ * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题,
|
|
|
+ * 而这里的量级(几十到几千)远没到需要省内存的地步。 */
|
|
|
+ collected.push(uid);
|
|
|
written += 1;
|
|
|
if (pending.length >= FLUSH_EVERY) {
|
|
|
flush();
|
|
|
}
|
|
|
},
|
|
|
+ /** 本轮收到的全部 uid(按收取顺序) */
|
|
|
+ list: function () {
|
|
|
+ return collected.slice(0);
|
|
|
+ },
|
|
|
/** 已写多少个 */
|
|
|
count: function () {
|
|
|
return written;
|
|
|
@@ -5523,6 +5541,409 @@ TikTokAuto.idsource = (function () {
|
|
|
}());
|
|
|
|
|
|
|
|
|
+/* ============================== src/resolve.js ============================== */
|
|
|
+/**
|
|
|
+ * 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()) {
|
|
|
+ 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
|
|
|
+ };
|
|
|
+}());
|
|
|
+
|
|
|
+
|
|
|
/* ============================== src/idflow.js ============================== */
|
|
|
/**
|
|
|
* idflow.js — ID 提取的编排
|
|
|
@@ -5607,7 +6028,9 @@ TikTokAuto.idflow = (function () {
|
|
|
dir: output.dir || '/sdcard/Download/tiktok_app',
|
|
|
historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
|
|
|
dedupeHistory: output.dedupeHistory !== false,
|
|
|
- dryRun: output.dryRun === true
|
|
|
+ dryRun: output.dryRun === true,
|
|
|
+ /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
|
|
|
+ resolveUid: output.resolveUid !== false
|
|
|
},
|
|
|
limit: Number(output.limit) || 0,
|
|
|
maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000
|
|
|
@@ -5717,6 +6140,43 @@ TikTokAuto.idflow = (function () {
|
|
|
return profile;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 收尾阶段:把本轮抓到的 username 换成数字 uid,另存一份映射文件。
|
|
|
+ *
|
|
|
+ * 产物分工:
|
|
|
+ * `uid_*.txt` 一行一个 username —— 给"搜人/私信"这类下游用
|
|
|
+ * `uid_map_*.txt` `username<TAB>uid` 两列 —— 给"要平台主键"的下游用
|
|
|
+ * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
|
|
|
+ */
|
|
|
+ function resolvePhase(target, settings) {
|
|
|
+ var names = writer.list();
|
|
|
+ if (names.length === 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var mapFile = settings.output.dir + '/' +
|
|
|
+ TikTokAuto.parse.timestampName('uid_map', new Date());
|
|
|
+
|
|
|
+ log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
|
|
|
+ var result = TikTokAuto.resolve.resolveAll(names, {
|
|
|
+ shouldStop: shouldStop,
|
|
|
+ onProgress: function (done, total) {
|
|
|
+ progress('解析 uid ' + done + '/' + total, target.extracted);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ target.resolved = result.map.length;
|
|
|
+ target.unresolved = result.failed.length;
|
|
|
+ if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
|
|
|
+ target.mapFile = mapFile;
|
|
|
+ }
|
|
|
+ if (result.failed.length > 0) {
|
|
|
+ target.warnings.push(result.failed.length +
|
|
|
+ ' 个账号换不到数字 uid(账号可能已注销,详见日志)');
|
|
|
+ }
|
|
|
+ log.step('idflow', 'uid 解析完成:成功 ' + result.map.length +
|
|
|
+ ' / 失败 ' + result.failed.length);
|
|
|
+ }
|
|
|
+
|
|
|
/** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
|
|
|
function handleRow(row) {
|
|
|
var parse = TikTokAuto.parse;
|
|
|
@@ -5905,6 +6365,27 @@ TikTokAuto.idflow = (function () {
|
|
|
if (!summary.ok && summary.errors.length === 0) {
|
|
|
summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
|
|
|
}
|
|
|
+
|
|
|
+ /* —— 收尾:username → 数字 uid ——
|
|
|
+ * 数字 uid 在 App 界面上任何地方都读不到,只能取网页版 SSR(见 resolve.js)。
|
|
|
+ * 它比抓取慢(每个约 2.5 秒)且依赖网络,所以三条边界:
|
|
|
+ * ① 只在**主产物已经落盘之后**做(先 close 一次,保证文件完整);
|
|
|
+ * ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
|
|
|
+ * ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
|
|
|
+ */
|
|
|
+ if (settings.output.resolveUid && summary.extracted > 0 && !settings.output.dryRun) {
|
|
|
+ try {
|
|
|
+ writer.close();
|
|
|
+ resolvePhase(summary, settings);
|
|
|
+ } catch (error) {
|
|
|
+ if (TikTokAuto.bridge.isStopSignal(error)) {
|
|
|
+ summary.warnings.push('解析 uid 阶段被停止,映射文件只含已换到的部分');
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ log.warn('idflow', '解析 uid 失败(不影响已抓到的 username):' + error);
|
|
|
+ summary.warnings.push('解析 uid 失败:' + error);
|
|
|
+ }
|
|
|
+ }
|
|
|
} finally {
|
|
|
/* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
|
|
|
* "点了停止但文件是空的"是最不能接受的一种失败。 */
|