@chainlesschain/personal-data-hub 0.4.7 → 0.4.23

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 (45) hide show
  1. package/__tests__/adapters/biz-tianyancha.test.js +159 -0
  2. package/__tests__/adapters/doc-baidu-netdisk.test.js +102 -0
  3. package/__tests__/adapters/doc-camscanner.test.js +147 -0
  4. package/__tests__/adapters/doc-platforms.test.js +177 -0
  5. package/__tests__/adapters/gov-ixiamen.test.js +150 -0
  6. package/__tests__/adapters/gov-tax.test.js +135 -0
  7. package/__tests__/adapters/health-meiyou.test.js +125 -0
  8. package/__tests__/adapters/music-kugou.test.js +187 -0
  9. package/__tests__/adapters/recruit-boss.test.js +180 -0
  10. package/__tests__/adapters/shopping-dianping.test.js +239 -0
  11. package/__tests__/adapters/social-csdn.test.js +175 -0
  12. package/__tests__/adapters/social-dongchedi.test.js +165 -0
  13. package/__tests__/adapters/social-zhihu.test.js +246 -0
  14. package/__tests__/adapters/travel-ctrip.test.js +175 -1
  15. package/__tests__/adapters/travel-didi.test.js +204 -0
  16. package/__tests__/adapters/travel-tongcheng.test.js +289 -0
  17. package/__tests__/adapters/video-platforms.test.js +152 -0
  18. package/__tests__/adapters/video-xigua.test.js +106 -0
  19. package/__tests__/adapters/wework-pc.test.js +124 -0
  20. package/lib/adapter-guide.js +25 -3
  21. package/lib/adapters/_document-base.js +370 -0
  22. package/lib/adapters/_video-base.js +331 -0
  23. package/lib/adapters/biz-tianyancha/index.js +348 -0
  24. package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
  25. package/lib/adapters/doc-camscanner/index.js +102 -0
  26. package/lib/adapters/doc-tencent-docs/index.js +94 -0
  27. package/lib/adapters/doc-wps/index.js +77 -0
  28. package/lib/adapters/gov-ixiamen/index.js +380 -0
  29. package/lib/adapters/gov-tax/index.js +451 -0
  30. package/lib/adapters/health-meiyou/index.js +393 -0
  31. package/lib/adapters/music-kugou/index.js +418 -0
  32. package/lib/adapters/recruit-boss/index.js +442 -0
  33. package/lib/adapters/shopping-dianping/index.js +473 -0
  34. package/lib/adapters/social-csdn/index.js +444 -0
  35. package/lib/adapters/social-dongchedi/index.js +360 -0
  36. package/lib/adapters/social-zhihu/index.js +488 -0
  37. package/lib/adapters/travel-ctrip/index.js +255 -40
  38. package/lib/adapters/travel-didi/index.js +327 -0
  39. package/lib/adapters/travel-tongcheng/index.js +393 -0
  40. package/lib/adapters/video-iqiyi/index.js +75 -0
  41. package/lib/adapters/video-tencent/index.js +78 -0
  42. package/lib/adapters/video-xigua/index.js +68 -0
  43. package/lib/adapters/wework-pc/index.js +31 -0
  44. package/lib/index.js +40 -0
  45. package/package.json +1 -1
