@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.
- package/__tests__/adapters/biz-tianyancha.test.js +159 -0
- package/__tests__/adapters/doc-baidu-netdisk.test.js +102 -0
- package/__tests__/adapters/doc-camscanner.test.js +147 -0
- package/__tests__/adapters/doc-platforms.test.js +177 -0
- package/__tests__/adapters/gov-ixiamen.test.js +150 -0
- package/__tests__/adapters/gov-tax.test.js +135 -0
- package/__tests__/adapters/health-meiyou.test.js +125 -0
- package/__tests__/adapters/music-kugou.test.js +187 -0
- package/__tests__/adapters/recruit-boss.test.js +180 -0
- package/__tests__/adapters/shopping-dianping.test.js +239 -0
- package/__tests__/adapters/social-csdn.test.js +175 -0
- package/__tests__/adapters/social-dongchedi.test.js +165 -0
- package/__tests__/adapters/social-zhihu.test.js +246 -0
- package/__tests__/adapters/travel-ctrip.test.js +175 -1
- package/__tests__/adapters/travel-didi.test.js +204 -0
- package/__tests__/adapters/travel-tongcheng.test.js +289 -0
- package/__tests__/adapters/video-platforms.test.js +152 -0
- package/__tests__/adapters/video-xigua.test.js +106 -0
- package/__tests__/adapters/wework-pc.test.js +124 -0
- package/lib/adapter-guide.js +25 -3
- package/lib/adapters/_document-base.js +370 -0
- package/lib/adapters/_video-base.js +331 -0
- package/lib/adapters/biz-tianyancha/index.js +348 -0
- package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
- package/lib/adapters/doc-camscanner/index.js +102 -0
- package/lib/adapters/doc-tencent-docs/index.js +94 -0
- package/lib/adapters/doc-wps/index.js +77 -0
- package/lib/adapters/gov-ixiamen/index.js +380 -0
- package/lib/adapters/gov-tax/index.js +451 -0
- package/lib/adapters/health-meiyou/index.js +393 -0
- package/lib/adapters/music-kugou/index.js +418 -0
- package/lib/adapters/recruit-boss/index.js +442 -0
- package/lib/adapters/shopping-dianping/index.js +473 -0
- package/lib/adapters/social-csdn/index.js +444 -0
- package/lib/adapters/social-dongchedi/index.js +360 -0
- package/lib/adapters/social-zhihu/index.js +488 -0
- package/lib/adapters/travel-ctrip/index.js +255 -40
- package/lib/adapters/travel-didi/index.js +327 -0
- package/lib/adapters/travel-tongcheng/index.js +393 -0
- package/lib/adapters/video-iqiyi/index.js +75 -0
- package/lib/adapters/video-tencent/index.js +78 -0
- package/lib/adapters/video-xigua/index.js +68 -0
- package/lib/adapters/wework-pc/index.js +31 -0
- package/lib/index.js +40 -0
- 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" };
|