@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,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* social-weibo-adb — Phase 3 (Weibo C 路径) entry.
|
|
5
|
+
*
|
|
6
|
+
* Phase 3a (this commit) — desktop ADB cookies + m.weibo.cn HTTP path:
|
|
7
|
+
* - weibo.cookies extension (pulls Chromium cookies from Weibo App)
|
|
8
|
+
* - WeiboApiClient (Node port, 4 endpoints, no signing)
|
|
9
|
+
* - buildSnapshot (post / favourite / follow → schemaVersion=1)
|
|
10
|
+
* - collect / collectAndSync
|
|
11
|
+
*
|
|
12
|
+
* Pipeline:
|
|
13
|
+
* bridge.invoke("weibo.cookies")
|
|
14
|
+
* → WeiboApiClient.fetchUid (cookie has no inline UID)
|
|
15
|
+
* → fetchPosts + fetchFavourites + fetchFollows
|
|
16
|
+
* → buildSnapshot + writeSnapshotJson
|
|
17
|
+
* → registry.syncAdapter("social-weibo", { inputPath })
|
|
18
|
+
*
|
|
19
|
+
* Reuses the existing `social-weibo` adapter's snapshot mode — same
|
|
20
|
+
* vault schema / dedup / event types. No 2nd adapter.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
createWeiboCookiesExtension,
|
|
25
|
+
WEIBO_COOKIES_REMOTE_PATH,
|
|
26
|
+
WEIBO_COOKIE_HOST_DOMAIN,
|
|
27
|
+
WEIBO_REQUIRED_COOKIE,
|
|
28
|
+
assembleWeiboCookieHeader,
|
|
29
|
+
} = require("./cookies-extension");
|
|
30
|
+
const { WeiboApiClient } = require("./api-client");
|
|
31
|
+
const {
|
|
32
|
+
buildSnapshot,
|
|
33
|
+
writeSnapshotJson,
|
|
34
|
+
cleanupSnapshotJson,
|
|
35
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
36
|
+
} = require("./snapshot-builder");
|
|
37
|
+
const { collect, collectAndSync } = require("./collector");
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
// Extension factory (wiring registers this on the bridge)
|
|
41
|
+
createWeiboCookiesExtension,
|
|
42
|
+
WEIBO_COOKIES_REMOTE_PATH,
|
|
43
|
+
WEIBO_COOKIE_HOST_DOMAIN,
|
|
44
|
+
WEIBO_REQUIRED_COOKIE,
|
|
45
|
+
assembleWeiboCookieHeader,
|
|
46
|
+
// API client + builder
|
|
47
|
+
WeiboApiClient,
|
|
48
|
+
buildSnapshot,
|
|
49
|
+
writeSnapshotJson,
|
|
50
|
+
cleanupSnapshotJson,
|
|
51
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
52
|
+
// Collector orchestrator
|
|
53
|
+
collect,
|
|
54
|
+
collectAndSync,
|
|
55
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3a (Weibo C 路径 — 2026-05-25): API responses → snapshot JSON.
|
|
5
|
+
*
|
|
6
|
+
* Matches the existing `social-weibo` adapter's snapshot mode schema:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "schemaVersion": 1,
|
|
10
|
+
* "snapshottedAt": <ms>,
|
|
11
|
+
* "account": { "uid": "<numeric uid as string>", "displayName": "" },
|
|
12
|
+
* "events": [
|
|
13
|
+
* { "kind": "post", "id": "post-<mid>", "capturedAt": <ms>,
|
|
14
|
+
* "text", "mid", "source", "repostsCount", "commentsCount",
|
|
15
|
+
* "likesCount", "picCount" },
|
|
16
|
+
* { "kind": "favourite", "id": "fav-<mid>", "capturedAt": <ms>,
|
|
17
|
+
* "text", "mid", "authorScreenName" },
|
|
18
|
+
* { "kind": "follow", "id": "follow-<uid>", "capturedAt": <ms>,
|
|
19
|
+
* "uid", "screenName", "description", "avatarUrl" }
|
|
20
|
+
* ]
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Note: `follow` items don't have an authoritative timestamp from
|
|
24
|
+
* m.weibo.cn's /api/friendships/friends — we use snapshottedAt as
|
|
25
|
+
* fallback so the timestamp is at least monotonic per sync.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require("node:fs");
|
|
29
|
+
const path = require("node:path");
|
|
30
|
+
const os = require("node:os");
|
|
31
|
+
const crypto = require("node:crypto");
|
|
32
|
+
|
|
33
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
function buildSnapshot(input) {
|
|
36
|
+
if (!input || typeof input !== "object") {
|
|
37
|
+
throw new TypeError("buildSnapshot: input must be an object");
|
|
38
|
+
}
|
|
39
|
+
const uid = input.uid;
|
|
40
|
+
if (!Number.isFinite(uid) || uid <= 0) {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
"buildSnapshot: input.uid must be a positive integer (was " + uid + ")",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const snapshottedAt =
|
|
46
|
+
Number.isFinite(input.snapshottedAt) && input.snapshottedAt > 0
|
|
47
|
+
? input.snapshottedAt
|
|
48
|
+
: Date.now();
|
|
49
|
+
const account = {
|
|
50
|
+
uid: String(uid),
|
|
51
|
+
displayName:
|
|
52
|
+
typeof input.displayName === "string" ? input.displayName : "",
|
|
53
|
+
};
|
|
54
|
+
const events = [];
|
|
55
|
+
|
|
56
|
+
// posts
|
|
57
|
+
const posts = Array.isArray(input.posts) ? input.posts : [];
|
|
58
|
+
posts.forEach((p, idx) => {
|
|
59
|
+
if (!p || typeof p !== "object") return;
|
|
60
|
+
events.push({
|
|
61
|
+
kind: "post",
|
|
62
|
+
id: p.mid ? `post-${p.mid}` : `post-${idx}`,
|
|
63
|
+
capturedAt: typeof p.createdAt === "number" && p.createdAt > 0 ? p.createdAt : snapshottedAt,
|
|
64
|
+
text: p.text || null,
|
|
65
|
+
mid: p.mid || null,
|
|
66
|
+
source: p.source || null,
|
|
67
|
+
repostsCount: typeof p.repostsCount === "number" ? p.repostsCount : 0,
|
|
68
|
+
commentsCount:
|
|
69
|
+
typeof p.commentsCount === "number" ? p.commentsCount : 0,
|
|
70
|
+
likesCount: typeof p.likesCount === "number" ? p.likesCount : 0,
|
|
71
|
+
picCount: typeof p.picCount === "number" ? p.picCount : 0,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// favourites
|
|
76
|
+
const favs = Array.isArray(input.favourites) ? input.favourites : [];
|
|
77
|
+
favs.forEach((f, idx) => {
|
|
78
|
+
if (!f || typeof f !== "object") return;
|
|
79
|
+
events.push({
|
|
80
|
+
kind: "favourite",
|
|
81
|
+
id: f.mid ? `fav-${f.mid}` : `fav-${idx}`,
|
|
82
|
+
capturedAt: typeof f.favAt === "number" && f.favAt > 0 ? f.favAt : snapshottedAt,
|
|
83
|
+
text: f.text || null,
|
|
84
|
+
mid: f.mid || null,
|
|
85
|
+
authorScreenName: f.authorScreenName || null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// follows
|
|
90
|
+
const fols = Array.isArray(input.follows) ? input.follows : [];
|
|
91
|
+
fols.forEach((fol, idx) => {
|
|
92
|
+
if (!fol || typeof fol !== "object") return;
|
|
93
|
+
const followUid = typeof fol.uid === "number" ? fol.uid : null;
|
|
94
|
+
events.push({
|
|
95
|
+
kind: "follow",
|
|
96
|
+
id: followUid != null ? `follow-${followUid}` : `follow-${idx}`,
|
|
97
|
+
// /api/friendships/friends doesn't return follow time → fall back
|
|
98
|
+
capturedAt:
|
|
99
|
+
typeof fol.followedAt === "number" && fol.followedAt > 0
|
|
100
|
+
? fol.followedAt
|
|
101
|
+
: snapshottedAt,
|
|
102
|
+
uid: followUid != null ? followUid : null,
|
|
103
|
+
screenName: fol.screenName || null,
|
|
104
|
+
description: fol.description || null,
|
|
105
|
+
avatarUrl: fol.avatarUrl || null,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
111
|
+
snapshottedAt,
|
|
112
|
+
account,
|
|
113
|
+
events,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeSnapshotJson(snapshot, opts = {}) {
|
|
118
|
+
const dir = opts.dir || os.tmpdir();
|
|
119
|
+
const fileName =
|
|
120
|
+
opts.fileName || `cc-weibo-snapshot-${crypto.randomUUID()}.json`;
|
|
121
|
+
if (fileName.includes("/") || fileName.includes("\\")) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"writeSnapshotJson: opts.fileName must be a basename, not a path",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const full = path.join(dir, fileName);
|
|
127
|
+
fs.writeFileSync(full, JSON.stringify(snapshot), "utf-8");
|
|
128
|
+
return full;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cleanupSnapshotJson(filePath) {
|
|
132
|
+
if (!filePath) return;
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(filePath);
|
|
135
|
+
} catch (_e) {
|
|
136
|
+
// ignore
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
buildSnapshot,
|
|
142
|
+
writeSnapshotJson,
|
|
143
|
+
cleanupSnapshotJson,
|
|
144
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
145
|
+
};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3c (Xhs C 路径 — 2026-05-25): Node-side XhsApiClient.
|
|
5
|
+
*
|
|
6
|
+
* Byte-parity port of XhsApiClient.kt 4 endpoints. **Best-effort X-S
|
|
7
|
+
* signing** (~60% GET / <30% POST hit rate) — collector tolerates
|
|
8
|
+
* partial failures.
|
|
9
|
+
*
|
|
10
|
+
* Endpoints:
|
|
11
|
+
* - `/api/sns/web/v1/user/me` — no X-S, cookies-only
|
|
12
|
+
* - `/api/sns/web/v2/user_posted` — needs X-S
|
|
13
|
+
* - `/api/sns/web/v1/note/like/page` — needs X-S
|
|
14
|
+
* - `/api/sns/web/v1/user/follow/list` — needs X-S
|
|
15
|
+
*
|
|
16
|
+
* **Anti-bot signal**: User-Agent must look like desktop Chrome (xhs
|
|
17
|
+
* web is desktop-tuned, NOT mobile like Bilibili/Weibo). Referer +
|
|
18
|
+
* Origin = `https://www.xiaohongshu.com/`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { computeXsXt } = require("./sign");
|
|
22
|
+
const { NULL_SIGN_PROVIDER } = require("../../sign-providers");
|
|
23
|
+
|
|
24
|
+
const DEFAULT_BASE_URL = "https://edith.xiaohongshu.com/";
|
|
25
|
+
|
|
26
|
+
const BROWSER_UA =
|
|
27
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
28
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
29
|
+
|
|
30
|
+
const BROWSER_HEADERS = Object.freeze({
|
|
31
|
+
"User-Agent": BROWSER_UA,
|
|
32
|
+
Referer: "https://www.xiaohongshu.com/",
|
|
33
|
+
Origin: "https://www.xiaohongshu.com",
|
|
34
|
+
Accept: "application/json, text/plain, */*",
|
|
35
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse xhs's interact_info count strings: "1.2万" / "10w+" / "234" / "亿".
|
|
40
|
+
* Mirrors XhsApiClient.kt:parseCount.
|
|
41
|
+
*/
|
|
42
|
+
function parseCount(raw) {
|
|
43
|
+
if (typeof raw !== "string" || raw.length === 0) return 0;
|
|
44
|
+
const trimmed = raw.trim();
|
|
45
|
+
if (trimmed.endsWith("万")) {
|
|
46
|
+
const n = parseFloat(trimmed.slice(0, -1));
|
|
47
|
+
return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
|
|
48
|
+
}
|
|
49
|
+
if (trimmed.endsWith("w+") || trimmed.endsWith("W+")) {
|
|
50
|
+
const n = parseFloat(trimmed.slice(0, -2));
|
|
51
|
+
return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
|
|
52
|
+
}
|
|
53
|
+
if (trimmed.endsWith("w") || trimmed.endsWith("W")) {
|
|
54
|
+
const n = parseFloat(trimmed.slice(0, -1));
|
|
55
|
+
return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
|
|
56
|
+
}
|
|
57
|
+
if (trimmed.endsWith("亿")) {
|
|
58
|
+
const n = parseFloat(trimmed.slice(0, -1));
|
|
59
|
+
return Number.isFinite(n) ? Math.floor(n * 100_000_000) : 0;
|
|
60
|
+
}
|
|
61
|
+
const n = parseInt(trimmed, 10);
|
|
62
|
+
return Number.isFinite(n) ? n : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalize a Xhs timestamp to milliseconds (seconds → ms when < 1e12).
|
|
67
|
+
*/
|
|
68
|
+
function normalizeMs(v) {
|
|
69
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return 0;
|
|
70
|
+
return v > 1e12 ? v : v * 1000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class XhsApiClient {
|
|
74
|
+
constructor(opts = {}) {
|
|
75
|
+
this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
|
|
76
|
+
if (!this.baseUrl.endsWith("/")) this.baseUrl += "/";
|
|
77
|
+
this._fetch = opts.fetch || globalThis.fetch;
|
|
78
|
+
if (typeof this._fetch !== "function") {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"XhsApiClient: fetch not available — pass opts.fetch or run on Node 18+",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
this._now = opts.now || Date.now;
|
|
84
|
+
// Phase 6b: signProvider injectable. Desktop wiring injects
|
|
85
|
+
// XhsSignBridge (Electron WebContentsView running xhs.js, ~100% hit
|
|
86
|
+
// rate). CLI / tests get NULL_SIGN_PROVIDER → falls back to the
|
|
87
|
+
// in-process best-effort computeXsXt (~60% GET / <30% POST hit).
|
|
88
|
+
// Both code paths are present so the client works in either context
|
|
89
|
+
// without the caller having to swap api-client implementations.
|
|
90
|
+
this.signProvider = opts.signProvider || NULL_SIGN_PROVIDER;
|
|
91
|
+
this.lastErrorCode = 0;
|
|
92
|
+
this.lastErrorMessage = null;
|
|
93
|
+
// Diagnostic counters — collector reads these to decide whether to
|
|
94
|
+
// surface "bridge upgrade succeeded" in the report.
|
|
95
|
+
this._bridgeHits = 0;
|
|
96
|
+
this._fallbackHits = 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _doGetJson(url, cookie, a1, requireSign) {
|
|
100
|
+
const headers = { ...BROWSER_HEADERS, Cookie: cookie };
|
|
101
|
+
if (requireSign && a1) {
|
|
102
|
+
const pathWithQuery = url.pathname + url.search;
|
|
103
|
+
// Phase 6b: prefer bridge over in-process computeXsXt.
|
|
104
|
+
// signedHeaders is async — bridge does executeJavaScript across
|
|
105
|
+
// Electron IPC. Returns {} on cold bridge / xhs.js rotation / IPC
|
|
106
|
+
// error, in which case we fall back to the best-effort md5.
|
|
107
|
+
const bridgeHeaders = await this.signProvider.signedHeaders(
|
|
108
|
+
url,
|
|
109
|
+
`${pathWithQuery}|`,
|
|
110
|
+
);
|
|
111
|
+
const bridgeKeys = Object.keys(bridgeHeaders);
|
|
112
|
+
if (bridgeKeys.length > 0) {
|
|
113
|
+
// Bridge produced headers — use them verbatim. xhs.js returns
|
|
114
|
+
// X-s / X-t (lowercase t in some builds) / X-s-common; we let
|
|
115
|
+
// the bridge's normalizeXhsHeader handle case.
|
|
116
|
+
Object.assign(headers, bridgeHeaders);
|
|
117
|
+
this._bridgeHits += 1;
|
|
118
|
+
} else {
|
|
119
|
+
// Fallback: in-process best-effort md5 (P3c path).
|
|
120
|
+
const { xs, xt } = computeXsXt(pathWithQuery, null, a1, {
|
|
121
|
+
now: this._now,
|
|
122
|
+
});
|
|
123
|
+
headers["X-S"] = xs;
|
|
124
|
+
headers["X-T"] = xt;
|
|
125
|
+
this._fallbackHits += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const resp = await this._fetch(url.toString(), {
|
|
130
|
+
method: "GET",
|
|
131
|
+
headers,
|
|
132
|
+
});
|
|
133
|
+
const body = await resp.text();
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
this._setLastError(resp.status, `HTTP ${resp.status}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const trimmed = body.trimStart();
|
|
139
|
+
if (!trimmed.startsWith("{")) {
|
|
140
|
+
this._setLastError(-4, "non-json (login redirect / anti-bot HTML)");
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
let obj;
|
|
144
|
+
try {
|
|
145
|
+
obj = JSON.parse(body);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// xhs returns either {code: N, msg:..., data:...} or {success:bool, code:N, data:...}
|
|
151
|
+
const success = obj.success === undefined ? true : obj.success;
|
|
152
|
+
if (success === false) {
|
|
153
|
+
this._setLastError(-5, "/success=false (no code)");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const code = typeof obj.code === "number" ? obj.code : 0;
|
|
157
|
+
if (code !== 0) {
|
|
158
|
+
this._setLastError(code, (obj.msg || "").toString());
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
this._clearLastError();
|
|
162
|
+
return obj;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
this._setLastError(-2, "IO: " + (e.message || String(e)));
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_setLastError(code, message) {
|
|
170
|
+
this.lastErrorCode = code;
|
|
171
|
+
this.lastErrorMessage = message;
|
|
172
|
+
}
|
|
173
|
+
_clearLastError() {
|
|
174
|
+
this.lastErrorCode = 0;
|
|
175
|
+
this.lastErrorMessage = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fetch /api/sns/web/v1/user/me — cookies-only, no X-S required.
|
|
180
|
+
* Returns `{userId, nickname}` or null on failure.
|
|
181
|
+
*/
|
|
182
|
+
async fetchMe(cookie) {
|
|
183
|
+
const url = new URL("api/sns/web/v1/user/me", this.baseUrl);
|
|
184
|
+
const obj = await this._doGetJson(url, cookie, null, false);
|
|
185
|
+
if (!obj) return null;
|
|
186
|
+
const data = obj.data || {};
|
|
187
|
+
const userId = (data.user_id && String(data.user_id)) || null;
|
|
188
|
+
if (!userId) {
|
|
189
|
+
this._setLastError(
|
|
190
|
+
-7,
|
|
191
|
+
"/user/me ok but user_id blank (cookie likely missing web_session)",
|
|
192
|
+
);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
userId,
|
|
197
|
+
nickname: data.nickname || null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch user's posted notes. Requires X-S signing.
|
|
203
|
+
*/
|
|
204
|
+
async fetchNotes(cookie, a1, userId, opts = {}) {
|
|
205
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 30;
|
|
206
|
+
const url = new URL("api/sns/web/v2/user_posted", this.baseUrl);
|
|
207
|
+
url.searchParams.set("user_id", userId);
|
|
208
|
+
url.searchParams.set("num", "30");
|
|
209
|
+
url.searchParams.set("cursor", "");
|
|
210
|
+
url.searchParams.set("image_formats", "jpg,webp,avif");
|
|
211
|
+
const obj = await this._doGetJson(url, cookie, a1, true);
|
|
212
|
+
if (!obj) return [];
|
|
213
|
+
const data = obj.data || {};
|
|
214
|
+
const notes = Array.isArray(data.notes) ? data.notes : [];
|
|
215
|
+
const out = [];
|
|
216
|
+
for (let i = 0; i < Math.min(limit, notes.length); i++) {
|
|
217
|
+
const n = notes[i];
|
|
218
|
+
if (!n) continue;
|
|
219
|
+
const noteId =
|
|
220
|
+
(n.note_id && String(n.note_id)) || (n.id && String(n.id));
|
|
221
|
+
if (!noteId) continue;
|
|
222
|
+
const interact = n.interact_info || {};
|
|
223
|
+
out.push({
|
|
224
|
+
noteId,
|
|
225
|
+
title:
|
|
226
|
+
n.display_title ||
|
|
227
|
+
n.title ||
|
|
228
|
+
"(no title)",
|
|
229
|
+
desc: n.desc || null,
|
|
230
|
+
type: n.type || "normal",
|
|
231
|
+
createdAt: normalizeMs(typeof n.time === "number" ? n.time : 0),
|
|
232
|
+
likedCount: parseCount(interact.liked_count),
|
|
233
|
+
collectedCount: parseCount(interact.collected_count),
|
|
234
|
+
commentCount: parseCount(interact.comment_count),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fetch user's liked notes. Requires X-S.
|
|
242
|
+
*/
|
|
243
|
+
async fetchLiked(cookie, a1, opts = {}) {
|
|
244
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 30;
|
|
245
|
+
const url = new URL("api/sns/web/v1/note/like/page", this.baseUrl);
|
|
246
|
+
url.searchParams.set("num", "20");
|
|
247
|
+
url.searchParams.set("cursor", "");
|
|
248
|
+
const obj = await this._doGetJson(url, cookie, a1, true);
|
|
249
|
+
if (!obj) return [];
|
|
250
|
+
const data = obj.data || {};
|
|
251
|
+
const notes = Array.isArray(data.notes) ? data.notes : [];
|
|
252
|
+
const out = [];
|
|
253
|
+
for (let i = 0; i < Math.min(limit, notes.length); i++) {
|
|
254
|
+
const n = notes[i];
|
|
255
|
+
if (!n) continue;
|
|
256
|
+
const noteId = n.note_id && String(n.note_id);
|
|
257
|
+
if (!noteId) continue;
|
|
258
|
+
const user = n.user || {};
|
|
259
|
+
out.push({
|
|
260
|
+
noteId,
|
|
261
|
+
title: n.display_title || n.title || "(no title)",
|
|
262
|
+
// xhs doesn't return explicit liked_at — collector fills with snapshotted_at
|
|
263
|
+
likedAt: 0,
|
|
264
|
+
authorNickname: user.nickname || null,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fetch follow list. Requires X-S.
|
|
272
|
+
*/
|
|
273
|
+
async fetchFollows(cookie, a1, userId, opts = {}) {
|
|
274
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
|
|
275
|
+
const url = new URL("api/sns/web/v1/user/follow/list", this.baseUrl);
|
|
276
|
+
url.searchParams.set("user_id", userId);
|
|
277
|
+
url.searchParams.set("num", "20");
|
|
278
|
+
url.searchParams.set("cursor", "");
|
|
279
|
+
const obj = await this._doGetJson(url, cookie, a1, true);
|
|
280
|
+
if (!obj) return [];
|
|
281
|
+
const data = obj.data || {};
|
|
282
|
+
const users = Array.isArray(data.users) ? data.users : [];
|
|
283
|
+
const out = [];
|
|
284
|
+
for (let i = 0; i < Math.min(limit, users.length); i++) {
|
|
285
|
+
const u = users[i];
|
|
286
|
+
if (!u) continue;
|
|
287
|
+
const userIdStr = u.user_id && String(u.user_id);
|
|
288
|
+
if (!userIdStr) continue;
|
|
289
|
+
out.push({
|
|
290
|
+
userId: userIdStr,
|
|
291
|
+
nickname: u.nickname || "(unnamed)",
|
|
292
|
+
image: u.image || null,
|
|
293
|
+
// xhs doesn't return explicit follow time
|
|
294
|
+
followedAt: 0,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
XhsApiClient,
|
|
303
|
+
_internals: {
|
|
304
|
+
parseCount,
|
|
305
|
+
normalizeMs,
|
|
306
|
+
BROWSER_UA,
|
|
307
|
+
BROWSER_HEADERS,
|
|
308
|
+
},
|
|
309
|
+
};
|