@chainlesschain/personal-data-hub 0.3.1 → 0.3.7
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/email-adapter-snapshot.test.js +237 -0
- package/__tests__/adapters/email-adapter.test.js +1 -1
- package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
- package/__tests__/adapters/email-retry-progress.test.js +1 -1
- package/__tests__/adapters/email-templates.test.js +1 -1
- package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
- package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
- package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
- package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
- package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
- package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
- package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
- package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
- package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
- package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
- package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
- package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
- package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
- package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
- package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
- package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
- package/__tests__/adapters/system-data-android.test.js +32 -1
- package/__tests__/longtail-adapters.test.js +15 -2
- package/__tests__/shopping-adapters.test.js +96 -0
- package/__tests__/sign-providers.test.js +62 -0
- package/__tests__/travel-adapters.test.js +66 -0
- package/__tests__/whatsapp-adapter.test.js +5 -2
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
- package/lib/adapters/email-imap/email-adapter.js +224 -17
- package/lib/adapters/messaging-telegram/index.js +15 -12
- package/lib/adapters/messaging-whatsapp/index.js +15 -12
- package/lib/adapters/shopping-taobao/index.js +161 -21
- package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
- package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
- package/lib/adapters/social-bilibili-adb/collector.js +190 -0
- package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
- package/lib/adapters/social-bilibili-adb/index.js +51 -0
- package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
- package/lib/adapters/social-douyin/index.js +4 -0
- package/lib/adapters/social-douyin-adb/collector.js +165 -0
- package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
- package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
- package/lib/adapters/social-douyin-adb/index.js +57 -0
- package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
- package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
- package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
- package/lib/adapters/social-kuaishou-adb/index.js +53 -0
- package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
- package/lib/adapters/social-toutiao-adb/collector.js +200 -0
- package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
- package/lib/adapters/social-toutiao-adb/index.js +52 -0
- package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
- package/lib/adapters/social-weibo-adb/api-client.js +281 -0
- package/lib/adapters/social-weibo-adb/collector.js +169 -0
- package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
- package/lib/adapters/social-weibo-adb/index.js +55 -0
- package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-xiaohongshu-adb/api-client.js +309 -0
- package/lib/adapters/social-xiaohongshu-adb/collector.js +209 -0
- package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
- package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
- package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
- package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
- package/lib/adapters/system-data-android/adapter.js +77 -3
- package/lib/adapters/travel-amap/index.js +16 -10
- package/lib/adapters/travel-ctrip/index.js +25 -9
- package/lib/adapters/vscode/vscode-reader.js +7 -1
- package/lib/sign-providers/index.js +20 -0
- package/lib/sign-providers/interface.js +82 -0
- package/lib/sign-providers/null-sign-provider.js +30 -0
- package/package.json +10 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 2a (Douyin C 路径 — 2026-05-25): IM-db parse result → snapshot JSON.
|
|
5
|
+
*
|
|
6
|
+
* Takes the `{messages, contacts}` shape from im-db-parser.js and produces
|
|
7
|
+
* a snapshot matching the existing `social-douyin` adapter's
|
|
8
|
+
* SNAPSHOT_SCHEMA_VERSION=1 contract — so we reuse the adapter's snapshot
|
|
9
|
+
* mode (`_syncViaSnapshot`) instead of opening a second adapter.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors social-bilibili-adb/snapshot-builder.js. Single-source-of-truth
|
|
12
|
+
* for the adapter; we feed it via different upstreams.
|
|
13
|
+
*
|
|
14
|
+
* Snapshot schema (matches social-douyin/index.js:SNAPSHOT_SCHEMA_VERSION):
|
|
15
|
+
*
|
|
16
|
+
* {
|
|
17
|
+
* "schemaVersion": 1,
|
|
18
|
+
* "snapshottedAt": <epoch-ms>,
|
|
19
|
+
* "account": {
|
|
20
|
+
* "secUid": null, // C 路径不调 X-Bogus profile, 不知 secUid
|
|
21
|
+
* "shortId": null,
|
|
22
|
+
* "displayName": ""
|
|
23
|
+
* },
|
|
24
|
+
* "events": [
|
|
25
|
+
* { "kind": "message", "id": "msg-<conv>-<time>", "capturedAt": <ms>,
|
|
26
|
+
* "senderUid": "...", "conversationId": "...",
|
|
27
|
+
* "text": "...", "readStatus": 0/1, "contentBlob": "..." },
|
|
28
|
+
* { "kind": "contact", "id": "contact-<uid>", "capturedAt": <ms>,
|
|
29
|
+
* "uid": "...", "shortId": "...", "name": "...",
|
|
30
|
+
* "avatarUrl": "...", "followStatus": 0/1/2 }
|
|
31
|
+
* ]
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Note: Douyin IM doesn't have a "this is me" marker — the db includes
|
|
35
|
+
* messages where `senderUid === <db-filename-uid>` (sent by self) and
|
|
36
|
+
* `senderUid !== <db-filename-uid>` (received). Both go into the snapshot;
|
|
37
|
+
* the consumer (e.g. PDH search) can filter by senderUid if needed.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require("node:fs");
|
|
41
|
+
const path = require("node:path");
|
|
42
|
+
const os = require("node:os");
|
|
43
|
+
const crypto = require("node:crypto");
|
|
44
|
+
|
|
45
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build an in-memory snapshot from parsed IM db rows. Pure function — no
|
|
49
|
+
* disk IO.
|
|
50
|
+
*
|
|
51
|
+
* @param {{
|
|
52
|
+
* uid: string,
|
|
53
|
+
* messages?: Array,
|
|
54
|
+
* contacts?: Array,
|
|
55
|
+
* snapshottedAt?: number,
|
|
56
|
+
* displayName?: string,
|
|
57
|
+
* }} input
|
|
58
|
+
* @returns {{schemaVersion: number, snapshottedAt: number, account: object, events: Array}}
|
|
59
|
+
*/
|
|
60
|
+
function buildSnapshot(input) {
|
|
61
|
+
if (!input || typeof input !== "object") {
|
|
62
|
+
throw new TypeError("buildSnapshot: input must be an object");
|
|
63
|
+
}
|
|
64
|
+
const uid = input.uid;
|
|
65
|
+
if (typeof uid !== "string" || uid.length === 0) {
|
|
66
|
+
throw new TypeError("buildSnapshot: input.uid must be a non-empty string");
|
|
67
|
+
}
|
|
68
|
+
const snapshottedAt =
|
|
69
|
+
Number.isFinite(input.snapshottedAt) && input.snapshottedAt > 0
|
|
70
|
+
? input.snapshottedAt
|
|
71
|
+
: Date.now();
|
|
72
|
+
const account = {
|
|
73
|
+
// secUid / shortId unknown via pure-db extraction (those live in the
|
|
74
|
+
// app's webview cookies / passport endpoint). Leave null so consumers
|
|
75
|
+
// know not to use them as canonical IDs.
|
|
76
|
+
secUid: null,
|
|
77
|
+
shortId: uid, // Douyin numeric uid is the shortId equivalent
|
|
78
|
+
displayName:
|
|
79
|
+
typeof input.displayName === "string" ? input.displayName : "",
|
|
80
|
+
};
|
|
81
|
+
const events = [];
|
|
82
|
+
|
|
83
|
+
// messages
|
|
84
|
+
const messages = Array.isArray(input.messages) ? input.messages : [];
|
|
85
|
+
messages.forEach((m, idx) => {
|
|
86
|
+
if (!m || typeof m !== "object") return;
|
|
87
|
+
const capturedAt =
|
|
88
|
+
typeof m.createdTimeMs === "number" && m.createdTimeMs > 0
|
|
89
|
+
? m.createdTimeMs
|
|
90
|
+
: snapshottedAt;
|
|
91
|
+
// ID strategy: conversationId + createdTime is a stable composite
|
|
92
|
+
// key (both required by Douyin's IM protocol). Fallback to senderUid
|
|
93
|
+
// + time for very old rows that pre-date conversation_id.
|
|
94
|
+
const idPart =
|
|
95
|
+
m.conversationId && m.createdTimeMs
|
|
96
|
+
? `${m.conversationId}-${m.createdTimeMs}`
|
|
97
|
+
: m.senderUid && m.createdTimeMs
|
|
98
|
+
? `${m.senderUid}-${m.createdTimeMs}`
|
|
99
|
+
: `msg-${idx}`;
|
|
100
|
+
events.push({
|
|
101
|
+
kind: "message",
|
|
102
|
+
id: `msg-${idPart}`,
|
|
103
|
+
capturedAt,
|
|
104
|
+
senderUid: m.senderUid || null,
|
|
105
|
+
conversationId: m.conversationId || null,
|
|
106
|
+
text: m.text || null,
|
|
107
|
+
readStatus: typeof m.readStatus === "number" ? m.readStatus : null,
|
|
108
|
+
contentBlob: m.contentBlob || null,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// contacts
|
|
113
|
+
const contacts = Array.isArray(input.contacts) ? input.contacts : [];
|
|
114
|
+
contacts.forEach((c, idx) => {
|
|
115
|
+
if (!c || typeof c !== "object") return;
|
|
116
|
+
events.push({
|
|
117
|
+
kind: "contact",
|
|
118
|
+
id: c.uid ? `contact-${c.uid}` : `contact-${idx}`,
|
|
119
|
+
capturedAt: snapshottedAt, // SIMPLE_USER has no per-row timestamp
|
|
120
|
+
uid: c.uid || null,
|
|
121
|
+
shortId: c.shortId || null,
|
|
122
|
+
name: c.name || null,
|
|
123
|
+
avatarUrl: c.avatarUrl || null,
|
|
124
|
+
followStatus:
|
|
125
|
+
typeof c.followStatus === "number" ? c.followStatus : null,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
131
|
+
snapshottedAt,
|
|
132
|
+
account,
|
|
133
|
+
events,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Write a snapshot to disk as JSON. Default destination is
|
|
139
|
+
* `<os.tmpdir()>/cc-douyin-snapshot-<uuid>.json`. Returns the absolute
|
|
140
|
+
* path. Caller is responsible for cleanup.
|
|
141
|
+
*/
|
|
142
|
+
function writeSnapshotJson(snapshot, opts = {}) {
|
|
143
|
+
const dir = opts.dir || os.tmpdir();
|
|
144
|
+
const fileName =
|
|
145
|
+
opts.fileName || `cc-douyin-snapshot-${crypto.randomUUID()}.json`;
|
|
146
|
+
if (fileName.includes("/") || fileName.includes("\\")) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"writeSnapshotJson: opts.fileName must be a basename, not a path",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const full = path.join(dir, fileName);
|
|
152
|
+
fs.writeFileSync(full, JSON.stringify(snapshot), "utf-8");
|
|
153
|
+
return full;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Best-effort delete of a snapshot file. Used in finally blocks; never
|
|
158
|
+
* throws.
|
|
159
|
+
*/
|
|
160
|
+
function cleanupSnapshotJson(filePath) {
|
|
161
|
+
if (!filePath) return;
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(filePath);
|
|
164
|
+
} catch (_e) {
|
|
165
|
+
// ignore
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
buildSnapshot,
|
|
171
|
+
writeSnapshotJson,
|
|
172
|
+
cleanupSnapshotJson,
|
|
173
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
174
|
+
};
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 6d (Kuaishou C 路径 — 2026-05-25): Node-side KuaishouApiClient.
|
|
5
|
+
*
|
|
6
|
+
* Byte-parity port of KuaishouApiClient.kt. **Profile from cookie (no
|
|
7
|
+
* HTTP) + 3 GraphQL POST endpoints (all signed)**:
|
|
8
|
+
* - `kuaishou.web.cp.api_ph` cookie payload → ProfileInfo (parseProfileFromCookie)
|
|
9
|
+
* - `/graphql` visionFeedRecommend — watch history (signed)
|
|
10
|
+
* - `/graphql` visionProfilePhotoList — user's posted photos (signed)
|
|
11
|
+
* - `/graphql` visionSearchPhoto — search history (signed)
|
|
12
|
+
*
|
|
13
|
+
* **signProvider injection (Phase 6d)**: defaults to NULL_SIGN_PROVIDER —
|
|
14
|
+
* signUrl returns null, so the 3 signed endpoints short-circuit with
|
|
15
|
+
* lastErrorCode=-99. Desktop wiring injects KuaishouSignBridge.
|
|
16
|
+
*
|
|
17
|
+
* **GraphQL nuances**:
|
|
18
|
+
* - POST `/graphql` with body `{operationName, variables, query}`
|
|
19
|
+
* - Body MUST match exactly what was signed (NS_sig3 hashes body bytes)
|
|
20
|
+
* - signedHeaders returns kpf/kpn that must be sent verbatim
|
|
21
|
+
*
|
|
22
|
+
* **Anti-bot signal**: User-Agent must be desktop Chrome 120+. Referer +
|
|
23
|
+
* Origin = https://www.kuaishou.com/. Without `kpf`/`kpn` headers
|
|
24
|
+
* GraphQL endpoint returns 403/Errors.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { NULL_SIGN_PROVIDER } = require("../../sign-providers");
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BASE_URL = "https://www.kuaishou.com/";
|
|
30
|
+
|
|
31
|
+
const BROWSER_UA =
|
|
32
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
33
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
34
|
+
|
|
35
|
+
const BROWSER_HEADERS = Object.freeze({
|
|
36
|
+
"User-Agent": BROWSER_UA,
|
|
37
|
+
Referer: "https://www.kuaishou.com/",
|
|
38
|
+
Origin: "https://www.kuaishou.com",
|
|
39
|
+
Accept: "application/json, text/plain, */*",
|
|
40
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const OP_FEED_RECOMMEND = "visionFeedRecommend";
|
|
45
|
+
const OP_PROFILE_PHOTOS = "visionProfilePhotoList";
|
|
46
|
+
const OP_SEARCH_PHOTO = "visionSearchPhoto";
|
|
47
|
+
|
|
48
|
+
function normalizeMs(v) {
|
|
49
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return 0;
|
|
50
|
+
return v > 1e12 ? v : v * 1000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class KuaishouApiClient {
|
|
54
|
+
constructor(opts = {}) {
|
|
55
|
+
this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
|
|
56
|
+
if (!this.baseUrl.endsWith("/")) this.baseUrl += "/";
|
|
57
|
+
this._fetch = opts.fetch || globalThis.fetch;
|
|
58
|
+
if (typeof this._fetch !== "function") {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"KuaishouApiClient: fetch not available — pass opts.fetch or run on Node 18+",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
this._now = opts.now || Date.now;
|
|
64
|
+
this.signProvider = opts.signProvider || NULL_SIGN_PROVIDER;
|
|
65
|
+
this.lastErrorCode = 0;
|
|
66
|
+
this.lastErrorMessage = null;
|
|
67
|
+
this._bridgeHits = 0;
|
|
68
|
+
this._fallbackHits = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract uid from cookie. Mirror of Kotlin extractUid:
|
|
73
|
+
* 1. `userId=N` direct cookie
|
|
74
|
+
* 2. Nested user_id / uid / userId inside `kuaishou.web.cp.api_ph`
|
|
75
|
+
* URL-encoded JSON
|
|
76
|
+
*/
|
|
77
|
+
extractUid(cookie) {
|
|
78
|
+
if (typeof cookie !== "string" || cookie.length === 0) {
|
|
79
|
+
this._setLastError(-1, "cookie 为空");
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const direct = /(?:^|; ?)userId=(\d+)/.exec(cookie);
|
|
83
|
+
if (direct && direct[1] && direct[1] !== "0") {
|
|
84
|
+
this._clearLastError();
|
|
85
|
+
return direct[1];
|
|
86
|
+
}
|
|
87
|
+
const cpMatch = /(?:^|; ?)kuaishou\.web\.cp\.api_ph=([^;]+)/.exec(cookie);
|
|
88
|
+
if (cpMatch && cpMatch[1]) {
|
|
89
|
+
const embedded = extractEmbeddedUid(cpMatch[1]);
|
|
90
|
+
if (embedded) {
|
|
91
|
+
this._clearLastError();
|
|
92
|
+
return embedded;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
this._setLastError(
|
|
96
|
+
-7,
|
|
97
|
+
"cookie 缺 userId / kuaishou.web.cp.api_ph 嵌套 user_id — 登录未完成或仅游客态",
|
|
98
|
+
);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse profile from cookie's `kuaishou.web.cp.api_ph` URL-encoded JSON.
|
|
104
|
+
* NO HTTP — this is purely cookie-derived (Kuaishou's passport writes
|
|
105
|
+
* the full profile JSON into the cookie at login time).
|
|
106
|
+
*
|
|
107
|
+
* Returns null if api_ph absent / un-decodable / lacks user_id.
|
|
108
|
+
*/
|
|
109
|
+
async fetchProfile(cookie) {
|
|
110
|
+
if (typeof cookie !== "string" || cookie.length === 0) {
|
|
111
|
+
this._setLastError(-1, "cookie 为空");
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const cpMatch = /(?:^|; ?)kuaishou\.web\.cp\.api_ph=([^;]+)/.exec(cookie);
|
|
115
|
+
if (!cpMatch || !cpMatch[1]) {
|
|
116
|
+
this._setLastError(
|
|
117
|
+
-8,
|
|
118
|
+
"cookie 缺 kuaishou.web.cp.api_ph (profile 解析需要)",
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
let decoded;
|
|
123
|
+
try {
|
|
124
|
+
decoded = decodeURIComponent(cpMatch[1]);
|
|
125
|
+
} catch {
|
|
126
|
+
decoded = cpMatch[1];
|
|
127
|
+
}
|
|
128
|
+
const trimmed = decoded.trimStart();
|
|
129
|
+
if (!trimmed.startsWith("{")) {
|
|
130
|
+
this._setLastError(
|
|
131
|
+
-9,
|
|
132
|
+
"kuaishou.web.cp.api_ph 解码后非 JSON (likely base64 — v0.3 加 fallback)",
|
|
133
|
+
);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let obj;
|
|
137
|
+
try {
|
|
138
|
+
obj = JSON.parse(decoded);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const uid =
|
|
144
|
+
pickString(obj.user_id) ||
|
|
145
|
+
pickString(obj.userId) ||
|
|
146
|
+
(Number.isFinite(obj.user_id) && obj.user_id > 0 && String(obj.user_id)) ||
|
|
147
|
+
(Number.isFinite(obj.userId) && obj.userId > 0 && String(obj.userId)) ||
|
|
148
|
+
null;
|
|
149
|
+
if (!uid || uid === "0") {
|
|
150
|
+
this._setLastError(
|
|
151
|
+
-7,
|
|
152
|
+
`api_ph JSON 缺 user_id (keys=[${Object.keys(obj).join(",")}])`,
|
|
153
|
+
);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
this._clearLastError();
|
|
157
|
+
return {
|
|
158
|
+
uid,
|
|
159
|
+
nickname:
|
|
160
|
+
pickString(obj.user_name) ||
|
|
161
|
+
pickString(obj.userName) ||
|
|
162
|
+
pickString(obj.nickname) ||
|
|
163
|
+
"(unnamed)",
|
|
164
|
+
kuaishouId:
|
|
165
|
+
pickString(obj.kuaishou_id) || pickString(obj.kuaishouId) || null,
|
|
166
|
+
avatarUrl:
|
|
167
|
+
pickString(obj.headurl) ||
|
|
168
|
+
pickString(obj.headUrl) ||
|
|
169
|
+
pickString(obj.avatar) ||
|
|
170
|
+
null,
|
|
171
|
+
sex: pickString(obj.sex) || pickString(obj.gender) || null,
|
|
172
|
+
city: pickString(obj.city) || null,
|
|
173
|
+
constellation: pickString(obj.constellation) || null,
|
|
174
|
+
description:
|
|
175
|
+
pickString(obj.description) || pickString(obj.signature) || null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _signedGraphQL(cookie, operationName, variables) {
|
|
180
|
+
const body = JSON.stringify({
|
|
181
|
+
operationName,
|
|
182
|
+
variables,
|
|
183
|
+
query: "",
|
|
184
|
+
});
|
|
185
|
+
const rawUrl = new URL("graphql", this.baseUrl);
|
|
186
|
+
const purpose = `${operationName}|${body}`;
|
|
187
|
+
// signProvider.signUrl + signedHeaders sequential. KuaishouSignBridge
|
|
188
|
+
// caches kpf/kpn from signUrl call so signedHeaders returns them.
|
|
189
|
+
const signedUrl = await this.signProvider.signUrl(rawUrl, purpose);
|
|
190
|
+
if (!signedUrl) {
|
|
191
|
+
this._setLastError(
|
|
192
|
+
-99,
|
|
193
|
+
"__NS_sig3 unavailable (signProvider returned null — bridge not warm or rotated)",
|
|
194
|
+
);
|
|
195
|
+
this._fallbackHits += 1;
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const extraHeaders = await this.signProvider.signedHeaders(rawUrl, purpose);
|
|
199
|
+
this._bridgeHits += 1;
|
|
200
|
+
const headers = { ...BROWSER_HEADERS, ...extraHeaders, Cookie: cookie };
|
|
201
|
+
try {
|
|
202
|
+
const resp = await this._fetch(signedUrl.toString(), {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers,
|
|
205
|
+
body,
|
|
206
|
+
});
|
|
207
|
+
const respBody = await resp.text();
|
|
208
|
+
if (!resp.ok) {
|
|
209
|
+
this._setLastError(resp.status, `HTTP ${resp.status}`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const trimmed = respBody.trimStart();
|
|
213
|
+
if (!trimmed.startsWith("{")) {
|
|
214
|
+
this._setLastError(
|
|
215
|
+
-4,
|
|
216
|
+
"non-json (cookie expired or anti-bot triggered)",
|
|
217
|
+
);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
let obj;
|
|
221
|
+
try {
|
|
222
|
+
obj = JSON.parse(respBody);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
// GraphQL errors come back as {errors: [...]} with HTTP 200.
|
|
228
|
+
if (Array.isArray(obj.errors) && obj.errors.length > 0) {
|
|
229
|
+
const first = obj.errors[0];
|
|
230
|
+
const msg = (first && first.message) || "graphql error";
|
|
231
|
+
this._setLastError(-5, "graphql: " + msg);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
this._clearLastError();
|
|
235
|
+
return obj.data || null;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
this._setLastError(-2, "IO: " + (e.message || String(e)));
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* /graphql visionFeedRecommend — watch history (recommended feed user
|
|
244
|
+
* dwelled on). Requires __NS_sig3.
|
|
245
|
+
*/
|
|
246
|
+
async fetchWatchHistory(cookie, opts = {}) {
|
|
247
|
+
const limit =
|
|
248
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 50;
|
|
249
|
+
const data = await this._signedGraphQL(cookie, OP_FEED_RECOMMEND, {
|
|
250
|
+
pcursor: "",
|
|
251
|
+
count: limit,
|
|
252
|
+
});
|
|
253
|
+
if (!data) return [];
|
|
254
|
+
const feeds =
|
|
255
|
+
(data.visionFeedRecommend && data.visionFeedRecommend.feeds) || [];
|
|
256
|
+
return extractPhotoList(feeds, limit, (item, photo, photoId, caption, ts) => ({
|
|
257
|
+
photoId,
|
|
258
|
+
caption,
|
|
259
|
+
authorName:
|
|
260
|
+
(item.author && item.author.name) || null,
|
|
261
|
+
authorId:
|
|
262
|
+
(item.author && item.author.id) || null,
|
|
263
|
+
viewedAt: ts,
|
|
264
|
+
duration: Number.isFinite(photo.duration) ? photo.duration : 0,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* /graphql visionProfilePhotoList — user's own posted photos. Requires
|
|
270
|
+
* __NS_sig3.
|
|
271
|
+
*/
|
|
272
|
+
async fetchProfilePhotos(cookie, userId, opts = {}) {
|
|
273
|
+
const limit =
|
|
274
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
|
|
275
|
+
const data = await this._signedGraphQL(cookie, OP_PROFILE_PHOTOS, {
|
|
276
|
+
userId,
|
|
277
|
+
pcursor: "",
|
|
278
|
+
count: limit,
|
|
279
|
+
page: "profile",
|
|
280
|
+
});
|
|
281
|
+
if (!data) return [];
|
|
282
|
+
const feeds =
|
|
283
|
+
(data.visionProfilePhotoList && data.visionProfilePhotoList.feeds) || [];
|
|
284
|
+
return extractPhotoList(feeds, limit, (_item, _photo, photoId, caption, ts) => ({
|
|
285
|
+
photoId,
|
|
286
|
+
caption,
|
|
287
|
+
postedAt: ts,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* /graphql visionSearchPhoto — user's recent search keywords. Requires
|
|
293
|
+
* __NS_sig3.
|
|
294
|
+
*
|
|
295
|
+
* Two response shapes observed: data.recentSearchList vs data.history.
|
|
296
|
+
*/
|
|
297
|
+
async fetchSearchHistory(cookie, opts = {}) {
|
|
298
|
+
const limit =
|
|
299
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 50;
|
|
300
|
+
const data = await this._signedGraphQL(cookie, OP_SEARCH_PHOTO, {
|
|
301
|
+
keyword: "",
|
|
302
|
+
pcursor: "",
|
|
303
|
+
page: "search",
|
|
304
|
+
});
|
|
305
|
+
if (!data) return [];
|
|
306
|
+
const root = data.visionSearchPhoto || {};
|
|
307
|
+
const arr = Array.isArray(root.recentSearchList)
|
|
308
|
+
? root.recentSearchList
|
|
309
|
+
: Array.isArray(root.history)
|
|
310
|
+
? root.history
|
|
311
|
+
: [];
|
|
312
|
+
const out = [];
|
|
313
|
+
const cap = Math.min(limit, arr.length);
|
|
314
|
+
const now = this._now();
|
|
315
|
+
for (let i = 0; i < cap; i++) {
|
|
316
|
+
const raw = arr[i];
|
|
317
|
+
let keyword = null;
|
|
318
|
+
let ts = 0;
|
|
319
|
+
if (raw && typeof raw === "object") {
|
|
320
|
+
keyword = raw.keyword || raw.query || null;
|
|
321
|
+
ts = normalizeMs(raw.time || raw.searchTime || 0);
|
|
322
|
+
} else if (typeof raw === "string") {
|
|
323
|
+
keyword = raw;
|
|
324
|
+
ts = now - i * 1000;
|
|
325
|
+
}
|
|
326
|
+
if (!keyword) continue;
|
|
327
|
+
out.push({ keyword, searchedAt: ts });
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_setLastError(code, message) {
|
|
333
|
+
this.lastErrorCode = code;
|
|
334
|
+
this.lastErrorMessage = message;
|
|
335
|
+
}
|
|
336
|
+
_clearLastError() {
|
|
337
|
+
this.lastErrorCode = 0;
|
|
338
|
+
this.lastErrorMessage = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function extractPhotoList(feeds, limit, build) {
|
|
343
|
+
if (!Array.isArray(feeds)) return [];
|
|
344
|
+
const out = [];
|
|
345
|
+
const cap = Math.min(limit, feeds.length);
|
|
346
|
+
for (let i = 0; i < cap; i++) {
|
|
347
|
+
const item = feeds[i];
|
|
348
|
+
if (!item || typeof item !== "object") continue;
|
|
349
|
+
// Kuaishou GraphQL nests the photo under `photo`; flat fallback.
|
|
350
|
+
const photo =
|
|
351
|
+
item.photo && typeof item.photo === "object" ? item.photo : item;
|
|
352
|
+
const photoId = pickString(photo.id);
|
|
353
|
+
if (!photoId) continue;
|
|
354
|
+
const caption = pickString(photo.caption) || "(no caption)";
|
|
355
|
+
const ts = normalizeMs(photo.timestamp || photo.createTime || 0);
|
|
356
|
+
const built = build(item, photo, photoId, caption, ts);
|
|
357
|
+
if (built) out.push(built);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extractEmbeddedUid(cpRaw) {
|
|
363
|
+
let decoded;
|
|
364
|
+
try {
|
|
365
|
+
decoded = decodeURIComponent(cpRaw);
|
|
366
|
+
} catch {
|
|
367
|
+
decoded = cpRaw;
|
|
368
|
+
}
|
|
369
|
+
for (const pat of [
|
|
370
|
+
/"?user_id"?\s*:\s*"?(\d+)"?/,
|
|
371
|
+
/"?uid"?\s*:\s*"?(\d+)"?/,
|
|
372
|
+
/"?userId"?\s*:\s*"?(\d+)"?/,
|
|
373
|
+
]) {
|
|
374
|
+
const m = pat.exec(decoded);
|
|
375
|
+
if (m && m[1] && m[1] !== "0") return m[1];
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function pickString(v) {
|
|
381
|
+
if (typeof v !== "string") return null;
|
|
382
|
+
return v.length > 0 ? v : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = {
|
|
386
|
+
KuaishouApiClient,
|
|
387
|
+
_internals: {
|
|
388
|
+
BROWSER_UA,
|
|
389
|
+
BROWSER_HEADERS,
|
|
390
|
+
OP_FEED_RECOMMEND,
|
|
391
|
+
OP_PROFILE_PHOTOS,
|
|
392
|
+
OP_SEARCH_PHOTO,
|
|
393
|
+
normalizeMs,
|
|
394
|
+
extractPhotoList,
|
|
395
|
+
extractEmbeddedUid,
|
|
396
|
+
},
|
|
397
|
+
};
|