@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,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3a (Weibo C 路径 — 2026-05-25): Node-side WeiboApiClient.
|
|
5
|
+
*
|
|
6
|
+
* Byte-parity port of
|
|
7
|
+
* `android-app/.../pdh/social/weibo/WeiboApiClient.kt` for the desktop
|
|
8
|
+
* PC + ADB path. Same m.weibo.cn endpoints, same headers, same JSON
|
|
9
|
+
* parse shape. Lockstep with the Kotlin version — if a real-device trap
|
|
10
|
+
* surfaces fix both sides.
|
|
11
|
+
*
|
|
12
|
+
* **Key differences from Bilibili Phase 1b**:
|
|
13
|
+
* 1. **No WBI signing** — m.weibo.cn mobile API requires cookie + UA +
|
|
14
|
+
* XHR header but no signature. Simpler client, no /nav handshake.
|
|
15
|
+
* 2. **UID via /api/config** — Weibo cookie has no DedeUserID equivalent;
|
|
16
|
+
* fetchUid() must do an HTTP roundtrip and persist the result.
|
|
17
|
+
* 3. **Time field is ISO 8601** — "Sun Jan 12 13:45:00 +0800 2026"
|
|
18
|
+
* format (not unix seconds like Bilibili). Java's SimpleDateFormat
|
|
19
|
+
* parses it; Node's Date can too once we know the format.
|
|
20
|
+
* 4. **Timeline endpoint via containerid** — user posts go through
|
|
21
|
+
* /api/container/getIndex?containerid=107603<uid>, not a dedicated
|
|
22
|
+
* /api/posts.
|
|
23
|
+
* 5. **Anti-bot signal**: missing `X-Requested-With: XMLHttpRequest` +
|
|
24
|
+
* `MWeibo-Pwa: 1` → 30x redirect to login HTML.
|
|
25
|
+
*
|
|
26
|
+
* 4 endpoints:
|
|
27
|
+
* - config /api/config (fetchUid + login state check)
|
|
28
|
+
* - posts /api/container/getIndex?type=uid&value=<uid>&containerid=107603<uid>
|
|
29
|
+
* - favourites /api/favorites?page=1
|
|
30
|
+
* - follows /api/friendships/friends?uid=<uid>&page=1
|
|
31
|
+
*
|
|
32
|
+
* Errors don't throw — endpoints that fail return [] and lastErrorCode +
|
|
33
|
+
* lastErrorMessage surface the cause for partial-result diagnostics.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_BASE_URL = "https://m.weibo.cn/";
|
|
37
|
+
|
|
38
|
+
// Pinned Chrome 120 mobile UA — must look like a browser, default
|
|
39
|
+
// `node-fetch/x.y.z` returns -100 silentband.
|
|
40
|
+
const BROWSER_UA =
|
|
41
|
+
"Mozilla/5.0 (Linux; Android 14; ChainlessChain) AppleWebKit/537.36 " +
|
|
42
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
|
|
43
|
+
|
|
44
|
+
const BROWSER_HEADERS = Object.freeze({
|
|
45
|
+
"User-Agent": BROWSER_UA,
|
|
46
|
+
Referer: "https://m.weibo.cn/",
|
|
47
|
+
Accept: "application/json, text/plain, */*",
|
|
48
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
49
|
+
// m.weibo.cn anti-bot: missing these → HTML redirect, not JSON
|
|
50
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
51
|
+
"MWeibo-Pwa": "1",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse Weibo's ISO-8601-ish timestamp.
|
|
56
|
+
* "Sun Jan 12 13:45:00 +0800 2026" → epoch ms
|
|
57
|
+
* "1716383021" → epoch ms (× 1000 since it's < 1e12)
|
|
58
|
+
* "1716383021000" → epoch ms (verbatim)
|
|
59
|
+
*
|
|
60
|
+
* Mirrors WeiboApiClient.kt:parseWeiboTime.
|
|
61
|
+
*/
|
|
62
|
+
function parseWeiboTime(raw) {
|
|
63
|
+
if (typeof raw !== "string" || raw.length === 0) return 0;
|
|
64
|
+
// Digits-only fallback — Weibo occasionally serves unix-seconds verbatim
|
|
65
|
+
if (/^\d+$/.test(raw)) {
|
|
66
|
+
const n = parseInt(raw, 10);
|
|
67
|
+
return n > 1e12 ? n : n * 1000;
|
|
68
|
+
}
|
|
69
|
+
// "EEE MMM dd HH:mm:ss Z yyyy" — JS Date.parse handles this in V8 / Node.
|
|
70
|
+
const t = Date.parse(raw);
|
|
71
|
+
return Number.isFinite(t) ? t : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Strip HTML from Weibo's `text` field (contains <a>, <span>, etc.).
|
|
76
|
+
* Mirrors WeiboApiClient.kt:stripHtml.
|
|
77
|
+
*/
|
|
78
|
+
function stripHtml(raw) {
|
|
79
|
+
if (typeof raw !== "string" || raw.length === 0) return "";
|
|
80
|
+
return raw
|
|
81
|
+
.replace(/<[^>]+>/g, "")
|
|
82
|
+
.replace(/ /g, " ")
|
|
83
|
+
.replace(/&/g, "&")
|
|
84
|
+
.replace(/</g, "<")
|
|
85
|
+
.replace(/>/g, ">")
|
|
86
|
+
.replace(/"/g, '"')
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class WeiboApiClient {
|
|
91
|
+
constructor(opts = {}) {
|
|
92
|
+
this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
|
|
93
|
+
if (!this.baseUrl.endsWith("/")) this.baseUrl += "/";
|
|
94
|
+
this._fetch = opts.fetch || globalThis.fetch;
|
|
95
|
+
if (typeof this._fetch !== "function") {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"WeiboApiClient: fetch not available — pass opts.fetch or run on Node 18+",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
this.lastErrorCode = 0;
|
|
101
|
+
this.lastErrorMessage = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* GET <url> with browser-like headers. Mirrors Kotlin doGetJson —
|
|
106
|
+
* including the non-JSON-body check (Weibo redirects to login HTML
|
|
107
|
+
* when cookie expired).
|
|
108
|
+
*/
|
|
109
|
+
async _doGetJson(url, cookie) {
|
|
110
|
+
try {
|
|
111
|
+
const resp = await this._fetch(url.toString(), {
|
|
112
|
+
method: "GET",
|
|
113
|
+
headers: { ...BROWSER_HEADERS, Cookie: cookie },
|
|
114
|
+
});
|
|
115
|
+
const body = await resp.text();
|
|
116
|
+
if (!resp.ok) {
|
|
117
|
+
this._setLastError(resp.status, `HTTP ${resp.status}`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const trimmed = body.trimStart();
|
|
121
|
+
if (!trimmed.startsWith("{")) {
|
|
122
|
+
// Login redirect / anti-bot HTML — cookie expired or anti-spider hit
|
|
123
|
+
this._setLastError(-4, "non-json (cookie expired?)");
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
let obj;
|
|
127
|
+
try {
|
|
128
|
+
obj = JSON.parse(body);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
this._setLastError(-3, "parse: " + (e.message || String(e)));
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const ok = typeof obj.ok === "number" ? obj.ok : 1;
|
|
134
|
+
if (ok !== 1) {
|
|
135
|
+
this._setLastError(ok, (obj.msg || "").toString());
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
this._clearLastError();
|
|
139
|
+
return obj;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
this._setLastError(-2, "IO: " + (e.message || String(e)));
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_setLastError(code, message) {
|
|
147
|
+
this.lastErrorCode = code;
|
|
148
|
+
this.lastErrorMessage = message;
|
|
149
|
+
}
|
|
150
|
+
_clearLastError() {
|
|
151
|
+
this.lastErrorCode = 0;
|
|
152
|
+
this.lastErrorMessage = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetch /api/config to get UID + validate login state. Returns numeric
|
|
157
|
+
* UID on success, null on failure (cookie expired / not logged in).
|
|
158
|
+
* Mirrors WeiboApiClient.kt:fetchUid.
|
|
159
|
+
*/
|
|
160
|
+
async fetchUid(cookie) {
|
|
161
|
+
const url = new URL("api/config", this.baseUrl);
|
|
162
|
+
const obj = await this._doGetJson(url, cookie);
|
|
163
|
+
if (!obj) return null;
|
|
164
|
+
const data = obj.data || {};
|
|
165
|
+
if (!data.login) return null;
|
|
166
|
+
const uidStr = data.uid;
|
|
167
|
+
const uid = parseInt(uidStr, 10);
|
|
168
|
+
return Number.isFinite(uid) && uid > 0 ? uid : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Fetch the user's own posts (timeline). Mirrors fetchPosts —
|
|
173
|
+
* containerid=107603<uid> is the magic "user's own mblog" container.
|
|
174
|
+
*/
|
|
175
|
+
async fetchPosts(cookie, uid, opts = {}) {
|
|
176
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
|
|
177
|
+
const containerid = `107603${uid}`;
|
|
178
|
+
const url = new URL("api/container/getIndex", this.baseUrl);
|
|
179
|
+
url.searchParams.set("type", "uid");
|
|
180
|
+
url.searchParams.set("value", String(uid));
|
|
181
|
+
url.searchParams.set("containerid", containerid);
|
|
182
|
+
const obj = await this._doGetJson(url, cookie);
|
|
183
|
+
if (!obj) return [];
|
|
184
|
+
const data = obj.data || {};
|
|
185
|
+
const cards = Array.isArray(data.cards) ? data.cards : [];
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const card of cards) {
|
|
188
|
+
if (out.length >= limit) break;
|
|
189
|
+
if (!card || card.card_type !== 9) continue; // card_type=9 = mblog
|
|
190
|
+
const blog = card.mblog;
|
|
191
|
+
if (!blog) continue;
|
|
192
|
+
const mid = (blog.mid && String(blog.mid)) || (blog.id && String(blog.id));
|
|
193
|
+
if (!mid) continue;
|
|
194
|
+
out.push({
|
|
195
|
+
mid,
|
|
196
|
+
text: stripHtml(blog.text),
|
|
197
|
+
createdAt: parseWeiboTime(blog.created_at),
|
|
198
|
+
source: blog.source || null,
|
|
199
|
+
repostsCount: typeof blog.reposts_count === "number" ? blog.reposts_count : 0,
|
|
200
|
+
commentsCount:
|
|
201
|
+
typeof blog.comments_count === "number" ? blog.comments_count : 0,
|
|
202
|
+
likesCount:
|
|
203
|
+
typeof blog.attitudes_count === "number" ? blog.attitudes_count : 0,
|
|
204
|
+
picCount: typeof blog.pic_num === "number" ? blog.pic_num : 0,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Mirrors fetchFavourites. */
|
|
211
|
+
async fetchFavourites(cookie, opts = {}) {
|
|
212
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
|
|
213
|
+
const url = new URL("api/favorites", this.baseUrl);
|
|
214
|
+
url.searchParams.set("page", "1");
|
|
215
|
+
const obj = await this._doGetJson(url, cookie);
|
|
216
|
+
if (!obj) return [];
|
|
217
|
+
const data = obj.data || {};
|
|
218
|
+
const favs = Array.isArray(data.favorites) ? data.favorites : [];
|
|
219
|
+
const out = [];
|
|
220
|
+
for (let i = 0; i < Math.min(limit, favs.length); i++) {
|
|
221
|
+
const fav = favs[i];
|
|
222
|
+
if (!fav) continue;
|
|
223
|
+
const status = fav.status;
|
|
224
|
+
if (!status) continue;
|
|
225
|
+
const mid = (status.mid && String(status.mid)) || (status.id && String(status.id));
|
|
226
|
+
if (!mid) continue;
|
|
227
|
+
const author = status.user || {};
|
|
228
|
+
const favAt =
|
|
229
|
+
parseWeiboTime(fav.favorited_time) ||
|
|
230
|
+
parseWeiboTime(status.created_at) ||
|
|
231
|
+
0;
|
|
232
|
+
out.push({
|
|
233
|
+
mid,
|
|
234
|
+
text: stripHtml(status.text),
|
|
235
|
+
favAt,
|
|
236
|
+
authorScreenName: author.screen_name || null,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Mirrors fetchFollows. */
|
|
243
|
+
async fetchFollows(cookie, uid, opts = {}) {
|
|
244
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 200;
|
|
245
|
+
const url = new URL("api/friendships/friends", this.baseUrl);
|
|
246
|
+
url.searchParams.set("uid", String(uid));
|
|
247
|
+
url.searchParams.set("page", "1");
|
|
248
|
+
const obj = await this._doGetJson(url, cookie);
|
|
249
|
+
if (!obj) return [];
|
|
250
|
+
const data = obj.data || {};
|
|
251
|
+
const users = Array.isArray(data.users) ? data.users : [];
|
|
252
|
+
const out = [];
|
|
253
|
+
for (let i = 0; i < Math.min(limit, users.length); i++) {
|
|
254
|
+
const u = users[i];
|
|
255
|
+
if (!u) continue;
|
|
256
|
+
const followUid = typeof u.id === "number" ? u.id : 0;
|
|
257
|
+
if (followUid === 0) continue;
|
|
258
|
+
out.push({
|
|
259
|
+
uid: followUid,
|
|
260
|
+
screenName: u.screen_name || "(unnamed)",
|
|
261
|
+
description: u.description || null,
|
|
262
|
+
avatarUrl: u.profile_image_url || null,
|
|
263
|
+
// m.weibo.cn /api/friendships/friends doesn't return follow_time —
|
|
264
|
+
// 0 lets the snapshot builder fall back to snapshottedAt.
|
|
265
|
+
followedAt: 0,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
WeiboApiClient,
|
|
274
|
+
// Exposed for tests
|
|
275
|
+
_internals: {
|
|
276
|
+
parseWeiboTime,
|
|
277
|
+
stripHtml,
|
|
278
|
+
BROWSER_UA,
|
|
279
|
+
BROWSER_HEADERS,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3a (Weibo C 路径 — 2026-05-25): end-to-end orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* bridge.invoke("weibo.cookies") ← Phase 3a cookies extension
|
|
7
|
+
* │
|
|
8
|
+
* ▼ {cookie, diagnostic}
|
|
9
|
+
* WeiboApiClient.fetchUid ← /api/config 拿 UID + 验登录
|
|
10
|
+
* │
|
|
11
|
+
* ▼ uid (numeric)
|
|
12
|
+
* fetchPosts + fetchFavourites + fetchFollows (partial-failure OK)
|
|
13
|
+
* │
|
|
14
|
+
* ▼ 3 arrays
|
|
15
|
+
* buildSnapshot + writeSnapshotJson ← schemaVersion=1
|
|
16
|
+
* │
|
|
17
|
+
* ▼
|
|
18
|
+
* registry.syncAdapter("social-weibo", { inputPath })
|
|
19
|
+
*
|
|
20
|
+
* Mirror of social-bilibili-adb/collector.js — same `{ok, report?, reason?,
|
|
21
|
+
* message?}` shape, same try/finally cleanup. **Key diff**: Weibo needs
|
|
22
|
+
* an extra fetchUid roundtrip after cookies extraction (cookie alone
|
|
23
|
+
* doesn't carry UID — Bilibili has DedeUserID inline).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { WeiboApiClient } = require("./api-client");
|
|
27
|
+
const {
|
|
28
|
+
buildSnapshot,
|
|
29
|
+
writeSnapshotJson,
|
|
30
|
+
cleanupSnapshotJson,
|
|
31
|
+
} = require("./snapshot-builder");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pull cookies → fetchUid → 3 endpoints → write snapshot. Returns the
|
|
35
|
+
* staging path + counts + diagnostic.
|
|
36
|
+
*
|
|
37
|
+
* Throws (with typed-reason BILIBILI_-style prefix) on cookie failures.
|
|
38
|
+
* Returns with empty events on /api/config failure or any endpoint
|
|
39
|
+
* failure (partial-result tolerated — lastErrorCode surfaces the cause
|
|
40
|
+
* for UI).
|
|
41
|
+
*/
|
|
42
|
+
async function collect(bridge, opts = {}) {
|
|
43
|
+
if (!bridge || typeof bridge.invoke !== "function") {
|
|
44
|
+
throw new TypeError(
|
|
45
|
+
"WeiboAdbCollector.collect: bridge must expose invoke(method, params)",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const now = opts.now || Date.now;
|
|
49
|
+
const client = opts.apiClient || new WeiboApiClient();
|
|
50
|
+
const limits = opts.limits || {};
|
|
51
|
+
|
|
52
|
+
// 1. Pull cookies via Phase 3a extension.
|
|
53
|
+
const cookieResult = await bridge.invoke("weibo.cookies");
|
|
54
|
+
if (!cookieResult || typeof cookieResult.cookie !== "string") {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"WeiboAdbCollector.collect: bridge.invoke('weibo.cookies') returned malformed payload — got cookie=" +
|
|
57
|
+
typeof cookieResult?.cookie,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const { cookie, diagnostic: cookieDiagnostic } = cookieResult;
|
|
61
|
+
|
|
62
|
+
// 2. fetchUid — required first call. Weibo cookie has no inline UID.
|
|
63
|
+
const uid = await client.fetchUid(cookie);
|
|
64
|
+
if (!uid) {
|
|
65
|
+
// /api/config returned login=false or non-2xx. Could be:
|
|
66
|
+
// - cookie expired (most common — user logged out on phone)
|
|
67
|
+
// - anti-bot 30x to login HTML (UA missing — but we set browser UA)
|
|
68
|
+
// - IO error
|
|
69
|
+
// Surface as ExtractFailed via the hub-level wrapper; here we
|
|
70
|
+
// produce an empty-event snapshot so the registry call doesn't
|
|
71
|
+
// throw (consumers can read douyin.lastErrorCode to disambiguate).
|
|
72
|
+
const snapshot = buildSnapshot({
|
|
73
|
+
uid: 1, // sentinel — buildSnapshot requires positive; sync emits 0 events
|
|
74
|
+
displayName: opts.displayName,
|
|
75
|
+
snapshottedAt: now(),
|
|
76
|
+
});
|
|
77
|
+
const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
|
|
78
|
+
return {
|
|
79
|
+
snapshotPath,
|
|
80
|
+
uid: null,
|
|
81
|
+
eventCounts: { post: 0, favourite: 0, follow: 0, total: 0 },
|
|
82
|
+
lastErrorCode: client.lastErrorCode,
|
|
83
|
+
lastErrorMessage: client.lastErrorMessage,
|
|
84
|
+
cookieDiagnostic: cookieDiagnostic || null,
|
|
85
|
+
uidFetchFailed: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Parallel fetch — partial failure tolerated (client returns []).
|
|
90
|
+
const [posts, favourites, follows] = await Promise.all([
|
|
91
|
+
client.fetchPosts(cookie, uid, {
|
|
92
|
+
limit: Number.isInteger(limits.post) ? limits.post : undefined,
|
|
93
|
+
}),
|
|
94
|
+
client.fetchFavourites(cookie, {
|
|
95
|
+
limit: Number.isInteger(limits.favourite) ? limits.favourite : undefined,
|
|
96
|
+
}),
|
|
97
|
+
client.fetchFollows(cookie, uid, {
|
|
98
|
+
limit: Number.isInteger(limits.follow) ? limits.follow : undefined,
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// 4. Build snapshot + write.
|
|
103
|
+
const snapshot = buildSnapshot({
|
|
104
|
+
uid,
|
|
105
|
+
displayName: opts.displayName,
|
|
106
|
+
posts,
|
|
107
|
+
favourites,
|
|
108
|
+
follows,
|
|
109
|
+
snapshottedAt: now(),
|
|
110
|
+
});
|
|
111
|
+
const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
snapshotPath,
|
|
115
|
+
uid,
|
|
116
|
+
eventCounts: {
|
|
117
|
+
post: posts.length,
|
|
118
|
+
favourite: favourites.length,
|
|
119
|
+
follow: follows.length,
|
|
120
|
+
total: snapshot.events.length,
|
|
121
|
+
},
|
|
122
|
+
lastErrorCode: client.lastErrorCode,
|
|
123
|
+
lastErrorMessage: client.lastErrorMessage,
|
|
124
|
+
cookieDiagnostic: cookieDiagnostic || null,
|
|
125
|
+
uidFetchFailed: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Convenience: collect + registry.syncAdapter("social-weibo") + cleanup.
|
|
131
|
+
*/
|
|
132
|
+
async function collectAndSync(bridge, registry, opts = {}) {
|
|
133
|
+
if (!registry || typeof registry.syncAdapter !== "function") {
|
|
134
|
+
throw new TypeError(
|
|
135
|
+
"WeiboAdbCollector.collectAndSync: registry must expose syncAdapter(name, options)",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const collectResult = await collect(bridge, opts);
|
|
139
|
+
let syncReport = null;
|
|
140
|
+
let cleanupFailed = false;
|
|
141
|
+
try {
|
|
142
|
+
syncReport = await registry.syncAdapter("social-weibo", {
|
|
143
|
+
inputPath: collectResult.snapshotPath,
|
|
144
|
+
});
|
|
145
|
+
} finally {
|
|
146
|
+
try {
|
|
147
|
+
cleanupSnapshotJson(collectResult.snapshotPath);
|
|
148
|
+
} catch (_e) {
|
|
149
|
+
cleanupFailed = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...syncReport,
|
|
154
|
+
weibo: {
|
|
155
|
+
uid: collectResult.uid,
|
|
156
|
+
eventCounts: collectResult.eventCounts,
|
|
157
|
+
lastErrorCode: collectResult.lastErrorCode,
|
|
158
|
+
lastErrorMessage: collectResult.lastErrorMessage,
|
|
159
|
+
cookieDiagnostic: collectResult.cookieDiagnostic,
|
|
160
|
+
uidFetchFailed: collectResult.uidFetchFailed,
|
|
161
|
+
cleanupFailed,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
collect,
|
|
168
|
+
collectAndSync,
|
|
169
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 3a (Weibo C 路径 — 2026-05-25): weibo.cookies ADB extension factory.
|
|
5
|
+
*
|
|
6
|
+
* Mirror of `social-bilibili-adb/cookies-extension.js` (P1a). Pipeline:
|
|
7
|
+
*
|
|
8
|
+
* 1. ADB-pull /data/data/com.sina.weibo/app_webview/Default/Cookies
|
|
9
|
+
* via `su -c "base64 ..."` streaming (avoids MIUI FUSE SELinux trap)
|
|
10
|
+
* 2. Parse the chromium-shape sqlite via the shared
|
|
11
|
+
* chromium-cookies-reader (Phase 1a generic module)
|
|
12
|
+
* 3. Filter to host_key match `m.weibo.cn` (Weibo's actual API host;
|
|
13
|
+
* `weibo.com` chromium cookies exist on desktop but not on the
|
|
14
|
+
* mobile App where chromium-cookies lives)
|
|
15
|
+
* 4. Validate at minimum SUB cookie present (the session cookie —
|
|
16
|
+
* without it /api/config returns "not logged in")
|
|
17
|
+
* 5. Assemble Cookie header from all m.weibo.cn cookies (Weibo's API
|
|
18
|
+
* doesn't enforce a strict required-cookie list like Bilibili's
|
|
19
|
+
* 5-cookie requirement; pass everything through and let the server
|
|
20
|
+
* pick what it needs)
|
|
21
|
+
*
|
|
22
|
+
* Returns:
|
|
23
|
+
* {
|
|
24
|
+
* cookie: string, // full Cookie header
|
|
25
|
+
* extractedAt: number,
|
|
26
|
+
* diagnostic: {
|
|
27
|
+
* cookieCount: number,
|
|
28
|
+
* hadEncrypted: boolean,
|
|
29
|
+
* hasSub: boolean,
|
|
30
|
+
* cookieNames: string[],
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Failure modes (all throw, UI maps the typed reason to a banner):
|
|
35
|
+
* - WEIBO_NOT_INSTALLED — package not on device
|
|
36
|
+
* - WEIBO_NO_ROOT — su not available
|
|
37
|
+
* - WEIBO_COOKIES_EMPTY — base64 stream returned 0 bytes
|
|
38
|
+
* - WEIBO_COOKIES_TRUNCATED — decoded file too small
|
|
39
|
+
* - WEIBO_NOT_SQLITE — magic header check failed
|
|
40
|
+
* - WEIBO_COOKIES_INCOMPLETE — SUB cookie missing (user logged out
|
|
41
|
+
* on the Weibo App or app uses a non-standard storage path)
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require("node:fs");
|
|
45
|
+
const path = require("node:path");
|
|
46
|
+
const os = require("node:os");
|
|
47
|
+
const crypto = require("node:crypto");
|
|
48
|
+
|
|
49
|
+
const {
|
|
50
|
+
readChromiumCookies,
|
|
51
|
+
} = require("../social-bilibili-adb/chromium-cookies-reader");
|
|
52
|
+
|
|
53
|
+
const WEIBO_COOKIES_REMOTE_PATH =
|
|
54
|
+
"/data/data/com.sina.weibo/app_webview/Default/Cookies";
|
|
55
|
+
|
|
56
|
+
const WEIBO_COOKIE_HOST_DOMAIN = "m.weibo.cn";
|
|
57
|
+
|
|
58
|
+
/** Minimum required cookie name — without SUB, /api/config returns login=false. */
|
|
59
|
+
const WEIBO_REQUIRED_COOKIE = "SUB";
|
|
60
|
+
|
|
61
|
+
async function pullCookiesViaSu(adb, serial, opts) {
|
|
62
|
+
const adbOpts = { serial, timeoutMs: opts?.timeoutMs || 60_000 };
|
|
63
|
+
const lsOut = await adb(
|
|
64
|
+
[
|
|
65
|
+
"shell",
|
|
66
|
+
"su",
|
|
67
|
+
"-c",
|
|
68
|
+
`ls ${WEIBO_COOKIES_REMOTE_PATH} 2>/dev/null || echo NOT_FOUND`,
|
|
69
|
+
],
|
|
70
|
+
adbOpts,
|
|
71
|
+
);
|
|
72
|
+
const lsLine = lsOut.replace(/\r+$/gm, "").trim();
|
|
73
|
+
if (lsLine === "NOT_FOUND" || lsLine === "") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"WEIBO_NOT_INSTALLED: " +
|
|
76
|
+
WEIBO_COOKIES_REMOTE_PATH +
|
|
77
|
+
" not found. Install Weibo App + log in once on the phone, then retry. (Some Weibo App versions store cookies in a non-default WebView profile dir; if Weibo is installed but the path is missing, file a bug to track the actual path.)",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// Probe root.
|
|
81
|
+
const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
|
|
82
|
+
const idLine = idOut.replace(/\r+$/gm, "").trim();
|
|
83
|
+
if (idLine !== "0" && !idLine.includes("uid=0")) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"WEIBO_NO_ROOT: this phone isn't rooted (su returned `" +
|
|
86
|
+
idLine.substring(0, 60) +
|
|
87
|
+
"`). Weibo release APK isn't debuggable, so root is required to read its Cookies DB.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
// Stream base64 (avoids MIUI FUSE label remap trap).
|
|
91
|
+
const b64 = await adb(
|
|
92
|
+
[
|
|
93
|
+
"shell",
|
|
94
|
+
"su",
|
|
95
|
+
"-c",
|
|
96
|
+
`base64 ${WEIBO_COOKIES_REMOTE_PATH} | tr -d '\\n\\r'`,
|
|
97
|
+
],
|
|
98
|
+
{ ...adbOpts, timeoutMs: opts?.timeoutMs || 60_000 },
|
|
99
|
+
);
|
|
100
|
+
const b64Clean = b64.replace(/[\r\n\t ]+/g, "");
|
|
101
|
+
if (b64Clean.length === 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"WEIBO_COOKIES_EMPTY: base64 stream returned 0 bytes (su exec may have silently failed on MIUI / OEM ROM, retry or check `adb logcat`)",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
let buf;
|
|
107
|
+
try {
|
|
108
|
+
buf = Buffer.from(b64Clean, "base64");
|
|
109
|
+
} catch (e) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"WEIBO_BASE64_PARSE: stream wasn't valid base64 (" +
|
|
112
|
+
(e.message || String(e)) +
|
|
113
|
+
")",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (buf.length < 1024) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"WEIBO_COOKIES_TRUNCATED: decoded file is only " +
|
|
119
|
+
buf.length +
|
|
120
|
+
" bytes — expected ≥4KB sqlite. Possible MIUI silent su fail; check `adb logcat`.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const magic = buf.subarray(0, 16).toString("latin1");
|
|
124
|
+
if (!magic.startsWith("SQLite format 3")) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"WEIBO_NOT_SQLITE: decoded file lacks `SQLite format 3` magic header. Got bytes: " +
|
|
127
|
+
buf.subarray(0, 16).toString("hex"),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const tmpDir = os.tmpdir();
|
|
131
|
+
const tmpFile = path.join(
|
|
132
|
+
tmpDir,
|
|
133
|
+
`cc-weibo-cookies-${crypto.randomUUID()}.db`,
|
|
134
|
+
);
|
|
135
|
+
fs.writeFileSync(tmpFile, buf);
|
|
136
|
+
return tmpFile;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a Cookie header from the chromium-cookies array. Weibo doesn't
|
|
141
|
+
* have a strict required-cookie list like Bilibili's 5 — but SUB must
|
|
142
|
+
* be present (it's the session cookie). Everything else is best-effort
|
|
143
|
+
* passthrough.
|
|
144
|
+
*/
|
|
145
|
+
function assembleWeiboCookieHeader(cookies) {
|
|
146
|
+
if (!Array.isArray(cookies)) {
|
|
147
|
+
throw new TypeError("assembleWeiboCookieHeader: cookies must be an array");
|
|
148
|
+
}
|
|
149
|
+
const byName = new Map();
|
|
150
|
+
for (const c of cookies) {
|
|
151
|
+
// Most-recently-set wins on duplicate names; prefer more-specific host
|
|
152
|
+
if (
|
|
153
|
+
!byName.has(c.name) ||
|
|
154
|
+
c.hostKey.length > (byName.get(c.name).hostKey || "").length
|
|
155
|
+
) {
|
|
156
|
+
byName.set(c.name, c);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const hasSub = byName.has(WEIBO_REQUIRED_COOKIE);
|
|
160
|
+
if (!hasSub) {
|
|
161
|
+
return {
|
|
162
|
+
header: null,
|
|
163
|
+
present: new Set(byName.keys()),
|
|
164
|
+
missing: [WEIBO_REQUIRED_COOKIE],
|
|
165
|
+
hasSub: false,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Pass everything through — Weibo's m.weibo.cn API picks what it needs
|
|
169
|
+
// (SUB / SUBP / _T_WM / MLOGIN / WEIBOCN_FROM / etc.)
|
|
170
|
+
const header = Array.from(byName.values())
|
|
171
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
172
|
+
.join("; ");
|
|
173
|
+
return {
|
|
174
|
+
header,
|
|
175
|
+
present: new Set(byName.keys()),
|
|
176
|
+
missing: [],
|
|
177
|
+
hasSub: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Factory: returns the extension handler. Same contract as Bilibili
|
|
183
|
+
* Phase 1a — stateless, no closure-captured device serial.
|
|
184
|
+
*/
|
|
185
|
+
function createWeiboCookiesExtension(factoryOpts = {}) {
|
|
186
|
+
const timeoutMs = factoryOpts.timeoutMs || 60_000;
|
|
187
|
+
const onCleanupFailed = factoryOpts.onCleanupFailed || (() => {});
|
|
188
|
+
|
|
189
|
+
return async function weiboCookiesHandler(_params, ctx) {
|
|
190
|
+
if (
|
|
191
|
+
!ctx ||
|
|
192
|
+
typeof ctx.adb !== "function" ||
|
|
193
|
+
typeof ctx.pickDevice !== "function"
|
|
194
|
+
) {
|
|
195
|
+
throw new TypeError(
|
|
196
|
+
"weibo.cookies extension: ctx must provide {adb, pickDevice} (got " +
|
|
197
|
+
typeof ctx +
|
|
198
|
+
")",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const serial = await ctx.pickDevice();
|
|
202
|
+
let tmpFile = null;
|
|
203
|
+
try {
|
|
204
|
+
tmpFile = await pullCookiesViaSu(ctx.adb, serial, { timeoutMs });
|
|
205
|
+
const cookies = readChromiumCookies(tmpFile, WEIBO_COOKIE_HOST_DOMAIN);
|
|
206
|
+
const cookieCount = cookies.length;
|
|
207
|
+
const hadEncrypted = (cookies._skippedEncryptedCount || 0) > 0;
|
|
208
|
+
const { header, missing, present, hasSub } =
|
|
209
|
+
assembleWeiboCookieHeader(cookies);
|
|
210
|
+
if (header === null) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"WEIBO_COOKIES_INCOMPLETE: missing required cookie " +
|
|
213
|
+
JSON.stringify(missing) +
|
|
214
|
+
". User probably logged out, or Weibo App uses a non-default WebView storage path (hadEncrypted=" +
|
|
215
|
+
hadEncrypted +
|
|
216
|
+
"). Tell user to relog on phone.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
cookie: header,
|
|
221
|
+
extractedAt: Date.now(),
|
|
222
|
+
diagnostic: {
|
|
223
|
+
cookieCount,
|
|
224
|
+
hadEncrypted,
|
|
225
|
+
hasSub,
|
|
226
|
+
cookieNames: Array.from(present),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
} finally {
|
|
230
|
+
if (tmpFile) {
|
|
231
|
+
try {
|
|
232
|
+
fs.unlinkSync(tmpFile);
|
|
233
|
+
} catch (_e) {
|
|
234
|
+
onCleanupFailed(tmpFile);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
createWeiboCookiesExtension,
|
|
243
|
+
WEIBO_COOKIES_REMOTE_PATH,
|
|
244
|
+
WEIBO_COOKIE_HOST_DOMAIN,
|
|
245
|
+
WEIBO_REQUIRED_COOKIE,
|
|
246
|
+
assembleWeiboCookieHeader,
|
|
247
|
+
// Exposed for tests
|
|
248
|
+
_internals: {
|
|
249
|
+
pullCookiesViaSu,
|
|
250
|
+
},
|
|
251
|
+
};
|