| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- /**
- * ============================================================================
- * 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/<pid>/root 是 magic symlink,会跟随目标进程的 mount ns,
- * **不需要 CAP_SYS_ADMIN**(nsenter 需要,所以优先用这个)。
- *
- * 坑 2:Cookie 存储不是明文(为什么 grep 命中 0 项)
- * · shared_prefs/ttnetCookieStore.xml 的值是 **Java 序列化的十六进制**
- * <string name="http://tiktok.com/|d_ticket">ACED0005…</string>
- * 根本没有明文 "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/<pkg>),
- // 要读它必须加 /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
- * 形如: <string name="http://tiktok.com/|d_ticket">ACED0005…</string>
- * 值解码后前两个 TC_STRING 就是 [cookie名, cookie值]。
- */
- function parseTtnetXml(xmlText) {
- var map = {};
- var re = /<string name="([^"]+)">([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();
|