@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.
Files changed (79) hide show
  1. package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
  2. package/__tests__/adapters/email-adapter.test.js +1 -1
  3. package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
  4. package/__tests__/adapters/email-retry-progress.test.js +1 -1
  5. package/__tests__/adapters/email-templates.test.js +1 -1
  6. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
  7. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
  8. package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
  9. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
  10. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
  11. package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
  12. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
  13. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
  14. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
  15. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
  16. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
  17. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
  18. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
  19. package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
  20. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
  21. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
  22. package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
  23. package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
  24. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
  25. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
  26. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
  27. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
  28. package/__tests__/adapters/system-data-android.test.js +32 -1
  29. package/__tests__/longtail-adapters.test.js +15 -2
  30. package/__tests__/shopping-adapters.test.js +96 -0
  31. package/__tests__/sign-providers.test.js +62 -0
  32. package/__tests__/travel-adapters.test.js +66 -0
  33. package/__tests__/whatsapp-adapter.test.js +5 -2
  34. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
  35. package/lib/adapters/email-imap/email-adapter.js +224 -17
  36. package/lib/adapters/messaging-telegram/index.js +15 -12
  37. package/lib/adapters/messaging-whatsapp/index.js +15 -12
  38. package/lib/adapters/shopping-taobao/index.js +161 -21
  39. package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
  40. package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
  41. package/lib/adapters/social-bilibili-adb/collector.js +190 -0
  42. package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
  43. package/lib/adapters/social-bilibili-adb/index.js +51 -0
  44. package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
  45. package/lib/adapters/social-douyin/index.js +4 -0
  46. package/lib/adapters/social-douyin-adb/collector.js +165 -0
  47. package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
  48. package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
  49. package/lib/adapters/social-douyin-adb/index.js +57 -0
  50. package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
  51. package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
  52. package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
  53. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
  54. package/lib/adapters/social-kuaishou-adb/index.js +53 -0
  55. package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
  56. package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
  57. package/lib/adapters/social-toutiao-adb/collector.js +200 -0
  58. package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
  59. package/lib/adapters/social-toutiao-adb/index.js +52 -0
  60. package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
  61. package/lib/adapters/social-weibo-adb/api-client.js +281 -0
  62. package/lib/adapters/social-weibo-adb/collector.js +169 -0
  63. package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
  64. package/lib/adapters/social-weibo-adb/index.js +55 -0
  65. package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
  66. package/lib/adapters/social-xiaohongshu-adb/api-client.js +309 -0
  67. package/lib/adapters/social-xiaohongshu-adb/collector.js +209 -0
  68. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
  69. package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
  70. package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
  71. package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
  72. package/lib/adapters/system-data-android/adapter.js +77 -3
  73. package/lib/adapters/travel-amap/index.js +16 -10
  74. package/lib/adapters/travel-ctrip/index.js +25 -9
  75. package/lib/adapters/vscode/vscode-reader.js +7 -1
  76. package/lib/sign-providers/index.js +20 -0
  77. package/lib/sign-providers/interface.js +82 -0
  78. package/lib/sign-providers/null-sign-provider.js +30 -0
  79. 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>, &nbsp; 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(/&nbsp;/g, " ")
83
+ .replace(/&amp;/g, "&")
84
+ .replace(/&lt;/g, "<")
85
+ .replace(/&gt;/g, ">")
86
+ .replace(/&quot;/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
+ };