/** * ============================================================================ * TikTok 登录态(Cookie)提取器 —— shell + root,无需无障碍 * * 产出:Cookie 串写入 /sdcard/Download/tiktok_data/tiktok_cookie.txt * tiktok_stats.js 会通过 CONFIG.cookieFile 自动读取。 * * ⚠ Rhino 引擎不允许脚本顶层使用 return,否则启动即报 "invalid return" * 并 0 秒退出 —— 所有逻辑已收进函数内。 * * --------------------------------------------------------------------------- * 【云手机上的两个大坑 —— 已解决,勿回退】 * * 坑 1:mount namespace 隔离(为什么 test -d /data/data/xxx 返回 NO) * 云机会把 /data/data 用 tmpfs 重新挂载成一个**空目录**, * 只把 Auto.js 自己和 GMS 的目录 bind 进去: * tmpfs on /data/data type tmpfs (mode=751) * /dev/loop2 on /data/data/org.autojs.autojspro * /dev/loop2 on /data/data/com.google.android.gms * 所以 Auto.js 里 `ls /data/data/` 只能看到这两个包, * TikTok 的目录**根本不存在**(不是权限问题,是挂载视图问题)。 * → 用 /proc/1/root 穿透到 init 的挂载视图: * /proc/1/root/data/user/0/com.zhiliaoapp.musically/… * /proc//root 是 magic symlink,会跟随目标进程的 mount ns, * **不需要 CAP_SYS_ADMIN**(nsenter 需要,所以优先用这个)。 * * 坑 2:Cookie 存储不是明文(为什么 grep 命中 0 项) * · shared_prefs/ttnetCookieStore.xml 的值是 **Java 序列化的十六进制** * ACED0005… * 根本没有明文 "sessionid=",grep 永远找不到。 * 字节流结构:[TC_STRING 名称][TC_STRING 值][TC_STRING 域名][TC_STRING 路径] * → 取第 2 个 TC_STRING(0x74) 即 cookie 值。 * · app_webview/Default/Cookies 是 SQLite,name 与 value **分列存储**, * grep "sessionid=xxx" 同样匹配不到 → 必须用 SQL 查。 * * 另外:files.* / java.io.* 在 Auto.js **进程 uid** 下运行(非 root), * 读不到 /proc/1/root 下的文件 → 必须先用 root shell `cp` 到 /sdcard。 * ============================================================================ */ // ============================== CONFIG ====================================== var CONFIG = { pkg: "com.zhiliaoapp.musically", outFile: "/sdcard/Download/tiktok_data/tiktok_cookie.txt", workDir: "/sdcard/Download/tiktok_data/_extract", verify: true, testAwemeId: "7552202593888603410" }; // 输出顺序(优先级从高到低);不在表内的 Cookie 会按字母序追加在后面 var PRIORITY_KEYS = [ "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" ]; // ============================== root shell ================================== function sh(cmd) { try { var r = shell(cmd, true); var out = ""; if (r && typeof r.result === "string") out = r.result; else if (typeof r === "string") out = r; return out || ""; } catch (e) { console.log("[sh] 执行失败: " + e); return ""; } } function shTrim(cmd) { return sh(cmd).replace(/^\s+|\s+$/g, ""); } // ============================== 数据目录定位 ================================= // 注意:pm/dumpsys 返回的是**宿主视图**路径(/data/user/0/), // 要读它必须加 /proc/1/root 前缀。 function pmDataDir(PKG) { var out = sh("pm dump " + PKG + " 2>&1 | grep -i -m1 datadir"); var m = out.match(/dataDir=([^\s,]+)/); if (m) return m[1]; var out2 = sh("dumpsys package " + PKG + " 2>&1 | grep -i -m1 datadir"); var m2 = out2.match(/dataDir=([^\s,]+)/); return m2 ? m2[1] : ""; } /** * 命名空间穿透策略(按顺序探测): * prefix : 直接加 /proc/1/root 前缀 —— magic symlink,**不需要 CAP_SYS_ADMIN**,首选 * nsenter : 用 nsenter 切到 init 的 mount ns —— 需要 CAP_SYS_ADMIN,作兜底 * direct : 不加前缀(普通真机,本来就能直接读) */ var NS_STRATEGIES = [ { kind: "prefix", root: "/proc/1/root", label: "/proc/1/root 前缀" }, { kind: "nsenter", mount: "/proc/1/ns/mnt", label: "nsenter 切换 mount ns" }, { kind: "direct", label: "直接路径" } ]; var NS = null; // 把宿主路径包装成当前策略下 shell 可读的路径片段(含引号) function nsPath(p) { if (NS && NS.kind === "prefix") return "'" + NS.root + p + "'"; return "'" + p + "'"; } // 把命令包装成当前策略下可执行的命令 function nsCmd(cmd) { if (NS && NS.kind === "nsenter") { return "nsenter --mount=" + NS.mount + " -- sh -c '" + cmd.replace(/'/g, "'\\''") + "'"; } return cmd; } // 在当前策略下测试路径是否存在 function nsTest(p) { return shTrim(nsCmd("test -d " + nsPath(p) + " && echo YES || echo NO")).indexOf("YES") !== -1; } // 依次探测可用策略 function probeNs(dataDir) { for (var i = 0; i < NS_STRATEGIES.length; i++) { NS = NS_STRATEGIES[i]; var ok = nsTest(dataDir + "/shared_prefs") || nsTest(dataDir + "/app_webview"); console.log("[ns] " + NS.label + " -> " + (ok ? "✓ 可用" : "✗ 不可用")); if (ok) return NS; } NS = null; return null; } // ============================== 字节流解析 ================================== function hexToBytes(hex) { var n = Math.floor(hex.length / 2); var arr = new Array(n); for (var i = 0; i < n; i++) arr[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); return arr; } function utf8Decode(bytes, start, len) { var s = "", i = start, end = start + len; while (i < end) { var c = bytes[i++]; if (c < 0x80) s += String.fromCharCode(c); else if ((c & 0xE0) === 0xC0 && i < end) { s += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i++] & 0x3F)); } else if ((c & 0xF0) === 0xE0 && i + 1 < end) { s += String.fromCharCode(((c & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F)); } else if ((c & 0xF8) === 0xF0 && i + 2 < end) { var cp = ((c & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F); cp -= 0x10000; s += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)); } else s += "?"; } return s; } // 顺序扫描 Java 序列化字节流,抽取全部 TC_STRING(0x74) function scanTcStrings(bytes) { var out = []; var i = 0; while (i < bytes.length) { var m = bytes[i]; if (m === 0x74) { // TC_STRING if (i + 2 >= bytes.length) break; var len = (bytes[i + 1] << 8) | bytes[i + 2]; if (i + 3 + len > bytes.length) { i++; continue; } out.push(utf8Decode(bytes, i + 3, len)); i += 3 + len; } else if (m === 0x77) { // TC_BLOCKDATA + 1B len i += 2 + bytes[i + 1]; } else if (m === 0x7a) { // TC_BLOCKDATALONG + 4B len i += 5 + ((bytes[i + 1] << 24) | (bytes[i + 2] << 16) | (bytes[i + 3] << 8) | bytes[i + 4]); } else { // TC_NULL / TC_ENDBLOCKDATA / 其它 i++; } } return out; } /** * 解析 ttnetCookieStore.xml * 形如: ACED0005… * 值解码后前两个 TC_STRING 就是 [cookie名, cookie值]。 */ function parseTtnetXml(xmlText) { var map = {}; var re = /([0-9A-Fa-f]+)<\/string>/g; var m, n = 0; while ((m = re.exec(xmlText)) !== null) { var key = m[1], hex = m[2]; var hostKey = key.split("|")[0]; var strs; try { strs = scanTcStrings(hexToBytes(hex)); } catch (e) { continue; } if (strs.length < 2) continue; var name = strs[0], value = strs[1]; if (!name || !value || value.length < 1) continue; // 同名取更长的一个 if (!map[name] || value.length > map[name].value.length) { map[name] = { value: value, domain: hostKey }; } n++; } console.log("[ttnet] 解析条目 " + n + " 个,去重后 Cookie 名 " + countKeys(map) + " 个"); return map; } function countKeys(o) { var c = 0; for (var k in o) if (o.hasOwnProperty(k)) c++; return c; } /** * 解析 WebView 的 Cookies SQLite(name / value 分列) * 用 Android 内置 SQLiteDatabase 直接查(Auto.js 是 Java 环境,可直接调用) */ function parseWebviewCookies(dbPath) { var map = {}; var db = null, cur = null; try { var SQLiteDatabase = android.database.sqlite.SQLiteDatabase; db = SQLiteDatabase.openDatabase(dbPath, null, 0); // 0 = OPEN_READWRITE cur = db.rawQuery("SELECT name, value, host_key, length(value) FROM cookies", null); var n = 0, total = 0; while (cur.moveToNext()) { total++; var name = cur.getString(0); var value = cur.getString(1); if (!name || value === null || value === "") continue; // 跳过无值行 var host = cur.getString(2) || ""; if (!map[name] || value.length > map[name].value.length) { map[name] = { value: value, domain: host }; } n++; } console.log("[sqlite] 读到 " + total + " 行,有效 Cookie 名 " + countKeys(map) + " 个"); } catch (e) { console.log("[sqlite] 解析失败(不影响主流程): " + e); } finally { try { if (cur) cur.close(); } catch (e2) {} try { if (db) db.close(); } catch (e3) {} } return map; } // ============================== 合并 & 输出 ================================= function mergeInto(dst, src) { for (var k in src) { if (!src.hasOwnProperty(k)) continue; if (!dst[k] || src[k].value.length > dst[k].value.length) dst[k] = src[k]; } return dst; } function buildCookieString(map) { var parts = [], used = {}; var i, k; for (i = 0; i < PRIORITY_KEYS.length; i++) { k = PRIORITY_KEYS[i]; if (map[k]) { parts.push(k + "=" + map[k].value); used[k] = true; } } // 其余按字母序追加 var rest = []; for (k in map) if (map.hasOwnProperty(k) && !used[k]) rest.push(k); rest.sort(); for (i = 0; i < rest.length; i++) parts.push(rest[i] + "=" + map[rest[i]].value); return parts.join("; "); } function mask(v) { if (!v) return ""; if (v.length <= 12) return v; return v.substring(0, 8) + "..." + v.substring(v.length - 4) + " (len=" + v.length + ")"; } // ============================== 登录态有效性实测 ============================= function readBody(conn) { try { var code = conn.getResponseCode(); var stream = (code >= 400) ? conn.getErrorStream() : conn.getInputStream(); if (!stream) { conn.disconnect(); return ""; } var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8")); var sb = new java.lang.StringBuilder(); var line; while ((line = reader.readLine()) !== null) sb.append(line); try { reader.close(); } catch (e1) {} conn.disconnect(); return sb.toString(); } catch (e) { try { conn.disconnect(); } catch (e2) {} return ""; } } function verifyCookie(cookieStr) { if (!CONFIG.verify || !cookieStr) return; console.log("[verify] 用登录态实测官方评论接口 ..."); try { var url = "https://www.tiktok.com/api/comment/list/?aid=1988&app_name=tiktok_web" + "&device_platform=web_pc&aweme_id=" + CONFIG.testAwemeId + "&cursor=0&count=20&item_type=0&from_page=video"; var conn = new java.net.URL(url).openConnection(); conn.setConnectTimeout(10000); conn.setReadTimeout(10000); conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); conn.setRequestProperty("Accept", "application/json, text/plain, */*"); conn.setRequestProperty("Referer", "https://www.tiktok.com/"); conn.setRequestProperty("Cookie", cookieStr); var code = conn.getResponseCode(); var body = readBody(conn); if (!body || body.replace(/\s/g, "") === "") { console.log("[verify] HTTP " + code + " 响应为空 —— 官方接口需 A-Bogus 签名,与登录态无关(预期内)"); console.log("[verify] 评论请走 tiktok_stats.js 的镜像接口(已实测可用)"); return; } var obj = null; try { obj = JSON.parse(body); } catch (e) {} if (obj && obj.comments && obj.comments.length > 0) { console.log("[verify] ✓ 拿到 " + obj.comments.length + " 条评论,Cookie 有效"); } else if (obj) { console.log("[verify] 返回 status_code=" + obj.status_code + "(官方接口被签名风控拦截,属预期)"); } } catch (e) { console.log("[verify] 请求异常: " + e); } } // ============================== 主流程 ====================================== function main() { try { console.show(); } catch (e) {} console.log("==== TikTok 登录态提取(root)开始 ===="); // --- 1. root 校验 --- var idOut = shTrim("id"); if (idOut.indexOf("uid=0") === -1) { console.log("✗ 未获取 root(id 输出: " + idOut + ")"); console.log(" 请在 Auto.js 中授予 root 授权后重试。"); return; } console.log("[root] ✓ " + idOut); // --- 2. 定位数据目录(宿主视图路径)--- var PKG = CONFIG.pkg; var dataDir = pmDataDir(PKG); if (!dataDir) { var cands = ["/data/user/0/" + PKG, "/data/data/" + PKG, "/data/user_de/0/" + PKG]; for (var c = 0; c < cands.length; c++) { if (shTrim("test -e " + cands[c] + " && echo YES || echo NO").indexOf("YES") !== -1 || shTrim("test -e /proc/1/root" + cands[c] + " && echo YES || echo NO").indexOf("YES") !== -1) { dataDir = cands[c]; break; } } } if (!dataDir) { console.log("✗ 未能定位 TikTok 数据目录(App 是否已安装?)"); return; } console.log("[dir] 宿主路径 = " + dataDir); // --- 3. 探测命名空间穿透策略(云机必须穿透 mount ns 才能看到 TikTok 目录)--- var ns = probeNs(dataDir); if (!ns) { console.log("✗ 所有命名空间策略都读不到该目录。"); console.log(" 诊断: ls -ld " + dataDir + " -> " + shTrim("ls -ld " + dataDir + " 2>&1")); console.log(" ls /data/data -> " + sh("ls /data/data 2>&1").replace(/\n/g, " | ").replace(/^\s+|\s+$/g, "")); console.log(" 若 ls /data/data 只列出很少的包,说明本机有 mount namespace 隔离。"); console.log(" 请把以上输出发我进一步定位。"); return; } console.log("[ns] 采用: " + ns.label); // --- 4. 用 root shell 把 Cookie 存储复制到 /sdcard --- // (files.* / java.io.* 在 App 进程 uid 下运行,读不了 /proc/1/root,必须先 cp 出来) sh("mkdir -p " + CONFIG.workDir); sh("mkdir -p " + CONFIG.outFile.substring(0, CONFIG.outFile.lastIndexOf("/"))); function stage(relPath, dstName) { var src = dataDir + "/" + relPath; var dst = CONFIG.workDir + "/" + dstName; sh(nsCmd("cp " + nsPath(src) + " '" + dst + "' 2>/dev/null; chmod 666 '" + dst + "' 2>/dev/null")); var ok = shTrim("test -s '" + dst + "' && echo YES || echo NO").indexOf("YES") !== -1; console.log("[stage] " + relPath + " -> " + (ok ? "✓ " + dst : "✗ 不存在/复制失败")); return ok ? dst : ""; } var xmlPath = stage("shared_prefs/ttnetCookieStore.xml", "ttnetCookieStore.xml"); var dbPath = stage("app_webview/Default/Cookies", "Cookies"); // WAL/SHM 一并带上,避免读到过期快照 stage("app_webview/Default/Cookies-wal", "Cookies-wal"); stage("app_webview/Default/Cookies-shm", "Cookies-shm"); if (!xmlPath && !dbPath) { console.log("✗ 两个 Cookie 存储都拿不到。请确认 TikTok 已登录并至少打开过一次。"); return; } // --- 5. 解析 --- var map = {}; if (xmlPath) { try { mergeInto(map, parseTtnetXml(String(files.read(xmlPath)))); } catch (e) { console.log("[ttnet] 读取/解析失败: " + e); } } if (dbPath) mergeInto(map, parseWebviewCookies(dbPath)); // --- 6. 输出 --- var cookieStr = buildCookieString(map); console.log("==== 提取结果 ===="); var keys = []; for (var k in map) if (map.hasOwnProperty(k)) keys.push(k); keys.sort(); for (var i = 0; i < keys.length; i++) { console.log(" " + keys[i] + " = " + mask(map[keys[i]].value)); } var essential = ["sessionid", "sid_tt", "uid_tt", "msToken"]; var miss = []; for (var e = 0; e < essential.length; e++) if (!map[essential[e]]) miss.push(essential[e]); if (keys.length === 0) { console.log("✗ 未提取到任何 Cookie。请确认 TikTok 已登录。"); return; } if (miss.length > 0) { console.log("⚠ 缺少关键项: " + miss.join(", ") + " —— 可能未登录或登录态已过期"); } else { console.log("✓ 关键登录态齐全 (sessionid / sid_tt / uid_tt / msToken)"); } try { files.write(CONFIG.outFile, cookieStr); console.log("[save] ✓ 已写入: " + CONFIG.outFile + " (" + cookieStr.length + " 字符)"); console.log("[save] tiktok_stats.js 会通过 CONFIG.cookieFile 自动读取"); } catch (e3) { console.log("[save] 写入失败: " + e3); } verifyCookie(cookieStr); console.log("==== 完成 ===="); } main();