@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.
Files changed (41) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/netease-music-live.test.js +244 -0
  9. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  10. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  11. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  12. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  13. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  14. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  15. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  16. package/lib/adapters/_live-json-helpers.js +50 -0
  17. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  18. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  19. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  20. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  21. package/lib/adapters/finance-alipay/api-client.js +268 -6
  22. package/lib/adapters/finance-alipay/index.js +85 -9
  23. package/lib/adapters/game-genshin/api-client.js +207 -6
  24. package/lib/adapters/game-genshin/index.js +90 -9
  25. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  26. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  27. package/lib/adapters/netease-music/api-client.js +284 -0
  28. package/lib/adapters/netease-music/index.js +85 -9
  29. package/lib/adapters/social-douyin/index.js +2 -0
  30. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  31. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  32. package/lib/adapters/social-douyin-adb/index.js +18 -1
  33. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  34. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  35. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  36. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  37. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  38. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  39. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  40. package/lib/index.js +1 -1
  41. 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 { collect, collectAndSync } = require("./collector");
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
- if (statusCode == null) {
186
- this._setLastError(
187
- -5,
188
- `passport/info/v2 missing status_code (keys=[${Object.keys(obj).join(",")}])`,
189
- );
190
- return null;
191
- }
192
- if (statusCode !== 0) {
193
- const msg =
194
- obj.status_msg ||
195
- obj.message ||
196
- obj.error_description ||
197
- `status_code=${statusCode}`;
198
- this._setLastError(statusCode, String(msg));
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
- const data = obj.data;
202
- if (!data || typeof data !== "object") {
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
- const profile = await client.fetchProfile(cookie);
76
- if (!profile) {
77
- // Cookie expired or sessionid missing emit empty snapshot using
78
- // best-effort cookie-derived uid (or sentinel if also absent).
79
- const uid = cookieUid || "unknown-user";
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: cookieUid,
118
+ uid: null,
89
119
  nickname: null,
90
120
  eventCounts: { profile: 0, feed: 0, collection: 0, search: 0, total: 0 },
91
- lastErrorCode: client.lastErrorCode,
92
- lastErrorMessage: client.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: profile.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: profile.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
- lastErrorCode: client.lastErrorCode,
141
- lastErrorMessage: client.lastErrorMessage,
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: false,
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. 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.",
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,