@chainlesschain/personal-data-hub 0.3.6 → 0.3.8
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/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-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
- package/__tests__/analysis.test.js +239 -14
- package/__tests__/query-parser.test.js +86 -0
- package/__tests__/vault.test.js +88 -0
- package/lib/adapters/ai-chat-history/health-checker.js +11 -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-xiaohongshu-adb/api-client.js +36 -5
- package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
- package/lib/analysis.js +154 -17
- package/lib/query-parser.js +93 -0
- package/lib/vault.js +64 -0
- package/package.json +5 -1
|
@@ -7,7 +7,9 @@ const {
|
|
|
7
7
|
parseTimeWindow,
|
|
8
8
|
parseFilters,
|
|
9
9
|
parseIntent,
|
|
10
|
+
parseEntityFocus,
|
|
10
11
|
extractEntityTerm,
|
|
12
|
+
extractPersonNameCandidate,
|
|
11
13
|
} = require("../lib/query-parser");
|
|
12
14
|
|
|
13
15
|
// Pin "now" to 2026-05-19 12:00:00 UTC for deterministic windows
|
|
@@ -126,6 +128,38 @@ describe("parseIntent", () => {
|
|
|
126
128
|
});
|
|
127
129
|
});
|
|
128
130
|
|
|
131
|
+
describe("parseEntityFocus", () => {
|
|
132
|
+
it("returns 'persons' for 联系人 / 通讯录 phrasing", () => {
|
|
133
|
+
expect(parseEntityFocus("我有哪些联系人")).toBe("persons");
|
|
134
|
+
expect(parseEntityFocus("通讯录里有多少人")).toBe("persons");
|
|
135
|
+
expect(parseEntityFocus("好友列表谁是张三")).toBe("persons");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns 'persons' for phone-number phrasing", () => {
|
|
139
|
+
expect(parseEntityFocus("妈手机号是多少")).toBe("persons");
|
|
140
|
+
expect(parseEntityFocus("王医生的电话号码")).toBe("persons");
|
|
141
|
+
expect(parseEntityFocus("show me my contacts")).toBe("persons");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns 'items' for installed-app phrasing", () => {
|
|
145
|
+
expect(parseEntityFocus("我装了哪些 app")).toBe("items");
|
|
146
|
+
expect(parseEntityFocus("有哪些游戏")).toBe("items");
|
|
147
|
+
expect(parseEntityFocus("installed apps")).toBe("items");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns null when no focus signal", () => {
|
|
151
|
+
expect(parseEntityFocus("上个月在淘宝花了多少")).toBeNull();
|
|
152
|
+
expect(parseEntityFocus("最近的订单")).toBeNull();
|
|
153
|
+
expect(parseEntityFocus("hello")).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns null for non-string / empty input", () => {
|
|
157
|
+
expect(parseEntityFocus("")).toBeNull();
|
|
158
|
+
expect(parseEntityFocus(null)).toBeNull();
|
|
159
|
+
expect(parseEntityFocus(undefined)).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
129
163
|
describe("parseQuery (integration)", () => {
|
|
130
164
|
it("full parse for spending question", () => {
|
|
131
165
|
const r = parseQuery("上个月在淘宝总共花了多少钱?", { now: NOW });
|
|
@@ -133,6 +167,13 @@ describe("parseQuery (integration)", () => {
|
|
|
133
167
|
expect(r.filters.subtype).toBe("payment");
|
|
134
168
|
expect(r.filters.adapter).toBe("taobao");
|
|
135
169
|
expect(r.intent).toBe("sum-amount");
|
|
170
|
+
expect(r.entityFocus).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("contact question carries entityFocus=persons", () => {
|
|
174
|
+
const r = parseQuery("我有哪些联系人", { now: NOW });
|
|
175
|
+
expect(r.entityFocus).toBe("persons");
|
|
176
|
+
expect(r.intent).toBe("list");
|
|
136
177
|
});
|
|
137
178
|
|
|
138
179
|
it("full parse for footprint question", () => {
|
|
@@ -214,3 +255,48 @@ describe("extractEntityTerm", () => {
|
|
|
214
255
|
expect(r).toBeNull();
|
|
215
256
|
});
|
|
216
257
|
});
|
|
258
|
+
|
|
259
|
+
// ─── extractPersonNameCandidate — persons-branch name search ─────────────
|
|
260
|
+
//
|
|
261
|
+
// 2026-05-27 — Powers AnalysisEngine entityFocus=persons name-search
|
|
262
|
+
// short-circuit. Differs from extractEntityTerm in two ways: strips
|
|
263
|
+
// person-FOCUS framing words first (联系人/手机号/etc.) and allows
|
|
264
|
+
// single-char Chinese names from a relation whitelist (妈/爸/姐/...).
|
|
265
|
+
|
|
266
|
+
describe("extractPersonNameCandidate", () => {
|
|
267
|
+
it("extracts multi-char name when present", () => {
|
|
268
|
+
expect(extractPersonNameCandidate("张三的电话号码")).toBe("张三");
|
|
269
|
+
expect(extractPersonNameCandidate("王医生手机号是多少")).toBe("王医生");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("falls back to single-char relation word ('妈', '爸', '姐')", () => {
|
|
273
|
+
expect(extractPersonNameCandidate("妈手机号是多少")).toBe("妈");
|
|
274
|
+
expect(extractPersonNameCandidate("爸的电话")).toBe("爸");
|
|
275
|
+
expect(extractPersonNameCandidate("姐姐的号码")).toBe("姐姐");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("multi-char wins over single-char fallback", () => {
|
|
279
|
+
// "王医生" (3 char) preferred over leaked single "医" / "生".
|
|
280
|
+
expect(extractPersonNameCandidate("王医生的手机号")).toBe("王医生");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns null when no name candidate (pure framing)", () => {
|
|
284
|
+
expect(extractPersonNameCandidate("我有哪些联系人")).toBeNull();
|
|
285
|
+
expect(extractPersonNameCandidate("通讯录里有多少人")).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("ignores single-char Chinese outside the relation whitelist", () => {
|
|
289
|
+
// "说" / "看" are not relation chars — should NOT slip through as names.
|
|
290
|
+
expect(extractPersonNameCandidate("说手机号")).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("returns null for non-string / empty input", () => {
|
|
294
|
+
expect(extractPersonNameCandidate("")).toBeNull();
|
|
295
|
+
expect(extractPersonNameCandidate(null)).toBeNull();
|
|
296
|
+
expect(extractPersonNameCandidate(undefined)).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("handles ASCII names ≥2 chars", () => {
|
|
300
|
+
expect(extractPersonNameCandidate("Alice 的电话号码")).toBe("Alice");
|
|
301
|
+
});
|
|
302
|
+
});
|
package/__tests__/vault.test.js
CHANGED
|
@@ -425,6 +425,94 @@ describe("LocalVault.queryEvents + countEvents", () => {
|
|
|
425
425
|
});
|
|
426
426
|
});
|
|
427
427
|
|
|
428
|
+
// ─── searchPersons (LIKE name search) ────────────────────────────────────
|
|
429
|
+
//
|
|
430
|
+
// 2026-05-27 — AnalysisEngine entityFocus="persons" routes to searchPersons
|
|
431
|
+
// when the question carries a name candidate ("妈手机号", "张三的电话").
|
|
432
|
+
// LIKE on names / identifiers / notes / relation, no FTS5 migration.
|
|
433
|
+
|
|
434
|
+
describe("LocalVault.searchPersons", () => {
|
|
435
|
+
it("matches against names column (JSON-serialized array)", () => {
|
|
436
|
+
freshVault();
|
|
437
|
+
vault.putPerson(personOk({ names: ["妈妈", "陈某某"] }));
|
|
438
|
+
vault.putPerson(personOk({ names: ["张三"] }));
|
|
439
|
+
vault.putPerson(personOk({ names: ["王医生"] }));
|
|
440
|
+
|
|
441
|
+
const r = vault.searchPersons({ q: "妈" });
|
|
442
|
+
expect(r.length).toBe(1);
|
|
443
|
+
expect(r[0].names).toContain("妈妈");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("matches against identifiers (phone numbers)", () => {
|
|
447
|
+
freshVault();
|
|
448
|
+
vault.putPerson(personOk({
|
|
449
|
+
names: ["张三"],
|
|
450
|
+
identifiers: { phone: ["13800001111"] },
|
|
451
|
+
}));
|
|
452
|
+
vault.putPerson(personOk({
|
|
453
|
+
names: ["李四"],
|
|
454
|
+
identifiers: { phone: ["13900002222"] },
|
|
455
|
+
}));
|
|
456
|
+
|
|
457
|
+
const r = vault.searchPersons({ q: "13800" });
|
|
458
|
+
expect(r.length).toBe(1);
|
|
459
|
+
expect(r[0].names).toContain("张三");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("matches against notes + relation", () => {
|
|
463
|
+
freshVault();
|
|
464
|
+
vault.putPerson(personOk({
|
|
465
|
+
names: ["陈某某"], relation: "母亲", notes: "best mom ever",
|
|
466
|
+
}));
|
|
467
|
+
vault.putPerson(personOk({ names: ["路人甲"], relation: "stranger" }));
|
|
468
|
+
|
|
469
|
+
expect(vault.searchPersons({ q: "母亲" }).length).toBe(1);
|
|
470
|
+
expect(vault.searchPersons({ q: "best mom" }).length).toBe(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("empty q delegates to queryPersons (ingest-ordered)", () => {
|
|
474
|
+
freshVault();
|
|
475
|
+
vault.putPerson(personOk({ names: ["A"] }));
|
|
476
|
+
vault.putPerson(personOk({ names: ["B"] }));
|
|
477
|
+
vault.putPerson(personOk({ names: ["C"] }));
|
|
478
|
+
|
|
479
|
+
const r = vault.searchPersons({ q: "", limit: 2 });
|
|
480
|
+
expect(r.length).toBe(2);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("LIKE meta-characters in user input are escaped (no wildcard injection)", () => {
|
|
484
|
+
freshVault();
|
|
485
|
+
vault.putPerson(personOk({ names: ["100%棉"] }));
|
|
486
|
+
vault.putPerson(personOk({ names: ["AAA"] }));
|
|
487
|
+
|
|
488
|
+
// "100%" should match only the literal "100%棉" row, not everything.
|
|
489
|
+
const r = vault.searchPersons({ q: "100%" });
|
|
490
|
+
expect(r.length).toBe(1);
|
|
491
|
+
expect(r[0].names).toContain("100%棉");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("respects subtype + adapter filters", () => {
|
|
495
|
+
freshVault();
|
|
496
|
+
vault.putPerson(personOk({
|
|
497
|
+
subtype: "contact", names: ["张三"],
|
|
498
|
+
source: source({ adapter: "wechat" }),
|
|
499
|
+
}));
|
|
500
|
+
vault.putPerson(personOk({
|
|
501
|
+
subtype: "merchant", names: ["张三"],
|
|
502
|
+
source: source({ adapter: "system-data-android" }),
|
|
503
|
+
}));
|
|
504
|
+
|
|
505
|
+
expect(vault.searchPersons({ q: "张三", subtype: "merchant" }).length).toBe(1);
|
|
506
|
+
expect(vault.searchPersons({ q: "张三", adapter: "wechat" }).length).toBe(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("returns empty array when no match", () => {
|
|
510
|
+
freshVault();
|
|
511
|
+
vault.putPerson(personOk({ names: ["张三"] }));
|
|
512
|
+
expect(vault.searchPersons({ q: "完全不存在的名字" })).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
428
516
|
// ─── sync watermarks ──────────────────────────────────────────────────────
|
|
429
517
|
|
|
430
518
|
describe("LocalVault sync watermarks", () => {
|
|
@@ -172,7 +172,18 @@ function createAIChatHealthChecker({
|
|
|
172
172
|
deps.logger.error("[aichat-health] interval run failed", err && err.message),
|
|
173
173
|
);
|
|
174
174
|
}, intervalMs);
|
|
175
|
+
// Don't keep the event loop alive on the periodic check alone. Without
|
|
176
|
+
// unref a one-shot `cc hub list-adapters --json` from in-APK Android
|
|
177
|
+
// sits idle in epoll_wait until Kotlin LocalCcRunner.waitFor 240s
|
|
178
|
+
// timeout → false "写入本地数据库失败". Real-device repro 2026-05-27
|
|
179
|
+
// Xiaomi 24115RA8EC (PID 24828 lingered with vault.db RW handles).
|
|
180
|
+
if (intervalHandle && typeof intervalHandle.unref === "function") {
|
|
181
|
+
intervalHandle.unref();
|
|
182
|
+
}
|
|
175
183
|
}, firstRunDelayMs);
|
|
184
|
+
if (firstRunHandle && typeof firstRunHandle.unref === "function") {
|
|
185
|
+
firstRunHandle.unref();
|
|
186
|
+
}
|
|
176
187
|
return true;
|
|
177
188
|
}
|
|
178
189
|
|
|
@@ -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
|
+
};
|