@chainlesschain/personal-data-hub 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
- package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
- package/__tests__/adapters/finance-alipay-live.test.js +258 -0
- package/__tests__/adapters/game-genshin-live.test.js +238 -0
- package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
- package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
- package/__tests__/adapters/netease-music-live.test.js +244 -0
- package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
- package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
- package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
- package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
- package/lib/adapters/_live-json-helpers.js +50 -0
- package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
- package/lib/adapters/edu-huawei-learning/index.js +83 -9
- package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
- package/lib/adapters/edu-zuoyebang/index.js +83 -9
- package/lib/adapters/finance-alipay/api-client.js +268 -6
- package/lib/adapters/finance-alipay/index.js +85 -9
- package/lib/adapters/game-genshin/api-client.js +207 -6
- package/lib/adapters/game-genshin/index.js +90 -9
- package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
- package/lib/adapters/game-honor-of-kings/index.js +80 -9
- package/lib/adapters/netease-music/api-client.js +284 -0
- package/lib/adapters/netease-music/index.js +85 -9
- package/lib/adapters/social-douyin/index.js +2 -0
- package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
- package/lib/adapters/social-douyin-adb/collector.js +114 -0
- package/lib/adapters/social-douyin-adb/index.js +18 -1
- package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
- package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
- package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
- package/lib/adapters/social-toutiao-adb/collector.js +55 -19
- package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
- package/lib/adapters/social-toutiao-adb/index.js +6 -0
- package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
|
@@ -38,13 +38,30 @@ const {
|
|
|
38
38
|
cleanupSnapshotJson,
|
|
39
39
|
SNAPSHOT_SCHEMA_VERSION,
|
|
40
40
|
} = require("./snapshot-builder");
|
|
41
|
-
const {
|
|
41
|
+
const {
|
|
42
|
+
collect,
|
|
43
|
+
collectAndSync,
|
|
44
|
+
collectWatchHistory,
|
|
45
|
+
collectWatchHistoryAndSync,
|
|
46
|
+
} = require("./collector");
|
|
47
|
+
const {
|
|
48
|
+
createDouyinWatchExtension,
|
|
49
|
+
VIDEO_RECORD_DB_REMOTE_PATH,
|
|
50
|
+
} = require("./watch-history-reader");
|
|
51
|
+
const { AwemeDetailClient } = require("./aweme-detail-client");
|
|
42
52
|
|
|
43
53
|
module.exports = {
|
|
44
54
|
// Extension factory (wiring registers this on the bridge)
|
|
45
55
|
createDouyinDbExtension,
|
|
46
56
|
DOUYIN_DB_REMOTE_DIR,
|
|
47
57
|
IM_DB_PATTERN,
|
|
58
|
+
// Watch-history (video_record.db) extension + path
|
|
59
|
+
createDouyinWatchExtension,
|
|
60
|
+
VIDEO_RECORD_DB_REMOTE_PATH,
|
|
61
|
+
collectWatchHistory,
|
|
62
|
+
collectWatchHistoryAndSync,
|
|
63
|
+
// Aweme title resolver (web detail endpoint, no signing)
|
|
64
|
+
AwemeDetailClient,
|
|
48
65
|
// Parser + builder (also exposed for advanced callers / tests)
|
|
49
66
|
parseImDb,
|
|
50
67
|
buildSnapshot,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Douyin on-device watch-history reader — recovers the user's video view
|
|
3
|
+
* history from the app's local `video_record.db` (table `record_<uid>`), a
|
|
4
|
+
* plaintext SQLite DB.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (real-device 2026-06-11, device 5lhyaqu8lbwstc6x):
|
|
7
|
+
* - Douyin's DM db is SQLCipher-encrypted (needs frida) and the web watch-
|
|
8
|
+
* history endpoint needs X-Bogus signing — both heavy/fragile.
|
|
9
|
+
* - But `/data/data/com.ss.android.ugc.aweme/databases/video_record.db`
|
|
10
|
+
* stores `record_<uid> { aid, view_time_timestamp, enter_from, ... }` in
|
|
11
|
+
* PLAINTEXT — 900 rows on the test device. This is "what/when the user
|
|
12
|
+
* watched", the core family-guard signal, with zero signing/encryption.
|
|
13
|
+
*
|
|
14
|
+
* Emits snapshot `history` events ({kind, awemeId, capturedAt, enterFrom}) the
|
|
15
|
+
* social-douyin adapter already understands (KIND_HISTORY → BROWSE event), so
|
|
16
|
+
* normalize is reused. Title/author aren't local (would need a web lookup);
|
|
17
|
+
* the behavioral signal (which aweme, when, from which surface) is the value.
|
|
18
|
+
*
|
|
19
|
+
* Pull mirrors social-toutiao-adb/account-reader.pullAccountDbViaSu (su base64
|
|
20
|
+
* stream, MIUI-safe). DB read reuses the bilibili dual-load sqlite open.
|
|
21
|
+
*/
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const os = require("node:os");
|
|
26
|
+
const path = require("node:path");
|
|
27
|
+
const crypto = require("node:crypto");
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
_internals: { loadDatabaseClass },
|
|
31
|
+
} = require("../social-bilibili-adb/chromium-cookies-reader");
|
|
32
|
+
|
|
33
|
+
const DOUYIN_PACKAGE = "com.ss.android.ugc.aweme";
|
|
34
|
+
const VIDEO_RECORD_DB_REMOTE_PATH =
|
|
35
|
+
"/data/data/com.ss.android.ugc.aweme/databases/video_record.db";
|
|
36
|
+
|
|
37
|
+
/** seconds-or-ms epoch → ms (heuristic: > 1e12 ⇒ already ms). */
|
|
38
|
+
function toEpochMs(v) {
|
|
39
|
+
const n = Number(v);
|
|
40
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
41
|
+
return n > 1e12 ? Math.floor(n) : Math.floor(n * 1000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function pullVideoRecordDbViaSu(adb, serial, opts = {}) {
|
|
45
|
+
const adbOpts = { serial, timeoutMs: opts.timeoutMs || 60_000 };
|
|
46
|
+
const lsOut = await adb(
|
|
47
|
+
["shell", "su", "-c", `ls ${VIDEO_RECORD_DB_REMOTE_PATH} 2>/dev/null || echo NOT_FOUND`],
|
|
48
|
+
adbOpts,
|
|
49
|
+
);
|
|
50
|
+
const lsLine = lsOut.replace(/\r+$/gm, "").trim();
|
|
51
|
+
if (lsLine === "NOT_FOUND" || lsLine === "") {
|
|
52
|
+
const pmOut = await adb(
|
|
53
|
+
["shell", "su", "-c", `pm list packages ${DOUYIN_PACKAGE}`],
|
|
54
|
+
adbOpts,
|
|
55
|
+
);
|
|
56
|
+
const installed = pmOut.replace(/\r/g, "").includes(`package:${DOUYIN_PACKAGE}`);
|
|
57
|
+
throw new Error(
|
|
58
|
+
installed
|
|
59
|
+
? "DOUYIN_VIDEO_RECORD_MISSING: 抖音已安装但无 video_record.db(未观看过视频?)— " +
|
|
60
|
+
VIDEO_RECORD_DB_REMOTE_PATH +
|
|
61
|
+
" 不存在。"
|
|
62
|
+
: "DOUYIN_NOT_INSTALLED: " + VIDEO_RECORD_DB_REMOTE_PATH + " not found and package not installed.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
|
|
66
|
+
const idLine = idOut.replace(/\r+$/gm, "").trim();
|
|
67
|
+
if (idLine !== "0" && !idLine.includes("uid=0")) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"DOUYIN_NO_ROOT: su not uid 0 (`" + idLine.substring(0, 60) + "`); root required to read video_record.db.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const b64 = await adb(
|
|
73
|
+
["shell", "su", "-c", `base64 ${VIDEO_RECORD_DB_REMOTE_PATH} | tr -d '\\n\\r'`],
|
|
74
|
+
{ ...adbOpts, timeoutMs: opts.timeoutMs || 60_000 },
|
|
75
|
+
);
|
|
76
|
+
const b64Clean = b64.replace(/[\r\n\t ]+/g, "");
|
|
77
|
+
if (b64Clean.length === 0) {
|
|
78
|
+
throw new Error("DOUYIN_VIDEO_RECORD_EMPTY: base64 stream returned 0 bytes (su may have silently failed on MIUI).");
|
|
79
|
+
}
|
|
80
|
+
const buf = Buffer.from(b64Clean, "base64");
|
|
81
|
+
if (buf.length < 1024 || !buf.subarray(0, 15).toString("latin1").startsWith("SQLite format 3")) {
|
|
82
|
+
throw new Error("DOUYIN_VIDEO_RECORD_NOT_SQLITE: decoded file lacks `SQLite format 3` magic (" + buf.length + " bytes).");
|
|
83
|
+
}
|
|
84
|
+
const tmpFile = path.join(os.tmpdir(), `cc-douyin-vrec-${crypto.randomUUID()}.db`);
|
|
85
|
+
fs.writeFileSync(tmpFile, buf);
|
|
86
|
+
return tmpFile;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read watch records from video_record.db. Tables are named `record_<uid>`
|
|
91
|
+
* (per-account) plus an anonymous `record_0`. Picks the numeric-uid table with
|
|
92
|
+
* the most rows (the logged-in account); records carry aid + view timestamp +
|
|
93
|
+
* enter_from surface.
|
|
94
|
+
*
|
|
95
|
+
* @returns {{uid: string|null, records: Array<{awemeId,capturedAt,enterFrom}>}}
|
|
96
|
+
*/
|
|
97
|
+
function readDouyinWatchHistory(dbPath, opts = {}) {
|
|
98
|
+
const Database = opts._databaseClass || loadDatabaseClass();
|
|
99
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 5000;
|
|
100
|
+
const db = new Database(dbPath, { readonly: true });
|
|
101
|
+
try {
|
|
102
|
+
const tables = db
|
|
103
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'record\\_%' ESCAPE '\\'")
|
|
104
|
+
.all()
|
|
105
|
+
.map((t) => t.name);
|
|
106
|
+
// Candidate uid tables: record_<digits>, uid != 0. Pick the largest.
|
|
107
|
+
let best = null;
|
|
108
|
+
for (const name of tables) {
|
|
109
|
+
const m = /^record_(\d+)$/.exec(name);
|
|
110
|
+
if (!m || m[1] === "0") continue;
|
|
111
|
+
let count = 0;
|
|
112
|
+
try {
|
|
113
|
+
count = db.prepare(`SELECT COUNT(*) c FROM "${name}"`).get().c;
|
|
114
|
+
} catch (_e) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!best || count > best.count) best = { name, uid: m[1], count };
|
|
118
|
+
}
|
|
119
|
+
if (!best) return { uid: null, records: [] };
|
|
120
|
+
const cols = new Set(
|
|
121
|
+
db.prepare(`PRAGMA table_info("${best.name}")`).all().map((c) => c.name),
|
|
122
|
+
);
|
|
123
|
+
const hasEnter = cols.has("enter_from");
|
|
124
|
+
const hasTs = cols.has("view_time_timestamp");
|
|
125
|
+
const rows = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT aid${hasTs ? ", view_time_timestamp" : ""}${hasEnter ? ", enter_from" : ""} ` +
|
|
128
|
+
`FROM "${best.name}"${hasTs ? " ORDER BY view_time_timestamp DESC" : ""} LIMIT ${limit}`,
|
|
129
|
+
)
|
|
130
|
+
.all();
|
|
131
|
+
const records = [];
|
|
132
|
+
for (const r of rows) {
|
|
133
|
+
const awemeId = r.aid != null ? String(r.aid) : null;
|
|
134
|
+
if (!awemeId) continue;
|
|
135
|
+
records.push({
|
|
136
|
+
awemeId,
|
|
137
|
+
capturedAt: hasTs ? toEpochMs(r.view_time_timestamp) : null,
|
|
138
|
+
enterFrom: hasEnter ? r.enter_from || null : null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { uid: best.uid, records };
|
|
142
|
+
} finally {
|
|
143
|
+
try {
|
|
144
|
+
db.close();
|
|
145
|
+
} catch (_e) {
|
|
146
|
+
/* best-effort */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Bridge handler factory: `bridge.invoke("douyin.watch-history")` → {uid, records}. */
|
|
152
|
+
function createDouyinWatchExtension(factoryOpts = {}) {
|
|
153
|
+
const timeoutMs = factoryOpts.timeoutMs || 60_000;
|
|
154
|
+
const onCleanupFailed = factoryOpts.onCleanupFailed || (() => {});
|
|
155
|
+
return async function douyinWatchHandler(params, ctx) {
|
|
156
|
+
if (!ctx || typeof ctx.adb !== "function" || typeof ctx.pickDevice !== "function") {
|
|
157
|
+
throw new TypeError("douyin.watch-history extension: ctx must provide {adb, pickDevice}");
|
|
158
|
+
}
|
|
159
|
+
const serial = await ctx.pickDevice();
|
|
160
|
+
let tmpFile = null;
|
|
161
|
+
try {
|
|
162
|
+
tmpFile = await pullVideoRecordDbViaSu(ctx.adb, serial, { timeoutMs });
|
|
163
|
+
const result = readDouyinWatchHistory(tmpFile, {
|
|
164
|
+
limit: params && params.limit,
|
|
165
|
+
});
|
|
166
|
+
return { ...result, extractedAt: Date.now() };
|
|
167
|
+
} finally {
|
|
168
|
+
if (tmpFile) {
|
|
169
|
+
try {
|
|
170
|
+
fs.unlinkSync(tmpFile);
|
|
171
|
+
} catch (_e) {
|
|
172
|
+
onCleanupFailed(tmpFile);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
createDouyinWatchExtension,
|
|
181
|
+
VIDEO_RECORD_DB_REMOTE_PATH,
|
|
182
|
+
DOUYIN_PACKAGE,
|
|
183
|
+
_internals: {
|
|
184
|
+
pullVideoRecordDbViaSu,
|
|
185
|
+
readDouyinWatchHistory,
|
|
186
|
+
toEpochMs,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toutiao on-device account reader — recovers the numeric uid + nickname from
|
|
3
|
+
* the app's local `account_db` (table `login_info`), a plaintext SQLite DB.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (real-device 2026-06-11, device 5lhyaqu8lbwstc6x):
|
|
6
|
+
* - The web profile endpoint `passport/account/info/v2` returns error_code 16
|
|
7
|
+
* "该应用无权限" even when fully logged in — so it can't supply the uid.
|
|
8
|
+
* - The www.toutiao.com WebView cookie jar carries NO numeric uid cookie
|
|
9
|
+
* (only hashed `uid_tt`/`sid_tt`; no passport_uid/multi_sids/__ac_uid).
|
|
10
|
+
* - But `/data/data/com.ss.android.article.news/databases/account_db` stores
|
|
11
|
+
* `login_info { uid, screen_name, sec_uid, ... }` in PLAINTEXT sqlite.
|
|
12
|
+
* Reading it gives the uid that the signed collection/search endpoints need,
|
|
13
|
+
* with no permission/signature/cookie-drift fragility.
|
|
14
|
+
*
|
|
15
|
+
* Pull mechanism mirrors cookies-extension.pullCookiesViaSu (su base64 stream,
|
|
16
|
+
* MIUI-safe). DB read reuses the bilibili chromium-cookies-reader dual-load
|
|
17
|
+
* (bs3mc ABI 140 under Electron / better-sqlite3 under Node test).
|
|
18
|
+
*/
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const os = require("node:os");
|
|
23
|
+
const path = require("node:path");
|
|
24
|
+
const crypto = require("node:crypto");
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
_internals: { loadDatabaseClass },
|
|
28
|
+
} = require("../social-bilibili-adb/chromium-cookies-reader");
|
|
29
|
+
|
|
30
|
+
const TOUTIAO_PACKAGE = "com.ss.android.article.news";
|
|
31
|
+
const ACCOUNT_DB_REMOTE_PATH =
|
|
32
|
+
"/data/data/com.ss.android.article.news/databases/account_db";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pull the account_db sqlite via `su base64` into a host tmp file.
|
|
36
|
+
* @param {(args: string[], opts?: object) => Promise<string>} adb
|
|
37
|
+
* @param {string} serial
|
|
38
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
39
|
+
* @returns {Promise<string>} tmp file path (caller deletes)
|
|
40
|
+
*/
|
|
41
|
+
async function pullAccountDbViaSu(adb, serial, opts = {}) {
|
|
42
|
+
const adbOpts = { serial, timeoutMs: opts.timeoutMs || 60_000 };
|
|
43
|
+
const lsOut = await adb(
|
|
44
|
+
["shell", "su", "-c", `ls ${ACCOUNT_DB_REMOTE_PATH} 2>/dev/null || echo NOT_FOUND`],
|
|
45
|
+
adbOpts,
|
|
46
|
+
);
|
|
47
|
+
const lsLine = lsOut.replace(/\r+$/gm, "").trim();
|
|
48
|
+
if (lsLine === "NOT_FOUND" || lsLine === "") {
|
|
49
|
+
const pmOut = await adb(
|
|
50
|
+
["shell", "su", "-c", `pm list packages ${TOUTIAO_PACKAGE}`],
|
|
51
|
+
adbOpts,
|
|
52
|
+
);
|
|
53
|
+
const installed = pmOut.replace(/\r/g, "").includes(`package:${TOUTIAO_PACKAGE}`);
|
|
54
|
+
throw new Error(
|
|
55
|
+
installed
|
|
56
|
+
? "TOUTIAO_ACCOUNT_DB_MISSING: 今日头条已安装但无 account_db(未登录账号?)— " +
|
|
57
|
+
ACCOUNT_DB_REMOTE_PATH +
|
|
58
|
+
" 不存在。"
|
|
59
|
+
: "TOUTIAO_NOT_INSTALLED: " +
|
|
60
|
+
ACCOUNT_DB_REMOTE_PATH +
|
|
61
|
+
" not found and package not installed.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
|
|
65
|
+
const idLine = idOut.replace(/\r+$/gm, "").trim();
|
|
66
|
+
if (idLine !== "0" && !idLine.includes("uid=0")) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"TOUTIAO_NO_ROOT: su not uid 0 (`" + idLine.substring(0, 60) + "`); root required to read account_db.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const b64 = await adb(
|
|
72
|
+
["shell", "su", "-c", `base64 ${ACCOUNT_DB_REMOTE_PATH} | tr -d '\\n\\r'`],
|
|
73
|
+
{ ...adbOpts, timeoutMs: opts.timeoutMs || 60_000 },
|
|
74
|
+
);
|
|
75
|
+
const b64Clean = b64.replace(/[\r\n\t ]+/g, "");
|
|
76
|
+
if (b64Clean.length === 0) {
|
|
77
|
+
throw new Error("TOUTIAO_ACCOUNT_DB_EMPTY: base64 stream returned 0 bytes (su may have silently failed on MIUI).");
|
|
78
|
+
}
|
|
79
|
+
const buf = Buffer.from(b64Clean, "base64");
|
|
80
|
+
if (buf.length < 1024 || !buf.subarray(0, 15).toString("latin1").startsWith("SQLite format 3")) {
|
|
81
|
+
throw new Error("TOUTIAO_ACCOUNT_DB_NOT_SQLITE: decoded file lacks `SQLite format 3` magic (" + buf.length + " bytes).");
|
|
82
|
+
}
|
|
83
|
+
const tmpFile = path.join(os.tmpdir(), `cc-toutiao-account-${crypto.randomUUID()}.db`);
|
|
84
|
+
fs.writeFileSync(tmpFile, buf);
|
|
85
|
+
return tmpFile;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the best login_info row from a pulled account_db.
|
|
90
|
+
* @param {string} dbPath
|
|
91
|
+
* @param {{_databaseClass?: any}} [opts]
|
|
92
|
+
* @returns {{uid: string, nickname: string|null, secUid: string|null}|null}
|
|
93
|
+
*/
|
|
94
|
+
function readToutiaoAccount(dbPath, opts = {}) {
|
|
95
|
+
const Database = opts._databaseClass || loadDatabaseClass();
|
|
96
|
+
const db = new Database(dbPath, { readonly: true });
|
|
97
|
+
try {
|
|
98
|
+
let info;
|
|
99
|
+
try {
|
|
100
|
+
info = db.prepare("PRAGMA table_info(login_info)").all();
|
|
101
|
+
} catch (_e) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(info) || info.length === 0) return null;
|
|
105
|
+
const cols = new Set(info.map((c) => c.name));
|
|
106
|
+
if (!cols.has("uid")) return null;
|
|
107
|
+
const hasTime = cols.has("time");
|
|
108
|
+
// Prefer the most-recently-written numeric-uid row (multi-account safe).
|
|
109
|
+
const rows = db
|
|
110
|
+
.prepare(
|
|
111
|
+
`SELECT * FROM login_info${hasTime ? " ORDER BY time DESC" : ""}`,
|
|
112
|
+
)
|
|
113
|
+
.all();
|
|
114
|
+
for (const r of rows) {
|
|
115
|
+
const uid = r.uid != null ? String(r.uid) : "";
|
|
116
|
+
if (/^\d+$/.test(uid) && uid !== "0") {
|
|
117
|
+
return {
|
|
118
|
+
uid,
|
|
119
|
+
nickname:
|
|
120
|
+
(r.screen_name && String(r.screen_name)) ||
|
|
121
|
+
(r.platform_screen_name && String(r.platform_screen_name)) ||
|
|
122
|
+
null,
|
|
123
|
+
secUid: r.sec_uid ? String(r.sec_uid) : null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
} finally {
|
|
129
|
+
try {
|
|
130
|
+
db.close();
|
|
131
|
+
} catch (_e) {
|
|
132
|
+
/* best-effort */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Bridge handler factory: `bridge.invoke("toutiao.account")` → account or
|
|
139
|
+
* throws. Mirrors createToutiaoCookiesExtension's ctx contract.
|
|
140
|
+
*/
|
|
141
|
+
function createToutiaoAccountExtension(factoryOpts = {}) {
|
|
142
|
+
const timeoutMs = factoryOpts.timeoutMs || 60_000;
|
|
143
|
+
const onCleanupFailed = factoryOpts.onCleanupFailed || (() => {});
|
|
144
|
+
return async function toutiaoAccountHandler(_params, ctx) {
|
|
145
|
+
if (!ctx || typeof ctx.adb !== "function" || typeof ctx.pickDevice !== "function") {
|
|
146
|
+
throw new TypeError("toutiao.account extension: ctx must provide {adb, pickDevice}");
|
|
147
|
+
}
|
|
148
|
+
const serial = await ctx.pickDevice();
|
|
149
|
+
let tmpFile = null;
|
|
150
|
+
try {
|
|
151
|
+
tmpFile = await pullAccountDbViaSu(ctx.adb, serial, { timeoutMs });
|
|
152
|
+
const account = readToutiaoAccount(tmpFile);
|
|
153
|
+
if (!account) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"TOUTIAO_ACCOUNT_DB_NO_UID: login_info has no numeric-uid row (logged out?).",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return { ...account, extractedAt: Date.now() };
|
|
159
|
+
} finally {
|
|
160
|
+
if (tmpFile) {
|
|
161
|
+
try {
|
|
162
|
+
fs.unlinkSync(tmpFile);
|
|
163
|
+
} catch (_e) {
|
|
164
|
+
onCleanupFailed(tmpFile);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
createToutiaoAccountExtension,
|
|
173
|
+
ACCOUNT_DB_REMOTE_PATH,
|
|
174
|
+
TOUTIAO_PACKAGE,
|
|
175
|
+
_internals: {
|
|
176
|
+
pullAccountDbViaSu,
|
|
177
|
+
readToutiaoAccount,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
@@ -154,6 +154,17 @@ class ToutiaoApiClient {
|
|
|
154
154
|
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
155
155
|
return null;
|
|
156
156
|
}
|
|
157
|
+
// Toutiao web endpoints signal failure via `err_no != 0` + `message`
|
|
158
|
+
// while still returning HTTP 200 and an empty `data:[]` — without this
|
|
159
|
+
// check that error is silently masked as "0 results" (real-device
|
|
160
|
+
// 2026-06-11: tab_comments → {err_no:1,"message":"params illegal"}).
|
|
161
|
+
if (typeof obj.err_no === "number" && obj.err_no !== 0) {
|
|
162
|
+
this._setLastError(
|
|
163
|
+
obj.err_no,
|
|
164
|
+
String(obj.message || obj.err_tips || `err_no=${obj.err_no}`),
|
|
165
|
+
);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
157
168
|
this._clearLastError();
|
|
158
169
|
return obj;
|
|
159
170
|
} catch (e) {
|
|
@@ -180,27 +191,40 @@ class ToutiaoApiClient {
|
|
|
180
191
|
url.searchParams.set("aid", AID_TOUTIAO_WEB);
|
|
181
192
|
const obj = await this._doGetJson(url, cookie, false, "profile");
|
|
182
193
|
if (!obj) return null;
|
|
194
|
+
// Two envelope shapes seen in the wild (real-device 2026-06-11):
|
|
195
|
+
// legacy: { status_code: 0, data: {...} }
|
|
196
|
+
// passport v2: { message: "success", data: {...} }
|
|
197
|
+
// { message: "error", data: { error_code, description } }
|
|
198
|
+
// The old code only understood status_code and mis-reported the v2
|
|
199
|
+
// envelope as "missing status_code" — masking the real error (e.g.
|
|
200
|
+
// error_code 16 "该应用无权限"). Parse both, surface the specific error.
|
|
183
201
|
const statusCode =
|
|
184
202
|
typeof obj.status_code === "number" ? obj.status_code : null;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
const message = typeof obj.message === "string" ? obj.message : null;
|
|
204
|
+
const data = obj.data && typeof obj.data === "object" ? obj.data : null;
|
|
205
|
+
const ok = statusCode === 0 || (statusCode == null && message === "success");
|
|
206
|
+
if (!ok) {
|
|
207
|
+
if (data && Number.isFinite(data.error_code)) {
|
|
208
|
+
// passport v2 error envelope — the actionable code + 中文 description.
|
|
209
|
+
this._setLastError(
|
|
210
|
+
data.error_code,
|
|
211
|
+
String(data.description || data.error_description || `error_code=${data.error_code}`),
|
|
212
|
+
);
|
|
213
|
+
} else if (statusCode != null) {
|
|
214
|
+
this._setLastError(
|
|
215
|
+
statusCode,
|
|
216
|
+
String(obj.status_msg || message || obj.error_description || `status_code=${statusCode}`),
|
|
217
|
+
);
|
|
218
|
+
} else {
|
|
219
|
+
this._setLastError(
|
|
220
|
+
-5,
|
|
221
|
+
`passport/info/v2 unrecognized envelope (message=${message}, keys=[${Object.keys(obj).join(",")}])`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
199
224
|
return null;
|
|
200
225
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this._setLastError(-6, "status_code=0 but no `data` object");
|
|
226
|
+
if (!data) {
|
|
227
|
+
this._setLastError(-6, "profile ok but no `data` object");
|
|
204
228
|
return null;
|
|
205
229
|
}
|
|
206
230
|
const rawUid =
|
|
@@ -72,26 +72,57 @@ async function collect(bridge, opts = {}) {
|
|
|
72
72
|
|
|
73
73
|
try {
|
|
74
74
|
// fetchProfile — passport endpoint, no _signature required.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
let profile = await client.fetchProfile(cookie);
|
|
76
|
+
const profileFailed = !profile;
|
|
77
|
+
// Capture the profile error BEFORE the signed fetches overwrite lastError —
|
|
78
|
+
// when profile is permission-denied (real-device 2026-06-11: passport
|
|
79
|
+
// error_code 16 该应用无权限) it's the headline diagnostic, more useful than
|
|
80
|
+
// a downstream -99 short-circuit.
|
|
81
|
+
const profileErrCode = profileFailed ? client.lastErrorCode : null;
|
|
82
|
+
const profileErrMsg = profileFailed ? client.lastErrorMessage : null;
|
|
83
|
+
|
|
84
|
+
// Local account_db fallback: the web profile endpoint is often
|
|
85
|
+
// permission-denied (error_code 16) AND the WebView cookie jar carries no
|
|
86
|
+
// numeric uid — but the app's local account_db has uid+nickname in
|
|
87
|
+
// plaintext. Recover it so the signed collection/search endpoints (which
|
|
88
|
+
// need a uid) can still run. Best-effort: the bridge may not expose
|
|
89
|
+
// "toutiao.account" (older wiring) — that's fine, we fall through.
|
|
90
|
+
let profileSource = profile ? "web" : null;
|
|
91
|
+
if (profileFailed && bridge && typeof bridge.invoke === "function") {
|
|
92
|
+
try {
|
|
93
|
+
const acct = await bridge.invoke("toutiao.account");
|
|
94
|
+
if (acct && acct.uid && /^\d+$/.test(String(acct.uid))) {
|
|
95
|
+
profile = { uid: String(acct.uid), nickname: acct.nickname || null };
|
|
96
|
+
profileSource = "local-account-db";
|
|
97
|
+
}
|
|
98
|
+
} catch (_e) {
|
|
99
|
+
// account extension unavailable / logged out — keep web error.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The signed feed endpoint is cookie-identified and collection/search can
|
|
104
|
+
// use a cookie-derived uid, so a profile failure should NOT abort the whole
|
|
105
|
+
// sync when we still have a usable uid — only bail when there is no uid at
|
|
106
|
+
// all. (Previously any profile failure returned an empty snapshot, so the
|
|
107
|
+
// SignBridge feed/collection/search were never even attempted.)
|
|
108
|
+
const effectiveUid = (profile && profile.uid) || cookieUid || null;
|
|
109
|
+
if (!effectiveUid) {
|
|
80
110
|
const snapshot = buildSnapshot({
|
|
81
|
-
uid,
|
|
111
|
+
uid: "unknown-user",
|
|
82
112
|
displayName: opts.displayName,
|
|
83
113
|
snapshottedAt: now(),
|
|
84
114
|
});
|
|
85
115
|
const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
|
|
86
116
|
return {
|
|
87
117
|
snapshotPath,
|
|
88
|
-
uid:
|
|
118
|
+
uid: null,
|
|
89
119
|
nickname: null,
|
|
90
120
|
eventCounts: { profile: 0, feed: 0, collection: 0, search: 0, total: 0 },
|
|
91
|
-
lastErrorCode:
|
|
92
|
-
lastErrorMessage:
|
|
121
|
+
lastErrorCode: profileErrCode,
|
|
122
|
+
lastErrorMessage: profileErrMsg,
|
|
93
123
|
cookieDiagnostic: cookieDiagnostic || null,
|
|
94
124
|
profileFetchFailed: true,
|
|
125
|
+
profileSource,
|
|
95
126
|
signProviderUsed: signProvider
|
|
96
127
|
? signProvider.constructor.name
|
|
97
128
|
: "none",
|
|
@@ -100,7 +131,9 @@ async function collect(bridge, opts = {}) {
|
|
|
100
131
|
};
|
|
101
132
|
}
|
|
102
133
|
|
|
103
|
-
// Parallel 3 signed endpoints — partial failure tolerated.
|
|
134
|
+
// Parallel 3 signed endpoints — partial failure tolerated. Attempted even
|
|
135
|
+
// when profile failed (as long as we have a uid), so feed can still flow
|
|
136
|
+
// through a SignBridge despite a permission-denied profile endpoint.
|
|
104
137
|
const [feed, collection, search] = await Promise.all([
|
|
105
138
|
client.fetchFeed(cookie, {
|
|
106
139
|
limit: Number.isInteger(limits.feed) ? limits.feed : undefined,
|
|
@@ -116,9 +149,9 @@ async function collect(bridge, opts = {}) {
|
|
|
116
149
|
]);
|
|
117
150
|
|
|
118
151
|
const snapshot = buildSnapshot({
|
|
119
|
-
uid:
|
|
120
|
-
displayName: opts.displayName || profile.nickname,
|
|
121
|
-
profile,
|
|
152
|
+
uid: effectiveUid,
|
|
153
|
+
displayName: opts.displayName || (profile && profile.nickname),
|
|
154
|
+
profile: profile || undefined,
|
|
122
155
|
feed,
|
|
123
156
|
collection,
|
|
124
157
|
search,
|
|
@@ -128,19 +161,22 @@ async function collect(bridge, opts = {}) {
|
|
|
128
161
|
|
|
129
162
|
return {
|
|
130
163
|
snapshotPath,
|
|
131
|
-
uid:
|
|
132
|
-
nickname: profile.nickname,
|
|
164
|
+
uid: effectiveUid,
|
|
165
|
+
nickname: profile ? profile.nickname : null,
|
|
133
166
|
eventCounts: {
|
|
134
|
-
profile: 1,
|
|
167
|
+
profile: profile ? 1 : 0,
|
|
135
168
|
feed: feed.length,
|
|
136
169
|
collection: collection.length,
|
|
137
170
|
search: search.length,
|
|
138
171
|
total: snapshot.events.length,
|
|
139
172
|
},
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
// On profile failure surface the profile error (the headline issue), not
|
|
174
|
+
// the last signed-endpoint status.
|
|
175
|
+
lastErrorCode: profileFailed ? profileErrCode : client.lastErrorCode,
|
|
176
|
+
lastErrorMessage: profileFailed ? profileErrMsg : client.lastErrorMessage,
|
|
142
177
|
cookieDiagnostic: cookieDiagnostic || null,
|
|
143
|
-
profileFetchFailed:
|
|
178
|
+
profileFetchFailed: profileFailed,
|
|
179
|
+
profileSource,
|
|
144
180
|
signProviderUsed: signProvider ? signProvider.constructor.name : "none",
|
|
145
181
|
signProviderHits: client._bridgeHits,
|
|
146
182
|
signProviderFallbacks: client._fallbackHits,
|
|
@@ -41,6 +41,7 @@ const {
|
|
|
41
41
|
readChromiumCookies,
|
|
42
42
|
} = require("../social-bilibili-adb/chromium-cookies-reader");
|
|
43
43
|
|
|
44
|
+
const TOUTIAO_PACKAGE = "com.ss.android.article.news";
|
|
44
45
|
const TOUTIAO_COOKIES_REMOTE_PATH =
|
|
45
46
|
"/data/data/com.ss.android.article.news/app_webview/Default/Cookies";
|
|
46
47
|
|
|
@@ -72,10 +73,29 @@ async function pullCookiesViaSu(adb, serial, opts) {
|
|
|
72
73
|
);
|
|
73
74
|
const lsLine = lsOut.replace(/\r+$/gm, "").trim();
|
|
74
75
|
if (lsLine === "NOT_FOUND" || lsLine === "") {
|
|
76
|
+
// Distinguish "app not installed" from "installed but no webview cookie
|
|
77
|
+
// store yet" (logged out / never opened an in-app WebView). Both leave the
|
|
78
|
+
// Cookies file absent, but the user action differs — so probe pm.
|
|
79
|
+
const pmOut = await adb(
|
|
80
|
+
["shell", "su", "-c", `pm list packages ${TOUTIAO_PACKAGE}`],
|
|
81
|
+
adbOpts,
|
|
82
|
+
);
|
|
83
|
+
const installed = pmOut
|
|
84
|
+
.replace(/\r/g, "")
|
|
85
|
+
.includes(`package:${TOUTIAO_PACKAGE}`);
|
|
86
|
+
if (installed) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"TOUTIAO_NO_WEBVIEW_COOKIES: 今日头条 App 已安装但无 WebView cookie 库 (" +
|
|
89
|
+
TOUTIAO_COOKIES_REMOTE_PATH +
|
|
90
|
+
" 不存在)。请在 App 内登录,并打开任意文章/网页(触发内置 WebView 写 cookie)后重试。注意:极速版 (.lite) 是另一个包,不支持。",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
75
93
|
throw new Error(
|
|
76
94
|
"TOUTIAO_NOT_INSTALLED: " +
|
|
77
95
|
TOUTIAO_COOKIES_REMOTE_PATH +
|
|
78
|
-
" not found
|
|
96
|
+
" not found and package " +
|
|
97
|
+
TOUTIAO_PACKAGE +
|
|
98
|
+
" is not installed. Install Toutiao App (今日头条 com.ss.android.article.news) + log in once, then retry. Note: 极速版 (.lite) uses a different package — only the standard app is supported.",
|
|
79
99
|
);
|
|
80
100
|
}
|
|
81
101
|
const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
|
|
@@ -34,9 +34,15 @@ const {
|
|
|
34
34
|
SNAPSHOT_SCHEMA_VERSION,
|
|
35
35
|
} = require("./snapshot-builder");
|
|
36
36
|
const { collect, collectAndSync } = require("./collector");
|
|
37
|
+
const {
|
|
38
|
+
createToutiaoAccountExtension,
|
|
39
|
+
ACCOUNT_DB_REMOTE_PATH,
|
|
40
|
+
} = require("./account-reader");
|
|
37
41
|
|
|
38
42
|
module.exports = {
|
|
39
43
|
createToutiaoCookiesExtension,
|
|
44
|
+
createToutiaoAccountExtension,
|
|
45
|
+
ACCOUNT_DB_REMOTE_PATH,
|
|
40
46
|
TOUTIAO_COOKIES_REMOTE_PATH,
|
|
41
47
|
TOUTIAO_COOKIE_HOST_DOMAIN,
|
|
42
48
|
TOUTIAO_SESSION_COOKIES,
|