@chainlesschain/personal-data-hub 0.4.18 → 0.4.24
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/bank-family.test.js +125 -0
- package/__tests__/adapters/biz-tianyancha.test.js +159 -0
- package/__tests__/adapters/car-mercedesme.test.js +74 -0
- package/__tests__/adapters/doc-camscanner.test.js +147 -0
- package/__tests__/adapters/finance-dcep.test.js +74 -0
- package/__tests__/adapters/fitness-joyrun.test.js +82 -0
- package/__tests__/adapters/gov-12123.test.js +103 -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-qq.test.js +112 -0
- package/__tests__/adapters/reading-family.test.js +108 -0
- package/__tests__/adapters/social-dongchedi.test.js +165 -0
- package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
- package/__tests__/adapters/video-xigua.test.js +106 -0
- package/__tests__/adapters/wework-pc.test.js +124 -0
- package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
- package/__tests__/fitness-keep-snapshot.test.js +224 -0
- package/__tests__/shopping-eleme-snapshot.test.js +454 -0
- package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
- package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
- package/__tests__/social-douban-snapshot.test.js +351 -0
- package/lib/adapter-guide.js +31 -3
- package/lib/adapters/_bank-base.js +405 -0
- package/lib/adapters/_reading-base.js +315 -0
- package/lib/adapters/audio-ximalaya/index.js +414 -0
- package/lib/adapters/bank-bankcomm/index.js +27 -0
- package/lib/adapters/bank-boc/index.js +26 -0
- package/lib/adapters/bank-cmbc/index.js +26 -0
- package/lib/adapters/bank-icbc/index.js +27 -0
- package/lib/adapters/biz-tianyancha/index.js +348 -0
- package/lib/adapters/car-mercedesme/index.js +225 -0
- package/lib/adapters/doc-camscanner/index.js +102 -0
- package/lib/adapters/finance-dcep/index.js +302 -0
- package/lib/adapters/fitness-joyrun/index.js +295 -0
- package/lib/adapters/fitness-keep/index.js +343 -0
- package/lib/adapters/gov-12123/index.js +391 -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-qq/index.js +372 -0
- package/lib/adapters/reading-fanqie/index.js +61 -0
- package/lib/adapters/reading-qimao/index.js +61 -0
- package/lib/adapters/shopping-eleme/index.js +441 -0
- package/lib/adapters/shopping-vipshop/index.js +429 -0
- package/lib/adapters/shopping-xianyu/index.js +454 -0
- package/lib/adapters/social-dongchedi/index.js +360 -0
- package/lib/adapters/social-douban/index.js +564 -0
- package/lib/adapters/travel-didi-consumer/index.js +148 -0
- package/lib/adapters/video-xigua/index.js +68 -0
- package/lib/adapters/wework-pc/index.js +31 -0
- package/lib/index.js +52 -0
- package/package.json +1 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ — QQ 音乐 (com.tencent.qqmusic) adapter, dual-mode (snapshot +
|
|
3
|
+
* cookie-api), "听歌历史". Device-installed gap discovered 2026-06-15 (have
|
|
4
|
+
* netease-music + music-kugou, not QQ音乐); added for completeness.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors music-kugou / netease-music's three-kind shape (play / favorite /
|
|
7
|
+
* playlist). QQ音乐 web (y.qq.com) endpoints fetched via a generic injected
|
|
8
|
+
* `fetchFn` + optional signProvider seam (best-effort, endpoints NOT
|
|
9
|
+
* field-verified — FAMILY-23 playbook; overridable via opts.*Url).
|
|
10
|
+
*
|
|
11
|
+
* Snapshot schema (schemaVersion 1, mirrors music-kugou):
|
|
12
|
+
* {
|
|
13
|
+
* "schemaVersion": 1, "snapshottedAt": <ms>,
|
|
14
|
+
* "account": { "userId": "...", "name": "..." },
|
|
15
|
+
* "events": [
|
|
16
|
+
* { "kind": "play", "id": "...", "songId": "...", "song": "...", "artist": "...", "album": "..." },
|
|
17
|
+
* { "kind": "favorite", "id": "...", "songId": "...", "song": "...", "artist": "..." },
|
|
18
|
+
* { "kind": "playlist", "id": "...", "playlistId": "...", "name": "...", "trackCount": N, "creator": "..." }
|
|
19
|
+
* ]
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const fs = require("node:fs");
|
|
26
|
+
const { newId } = require("../../ids");
|
|
27
|
+
const { ENTITY_TYPES, EVENT_SUBTYPES, ITEM_SUBTYPES, CAPTURED_BY } = require("../../constants");
|
|
28
|
+
const { CookieAuth } = require("../shopping-base");
|
|
29
|
+
|
|
30
|
+
const NAME = "music-qq";
|
|
31
|
+
const VERSION = "0.1.0";
|
|
32
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
const KIND_PLAY = "play";
|
|
35
|
+
const KIND_FAVORITE = "favorite";
|
|
36
|
+
const KIND_PLAYLIST = "playlist";
|
|
37
|
+
const VALID_KINDS = Object.freeze([KIND_PLAY, KIND_FAVORITE, KIND_PLAYLIST]);
|
|
38
|
+
|
|
39
|
+
// Best-effort QQ音乐 web endpoints. Overridable via opts.*Url.
|
|
40
|
+
const PLAYS_URL = "https://c.y.qq.com/api/v1/user/listen/list";
|
|
41
|
+
const FAVORITES_URL = "https://c.y.qq.com/api/v1/user/favorite/songlist";
|
|
42
|
+
const PLAYLISTS_URL = "https://c.y.qq.com/api/v1/user/playlist/list";
|
|
43
|
+
const PAGE_SIZE = 30;
|
|
44
|
+
|
|
45
|
+
function parseTime(v) {
|
|
46
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
|
|
47
|
+
if (typeof v === "string") {
|
|
48
|
+
if (/^\d+$/.test(v)) {
|
|
49
|
+
const n = parseInt(v, 10);
|
|
50
|
+
return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
51
|
+
}
|
|
52
|
+
const t = Date.parse(v);
|
|
53
|
+
return Number.isFinite(t) ? t : null;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stableOriginalId(kind, id) {
|
|
59
|
+
const safe =
|
|
60
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
61
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
62
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
63
|
+
return `qqmusic:${kind}:${safe}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** QQ音乐 `singer` is usually an array of {name}; flatten to "A/B". */
|
|
67
|
+
function flattenSinger(singer) {
|
|
68
|
+
if (Array.isArray(singer)) {
|
|
69
|
+
return singer.map((s) => (s && (s.name || s.title)) || "").filter(Boolean).join("/");
|
|
70
|
+
}
|
|
71
|
+
if (typeof singer === "string") return singer;
|
|
72
|
+
if (singer && typeof singer === "object") return singer.name || singer.title || "";
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class QQMusicAdapter {
|
|
77
|
+
constructor(opts = {}) {
|
|
78
|
+
this.account = opts.account || null;
|
|
79
|
+
this._cookieAuth =
|
|
80
|
+
opts.account && opts.account.cookies ? new CookieAuth({ platform: "qqmusic", cookies: opts.account.cookies }) : null;
|
|
81
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
82
|
+
this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
83
|
+
this._urls = {
|
|
84
|
+
play: opts.playsUrl || PLAYS_URL,
|
|
85
|
+
favorite: opts.favoritesUrl || FAVORITES_URL,
|
|
86
|
+
playlist: opts.playlistsUrl || PLAYLISTS_URL,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.name = NAME;
|
|
90
|
+
this.version = VERSION;
|
|
91
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:qqmusic-play", "parse:qqmusic-favorite", "parse:qqmusic-playlist"];
|
|
92
|
+
this.extractMode = "web-api";
|
|
93
|
+
this.rateLimits = {};
|
|
94
|
+
this.dataDisclosure = {
|
|
95
|
+
fields: ["qqmusic:play (歌名 / 歌手 / 专辑)", "qqmusic:favorite (收藏的歌)", "qqmusic:playlist (歌单名 / 曲目数)"],
|
|
96
|
+
sensitivity: "low",
|
|
97
|
+
legalGate: false,
|
|
98
|
+
defaultInclude: { play: true, favorite: true, playlist: true },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
this._deps = { fs };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async authenticate(ctx = {}) {
|
|
105
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
106
|
+
try {
|
|
107
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, mode: "snapshot-file" };
|
|
112
|
+
}
|
|
113
|
+
if (this._cookieAuth) {
|
|
114
|
+
const ok = await this._cookieAuth.validate();
|
|
115
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
116
|
+
return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie" };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: "NO_INPUT",
|
|
121
|
+
message: "music-qq.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async healthCheck() {
|
|
126
|
+
if (this._cookieAuth) {
|
|
127
|
+
const r = await this.authenticate();
|
|
128
|
+
return r.ok ? { ok: true, lastChecked: Date.now() } : { ok: false, reason: r.reason, error: r.error };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, lastChecked: Date.now() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async *sync(opts = {}) {
|
|
134
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
135
|
+
yield* this._syncViaSnapshot(opts);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (this._cookieAuth) {
|
|
139
|
+
yield* this._syncViaCookie(opts);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
throw new Error("music-qq.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async *_syncViaSnapshot(opts) {
|
|
146
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
147
|
+
const snapshot = JSON.parse(raw);
|
|
148
|
+
if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
149
|
+
throw new Error(`music-qq.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
|
|
150
|
+
}
|
|
151
|
+
const fallback =
|
|
152
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
|
|
153
|
+
const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
|
|
154
|
+
const include = opts.include || {};
|
|
155
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
156
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
157
|
+
let emitted = 0;
|
|
158
|
+
for (const ev of events) {
|
|
159
|
+
if (emitted >= limit) return;
|
|
160
|
+
if (!ev || typeof ev !== "object" || !VALID_KINDS.includes(ev.kind)) continue;
|
|
161
|
+
if (include[ev.kind] === false) continue;
|
|
162
|
+
const id = (typeof ev.id === "string" && ev.id) || ev.songId || ev.playlistId || null;
|
|
163
|
+
yield {
|
|
164
|
+
adapter: NAME,
|
|
165
|
+
kind: ev.kind,
|
|
166
|
+
originalId: stableOriginalId(ev.kind, id),
|
|
167
|
+
capturedAt: parseTime(ev.capturedAt) || fallback,
|
|
168
|
+
payload: { ...ev, account },
|
|
169
|
+
};
|
|
170
|
+
emitted += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async *_syncViaCookie(opts = {}) {
|
|
175
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
176
|
+
const cookies = this._cookieAuth.toHeader();
|
|
177
|
+
const include = opts.include || {};
|
|
178
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
179
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
|
|
180
|
+
|
|
181
|
+
const plan = [
|
|
182
|
+
{ kind: KIND_PLAY, url: this._urls.play, map: songItemToRecord },
|
|
183
|
+
{ kind: KIND_FAVORITE, url: this._urls.favorite, map: songItemToRecord },
|
|
184
|
+
{ kind: KIND_PLAYLIST, url: this._urls.playlist, map: playlistItemToRecord },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
let emitted = 0;
|
|
188
|
+
for (const step of plan) {
|
|
189
|
+
if (include[step.kind] === false) continue;
|
|
190
|
+
let page = 1;
|
|
191
|
+
while (page <= maxPages) {
|
|
192
|
+
const query = { page, num: PAGE_SIZE };
|
|
193
|
+
let sign = null;
|
|
194
|
+
if (this._signProvider) sign = await this._signProvider({ url: step.url, query, cookies });
|
|
195
|
+
const resp = await this._fetchFn({ url: step.url, cookies, query, sign });
|
|
196
|
+
const items = extractList(resp);
|
|
197
|
+
if (!items.length) break;
|
|
198
|
+
for (const it of items) {
|
|
199
|
+
const rec = step.map(it);
|
|
200
|
+
if (!rec) continue;
|
|
201
|
+
if (emitted >= limit) return;
|
|
202
|
+
yield {
|
|
203
|
+
adapter: NAME,
|
|
204
|
+
kind: step.kind,
|
|
205
|
+
originalId: stableOriginalId(step.kind, rec.id),
|
|
206
|
+
capturedAt: rec.occurredAt || Date.now(),
|
|
207
|
+
payload: { ...rec, kind: step.kind, cookie: true },
|
|
208
|
+
};
|
|
209
|
+
emitted += 1;
|
|
210
|
+
}
|
|
211
|
+
if (items.length < PAGE_SIZE) break;
|
|
212
|
+
page += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
normalize(raw) {
|
|
218
|
+
if (!raw || !raw.payload) throw new Error("QQMusicAdapter.normalize: payload missing");
|
|
219
|
+
const kind = raw.kind || raw.payload.kind;
|
|
220
|
+
const ingestedAt = Date.now();
|
|
221
|
+
if (kind === KIND_PLAY) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.MEDIA, "听了");
|
|
222
|
+
if (kind === KIND_FAVORITE) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.LIKE, "收藏");
|
|
223
|
+
if (kind === KIND_PLAYLIST) return normalizePlaylist(raw.payload, raw, ingestedAt);
|
|
224
|
+
throw new Error(`QQMusicAdapter.normalize: unknown kind ${kind}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── cookie response → intermediate record ───────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function extractList(resp) {
|
|
231
|
+
if (!resp || typeof resp !== "object") return [];
|
|
232
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
233
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
234
|
+
const d = resp.data;
|
|
235
|
+
if (d && typeof d === "object") {
|
|
236
|
+
if (Array.isArray(d.list)) return d.list;
|
|
237
|
+
if (Array.isArray(d.songlist)) return d.songlist;
|
|
238
|
+
if (Array.isArray(d.songs)) return d.songs;
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function songItemToRecord(it) {
|
|
244
|
+
if (!it || typeof it !== "object") return null;
|
|
245
|
+
const id = it.songmid || it.mid || it.songid || it.id;
|
|
246
|
+
if (!id) return null;
|
|
247
|
+
const song = it.songname || it.title || it.name || null;
|
|
248
|
+
const artist = flattenSinger(it.singer || it.singername || it.author) || null;
|
|
249
|
+
return {
|
|
250
|
+
id: String(id),
|
|
251
|
+
songId: String(id),
|
|
252
|
+
song: song || "(未知歌曲)",
|
|
253
|
+
artist: artist || "",
|
|
254
|
+
album: (it.album && (it.album.name || it.album.title)) || it.albumname || it.album_name || null,
|
|
255
|
+
occurredAt: parseTime(it.time || it.playtime || it.update_time || it.timestamp),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function playlistItemToRecord(it) {
|
|
260
|
+
if (!it || typeof it !== "object") return null;
|
|
261
|
+
const id = it.dissid || it.disstid || it.tid || it.id;
|
|
262
|
+
if (!id) return null;
|
|
263
|
+
return {
|
|
264
|
+
id: String(id),
|
|
265
|
+
playlistId: String(id),
|
|
266
|
+
name: it.dissname || it.title || it.name || "(未命名歌单)",
|
|
267
|
+
trackCount: it.songnum != null ? it.songnum : it.song_count != null ? it.song_count : it.count != null ? it.count : null,
|
|
268
|
+
creator: (it.creator && (it.creator.name || it.creator.nick)) || it.nickname || it.creator_name || null,
|
|
269
|
+
occurredAt: parseTime(it.createtime || it.create_time || it.addtime),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── normalizers (mirror music-kugou / netease-music) ─────────────────────────
|
|
274
|
+
|
|
275
|
+
function buildSource(raw, occurredAt) {
|
|
276
|
+
return {
|
|
277
|
+
adapter: NAME,
|
|
278
|
+
adapterVersion: VERSION,
|
|
279
|
+
originalId: raw.originalId,
|
|
280
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
281
|
+
capturedBy: CAPTURED_BY.API,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeSong(p, raw, ingestedAt, subtype, verb) {
|
|
286
|
+
const occurredAt = parseTime(p.occurredAt || p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
287
|
+
const source = buildSource(raw, occurredAt);
|
|
288
|
+
const song = p.song || "(未知歌曲)";
|
|
289
|
+
const artist = p.artist || "";
|
|
290
|
+
const songId = p.songId != null ? String(p.songId) : null;
|
|
291
|
+
const itemId = songId ? `item-qqmusic-song-${songId}` : `item-qqmusic-song-${newId()}`;
|
|
292
|
+
return {
|
|
293
|
+
events: [
|
|
294
|
+
{
|
|
295
|
+
id: newId(),
|
|
296
|
+
type: ENTITY_TYPES.EVENT,
|
|
297
|
+
subtype,
|
|
298
|
+
occurredAt,
|
|
299
|
+
actor: "person-self",
|
|
300
|
+
content: { title: `${verb}: ${song}${artist ? " - " + artist : ""}`, text: `${song} ${artist}`.trim() },
|
|
301
|
+
ingestedAt,
|
|
302
|
+
source,
|
|
303
|
+
extra: {
|
|
304
|
+
platform: "qqmusic",
|
|
305
|
+
song,
|
|
306
|
+
artist,
|
|
307
|
+
album: p.album || null,
|
|
308
|
+
songId,
|
|
309
|
+
playCount: p.playCount != null ? p.playCount : null,
|
|
310
|
+
itemRef: itemId,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
items: [
|
|
315
|
+
{
|
|
316
|
+
id: itemId,
|
|
317
|
+
type: ENTITY_TYPES.ITEM,
|
|
318
|
+
subtype: ITEM_SUBTYPES.MEDIA,
|
|
319
|
+
name: artist ? `${song} - ${artist}` : song,
|
|
320
|
+
ingestedAt,
|
|
321
|
+
source,
|
|
322
|
+
extra: { platform: "qqmusic", kind: "song", song, artist, album: p.album || null, songId },
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
persons: [],
|
|
326
|
+
places: [],
|
|
327
|
+
topics: [],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizePlaylist(p, raw, ingestedAt) {
|
|
332
|
+
const occurredAt = parseTime(p.occurredAt || p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
333
|
+
const source = buildSource(raw, occurredAt);
|
|
334
|
+
const pid = p.playlistId != null ? String(p.playlistId) : null;
|
|
335
|
+
return {
|
|
336
|
+
events: [],
|
|
337
|
+
persons: [],
|
|
338
|
+
places: [],
|
|
339
|
+
items: [],
|
|
340
|
+
topics: [
|
|
341
|
+
{
|
|
342
|
+
id: pid ? `topic-qqmusic-playlist-${pid}` : `topic-qqmusic-playlist-${newId()}`,
|
|
343
|
+
type: ENTITY_TYPES.TOPIC,
|
|
344
|
+
name: p.name || "(未命名歌单)",
|
|
345
|
+
ingestedAt,
|
|
346
|
+
source,
|
|
347
|
+
extra: {
|
|
348
|
+
platform: "qqmusic",
|
|
349
|
+
playlistId: pid,
|
|
350
|
+
trackCount: p.trackCount != null ? p.trackCount : null,
|
|
351
|
+
creator: p.creator || null,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function defaultFetch(_opts) {
|
|
359
|
+
throw new Error("music-qq: no fetchFn configured for cookie-api mode");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = {
|
|
363
|
+
QQMusicAdapter,
|
|
364
|
+
extractList,
|
|
365
|
+
songItemToRecord,
|
|
366
|
+
playlistItemToRecord,
|
|
367
|
+
flattenSinger,
|
|
368
|
+
NAME,
|
|
369
|
+
VERSION,
|
|
370
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
371
|
+
VALID_KINDS,
|
|
372
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ — 番茄免费小说 (Fanqie, com.dragon.read) adapter, "阅读历史".
|
|
3
|
+
* Device-discovered gap (2026-06-15). Thin wrapper over _reading-base.
|
|
4
|
+
*
|
|
5
|
+
* BEST-EFFORT: fanqienovel.com endpoints are FABRICATED placeholders
|
|
6
|
+
* (overridable, NOT field-verified — FAMILY-23 playbook); snapshot mode is the
|
|
7
|
+
* reliable path. Low sensitivity (reading history).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const { createReadingAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_reading-base");
|
|
13
|
+
|
|
14
|
+
const NAME = "reading-fanqie";
|
|
15
|
+
const VERSION = "0.1.0";
|
|
16
|
+
|
|
17
|
+
const READ_URL = "https://fanqienovel.com/api/reader/history";
|
|
18
|
+
const FAVOURITE_URL = "https://fanqienovel.com/api/bookshelf/list";
|
|
19
|
+
|
|
20
|
+
function extractItems(resp) {
|
|
21
|
+
if (!resp || typeof resp !== "object") return [];
|
|
22
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
23
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
24
|
+
const d = resp.data;
|
|
25
|
+
if (d && typeof d === "object") {
|
|
26
|
+
if (Array.isArray(d.list)) return d.list;
|
|
27
|
+
if (Array.isArray(d.books)) return d.books;
|
|
28
|
+
if (Array.isArray(d.book_list)) return d.book_list;
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mapItem(it) {
|
|
34
|
+
if (!it || typeof it !== "object") return null;
|
|
35
|
+
const id = it.book_id || it.bookId || it.id || it.bookid;
|
|
36
|
+
if (!id) return null;
|
|
37
|
+
let progress = it.progress != null ? it.progress : it.read_progress;
|
|
38
|
+
if (typeof progress === "string") progress = parseFloat(progress);
|
|
39
|
+
return {
|
|
40
|
+
bookId: String(id),
|
|
41
|
+
title: it.book_name || it.bookName || it.title || it.name || "(未知书籍)",
|
|
42
|
+
author: it.author || it.author_name || null,
|
|
43
|
+
category: it.category || it.category_name || it.genre || null,
|
|
44
|
+
chapter: it.last_chapter_title || it.chapter || it.last_chapter || null,
|
|
45
|
+
progress: Number.isFinite(progress) ? progress : null,
|
|
46
|
+
url: it.url || (id ? `https://fanqienovel.com/page/${id}` : null),
|
|
47
|
+
occurredAt: parseTime(it.read_time || it.last_read_time || it.update_time || it.add_time),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FanqieReadingAdapter = createReadingAdapter({
|
|
52
|
+
NAME,
|
|
53
|
+
VERSION,
|
|
54
|
+
platform: "fanqie",
|
|
55
|
+
readUrl: READ_URL,
|
|
56
|
+
favouriteUrl: FAVOURITE_URL,
|
|
57
|
+
extractItems,
|
|
58
|
+
mapItem,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
module.exports = { FanqieReadingAdapter, extractItems, mapItem, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ — 七猫免费小说 (Qimao, com.phoenix.read) adapter, "阅读历史".
|
|
3
|
+
* Device-discovered gap (2026-06-15). Thin wrapper over _reading-base.
|
|
4
|
+
*
|
|
5
|
+
* BEST-EFFORT: wtzw.com / qimao endpoints are FABRICATED placeholders
|
|
6
|
+
* (overridable, NOT field-verified — FAMILY-23 playbook); snapshot mode is the
|
|
7
|
+
* reliable path. Low sensitivity (reading history).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const { createReadingAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_reading-base");
|
|
13
|
+
|
|
14
|
+
const NAME = "reading-qimao";
|
|
15
|
+
const VERSION = "0.1.0";
|
|
16
|
+
|
|
17
|
+
const READ_URL = "https://api.wtzw.com/api/v1/reader/history";
|
|
18
|
+
const FAVOURITE_URL = "https://api.wtzw.com/api/v1/bookshelf/list";
|
|
19
|
+
|
|
20
|
+
function extractItems(resp) {
|
|
21
|
+
if (!resp || typeof resp !== "object") return [];
|
|
22
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
23
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
24
|
+
const d = resp.data;
|
|
25
|
+
if (d && typeof d === "object") {
|
|
26
|
+
if (Array.isArray(d.list)) return d.list;
|
|
27
|
+
if (Array.isArray(d.books)) return d.books;
|
|
28
|
+
if (Array.isArray(d.shelf)) return d.shelf;
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mapItem(it) {
|
|
34
|
+
if (!it || typeof it !== "object") return null;
|
|
35
|
+
const id = it.book_id || it.bookId || it.id;
|
|
36
|
+
if (!id) return null;
|
|
37
|
+
let progress = it.progress != null ? it.progress : it.read_proportion;
|
|
38
|
+
if (typeof progress === "string") progress = parseFloat(progress);
|
|
39
|
+
return {
|
|
40
|
+
bookId: String(id),
|
|
41
|
+
title: it.title || it.book_title || it.book_name || it.name || "(未知书籍)",
|
|
42
|
+
author: it.author || it.author_name || null,
|
|
43
|
+
category: it.category || it.classify || it.genre || null,
|
|
44
|
+
chapter: it.chapter_name || it.last_chapter || it.chapter || null,
|
|
45
|
+
progress: Number.isFinite(progress) ? progress : null,
|
|
46
|
+
url: it.url || null,
|
|
47
|
+
occurredAt: parseTime(it.read_time || it.last_read_time || it.update_time),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const QimaoReadingAdapter = createReadingAdapter({
|
|
52
|
+
NAME,
|
|
53
|
+
VERSION,
|
|
54
|
+
platform: "qimao",
|
|
55
|
+
readUrl: READ_URL,
|
|
56
|
+
favouriteUrl: FAVOURITE_URL,
|
|
57
|
+
extractItems,
|
|
58
|
+
mapItem,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
module.exports = { QimaoReadingAdapter, extractItems, mapItem, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
|