@chainlesschain/personal-data-hub 0.4.3 → 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/messaging-whatsapp.test.js +289 -0
- package/__tests__/adapters/netease-music-live.test.js +244 -0
- package/__tests__/adapters/shopping-base.test.js +179 -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-kuaishou-adb-api-client.test.js +64 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +11 -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-api-client.test.js +431 -0
- package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
- package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +200 -0
- package/__tests__/adapters/travel-12306.test.js +279 -0
- package/__tests__/adapters/travel-amap.test.js +219 -0
- package/__tests__/adapters/travel-baidu-map.test.js +305 -0
- package/__tests__/adapters/travel-base.test.js +205 -0
- package/__tests__/adapters/travel-ctrip.test.js +203 -0
- package/__tests__/adapters/travel-tencent-map.test.js +207 -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-kuaishou/index.js +7 -2
- package/lib/adapters/social-kuaishou-adb/api-client.js +38 -18
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +16 -15
- package/lib/adapters/social-toutiao/index.js +8 -4
- 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/adapters/travel-base/index.js +9 -2
- 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
|
+
};
|
|
@@ -54,8 +54,13 @@ const KIND_PROFILE = "profile";
|
|
|
54
54
|
const KIND_WATCH = "watch";
|
|
55
55
|
const KIND_COLLECT = "collect";
|
|
56
56
|
const KIND_SEARCH = "search";
|
|
57
|
-
// v0.2.1 — KIND_PROFILE added (mirrors Douyin/Toutiao)
|
|
58
|
-
//
|
|
57
|
+
// v0.2.1 — KIND_PROFILE added (mirrors Douyin/Toutiao). The watch/collect/
|
|
58
|
+
// search producers LANDED since (verified 2026-06-11): Android
|
|
59
|
+
// KuaishouLocalCollector emits all 4 kinds via the NS_sig3 WebSignBridge
|
|
60
|
+
// path, KuaishouRootDbExtractor emits watch/collect/search, and the PC ADB
|
|
61
|
+
// KuaishouApiClient fetches them through its injected signProvider (signed
|
|
62
|
+
// GraphQL). This adapter normalizes whatever the snapshot carries.
|
|
63
|
+
// SNAPSHOT_SCHEMA_VERSION stays at 1 — additive.
|
|
59
64
|
const VALID_SNAPSHOT_KINDS = Object.freeze([
|
|
60
65
|
KIND_PROFILE,
|
|
61
66
|
KIND_WATCH,
|
|
@@ -119,23 +119,19 @@ class KuaishouApiClient {
|
|
|
119
119
|
);
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
decoded = cpMatch[1];
|
|
127
|
-
}
|
|
128
|
-
const trimmed = decoded.trimStart();
|
|
129
|
-
if (!trimmed.startsWith("{")) {
|
|
122
|
+
const jsonText = apiPhDecodeCandidates(cpMatch[1]).find((c) =>
|
|
123
|
+
c.trimStart().startsWith("{"),
|
|
124
|
+
);
|
|
125
|
+
if (!jsonText) {
|
|
130
126
|
this._setLastError(
|
|
131
127
|
-9,
|
|
132
|
-
"kuaishou.web.cp.api_ph 解码后非 JSON (
|
|
128
|
+
"kuaishou.web.cp.api_ph 解码后非 JSON (urlencoded + base64 fallback 均失败)",
|
|
133
129
|
);
|
|
134
130
|
return null;
|
|
135
131
|
}
|
|
136
132
|
let obj;
|
|
137
133
|
try {
|
|
138
|
-
obj = JSON.parse(
|
|
134
|
+
obj = JSON.parse(jsonText);
|
|
139
135
|
} catch (e) {
|
|
140
136
|
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
141
137
|
return null;
|
|
@@ -359,20 +355,43 @@ function extractPhotoList(feeds, limit, build) {
|
|
|
359
355
|
return out;
|
|
360
356
|
}
|
|
361
357
|
|
|
362
|
-
|
|
358
|
+
/**
|
|
359
|
+
* api_ph payload decode chain (v0.3): newer Kuaishou builds write the
|
|
360
|
+
* `kuaishou.web.cp.api_ph` cookie as base64(JSON) instead of urlencoded
|
|
361
|
+
* JSON. Yields the URI-decoded string first; when that doesn't look like
|
|
362
|
+
* JSON but matches the base64 charset (std or url-safe), also yields the
|
|
363
|
+
* base64-decoded form — gated on the result starting with `{` so lenient
|
|
364
|
+
* Buffer decoding of arbitrary text can't surface garbage.
|
|
365
|
+
*/
|
|
366
|
+
function apiPhDecodeCandidates(cpRaw) {
|
|
363
367
|
let decoded;
|
|
364
368
|
try {
|
|
365
369
|
decoded = decodeURIComponent(cpRaw);
|
|
366
370
|
} catch {
|
|
367
371
|
decoded = cpRaw;
|
|
368
372
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
373
|
+
const out = [decoded];
|
|
374
|
+
const trimmed = decoded.trim();
|
|
375
|
+
if (!trimmed.startsWith("{") && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(trimmed)) {
|
|
376
|
+
const b64 = Buffer.from(
|
|
377
|
+
trimmed.replace(/-/g, "+").replace(/_/g, "/"),
|
|
378
|
+
"base64",
|
|
379
|
+
).toString("utf-8");
|
|
380
|
+
if (b64.trimStart().startsWith("{")) out.push(b64);
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function extractEmbeddedUid(cpRaw) {
|
|
386
|
+
for (const decoded of apiPhDecodeCandidates(cpRaw)) {
|
|
387
|
+
for (const pat of [
|
|
388
|
+
/"?user_id"?\s*:\s*"?(\d+)"?/,
|
|
389
|
+
/"?uid"?\s*:\s*"?(\d+)"?/,
|
|
390
|
+
/"?userId"?\s*:\s*"?(\d+)"?/,
|
|
391
|
+
]) {
|
|
392
|
+
const m = pat.exec(decoded);
|
|
393
|
+
if (m && m[1] && m[1] !== "0") return m[1];
|
|
394
|
+
}
|
|
376
395
|
}
|
|
377
396
|
return null;
|
|
378
397
|
}
|
|
@@ -393,5 +412,6 @@ module.exports = {
|
|
|
393
412
|
normalizeMs,
|
|
394
413
|
extractPhotoList,
|
|
395
414
|
extractEmbeddedUid,
|
|
415
|
+
apiPhDecodeCandidates,
|
|
396
416
|
},
|
|
397
417
|
};
|
|
@@ -34,6 +34,9 @@ const crypto = require("node:crypto");
|
|
|
34
34
|
const {
|
|
35
35
|
readChromiumCookies,
|
|
36
36
|
} = require("../social-bilibili-adb/chromium-cookies-reader");
|
|
37
|
+
const {
|
|
38
|
+
_internals: { apiPhDecodeCandidates },
|
|
39
|
+
} = require("./api-client");
|
|
37
40
|
|
|
38
41
|
const KUAISHOU_COOKIES_REMOTE_PATH =
|
|
39
42
|
"/data/data/com.smile.gifmaker/app_webview/Default/Cookies";
|
|
@@ -137,22 +140,20 @@ function pickUidFromCookieMap(byName) {
|
|
|
137
140
|
}
|
|
138
141
|
const cpRaw = byName.get("kuaishou.web.cp.api_ph")?.value;
|
|
139
142
|
if (cpRaw) {
|
|
140
|
-
let decoded;
|
|
141
|
-
try {
|
|
142
|
-
decoded = decodeURIComponent(cpRaw);
|
|
143
|
-
} catch {
|
|
144
|
-
decoded = cpRaw;
|
|
145
|
-
}
|
|
146
143
|
// Try nested user_id / uid / userId regex (don't require strict JSON
|
|
147
|
-
// — api_ph format isn't documented and varies)
|
|
148
|
-
for
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
144
|
+
// — api_ph format isn't documented and varies). v0.3: candidates
|
|
145
|
+
// include the base64-decoded form for newer Kuaishou builds that
|
|
146
|
+
// write api_ph as base64(JSON).
|
|
147
|
+
for (const decoded of apiPhDecodeCandidates(cpRaw)) {
|
|
148
|
+
for (const pat of [
|
|
149
|
+
/"?user_id"?\s*:\s*"?(\d+)"?/,
|
|
150
|
+
/"?uid"?\s*:\s*"?(\d+)"?/,
|
|
151
|
+
/"?userId"?\s*:\s*"?(\d+)"?/,
|
|
152
|
+
]) {
|
|
153
|
+
const m = pat.exec(decoded);
|
|
154
|
+
if (m && m[1] && m[1] !== "0") {
|
|
155
|
+
return m[1];
|
|
156
|
+
}
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
}
|
|
@@ -57,10 +57,14 @@ const KIND_PROFILE = "profile";
|
|
|
57
57
|
const KIND_READ = "read";
|
|
58
58
|
const KIND_COLLECTION = "collection";
|
|
59
59
|
const KIND_SEARCH = "search";
|
|
60
|
-
// v0.2.1 — KIND_PROFILE added (mirrors Douyin)
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
60
|
+
// v0.2.1 — KIND_PROFILE added (mirrors Douyin). The read/collection/search
|
|
61
|
+
// producers LANDED since (verified 2026-06-11): Android ToutiaoLocalCollector
|
|
62
|
+
// emits all 4 kinds via the _signature WebSignBridge path, ToutiaoRootDbExtractor
|
|
63
|
+
// emits read/collection/search, and the PC ADB ToutiaoApiClient fetches
|
|
64
|
+
// feed/collection/search through its injected signProvider. This adapter
|
|
65
|
+
// normalizes whatever the snapshot carries. SNAPSHOT_SCHEMA_VERSION stays
|
|
66
|
+
// at 1: old (events-only) snapshots remain compatible; profile events are
|
|
67
|
+
// an additive extension.
|
|
64
68
|
const VALID_SNAPSHOT_KINDS = Object.freeze([
|
|
65
69
|
KIND_PROFILE,
|
|
66
70
|
KIND_READ,
|
|
@@ -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 =
|