@@ -0,0 +1,393 @@
1
+ /**
2
+ * §9.3d — Tongcheng (同程旅行) order adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 同程 (com.tongcheng.android) is a Phase 9 travel platform (ROI ⭐⭐⭐ in
5
+ * docs/design/Personal_Data_Hub_Architecture.md §12.1, "与携程互补") that was
6
+ * skipped when the travel 四件套 (amap/baidu/ctrip/12306) shipped. It is an OTA
7
+ * like 携程, so this adapter mirrors travel-ctrip: orders span flight / hotel /
8
+ * train / bus / scenery (门票) / cruise / car, each mapped to the appropriate
9
+ * `vehicleType` in the vendor-neutral TravelRecord.
10
+ *
11
+ * 1. snapshot / file-import mode (opts.inputPath | opts.dataPath): ingest a
12
+ * JSON dump from an Android in-APK collector / browser extension / curated
13
+ * file. account OPTIONAL (file-import is stateless).
14
+ *
15
+ * 2. cookie-api mode (opts.account.cookies, v0.1): fetch the Tongcheng order
16
+ * centre directly from the hub. After login on the ly.com domain the order
17
+ * list is reachable under the `.ly.com` cookie. As with the other travel /
18
+ * shopping adapters the actual HTTP call is delegated to an injected
19
+ * `fetchFn` (Android in-APK cc → OkHttp; desktop hub → Electron WebView net
20
+ * request) so this module stays a pure-Node parser + orchestrator. account
21
+ * OPTIONAL — the cookie carries identity.
22
+ *
23
+ * ── sign seam ──────────────────────────────────────────────────────────
24
+ * Tongcheng's H5 / m-API requests carry an anti-bot signature computed by
25
+ * client-side JS (analogous to 携程 mtgsig / 拼多多 anti_token). No pure-Node
26
+ * implementation survives the rotation, so signing is injected via
27
+ * `opts.signProvider` (or constructor `signProvider`). When absent the
28
+ * request is still issued unsigned — best-effort, the endpoint may reject
29
+ * it, which surfaces as zero events rather than a crash. The endpoint
30
+ * constant is best-effort and overridable via `opts.ordersUrl`; Tongcheng
31
+ * rotates H5 paths, so adjust / pass opts.ordersUrl if it drifts
32
+ * (FAMILY-23 playbook — endpoints are not field-verified here).
33
+ */
34
+
35
+ "use strict";
36
+
37
+ const fs = require("node:fs");
38
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
39
+ const { CookieAuth } = require("../shopping-base");
40
+
41
+ const NAME = "travel-tongcheng";
42
+ const VERSION = "0.1.0";
43
+
44
+ // Best-effort Tongcheng order-centre list endpoint. Overridable via
45
+ // opts.ordersUrl (Tongcheng rotates H5 paths; the injected fetchFn host may
46
+ // also point at whichever order API the captured cookie is currently scoped to).
47
+ const TONGCHENG_ORDERS_URL = "https://m.ly.com/order/orderList";
48
+ const DEFAULT_PAGE_SIZE = 20;
49
+ const DEFAULT_MAX_PAGES = 10;
50
+
51
+ const TYPE_MAP = {
52
+ flight: "flight",
53
+ airline: "flight",
54
+ jipiao: "flight",
55
+ 机票: "flight",
56
+ hotel: "hotel",
57
+ jiudian: "hotel",
58
+ 酒店: "hotel",
59
+ train: "train",
60
+ huoche: "train",
61
+ 火车: "train",
62
+ 火车票: "train",
63
+ bus: "bus",
64
+ qiche: "bus",
65
+ 汽车: "bus",
66
+ 汽车票: "bus",
67
+ coach: "bus",
68
+ scenery: "attraction",
69
+ jingdian: "attraction",
70
+ 景点: "attraction",
71
+ 门票: "attraction",
72
+ ticket: "attraction",
73
+ cruise: "cruise",
74
+ youlun: "cruise",
75
+ 邮轮: "cruise",
76
+ car: "car",
77
+ yongche: "car",
78
+ 用车: "car",
79
+ };
80
+
81
+ class TongchengAdapter {
82
+ constructor(opts = {}) {
83
+ this.account = opts.account || null;
84
+ this._dataPath = opts.dataPath || null;
85
+
86
+ // cookie-api mode — activates when account.cookies is supplied.
87
+ this._cookieAuth =
88
+ opts.account && opts.account.cookies
89
+ ? new CookieAuth({ platform: "tongcheng", cookies: opts.account.cookies })
90
+ : null;
91
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
92
+ this._signProvider =
93
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
94
+ this._ordersUrl =
95
+ typeof opts.ordersUrl === "string" && opts.ordersUrl.length > 0
96
+ ? opts.ordersUrl
97
+ : TONGCHENG_ORDERS_URL;
98
+
99
+ this.name = NAME;
100
+ this.version = VERSION;
101
+ this.capabilities = [
102
+ "import:json",
103
+ "sync:snapshot",
104
+ "sync:cookie-api",
105
+ "parse:tongcheng-orders",
106
+ ];
107
+ this.extractMode = "file-import";
108
+ this.rateLimits = {};
109
+ this.dataDisclosure = {
110
+ fields: [
111
+ "tongcheng:orderId / type / fromCity / toCity / dates / passengerName / price / carrier",
112
+ ],
113
+ sensitivity: "medium",
114
+ legalGate: false,
115
+ };
116
+
117
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require.
118
+ this._deps = { fs };
119
+ }
120
+
121
+ async authenticate(ctx = {}) {
122
+ const filePath = (ctx && ctx.inputPath) || ctx.dataPath || this._dataPath;
123
+ if (filePath) {
124
+ try {
125
+ this._deps.fs.accessSync(filePath, this._deps.fs.constants.R_OK);
126
+ } catch (err) {
127
+ return {
128
+ ok: false,
129
+ reason: "INPUT_PATH_UNREADABLE",
130
+ message: `not readable at ${filePath}: ${err.message}`,
131
+ };
132
+ }
133
+ return { ok: true, mode: "snapshot-file" };
134
+ }
135
+ if (this._cookieAuth) {
136
+ const ok = await this._cookieAuth.validate();
137
+ if (!ok) {
138
+ return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
139
+ }
140
+ // account is OPTIONAL in cookie mode — the .ly.com cookie carries identity.
141
+ return {
142
+ ok: true,
143
+ account: (this.account && this.account.email) || null,
144
+ mode: "cookie",
145
+ };
146
+ }
147
+ return {
148
+ ok: true,
149
+ account: this.account ? this.account.email : null,
150
+ mode: "ready",
151
+ };
152
+ }
153
+
154
+ async healthCheck() {
155
+ if (this._cookieAuth) {
156
+ const r = await this.authenticate();
157
+ return r.ok
158
+ ? { ok: true, lastChecked: Date.now() }
159
+ : { ok: false, reason: r.reason, error: r.error };
160
+ }
161
+ return { ok: true, lastChecked: Date.now() };
162
+ }
163
+
164
+ async *sync(opts = {}) {
165
+ const dataPath = opts.inputPath || opts.dataPath || this._dataPath;
166
+ if (dataPath) {
167
+ if (!this._deps.fs.existsSync(dataPath)) return;
168
+ const text = this._deps.fs.readFileSync(dataPath, "utf-8");
169
+ let records;
170
+ try {
171
+ records = parseRecords(text);
172
+ } catch (err) {
173
+ throw new Error(`TongchengAdapter: parse failed: ${err.message}`);
174
+ }
175
+ for (const r of records) {
176
+ yield {
177
+ adapter: NAME,
178
+ originalId: r.recordId,
179
+ capturedAt: r.bookedAt || r.departureMs || Date.now(),
180
+ payload: { record: r },
181
+ };
182
+ }
183
+ return;
184
+ }
185
+ if (this._cookieAuth) {
186
+ yield* this._syncViaCookie(opts);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * §9.3d — cookie-api live fetch. Hits the Tongcheng order-centre list
192
+ * endpoint via the injected fetchFn, paginates with a pageIndex cursor, stops
193
+ * at the sinceWatermark / maxPages, maps each order through orderToRecord (so
194
+ * the existing normalize path applies unchanged) and yields it.
195
+ */
196
+ async *_syncViaCookie(opts = {}) {
197
+ if (!(await this._cookieAuth.validate())) return;
198
+ const cookies = this._cookieAuth.toHeader();
199
+ const sinceMs =
200
+ opts.sinceWatermark != null
201
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
202
+ : Date.now() - 365 * 24 * 3600_000; // default last year
203
+ const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : DEFAULT_PAGE_SIZE;
204
+ const maxPages =
205
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0
206
+ ? opts.maxPages
207
+ : DEFAULT_MAX_PAGES;
208
+ const limit =
209
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
210
+
211
+ let emitted = 0;
212
+ let pageIndex = 1;
213
+ while (pageIndex <= maxPages) {
214
+ const query = { pageIndex, pageSize, ts: Date.now() };
215
+ // sign seam — best-effort. null when no signProvider.
216
+ let sign = null;
217
+ if (this._signProvider) {
218
+ sign = await this._signProvider({ url: this._ordersUrl, query, cookies });
219
+ }
220
+ const resp = await this._fetchFn({ url: this._ordersUrl, cookies, query, sign });
221
+ const orders = extractOrders(resp);
222
+ if (!orders.length) break;
223
+
224
+ let pageHasNew = false;
225
+ let reachedWatermark = false;
226
+ for (const raw of orders) {
227
+ const rec = orderToRecord(raw, { capturedVia: "cookie-api" });
228
+ if (!rec) continue;
229
+ const ts = rec.bookedAt || rec.departureMs || null;
230
+ if (ts && ts < sinceMs) {
231
+ reachedWatermark = true; // remaining orders are older
232
+ break;
233
+ }
234
+ pageHasNew = true;
235
+ if (emitted >= limit) return;
236
+ yield {
237
+ adapter: NAME,
238
+ originalId: rec.recordId,
239
+ capturedAt: ts || Date.now(),
240
+ payload: { record: rec },
241
+ };
242
+ emitted += 1;
243
+ }
244
+ if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
245
+ pageIndex += 1;
246
+ }
247
+ }
248
+
249
+ normalize(raw) {
250
+ if (!raw || !raw.payload || !raw.payload.record) {
251
+ throw new Error("TongchengAdapter.normalize: raw.payload.record missing");
252
+ }
253
+ return normalizeTravelRecord(raw.payload.record, {
254
+ adapterName: NAME,
255
+ adapterVersion: VERSION,
256
+ });
257
+ }
258
+ }
259
+
260
+ function parseRecords(text) {
261
+ let raw;
262
+ try {
263
+ raw = JSON.parse(text);
264
+ } catch (_e) {
265
+ // Try JSONL
266
+ raw = text
267
+ .split(/\r?\n/)
268
+ .filter((l) => l.trim().startsWith("{"))
269
+ .map((l) => JSON.parse(l));
270
+ }
271
+ const orders = Array.isArray(raw) ? raw : raw.orders || [];
272
+ return orders.map((o) => orderToRecord(o)).filter(Boolean);
273
+ }
274
+
275
+ /**
276
+ * Map one Tongcheng order object → vendor-neutral TravelRecord. Field names are
277
+ * best-effort across H5 versions (camelCase + snake_case + Chinese fallbacks).
278
+ */
279
+ function orderToRecord(o, opts = {}) {
280
+ if (!o || typeof o !== "object") return null;
281
+ const recordId =
282
+ o.orderId || o.orderSerialId || o.serialId || o.id || o.order_no || o.orderNo;
283
+ if (!recordId) return null;
284
+ const type = (
285
+ o.type ||
286
+ o.orderType ||
287
+ o.projectType ||
288
+ o.projectTag ||
289
+ o.bizType ||
290
+ o.businessType ||
291
+ ""
292
+ )
293
+ .toString()
294
+ .toLowerCase();
295
+ const vehicleType = TYPE_MAP[type] || "trip";
296
+
297
+ const priceRaw =
298
+ o.price != null
299
+ ? o.price
300
+ : o.amount != null
301
+ ? o.amount
302
+ : o.orderAmount != null
303
+ ? o.orderAmount
304
+ : o.totalAmount != null
305
+ ? o.totalAmount
306
+ : o.payAmount != null
307
+ ? o.payAmount
308
+ : o.totalPrice != null
309
+ ? o.totalPrice
310
+ : null;
311
+
312
+ return {
313
+ vendorId: "tongcheng",
314
+ recordId: String(recordId),
315
+ vehicleType,
316
+ from:
317
+ o.fromCity || o.from_city || o.depCity || o.departureCity
318
+ ? { city: o.fromCity || o.from_city || o.depCity || o.departureCity }
319
+ : null,
320
+ to:
321
+ o.toCity || o.to_city || o.arrCity || o.arrivalCity || o.hotelCity || o.sceneryName
322
+ ? { city: o.toCity || o.to_city || o.arrCity || o.arrivalCity || o.hotelCity || o.sceneryName }
323
+ : null,
324
+ departureMs: numberOrParse(
325
+ o.departureTime || o.dep_time || o.departureDate || o.useDate || o.checkIn || o.check_in || o.startDate,
326
+ ),
327
+ arrivalMs: numberOrParse(
328
+ o.arrivalTime || o.arr_time || o.arrivalDate || o.checkOut || o.check_out || o.endDate,
329
+ ),
330
+ carrier:
331
+ o.carrier || o.airline || o.airlineName || o.hotelName || o.hotel_name || o.sceneryName || o.title || "同程",
332
+ vehicleNumber: o.flightNumber || o.flight_no || o.trainNumber || o.train_no || o.trainNo,
333
+ totalCost:
334
+ priceRaw != null
335
+ ? { value: parseFloat(priceRaw), currency: o.currency || "CNY" }
336
+ : null,
337
+ traveler:
338
+ o.passengerName || o.passenger || o.guestName || o.guest_name || o.linkName || o.contactName,
339
+ confirmationCode: o.confirmationCode || o.pnr || o.confirmation_no || o.serialId,
340
+ bookedAt: numberOrParse(o.bookedAt || o.order_time || o.orderDate || o.createDate || o.createTime),
341
+ extras: {
342
+ type,
343
+ ...(o.nights != null ? { nights: o.nights } : {}),
344
+ ...(opts.capturedVia ? { capturedVia: opts.capturedVia } : {}),
345
+ },
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Pull the order array out of a Tongcheng order-centre response. Tongcheng
351
+ * nests the list under different keys across H5 versions; the injected fetchFn
352
+ * may also pre-flatten to `{ orders }`. Tolerant of all common shapes.
353
+ */
354
+ function extractOrders(resp) {
355
+ if (!resp || typeof resp !== "object") return [];
356
+ if (Array.isArray(resp.orders)) return resp.orders;
357
+ if (Array.isArray(resp.orderList)) return resp.orderList;
358
+ if (Array.isArray(resp.list)) return resp.list;
359
+ const data = resp.data && typeof resp.data === "object" ? resp.data : null;
360
+ if (data) {
361
+ if (Array.isArray(data.orders)) return data.orders;
362
+ if (Array.isArray(data.orderList)) return data.orderList;
363
+ if (Array.isArray(data.list)) return data.list;
364
+ if (Array.isArray(data.records)) return data.records;
365
+ }
366
+ return [];
367
+ }
368
+
369
+ function numberOrParse(v) {
370
+ if (Number.isFinite(v)) return v;
371
+ if (typeof v === "string") {
372
+ if (/^\d+$/.test(v) && v.length >= 10) return parseInt(v, 10);
373
+ return parseChineseDateTime(v);
374
+ }
375
+ return null;
376
+ }
377
+
378
+ async function defaultFetch(_opts) {
379
+ // Pure-Node has no HTTP layer; the host (Android cc → OkHttp; desktop hub →
380
+ // Electron WebView net) injects a real fetchFn. A missing fetchFn is a wiring
381
+ // bug, not a runtime data condition, so it throws loudly (mirrors travel-ctrip).
382
+ throw new Error("travel-tongcheng: no fetchFn configured for cookie-api mode");
383
+ }
384
+
385
+ module.exports = {
386
+ TongchengAdapter,
387
+ parseRecords,
388
+ orderToRecord,
389
+ extractOrders,
390
+ TYPE_MAP,
391
+ NAME,
392
+ VERSION,
393
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * §13+ — 爱奇艺 (iQiyi, com.qiyi.video) adapter. §12.1 Phase 13+ ROI ⭐⭐
3
+ * "观看历史". Thin wrapper over _video-base.
4
+ *
5
+ * iQiyi exposes the user's watch history + favourites (追剧) via iqiyi.com APIs;
6
+ * this adapter supplies the endpoints + field mapping, the base handles snapshot
7
+ * + cookie-api orchestration + normalize (MEDIA / LIKE event + MEDIA item).
8
+ * Endpoints best-effort + overridable (not field-verified — FAMILY-23 playbook).
9
+ */
10
+
11
+ "use strict";
12
+
13
+ const { createVideoAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_video-base");
14
+
15
+ const NAME = "video-iqiyi";
16
+ const VERSION = "0.1.0";
17
+
18
+ const WATCH_URL = "https://l.rcd.iqiyi.com/apis/rcd/myRC";
19
+ const FAVOURITE_URL = "https://collect.if.iqiyi.com/japi/collect/list";
20
+
21
+ // iQiyi channel/category code → label (best-effort).
22
+ const CHANNEL_MAP = {
23
+ 1: "movie",
24
+ 2: "tv",
25
+ 3: "variety",
26
+ 4: "anime",
27
+ 6: "documentary",
28
+ };
29
+
30
+ function mapCategory(it) {
31
+ const c = it.channelId != null ? it.channelId : it.chnId;
32
+ if (c != null && CHANNEL_MAP[c]) return CHANNEL_MAP[c];
33
+ return it.channelName || it.categoryName || it.category || null;
34
+ }
35
+
36
+ function extractItems(resp) {
37
+ if (!resp || typeof resp !== "object") return [];
38
+ if (Array.isArray(resp.data)) return resp.data;
39
+ if (Array.isArray(resp.list)) return resp.list;
40
+ const d = resp.data;
41
+ if (d && typeof d === "object") {
42
+ if (Array.isArray(d.list)) return d.list;
43
+ if (Array.isArray(d.records)) return d.records;
44
+ if (Array.isArray(d.rc)) return d.rc;
45
+ }
46
+ return [];
47
+ }
48
+
49
+ function mapItem(it) {
50
+ if (!it || typeof it !== "object") return null;
51
+ const videoId = it.tvId || it.tvid || it.albumId || it.qipuId || it.id;
52
+ if (!videoId) return null;
53
+ return {
54
+ videoId: String(videoId),
55
+ title: it.albumName || it.videoName || it.title || it.name || "(未知视频)",
56
+ category: mapCategory(it),
57
+ episode: it.videoName && it.albumName && it.videoName !== it.albumName ? it.videoName : it.order ? `第${it.order}集` : null,
58
+ channel: it.channelName || null,
59
+ durationSec: Number.isFinite(it.videoDuration) ? it.videoDuration : Number.isFinite(it.duration) ? it.duration : null,
60
+ url: it.pageUrl || it.url || (it.tvId ? `https://www.iqiyi.com/v_${it.tvId}.html` : null),
61
+ occurredAt: parseTime(it.addtime || it.playTime || it.updateTime || it.timestamp),
62
+ };
63
+ }
64
+
65
+ const IqiyiVideoAdapter = createVideoAdapter({
66
+ NAME,
67
+ VERSION,
68
+ platform: "iqiyi",
69
+ watchUrl: WATCH_URL,
70
+ favouriteUrl: FAVOURITE_URL,
71
+ extractItems,
72
+ mapItem,
73
+ });
74
+
75
+ module.exports = { IqiyiVideoAdapter, extractItems, mapItem, CHANNEL_MAP, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * §13+ — 腾讯视频 (Tencent Video, com.tencent.qqlive) adapter. §12.1 Phase 13+
3
+ * ROI ⭐⭐ "观看历史". Thin wrapper over _video-base.
4
+ *
5
+ * Tencent Video exposes the user's watch history + 追剧/收藏 via v.qq.com APIs;
6
+ * this adapter supplies the endpoints + field mapping, the base handles snapshot
7
+ * + cookie-api orchestration + normalize. Endpoints best-effort + overridable
8
+ * (not field-verified — FAMILY-23 playbook).
9
+ */
10
+
11
+ "use strict";
12
+
13
+ const { createVideoAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_video-base");
14
+
15
+ const NAME = "video-tencent";
16
+ const VERSION = "0.1.0";
17
+
18
+ const WATCH_URL = "https://pbaccess.video.qq.com/trpc.v...history.HistoryServer/GetHistory";
19
+ const FAVOURITE_URL = "https://pbaccess.video.qq.com/trpc.v...favorite.FavoriteServer/GetFavorite";
20
+
21
+ const TYPE_MAP = {
22
+ 1: "tv",
23
+ 2: "movie",
24
+ 3: "variety",
25
+ 4: "anime",
26
+ 10: "documentary",
27
+ movie: "movie",
28
+ tv: "tv",
29
+ variety: "variety",
30
+ anime: "anime",
31
+ };
32
+
33
+ function mapCategory(it) {
34
+ const raw = it.cTypeId != null ? it.cTypeId : it.typeId != null ? it.typeId : it.category;
35
+ const key = String(raw == null ? "" : raw).toLowerCase();
36
+ return TYPE_MAP[key] || TYPE_MAP[raw] || it.categoryName || null;
37
+ }
38
+
39
+ function extractItems(resp) {
40
+ if (!resp || typeof resp !== "object") return [];
41
+ if (Array.isArray(resp.list)) return resp.list;
42
+ if (Array.isArray(resp.data)) return resp.data;
43
+ const d = resp.data;
44
+ if (d && typeof d === "object") {
45
+ if (Array.isArray(d.list)) return d.list;
46
+ if (Array.isArray(d.records)) return d.records;
47
+ if (Array.isArray(d.videoList)) return d.videoList;
48
+ }
49
+ return [];
50
+ }
51
+
52
+ function mapItem(it) {
53
+ if (!it || typeof it !== "object") return null;
54
+ const videoId = it.cid || it.vid || it.lid || it.id;
55
+ if (!videoId) return null;
56
+ return {
57
+ videoId: String(videoId),
58
+ title: it.cTitle || it.title || it.videoTitle || it.name || "(未知视频)",
59
+ category: mapCategory(it),
60
+ episode: it.episode || it.vTitle || (it.episodeNum ? `第${it.episodeNum}集` : null),
61
+ channel: it.channelName || null,
62
+ durationSec: Number.isFinite(it.duration) ? it.duration : Number.isFinite(it.totalTime) ? it.totalTime : null,
63
+ url: it.url || (it.cid ? `https://v.qq.com/x/cover/${it.cid}.html` : null),
64
+ occurredAt: parseTime(it.viewTime || it.updateTime || it.markTime || it.time),
65
+ };
66
+ }
67
+
68
+ const TencentVideoAdapter = createVideoAdapter({
69
+ NAME,
70
+ VERSION,
71
+ platform: "tencent-video",
72
+ watchUrl: WATCH_URL,
73
+ favouriteUrl: FAVOURITE_URL,
74
+ extractItems,
75
+ mapItem,
76
+ });
77
+
78
+ module.exports = { TencentVideoAdapter, extractItems, mapItem, TYPE_MAP, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * §13+ — 西瓜视频 (Xigua / Ixigua, com.ss.android.article.video) adapter.
3
+ * §12.1 Phase 13+ ROI ⭐⭐ "观看历史". Thin wrapper over _video-base.
4
+ *
5
+ * 西瓜视频 (ByteDance) exposes the user's watch history + favourites via
6
+ * ixigua.com APIs; this adapter supplies the endpoints + field mapping, the base
7
+ * handles snapshot + cookie-api orchestration + normalize (MEDIA / LIKE event +
8
+ * MEDIA item). Endpoints best-effort + overridable (some need a ByteDance signed
9
+ * token via opts.signProvider — not field-verified, FAMILY-23 playbook).
10
+ */
11
+
12
+ "use strict";
13
+
14
+ const { createVideoAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_video-base");
15
+
16
+ const NAME = "video-xigua";
17
+ const VERSION = "0.1.0";
18
+
19
+ const WATCH_URL = "https://api.ixigua.com/api/history/list";
20
+ const FAVOURITE_URL = "https://api.ixigua.com/api/favorite/list";
21
+
22
+ function extractItems(resp) {
23
+ if (!resp || typeof resp !== "object") return [];
24
+ if (Array.isArray(resp.data)) return resp.data;
25
+ if (Array.isArray(resp.list)) return resp.list;
26
+ const d = resp.data;
27
+ if (d && typeof d === "object") {
28
+ if (Array.isArray(d.list)) return d.list;
29
+ if (Array.isArray(d.records)) return d.records;
30
+ if (Array.isArray(d.history)) return d.history;
31
+ if (Array.isArray(d.favorites)) return d.favorites;
32
+ }
33
+ return [];
34
+ }
35
+
36
+ function mapItem(it) {
37
+ if (!it || typeof it !== "object") return null;
38
+ // ByteDance items nest the video under article/item_info on some endpoints.
39
+ const v = it.article || it.item_info || it.video || it;
40
+ const videoId = v.group_id || v.groupId || v.item_id || v.gid || v.vid || v.id || it.group_id || it.id;
41
+ if (!videoId) return null;
42
+ return {
43
+ videoId: String(videoId),
44
+ title: v.title || v.video_title || v.name || it.title || "(未知视频)",
45
+ category: v.category || v.category_name || v.channel || null,
46
+ episode: null,
47
+ channel: v.user_name || v.author_name || v.source || null,
48
+ durationSec: Number.isFinite(v.video_duration)
49
+ ? v.video_duration
50
+ : Number.isFinite(v.duration)
51
+ ? v.duration
52
+ : null,
53
+ url: v.share_url || v.url || (videoId ? `https://www.ixigua.com/${videoId}` : null),
54
+ occurredAt: parseTime(it.behot_time || it.action_time || it.create_time || v.behot_time || v.publish_time),
55
+ };
56
+ }
57
+
58
+ const XiguaVideoAdapter = createVideoAdapter({
59
+ NAME,
60
+ VERSION,
61
+ platform: "xigua",
62
+ watchUrl: WATCH_URL,
63
+ favouriteUrl: FAVOURITE_URL,
64
+ extractItems,
65
+ mapItem,
66
+ });
67
+
68
+ module.exports = { XiguaVideoAdapter, extractItems, mapItem, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * 企业微信 (WeChat Work / WeCom) 电脑版 — honest best-effort local IM DB reader
5
+ * (qq-pc / dingtalk-pc / feishu-pc 模式).
6
+ *
7
+ * ⚠️ v0.1: 企业微信桌面本地库(C:\\Users\\<user>\\Documents\\WXWork\\...)为私有
8
+ * 结构、通常加密(同微信 SQLCipher 系)、随版本变化。本 adapter 做到可靠开库 +
9
+ * 发现消息表 + 防御探测列 + 保留原始行 + 响亮诊断;文本解析尽力而为,真机上按需
10
+ * 扩展 colCandidates。建议先把库解密为明文(或采集时附带 --key)再指向它。
11
+ */
12
+
13
+ const { createLocalImPcAdapter } = require("../_local-im-pc-adapter");
14
+
15
+ const WeWorkPcAdapter = createLocalImPcAdapter({
16
+ name: "wework-pc",
17
+ platform: "wework",
18
+ version: "0.1.0",
19
+ tablePattern: /msg|message|chat|conversation|im_|session/i,
20
+ colCandidates: {
21
+ // 企业微信常见列猜测(真机微调)
22
+ time: ["createTime", "create_time", "msgCreateTime", "sendTime", "send_time", "timestamp"],
23
+ sender: ["sender", "senderId", "fromUser", "from", "talker", "vid"],
24
+ peer: ["conversationId", "roomId", "chatId", "talker", "toUser", "conversation"],
25
+ content: ["content", "text", "msgContent", "message", "digest", "summary"],
26
+ },
27
+ needHint:
28
+ "wework-pc: 需提供企业微信桌面本地库路径(WXWork 目录下,私有/通常加密,建议先解密为明文或提供 key)",
29
+ });
30
+
31
+ module.exports = { WeWorkPcAdapter, NAME: "wework-pc", VERSION: "0.1.0" };