Ver código fonte

抓完自动生成 username → 数字 uid 映射(三级降级,吸收 tiktok-uid和视频短链解析.js)

抓取产物里存的是 username(@handle),而平台主键是数字 uid,两者完全不同:
  uniqueId  beautifulsoul78869      能搜到人、能 @,界面上可见
  nickname  🌹Beautiful Soul💀     显示名,可重名、可随时改
  id        254801657882882048     平台内部主键,**任何界面都不显示**

数字 uid 只能走 HTTP。新模块 src/resolve.js 在抓取完成后补这一步,取数策略
照搬 tiktok-uid和视频短链解析.js 的三级降级(轻 → 重):

  ① api/user/detail/?uniqueId=    2026-10-20 实测:200 但响应体为空(风控静默拒绝)
  ② node/share/user/@handle       实测:403
  ③ @handle 整页 SSR              ✅ 200 / 388KB / ~2 秒 / JSON.parse 7ms

三级都实现着,且**每条只探一次**:第一个账号证明 ①② 不可用后,本次运行内不再试
—— 只有第一个账号付这个代价,之后每个直接走 ③。将来接口恢复了第一次就会用轻的,
日志里的 `(via api)` 就是证据。

从那个脚本吸收的两个实现细节(我原来的版本没有):
- 区分正常流与错误流:getInputStream() 在 4xx 时直接抛异常,会把「账号不存在」
  误判成「网络错误」→ 改成 code>=400 ? getErrorStream() : getInputStream()
- ttwid:首页 Set-Cookie 取一次复用(①② 需要,③ 不需要)

其它
- 解析 __UNIVERSAL_DATA_FOR_REHYDRATION__ 的 userInfo.user,不写死键路径
  (层级改过),走到第一处带 user.id 的 userInfo 就停;JSON 失败还有正则兜底
- 拿到的 uniqueId 必须与请求的 handle 一致,否则丢弃(网页版会因别名重定向)
- HTTP 用 Java HttpURLConnection(AutoX 的 http.get 在本机坏了:
  Cannot find function newCall)
- 串行 + 400~900ms 随机间隔,不并发(网页版接口高频会被要求过验证码)
- 换不到的不写映射文件,只进日志与 warnings;**失败不影响主产物**
- 开关 id.output.resolveUid(默认开);summary 新增 resolved / unresolved / mapFile

产物(两份并存,用途不同)
  uid_YYYYMMDD_HHmm.txt      一行一个 username —— 给"搜人/私信"用
  uid_map_YYYYMMDD_HHmm.txt  username<TAB>数字uid —— 给"要平台主键"用

真机验证(3 个真实账号 + 1 个格式合法但不存在的)
  beautifulsoul78869 -> 254801657882882048
  ttbeauties6        -> 7666124051215025169
  beautifulgirl12e   -> 7604508067115779086
  zzzz_not_exist_12345 -> 页面里没有用户数据(只进 warning,映射文件里不含它)
梦境 1 semana atrás
pai
commit
2ef4449b4f

+ 46 - 0
tiktok-auto/README.md

@@ -247,6 +247,7 @@ tiktok-auto/
 |---|---|
 | `/sdcard/Download/tiktok_app/uid_YYYYMMDD_HHmm.txt` | 本次新收的 uid,一行一个 |
 | `/sdcard/Download/tiktok_app/uid_extracted.txt` | 累计(跨轮去重基准),**只追加不删除** |
+| `/sdcard/Download/tiktok_app/uid_map_YYYYMMDD_HHmm.txt` | `username<TAB>数字uid` 两列,见 3.5.6.1 |
 
 产物**不带 `@`、无表头、无注释**(下游 UID 私信直接当输入读)。
 文件名加时分是对规格书的有意偏离:规格书只有日期,同一天跑两次会互相覆盖。
@@ -254,6 +255,51 @@ tiktok-auto/
 去重三源合一:本轮已收 + 历史已收 + 输入文本里已有的。
 **每 20 个刷一次盘**(不是结束时一次性写)—— 用户点停或脚本被杀时,已抓的部分不能全丢。
 
