/** * ============================================================================ * TikTok 登录态提取(PC 侧 / Node.js + adb) * * 用途:跟 Auto.js 版(tiktok_extract_cookie.js)等价的 PC 备用通道。 * 在设备有 mount namespace 隔离、且 Auto.js 侧不便运行时,从 PC 侧 * `adb shell su 0` 提取(adb shell 是宿主完整 root 视图)。 * * Cookie 来源(双源互相校验): * 1) /shared_prefs/ttnetCookieStore.xml * 值 = Java 序列化的 hex(ACED0005…),不是明文! * 字节流结构:[TC_STRING 名称][TC_STRING 值][TC_STRING 域名][TC_STRING 路径] * → 取第 2 个 TC_STRING(0x74) 即 cookie 值。 * 2) /app_webview/Default/Cookies (SQLite,name/value 分列) * → 用 node:sqlite 读(Node 22 内置,可能需要 --experimental-sqlite) * * 【运行】 * node tiktok_extract_cookie_pc.js * 前置:adb 已 connect 设备、设备已 root、TikTok 已登录。 * 产出:/sdcard/Download/tiktok_data/tiktok_cookie.txt(tiktok_stats.js 自动读取) * ============================================================================ */ const { spawnSync } = require("child_process"); const fs = require("fs"); const path = require("path"); // ============================== 配置 ======================================== const CFG = { pkg: "com.zhiliaoapp.musically", adbSerial: "", // 多设备时填,如 "106.119.179.124:23098";单设备留空 remoteDir: "/sdcard/Download/tiktok_data", tmpDir: path.join(__dirname, "_extract_tmp"), remoteTmp: "/sdcard/Download/_ttnet_dump", cookieFileRemote: "/sdcard/Download/tiktok_data/tiktok_cookie.txt", // 最终 Cookie 串的 key 顺序(优先级从高到低) priorityKeys: [ "sessionid", "sessionid_ss", "sid_tt", "uid_tt", "uid_tt_ss", "sid_guard", "msToken", "odin_tt", "passport_csrf_token", "passport_csrf_token_default", "multi_sids", "tt-target-idc", "store-idc", "ttwid", "s_v_web_id", "cmpl_token", "d_ticket", "install_id", "ttreq" ] }; // ============================== adb 封装 ==================================== function adb(parts) { const s = CFG.adbSerial ? ["-s", CFG.adbSerial] : []; const argv = ["adb"].concat(s, parts); const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); const out = (r.stdout || "") + (r.stderr || ""); if (r.error && !out) throw r.error; return out; } function adbShell(cmdStr) { return adb(["shell", cmdStr]); } // 用 su 0 执行(本设备 su 不支持 -c,必须 su 0 sh -c '...') function su(cmdStr) { return adbShell("su 0 sh -c '" + cmdStr.replace(/'/g, "'\\''") + "'"); } function mask(v) { if (!v) return ""; return v.length <= 14 ? v : v.slice(0, 10) + "..." + v.slice(-4) + " (len=" + v.length + ")"; } // ============================== Java 序列化 hex 解析 ========================= // 顺序扫描字节流,抽取全部 TC_STRING(0x74) function scanTcStrings(buf) { const out = []; let i = 0; while (i < buf.length) { const m = buf[i]; if (m === 0x74) { // TC_STRING if (i + 2 >= buf.length) break; const len = buf.readUInt16BE(i + 1); if (i + 3 + len > buf.length) { i++; continue; } out.push(buf.slice(i + 3, i + 3 + len).toString("utf8")); i += 3 + len; } else if (m === 0x77) { // TC_BLOCKDATA + 1B len i += 2 + buf[i + 1]; } else if (m === 0x7a) { // TC_BLOCKDATALONG + 4B len i += 5 + buf.readUInt32BE(i + 1); } else i++; // TC_NULL/ENDBLOCKDATA/… } return out; } function parseTtnetXml(xml) { const map = {}; const re = /([0-9A-Fa-f]+)<\/string>/g; let m, n = 0; while ((m = re.exec(xml)) !== null) { const key = m[1], hex = m[2]; const host = key.split("|")[0]; let strs; try { strs = scanTcStrings(Buffer.from(hex, "hex")); } catch (e) { continue; } if (strs.length < 2) continue; const name = strs[0], value = strs[1]; if (!name || !value) continue; if (!map[name] || value.length > map[name].value.length) map[name] = { value, domain: host }; n++; } console.log("[xml] 解析条目 " + n + " 个,去重后 Cookie 名 " + Object.keys(map).length + " 个"); return map; } // 读 WebView 的 Cookies SQLite(可选,失败不影响) function parseWebviewCookies(dbPath) { const map = {}; let sqlite; try { sqlite = require("node:sqlite"); } catch (e) { console.log("[sqlite] node:sqlite 不可用(Node 22 需加 --experimental-sqlite),跳过"); return map; } try { const db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); const rows = db.prepare("SELECT name, value, host_key FROM cookies").all(); let n = 0; for (const r of rows) { if (!r.name || !r.value) continue; if (!map[r.name] || r.value.length > map[r.name].value.length) { map[r.name] = { value: r.value, domain: r.host_key || "" }; } n++; } db.close(); console.log("[sqlite] 读到 " + rows.length + " 行,有效 Cookie 名 " + Object.keys(map).length + " 个"); } catch (e) { console.log("[sqlite] 解析失败(不影响主流程): " + e.message); } return map; } function mergeInto(dst, src) { for (const k of Object.keys(src)) { if (!dst[k] || src[k].value.length > dst[k].value.length) dst[k] = src[k]; } return dst; } function buildCookieString(map) { const parts = [], used = new Set(); for (const k of CFG.priorityKeys) { if (map[k]) { parts.push(k + "=" + map[k].value); used.add(k); } } for (const k of Object.keys(map).sort()) { if (!used.has(k)) parts.push(k + "=" + map[k].value); } return parts.join("; "); } // ============================== 主流程 ====================================== function main() { console.log("==== TikTok 登录态提取(PC/adb) ====\n"); // 0) 设备检查 const devs = adb(["devices"]).trim().split("\n").slice(1) .filter(l => l.trim() && !l.startsWith("*")).map(l => l.split("\t")[0]); console.log("[adb] 在线设备: " + (devs.join(", ") || "(无)")); if (devs.length === 0) { console.log("✗ 无在线设备,请先 adb connect"); process.exit(1); } if (CFG.adbSerial && !devs.includes(CFG.adbSerial)) { console.log("✗ 指定的设备 " + CFG.adbSerial + " 不在线"); process.exit(1); } // 1) 定位数据目录(pm dump 最权威),并确认可读 let dataDir = ""; const dump = su("pm dump " + CFG.pkg + " 2>&1 | grep -i -m1 datadir"); const dm = dump.match(/dataDir=([^\s,]+)/); if (dm) dataDir = dm[1]; if (!dataDir) { for (const c of ["/data/user/0/" + CFG.pkg, "/data/data/" + CFG.pkg]) { if (su("test -d " + c + " && echo YES || echo NO").includes("YES")) { dataDir = c; break; } } } if (!dataDir) { console.log("✗ 未能定位数据目录(未 root / TikTok 未安装?)"); process.exit(1); } console.log("[dir] " + dataDir + " -> " + (su("test -d " + dataDir + "/shared_prefs && echo YES || echo NO").includes("YES") ? "可读 ✓" : "不可读 ✗")); fs.mkdirSync(CFG.tmpDir, { recursive: true }); adbShell("mkdir -p " + CFG.remoteDir); // 2) 把两个 Cookie 存储拷到 /sdcard 再 pull const stage = [ ["shared_prefs/ttnetCookieStore.xml", "ttnetCookieStore.xml"], ["app_webview/Default/Cookies", "Cookies"], ["app_webview/Default/Cookies-wal", "Cookies-wal"], ["app_webview/Default/Cookies-shm", "Cookies-shm"] ]; const staged = {}; for (const [rel, name] of stage) { const remote = CFG.remoteTmp + "_" + name; su("cp '" + dataDir + "/" + rel + "' '" + remote + "' 2>/dev/null; chmod 644 '" + remote + "' 2>/dev/null"); const local = path.join(CFG.tmpDir, name); adb(["pull", remote, local]); const ok = fs.existsSync(local) && fs.statSync(local).size > 0; console.log("[stage] " + rel + " -> " + (ok ? "✓ " + fs.statSync(local).size + " 字节" : "✗ 缺失")); if (ok) staged[name] = local; } if (!staged["ttnetCookieStore.xml"] && !staged["Cookies"]) { console.log("✗ 两个 Cookie 存储都拿不到"); process.exit(1); } // 3) 解析(双源合并) let map = {}; if (staged["ttnetCookieStore.xml"]) { map = mergeInto(map, parseTtnetXml(fs.readFileSync(staged["ttnetCookieStore.xml"], "utf8"))); } if (staged["Cookies"]) { map = mergeInto(map, parseWebviewCookies(staged["Cookies"])); } const keys = Object.keys(map).sort(); if (keys.length === 0) { console.log("✗ 未解析到任何 Cookie,TikTok 是否已登录?"); process.exit(1); } const essential = ["sessionid", "sid_tt", "uid_tt", "msToken"]; const missing = essential.filter(k => !map[k]); console.log("\n---- Cookie(脱敏)----"); for (const k of keys) { const tag = CFG.priorityKeys.includes(k) ? "★" : " "; console.log(" " + tag + " " + k.padEnd(34) + "= " + mask(map[k].value)); } console.log("\n" + (missing.length ? "⚠ 缺少关键项: " + missing.join(", ") : "✓ 关键登录态齐全 (sessionid / sid_tt / uid_tt / msToken)")); // 4) 写本地 + push 回设备 const cookieStr = buildCookieString(map); const localCookie = path.join(CFG.tmpDir, "tiktok_cookie.txt"); fs.writeFileSync(localCookie, cookieStr); console.log("\n[cookie] 串长度 " + cookieStr.length + ",段数 " + cookieStr.split("; ").length); const pushOut = adb(["push", localCookie, CFG.cookieFileRemote]); console.log("[push] " + pushOut.trim().split("\n")[0]); const back = adb(["shell", "cat " + CFG.cookieFileRemote]).trim(); console.log("[verify] 设备端长度 " + back.length + (back === cookieStr ? " ✓ 与本地一致" : " ✗ 不一致")); console.log("\n==== 完成 ===="); console.log("下一步:运行 tiktok_stats.js(会自动读取 " + CFG.cookieFileRemote + ")"); console.log("注:官方评论接口已被 A-Bogus 签名拦截,登录态对评论无帮助;"); console.log(" 评论数据由 tiktok_stats.js 走镜像接口获取。"); } main();