@chainlesschain/personal-data-hub 0.1.0 → 0.2.0
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/ai-chat-history.test.js +395 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/analysis-skills.test.js +409 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +221 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/index.js +28 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +216 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +115 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +266 -0
- package/package.json +29 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.3b — Meituan (美团) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Meituan covers 外卖 (food delivery) + 团购 (group buy) + 酒店 (hotel
|
|
5
|
+
* — though Ctrip is more common). v0 focuses on 外卖 + 团购 since that's
|
|
6
|
+
* where most users have the longest history.
|
|
7
|
+
*
|
|
8
|
+
* Auth: cookie-based, similar to Taobao/JD.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
|
|
14
|
+
|
|
15
|
+
const NAME = "shopping-meituan";
|
|
16
|
+
const VERSION = "0.5.0";
|
|
17
|
+
|
|
18
|
+
const MEITUAN_ORDERS_URL = "https://h5.meituan.com/order/list";
|
|
19
|
+
|
|
20
|
+
class MeituanAdapter {
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
if (!opts.account || !opts.account.userId) {
|
|
23
|
+
throw new Error("MeituanAdapter: opts.account.userId required");
|
|
24
|
+
}
|
|
25
|
+
this.account = opts.account;
|
|
26
|
+
this._cookieAuth = new CookieAuth({
|
|
27
|
+
platform: "meituan",
|
|
28
|
+
cookies: opts.account.cookies || "",
|
|
29
|
+
});
|
|
30
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
31
|
+
|
|
32
|
+
this.name = NAME;
|
|
33
|
+
this.version = VERSION;
|
|
34
|
+
this.capabilities = ["sync:cookie-api", "parse:meituan-orders"];
|
|
35
|
+
this.extractMode = "web-api";
|
|
36
|
+
this.rateLimits = { perMinute: 8, perDay: 200 };
|
|
37
|
+
this.dataDisclosure = {
|
|
38
|
+
fields: ["meituan:orderId / poiName / dishes / totalPrice / deliveryAddress"],
|
|
39
|
+
sensitivity: "high",
|
|
40
|
+
legalGate: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async authenticate() {
|
|
45
|
+
const ok = await this._cookieAuth.validate();
|
|
46
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
47
|
+
return { ok: true, account: this.account.userId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async healthCheck() {
|
|
51
|
+
const r = await this.authenticate();
|
|
52
|
+
return r.ok
|
|
53
|
+
? { ok: true, lastChecked: Date.now() }
|
|
54
|
+
: { ok: false, reason: r.reason, error: r.error };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async *sync(opts = {}) {
|
|
58
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
59
|
+
const sinceMs = opts.sinceWatermark != null
|
|
60
|
+
? parseInt(String(opts.sinceWatermark), 10) || 0
|
|
61
|
+
: (Date.now() - 365 * 24 * 3600_000);
|
|
62
|
+
const platforms = opts.platforms || ["waimai", "groupbuy"]; // sub-types
|
|
63
|
+
|
|
64
|
+
for (const platform of platforms) {
|
|
65
|
+
let page = 1;
|
|
66
|
+
while (true) {
|
|
67
|
+
const resp = await this._fetchFn({
|
|
68
|
+
url: MEITUAN_ORDERS_URL,
|
|
69
|
+
cookies: this._cookieAuth.toHeader(),
|
|
70
|
+
query: { page, platform },
|
|
71
|
+
});
|
|
72
|
+
if (!resp || !Array.isArray(resp.orders)) break;
|
|
73
|
+
let pageHasNew = false;
|
|
74
|
+
for (const raw of resp.orders) {
|
|
75
|
+
const rec = orderToRecord(raw, platform);
|
|
76
|
+
if (!rec) continue;
|
|
77
|
+
if (rec.placedAt && rec.placedAt < sinceMs) break;
|
|
78
|
+
pageHasNew = true;
|
|
79
|
+
yield {
|
|
80
|
+
adapter: NAME,
|
|
81
|
+
originalId: rec.orderId,
|
|
82
|
+
capturedAt: rec.placedAt || Date.now(),
|
|
83
|
+
payload: { record: rec, platform },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!pageHasNew || resp.orders.length < 10) break;
|
|
87
|
+
page += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
normalize(raw) {
|
|
93
|
+
return normalizeOrderRecord(raw.payload.record, {
|
|
94
|
+
adapterName: NAME, adapterVersion: VERSION,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function orderToRecord(o, platform = "waimai") {
|
|
100
|
+
if (!o || typeof o !== "object") return null;
|
|
101
|
+
const orderId = o.orderId || o.viewOrderId || o.id;
|
|
102
|
+
if (!orderId) return null;
|
|
103
|
+
const merchant = o.poiName || o.dealName || o.shopName || o.merchantName || "美团";
|
|
104
|
+
|
|
105
|
+
const items = [];
|
|
106
|
+
const dishes = o.dishes || o.dealList || o.itemList || o.items || [];
|
|
107
|
+
for (const it of dishes) {
|
|
108
|
+
if (!it) continue;
|
|
109
|
+
items.push({
|
|
110
|
+
name: it.name || it.dishName || it.dealName,
|
|
111
|
+
quantity: parseInt(it.quantity || it.count || 1, 10),
|
|
112
|
+
unitPrice: parseFloat(it.price || it.unitPrice || 0),
|
|
113
|
+
sku: it.sku || null,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
vendorId: "meituan",
|
|
119
|
+
orderId: String(orderId),
|
|
120
|
+
placedAt: parseTime(o.orderTime || o.createTime),
|
|
121
|
+
paidAt: parseTime(o.payTime),
|
|
122
|
+
status: mapStatus(o.statusDesc || o.statusText || o.status),
|
|
123
|
+
merchantName: merchant,
|
|
124
|
+
totalAmount: { value: parseFloat(o.totalPrice || o.totalFee || o.payAmount || 0), currency: "CNY" },
|
|
125
|
+
items,
|
|
126
|
+
recipient: o.recipientName,
|
|
127
|
+
shippingAddress: o.recipientAddress || o.deliveryAddress,
|
|
128
|
+
extras: { platform, rawStatus: o.statusDesc || o.statusText },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseTime(v) {
|
|
133
|
+
if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
|
|
134
|
+
if (typeof v === "string") {
|
|
135
|
+
const t = Date.parse(v);
|
|
136
|
+
if (Number.isFinite(t)) return t;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function mapStatus(s) {
|
|
142
|
+
const t = String(s || "").toLowerCase();
|
|
143
|
+
if (t.includes("退款") || t.includes("refund")) return "refunded";
|
|
144
|
+
if (t.includes("取消") || t.includes("cancel")) return "cancelled";
|
|
145
|
+
if (t.includes("配送中") || t.includes("派送") || t.includes("shipped")) return "shipped";
|
|
146
|
+
if (t.includes("已完成") || t.includes("已送达") || t.includes("delivered")) return "delivered";
|
|
147
|
+
return "placed";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function defaultFetch(_opts) {
|
|
151
|
+
throw new Error("MeituanAdapter: no fetchFn configured");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { MeituanAdapter, orderToRecord, NAME, VERSION };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.2 — Taobao + Tmall adapter.
|
|
3
|
+
*
|
|
4
|
+
* Auth: cookie-based. User opens taobao.com in a browser, logs in,
|
|
5
|
+
* copies cookies via the web extension, pastes into hub UI.
|
|
6
|
+
*
|
|
7
|
+
* Fetch: Taobao's `/orderList/list.htm` returns a JSON page with order
|
|
8
|
+
* cards. We use a DI seam `fetchFn` so tests can run on fixture
|
|
9
|
+
* responses without hitting the network.
|
|
10
|
+
*
|
|
11
|
+
* Order schema mapping (Taobao → OrderRecord):
|
|
12
|
+
* bizOrderId → orderId
|
|
13
|
+
* sellerNick → merchantName
|
|
14
|
+
* actualFee → totalAmount.value (yuan, sometimes string)
|
|
15
|
+
* mainOrders/subOrders → items[]
|
|
16
|
+
* payTime → paidAt (ms epoch)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
|
|
22
|
+
|
|
23
|
+
const NAME = "shopping-taobao";
|
|
24
|
+
const VERSION = "0.5.0";
|
|
25
|
+
|
|
26
|
+
const TAOBAO_ORDERS_URL = "https://h5.m.taobao.com/mlapp/olist.html";
|
|
27
|
+
|
|
28
|
+
class TaobaoAdapter {
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
if (!opts.account || !opts.account.userId) {
|
|
31
|
+
throw new Error("TaobaoAdapter: opts.account.userId required");
|
|
32
|
+
}
|
|
33
|
+
this.account = opts.account;
|
|
34
|
+
this._cookieAuth = new CookieAuth({
|
|
35
|
+
platform: "taobao",
|
|
36
|
+
cookies: opts.account.cookies || "",
|
|
37
|
+
});
|
|
38
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
39
|
+
|
|
40
|
+
this.name = NAME;
|
|
41
|
+
this.version = VERSION;
|
|
42
|
+
this.capabilities = ["sync:cookie-api", "parse:taobao-orders"];
|
|
43
|
+
this.extractMode = "web-api";
|
|
44
|
+
this.rateLimits = { perMinute: 6, perDay: 200 }; // respect Taobao风控
|
|
45
|
+
this.dataDisclosure = {
|
|
46
|
+
fields: [
|
|
47
|
+
"taobao:bizOrderId / sellerNick / items / payTime / actualFee / address",
|
|
48
|
+
],
|
|
49
|
+
sensitivity: "high",
|
|
50
|
+
legalGate: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async authenticate() {
|
|
55
|
+
const ok = await this._cookieAuth.validate();
|
|
56
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing or empty" };
|
|
57
|
+
return { ok: true, account: this.account.userId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async healthCheck() {
|
|
61
|
+
const r = await this.authenticate();
|
|
62
|
+
return r.ok
|
|
63
|
+
? { ok: true, lastChecked: Date.now() }
|
|
64
|
+
: { ok: false, reason: r.reason, error: r.error };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async *sync(opts = {}) {
|
|
68
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
69
|
+
const sinceMs = opts.sinceWatermark != null
|
|
70
|
+
? parseWatermarkMs(opts.sinceWatermark)
|
|
71
|
+
: (Date.now() - 365 * 24 * 3600_000); // default last year
|
|
72
|
+
const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 20;
|
|
73
|
+
let page = 1;
|
|
74
|
+
while (true) {
|
|
75
|
+
const resp = await this._fetchFn({
|
|
76
|
+
url: TAOBAO_ORDERS_URL,
|
|
77
|
+
cookies: this._cookieAuth.toHeader(),
|
|
78
|
+
query: { page, pageSize, ts: Date.now() },
|
|
79
|
+
});
|
|
80
|
+
if (!resp || !Array.isArray(resp.orders)) break;
|
|
81
|
+
let pageHasNew = false;
|
|
82
|
+
for (const raw of resp.orders) {
|
|
83
|
+
const rec = orderToRecord(raw);
|
|
84
|
+
if (!rec) continue;
|
|
85
|
+
if (rec.placedAt && rec.placedAt < sinceMs) break; // older than watermark
|
|
86
|
+
pageHasNew = true;
|
|
87
|
+
yield {
|
|
88
|
+
adapter: NAME,
|
|
89
|
+
originalId: rec.orderId,
|
|
90
|
+
capturedAt: rec.paidAt || rec.placedAt || Date.now(),
|
|
91
|
+
payload: { record: rec },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!pageHasNew || resp.orders.length < pageSize) break;
|
|
95
|
+
page += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
normalize(raw) {
|
|
100
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
101
|
+
throw new Error("TaobaoAdapter.normalize: raw.payload.record missing");
|
|
102
|
+
}
|
|
103
|
+
return normalizeOrderRecord(raw.payload.record, {
|
|
104
|
+
adapterName: NAME,
|
|
105
|
+
adapterVersion: VERSION,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function orderToRecord(o) {
|
|
111
|
+
if (!o || typeof o !== "object") return null;
|
|
112
|
+
const orderId = o.bizOrderId || o.orderId || o.id;
|
|
113
|
+
const merchant = o.sellerNick || o.shopName || o.merchantName;
|
|
114
|
+
if (!orderId || !merchant) return null;
|
|
115
|
+
const items = [];
|
|
116
|
+
const subOrders = o.subOrders || o.itemList || o.items || [];
|
|
117
|
+
for (const it of subOrders) {
|
|
118
|
+
if (!it) continue;
|
|
119
|
+
items.push({
|
|
120
|
+
name: it.itemTitle || it.title || it.name,
|
|
121
|
+
quantity: parseInt(it.quantity || it.buyCount || it.num || 1, 10),
|
|
122
|
+
unitPrice: parseFloat(it.itemPrice || it.unitPrice || it.price || 0),
|
|
123
|
+
sku: it.skuText || it.sku || null,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
vendorId: "taobao",
|
|
128
|
+
orderId: String(orderId),
|
|
129
|
+
placedAt: parseTaobaoTime(o.createTime || o.orderCreateTime),
|
|
130
|
+
paidAt: parseTaobaoTime(o.payTime || o.paidAt),
|
|
131
|
+
status: mapStatus(o.statusText || o.statusDesc || o.status),
|
|
132
|
+
merchantName: merchant,
|
|
133
|
+
totalAmount: { value: parseFloat(o.actualFee || o.payAmount || o.totalAmount || 0), currency: "CNY" },
|
|
134
|
+
items,
|
|
135
|
+
recipient: o.receiverName || o.address?.receiverName || null,
|
|
136
|
+
shippingAddress: o.fullAddress || o.address?.fullAddress || null,
|
|
137
|
+
trackingNumber: o.trackingNumber || o.expressNo || null,
|
|
138
|
+
extras: { rawStatus: o.statusText || o.statusDesc, isTmall: !!o.tmallFlag },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseTaobaoTime(v) {
|
|
143
|
+
if (Number.isFinite(v)) {
|
|
144
|
+
// Sometimes ms, sometimes seconds (10-digit)
|
|
145
|
+
return v < 1e12 ? v * 1000 : v;
|
|
146
|
+
}
|
|
147
|
+
if (typeof v === "string") {
|
|
148
|
+
const t = Date.parse(v);
|
|
149
|
+
if (Number.isFinite(t)) return t;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseWatermarkMs(wm) {
|
|
155
|
+
if (Number.isFinite(wm)) return wm;
|
|
156
|
+
const n = parseInt(String(wm), 10);
|
|
157
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function mapStatus(s) {
|
|
161
|
+
const t = String(s || "").toLowerCase();
|
|
162
|
+
if (t.includes("退款") || t.includes("refund")) return "refunded";
|
|
163
|
+
if (t.includes("已取消") || t.includes("cancel") || t.includes("已关闭")) return "cancelled";
|
|
164
|
+
if (t.includes("已发货") || t.includes("shipped")) return "shipped";
|
|
165
|
+
if (t.includes("已签收") || t.includes("已完成") || t.includes("delivered")) return "delivered";
|
|
166
|
+
return "placed";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function defaultFetch(_opts) {
|
|
170
|
+
// Default: no-op so adapter doesn't accidentally hit real Taobao when
|
|
171
|
+
// user hasn't configured a fetcher. Production wires a real HTTPS
|
|
172
|
+
// fetch via the desktop main process (not from renderer).
|
|
173
|
+
throw new Error("TaobaoAdapter: no fetchFn configured (use a desktop-main wrapper)");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { TaobaoAdapter, orderToRecord, parseTaobaoTime, NAME, VERSION };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 13.1 — Bilibili (B站) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Source: B站 Android app stores user data in SQLite (per sjqz/parsers/
|
|
5
|
+
* social.py BilibiliParser). Phase 7.5 AndroidExtractor pulls the DB
|
|
6
|
+
* to a local cache; this adapter parses it.
|
|
7
|
+
*
|
|
8
|
+
* Tables (sjqz reference):
|
|
9
|
+
* - history watched videos
|
|
10
|
+
* - bili_favourite favorited videos / playlists
|
|
11
|
+
* - bili_user user profile
|
|
12
|
+
* - bili_message 私信
|
|
13
|
+
*
|
|
14
|
+
* Each row → Event with subtype "browse" (history) / "like" (favorites)
|
|
15
|
+
* / "message" (DMs) per UnifiedSchema enum.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const { newId } = require("../../ids");
|
|
22
|
+
|
|
23
|
+
const NAME = "social-bilibili";
|
|
24
|
+
const VERSION = "0.5.0";
|
|
25
|
+
|
|
26
|
+
class BilibiliAdapter {
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
if (!opts.account || !opts.account.uid) {
|
|
29
|
+
throw new Error("BilibiliAdapter: opts.account.uid required");
|
|
30
|
+
}
|
|
31
|
+
this.account = opts.account;
|
|
32
|
+
this._dbPath = opts.dbPath || null;
|
|
33
|
+
this._dbDriverFactory = opts.dbDriverFactory || null;
|
|
34
|
+
|
|
35
|
+
this.name = NAME;
|
|
36
|
+
this.version = VERSION;
|
|
37
|
+
this.capabilities = ["sync:sqlite", "parse:bilibili-history", "parse:bilibili-favourite"];
|
|
38
|
+
this.extractMode = "device-pull";
|
|
39
|
+
this.rateLimits = {};
|
|
40
|
+
this.dataDisclosure = {
|
|
41
|
+
fields: [
|
|
42
|
+
"bilibili:history (avid / bvid / title / view_at / duration)",
|
|
43
|
+
"bilibili:favourite (folder / video / save_time)",
|
|
44
|
+
"bilibili:message (peer / content / time)",
|
|
45
|
+
],
|
|
46
|
+
sensitivity: "medium",
|
|
47
|
+
legalGate: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async authenticate() {
|
|
52
|
+
return { ok: true, account: this.account.uid };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async healthCheck() {
|
|
56
|
+
return { ok: true, lastChecked: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async *sync(opts = {}) {
|
|
60
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
61
|
+
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
62
|
+
const Driver = this._dbDriverFactory
|
|
63
|
+
? this._dbDriverFactory()
|
|
64
|
+
: require("better-sqlite3-multiple-ciphers");
|
|
65
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const history = trySelect(db, "SELECT * FROM history ORDER BY view_at DESC LIMIT 5000") || [];
|
|
69
|
+
for (const row of history) {
|
|
70
|
+
yield {
|
|
71
|
+
adapter: NAME,
|
|
72
|
+
originalId: `history-${row.id || row._id || row.kid || row.bvid || row.avid}`,
|
|
73
|
+
capturedAt: parseTime(row.view_at || row.create_at || row.time),
|
|
74
|
+
payload: { row, kind: "history" },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const favs = trySelect(db, "SELECT * FROM bili_favourite ORDER BY save_time DESC LIMIT 5000") || [];
|
|
79
|
+
for (const row of favs) {
|
|
80
|
+
yield {
|
|
81
|
+
adapter: NAME,
|
|
82
|
+
originalId: `fav-${row.id || row.fav_id || row.bvid}`,
|
|
83
|
+
capturedAt: parseTime(row.save_time || row.time),
|
|
84
|
+
payload: { row, kind: "favourite" },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
try { db.close(); } catch (_e) {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
normalize(raw) {
|
|
93
|
+
if (!raw || !raw.payload || !raw.payload.row) {
|
|
94
|
+
throw new Error("BilibiliAdapter.normalize: row missing");
|
|
95
|
+
}
|
|
96
|
+
const { kind, row } = raw.payload;
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const occurredAt = parseTime(row.view_at || row.save_time || row.create_at || row.time) || now;
|
|
99
|
+
const source = {
|
|
100
|
+
adapter: NAME, adapterVersion: VERSION,
|
|
101
|
+
originalId: raw.originalId, capturedAt: occurredAt,
|
|
102
|
+
capturedBy: "sqlite",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (kind === "favourite") {
|
|
106
|
+
return {
|
|
107
|
+
events: [{
|
|
108
|
+
id: newId(),
|
|
109
|
+
type: "event",
|
|
110
|
+
subtype: "like",
|
|
111
|
+
occurredAt,
|
|
112
|
+
actor: "person-self",
|
|
113
|
+
content: {
|
|
114
|
+
title: row.title || row.video_title || "(no title)",
|
|
115
|
+
},
|
|
116
|
+
ingestedAt: now,
|
|
117
|
+
source,
|
|
118
|
+
extra: {
|
|
119
|
+
bvid: row.bvid || null,
|
|
120
|
+
avid: row.avid || null,
|
|
121
|
+
folder: row.folder_name || null,
|
|
122
|
+
uploader: row.uploader || row.up_name || null,
|
|
123
|
+
},
|
|
124
|
+
}],
|
|
125
|
+
persons: [], places: [], items: [], topics: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// history → browse event
|
|
129
|
+
return {
|
|
130
|
+
events: [{
|
|
131
|
+
id: newId(),
|
|
132
|
+
type: "event",
|
|
133
|
+
subtype: "browse",
|
|
134
|
+
occurredAt,
|
|
135
|
+
actor: "person-self",
|
|
136
|
+
content: {
|
|
137
|
+
title: row.title || row.video_title || "(no title)",
|
|
138
|
+
},
|
|
139
|
+
ingestedAt: now,
|
|
140
|
+
source,
|
|
141
|
+
extra: {
|
|
142
|
+
bvid: row.bvid || null,
|
|
143
|
+
avid: row.avid || null,
|
|
144
|
+
duration: row.duration || row.progress || null,
|
|
145
|
+
uploader: row.uploader || row.up_name || null,
|
|
146
|
+
part: row.part_name || null,
|
|
147
|
+
},
|
|
148
|
+
}],
|
|
149
|
+
persons: [], places: [], items: [], topics: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function trySelect(db, sql) {
|
|
155
|
+
try { return db.prepare(sql).all(); } catch (_e) { return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseTime(v) {
|
|
159
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
160
|
+
if (typeof v === "string") {
|
|
161
|
+
if (/^\d+$/.test(v)) {
|
|
162
|
+
const n = parseInt(v, 10);
|
|
163
|
+
return n > 1e12 ? n : n * 1000;
|
|
164
|
+
}
|
|
165
|
+
const t = Date.parse(v);
|
|
166
|
+
return Number.isFinite(t) ? t : null;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { BilibiliAdapter, NAME, VERSION };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 13.3 — Douyin (抖音) adapter — short video platform.
|
|
3
|
+
*
|
|
4
|
+
* Source: Douyin Android app SQLite (per sjqz/parsers/douyin.py
|
|
5
|
+
* DouyinParser). Tables of interest:
|
|
6
|
+
* - history / video_history watched videos
|
|
7
|
+
* - favourite / user_favorite liked / saved
|
|
8
|
+
* - search_history queries
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const { newId } = require("../../ids");
|
|
15
|
+
|
|
16
|
+
const NAME = "social-douyin";
|
|
17
|
+
const VERSION = "0.5.0";
|
|
18
|
+
|
|
19
|
+
class DouyinAdapter {
|
|
20
|
+
constructor(opts = {}) {
|
|
21
|
+
if (!opts.account || !opts.account.uid) {
|
|
22
|
+
throw new Error("DouyinAdapter: opts.account.uid required");
|
|
23
|
+
}
|
|
24
|
+
this.account = opts.account;
|
|
25
|
+
this._dbPath = opts.dbPath || null;
|
|
26
|
+
this._dbDriverFactory = opts.dbDriverFactory || null;
|
|
27
|
+
|
|
28
|
+
this.name = NAME;
|
|
29
|
+
this.version = VERSION;
|
|
30
|
+
this.capabilities = ["sync:sqlite", "parse:douyin-history"];
|
|
31
|
+
this.extractMode = "device-pull";
|
|
32
|
+
this.rateLimits = {};
|
|
33
|
+
this.dataDisclosure = {
|
|
34
|
+
fields: [
|
|
35
|
+
"douyin:history (aweme_id / title / author / view_time / duration)",
|
|
36
|
+
"douyin:favourite",
|
|
37
|
+
"douyin:search_history",
|
|
38
|
+
],
|
|
39
|
+
sensitivity: "medium",
|
|
40
|
+
legalGate: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async authenticate() {
|
|
45
|
+
return { ok: true, account: this.account.uid };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async healthCheck() {
|
|
49
|
+
return { ok: true, lastChecked: Date.now() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async *sync(opts = {}) {
|
|
53
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
54
|
+
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
55
|
+
const Driver = this._dbDriverFactory
|
|
56
|
+
? this._dbDriverFactory()
|
|
57
|
+
: require("better-sqlite3-multiple-ciphers");
|
|
58
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
59
|
+
try {
|
|
60
|
+
const histories = trySelect(db, "SELECT * FROM video_history ORDER BY view_time DESC LIMIT 5000")
|
|
61
|
+
|| trySelect(db, "SELECT * FROM history ORDER BY view_time DESC LIMIT 5000") || [];
|
|
62
|
+
for (const row of histories) {
|
|
63
|
+
yield { adapter: NAME, originalId: `history-${row.id || row.aweme_id}`, capturedAt: parseTime(row.view_time), payload: { row, kind: "history" } };
|
|
64
|
+
}
|
|
65
|
+
const favs = trySelect(db, "SELECT * FROM user_favorite ORDER BY create_time DESC LIMIT 5000")
|
|
66
|
+
|| trySelect(db, "SELECT * FROM favourite ORDER BY time DESC LIMIT 5000") || [];
|
|
67
|
+
for (const row of favs) {
|
|
68
|
+
yield { adapter: NAME, originalId: `fav-${row.id || row.aweme_id}`, capturedAt: parseTime(row.create_time || row.time), payload: { row, kind: "favourite" } };
|
|
69
|
+
}
|
|
70
|
+
const searches = trySelect(db, "SELECT * FROM search_history ORDER BY time DESC LIMIT 5000") || [];
|
|
71
|
+
for (const row of searches) {
|
|
72
|
+
yield { adapter: NAME, originalId: `search-${row.id || row._id}`, capturedAt: parseTime(row.time), payload: { row, kind: "search" } };
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
try { db.close(); } catch (_e) {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
normalize(raw) {
|
|
80
|
+
const { kind, row } = raw.payload;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const occurredAt = parseTime(row.view_time || row.create_time || row.time) || now;
|
|
83
|
+
const source = { adapter: NAME, adapterVersion: VERSION, originalId: raw.originalId, capturedAt: occurredAt, capturedBy: "sqlite" };
|
|
84
|
+
const subtypeMap = { history: "browse", favourite: "like", search: "interaction" };
|
|
85
|
+
return {
|
|
86
|
+
events: [{
|
|
87
|
+
id: newId(), type: "event",
|
|
88
|
+
subtype: subtypeMap[kind] || "browse",
|
|
89
|
+
occurredAt, actor: "person-self",
|
|
90
|
+
content: {
|
|
91
|
+
title: row.title || row.desc || row.keyword || row.query || "(no title)",
|
|
92
|
+
...(row.desc && kind !== "search" ? { text: row.desc } : {}),
|
|
93
|
+
},
|
|
94
|
+
ingestedAt: now, source,
|
|
95
|
+
extra: {
|
|
96
|
+
awemeId: row.aweme_id || null,
|
|
97
|
+
author: row.author || row.nickname || null,
|
|
98
|
+
duration: row.duration || null,
|
|
99
|
+
...(kind === "search" ? { query: row.keyword || row.query } : {}),
|
|
100
|
+
},
|
|
101
|
+
}],
|
|
102
|
+
persons: [], places: [], items: [], topics: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function trySelect(db, sql) { try { return db.prepare(sql).all(); } catch (_e) { return null; } }
|
|
108
|
+
function parseTime(v) {
|
|
109
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
110
|
+
if (typeof v === "string") {
|
|
111
|
+
if (/^\d+$/.test(v)) { const n = parseInt(v, 10); return n > 1e12 ? n : n * 1000; }
|
|
112
|
+
const t = Date.parse(v); return Number.isFinite(t) ? t : null;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
module.exports = { DouyinAdapter, NAME, VERSION };
|