+#### 3.5.6.1 username 与 uid 不是一回事
+
+抓取产物里存的是 **username(@handle)**,不是数字 uid。三者的区别:
+
+| | 例子 | 用途 | 界面上能看到吗 |
+|---|---|---|---|
+| **uniqueId / handle** | `beautifulsoul78869` | 能搜到人、能 @ | ✅ 用户主页顶部 `@xxx` |
+| nickname | `🌹Beautiful Soul💀` | 显示名,可重名、可随时改 | ✅ |
+| **id(数字 uid)** | `254801657882882048` | 平台内部主键,对接口/入库要它 | ❌ **任何界面都不显示** |
+
+所以数字 uid 只能靠 HTTP 拿。`src/resolve.js` 在**抓取完成之后**做这一步,
+取数策略照搬 `tiktok-uid和视频短链解析.js` 的**三级降级**(轻 → 重):
+
+| 级 | 路径 | 2026-10-20 实测 |
+|---|---|---|
+| ① | `www.tiktok.com/api/user/detail/?uniqueId=` | **200 但响应体为空**(风控静默拒绝 —— 最容易误以为"接口还在") |
+| ② | `www.tiktok.com/node/share/user/@<handle>` | **403** |
+| ③ | `www.tiktok.com/@<handle>` 整页 SSR | ✅ 200 / 约 388KB / **约 2 秒** / `JSON.parse` 仅 7ms |
+
+三级都实现着,且**每条只探一次**:某个账号证明 ① 不可用之后,本次运行内不再尝试 ①。
+于是只有第一个账号付这个代价(约 0.5 秒),之后每个直接走 ③。
+将来接口恢复了,第一次就会自动用轻的那条 —— 日志里 `(via api)` 就是证据。
+
+其它实现要点:
+
+- 数据源:`<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__">` 里的 `userInfo.user`,
+  同时带 `id` / `uniqueId` / `secUid` / `nickname`。**不写死键路径**(层级改过),
+  走到第一处带 `user.id` 的 `userInfo` 就停;JSON 解析失败还有正则兜底
+- 拿到的 `uniqueId` 必须与请求的 handle 一致,否则丢弃(网页版对大小写/别名会重定向)
+- 串行 + 400~900ms 随机间隔(不并发:网页版接口高频请求会被要求过验证码)
+- 换不到的不写进映射文件(文件里全是可用对应关系),只在日志与 warnings 里说
+- **失败不影响主产物**:已经抓到的 username 照常落盘
+- 开关:`id.output.resolveUid`(默认开;只要 handle 的场景可以关掉省时间)
+- HTTP 用 Java `HttpURLConnection` 而不是 `http.get`,并**区分正常流与错误流**
+  (`getInputStream()` 在 4xx 时会抛异常,会把"账号不存在"误判成"网络错误")
+
+**试过但走不通的路(别再试)**:
+
+| 路径 | 结果 |
+|---|---|
+| `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` | 本机这个版本是坏的:`Cannot find function newCall`(OkHttp 绑定冲突) |
+
 ### 3.5.7 物理护栏("不限"不等于跑到没电)
 
 界面里 `limit = 0` 的语义是"不限制",但真跑起来没有上限等于跑到没电。所以引擎另加:

+ 486 - 5
tiktok-auto/dist/tiktok-auto.js

@@ -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 {
             /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
              * "点了停止但文件是空的"是最不能接受的一种失败。 */

+ 3 - 1
tiktok-auto/src/bridge.js

@@ -261,7 +261,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;

+ 8 - 1
tiktok-auto/src/config.js

@@ -54,7 +54,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` 的语义是"不限",

+ 61 - 1
tiktok-auto/src/idflow.js

@@ -81,7 +81,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
@@ -191,6 +193,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;
@@ -379,6 +418,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 {
             /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
              * "点了停止但文件是空的"是最不能接受的一种失败。 */

+ 400 - 0
tiktok-auto/src/resolve.js

@@ -0,0 +1,400 @@
+/**
+ * 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
+    };
+}());

+ 9 - 0
tiktok-auto/src/uidset.js

@@ -102,6 +102,7 @@ TikTokAuto.uidset = (function () {
      */
     function createWriter(options) {
         var pending = [];
+        var collected = [];
         var written = 0;
         var flushFailed = false;
 
@@ -133,11 +134,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;

+ 1 - 0
tiktok-auto/tools/build.js

@@ -58,6 +58,7 @@ const MODULE_ORDER = [
     'filters',
     'profile',
     'idsource',
+    'resolve',
     'idflow'
 ];