@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,488 @@
1
+ /**
2
+ * §A9 — Zhihu (知乎) adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 知乎 (com.zhihu.android) is a Phase 13+ roadmap platform (ROI ⭐⭐⭐ in
5
+ * docs/design/Personal_Data_Hub_Architecture.md §12.1, "收藏 + 关注 + 自己回答")
6
+ * — the highest-value feasible long-tail social source: 知乎's own-data web API
7
+ * (zhihu.com /api/v4) is cookie-accessible and device-independent, feeding the
8
+ * "interests" analysis skill. Mirrors the social-weibo two-mode shape but uses
9
+ * cookie-api (not sqlite) since 知乎 has a clean web API and no need for a pulled
10
+ * device DB.
11
+ *
12
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc / browser-extension /
13
+ * curated JSON. account OPTIONAL — the snapshot carries account.
14
+ *
15
+ * 2. cookie-api mode (opts.account.cookies + account.urlToken): fetch the
16
+ * logged-in user's answers / followees / collections via the injected
17
+ * `fetchFn` (Android in-APK cc → OkHttp; desktop hub → Electron WebView net
18
+ * request) so this module stays a pure-Node parser + orchestrator. Zhihu's
19
+ * /api/v4 member endpoints key off the user's `url_token`, so account.urlToken
20
+ * is REQUIRED in cookie mode (checked at sync time). Pagination follows
21
+ * zhihu's `{ data, paging: { is_end, next, ... } }` offset cursor.
22
+ *
23
+ * ── sign seam ──────────────────────────────────────────────────────────
24
+ * Some zhihu /api/v4 endpoints require an `x-zse-96` signature header
25
+ * computed by client-side JS (analogous to 抖音 X-Bogus). No pure-Node impl
26
+ * survives the rotation, so signing is injected via `opts.signProvider`
27
+ * (or constructor `signProvider`). When absent the request is still issued
28
+ * unsigned — best-effort, the endpoint may reject it, which surfaces as zero
29
+ * events rather than a crash. Endpoint constants are best-effort and
30
+ * overridable via opts.*Url; zhihu rotates these (FAMILY-23 playbook —
31
+ * endpoints are not field-verified here).
32
+ *
33
+ * Snapshot schema (schemaVersion 1):
34
+ *
35
+ * {
36
+ * "schemaVersion": 1,
37
+ * "snapshottedAt": <epoch-ms>,
38
+ * "account": { "urlToken": "...", "name": "..." },
39
+ * "events": [
40
+ * { "kind": "answer", "id": "answer-<id>", "answerId": "...",
41
+ * "questionTitle": "...", "excerpt": "...", "voteupCount": N,
42
+ * "commentCount": N, "createdTime": <s|ms>, "url": "..." },
43
+ * { "kind": "favourite", "id": "fav-<id>", "itemId": "...",
44
+ * "title": "...", "url": "...", "collectionName": "...", "capturedAt": <ms> },
45
+ * { "kind": "follow", "id": "follow-<token>", "memberToken": "...",
46
+ * "name": "...", "headline": "...", "avatarUrl": "...", "capturedAt": <ms> }
47
+ * ]
48
+ * }
49
+ */
50
+
51
+ "use strict";
52
+
53
+ const fs = require("node:fs");
54
+ const { newId } = require("../../ids");
55
+ const {
56
+ ENTITY_TYPES,
57
+ PERSON_SUBTYPES,
58
+ EVENT_SUBTYPES,
59
+ CAPTURED_BY,
60
+ } = require("../../constants");
61
+ const { CookieAuth } = require("../shopping-base");
62
+
63
+ const NAME = "social-zhihu";
64
+ const VERSION = "0.1.0";
65
+ const SNAPSHOT_SCHEMA_VERSION = 1;
66
+
67
+ const KIND_ANSWER = "answer";
68
+ const KIND_FAVOURITE = "favourite";
69
+ const KIND_FOLLOW = "follow";
70
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ANSWER, KIND_FAVOURITE, KIND_FOLLOW]);
71
+
72
+ // Best-effort zhihu /api/v4 endpoints. `{token}` is replaced with url_token.
73
+ // Overridable via opts.answersUrl / opts.followeesUrl / opts.collectionsUrl.
74
+ const ANSWERS_URL = "https://www.zhihu.com/api/v4/members/{token}/answers";
75
+ const FOLLOWEES_URL = "https://www.zhihu.com/api/v4/members/{token}/followees";
76
+ const COLLECTIONS_URL = "https://www.zhihu.com/api/v4/people/{token}/collections";
77
+ const PAGE_LIMIT = 20;
78
+
79
+ function stableOriginalId(kind, id) {
80
+ const stringified =
81
+ (typeof id === "string" && id.length > 0 && id) ||
82
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
83
+ null;
84
+ const safe =
85
+ stringified ||
86
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
87
+ return `zhihu:${kind}:${safe}`;
88
+ }
89
+
90
+ function parseTime(v) {
91
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
92
+ if (typeof v === "string") {
93
+ if (/^\d+$/.test(v)) {
94
+ const n = parseInt(v, 10);
95
+ return n > 1e12 ? n : n * 1000;
96
+ }
97
+ const t = Date.parse(v);
98
+ return Number.isFinite(t) ? t : null;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ class ZhihuAdapter {
104
+ constructor(opts = {}) {
105
+ this.account = opts.account || null;
106
+
107
+ this._cookieAuth =
108
+ opts.account && opts.account.cookies
109
+ ? new CookieAuth({ platform: "zhihu", cookies: opts.account.cookies })
110
+ : null;
111
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
112
+ this._signProvider =
113
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
114
+ this._urls = {
115
+ answers: opts.answersUrl || ANSWERS_URL,
116
+ followees: opts.followeesUrl || FOLLOWEES_URL,
117
+ collections: opts.collectionsUrl || COLLECTIONS_URL,
118
+ };
119
+
120
+ this.name = NAME;
121
+ this.version = VERSION;
122
+ this.capabilities = [
123
+ "sync:snapshot",
124
+ "sync:cookie-api",
125
+ "parse:zhihu-answer",
126
+ "parse:zhihu-favourite",
127
+ "parse:zhihu-follow",
128
+ ];
129
+ this.extractMode = "web-api";
130
+ this.rateLimits = { perMinute: 8, perDay: 200 };
131
+ this.dataDisclosure = {
132
+ fields: [
133
+ "zhihu:answer (questionTitle / excerpt / voteupCount)",
134
+ "zhihu:favourite (title / url / collectionName)",
135
+ "zhihu:follow (memberToken / name / headline)",
136
+ ],
137
+ sensitivity: "medium",
138
+ legalGate: false,
139
+ defaultInclude: { answer: true, favourite: true, follow: true },
140
+ };
141
+
142
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require.
143
+ this._deps = { fs };
144
+ }
145
+
146
+ async authenticate(ctx = {}) {
147
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
148
+ try {
149
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
150
+ } catch (err) {
151
+ return {
152
+ ok: false,
153
+ reason: "INPUT_PATH_UNREADABLE",
154
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
155
+ };
156
+ }
157
+ return { ok: true, mode: "snapshot-file" };
158
+ }
159
+ if (this._cookieAuth) {
160
+ const ok = await this._cookieAuth.validate();
161
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
162
+ if (!this.account || !this.account.urlToken) {
163
+ return {
164
+ ok: false,
165
+ reason: "NO_ACCOUNT_URL_TOKEN",
166
+ message: "cookie-api mode requires account.urlToken (zhihu member url_token)",
167
+ };
168
+ }
169
+ return { ok: true, account: this.account.urlToken, mode: "cookie" };
170
+ }
171
+ return {
172
+ ok: false,
173
+ reason: "NO_INPUT",
174
+ message:
175
+ "social-zhihu.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies + urlToken (cookie-api mode)",
176
+ };
177
+ }
178
+
179
+ async healthCheck() {
180
+ if (this._cookieAuth) {
181
+ const r = await this.authenticate();
182
+ return r.ok
183
+ ? { ok: true, lastChecked: Date.now() }
184
+ : { ok: false, reason: r.reason, error: r.error };
185
+ }
186
+ return { ok: true, lastChecked: Date.now() };
187
+ }
188
+
189
+ async *sync(opts = {}) {
190
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
191
+ yield* this._syncViaSnapshot(opts);
192
+ return;
193
+ }
194
+ if (this._cookieAuth) {
195
+ yield* this._syncViaCookie(opts);
196
+ return;
197
+ }
198
+ throw new Error(
199
+ "social-zhihu.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies + urlToken (cookie-api mode; some zhihu endpoints need x-zse-96 via opts.signProvider)",
200
+ );
201
+ }
202
+
203
+ async *_syncViaSnapshot(opts) {
204
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
205
+ const snapshot = JSON.parse(raw);
206
+ if (
207
+ !snapshot ||
208
+ typeof snapshot !== "object" ||
209
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
210
+ ) {
211
+ throw new Error(
212
+ `social-zhihu.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
213
+ );
214
+ }
215
+ const fallbackCapturedAt =
216
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
217
+ ? Math.floor(snapshot.snapshottedAt)
218
+ : Date.now();
219
+ const account =
220
+ snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
221
+ const include = opts.include || {};
222
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
223
+
224
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
225
+ let emitted = 0;
226
+ for (const ev of events) {
227
+ if (emitted >= limit) return;
228
+ if (!ev || typeof ev !== "object") continue;
229
+ const kind = ev.kind;
230
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
231
+ if (include[kind] === false) continue;
232
+
233
+ const capturedAt =
234
+ parseTime(ev.capturedAt) || parseTime(ev.createdTime) || fallbackCapturedAt;
235
+ const id =
236
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
237
+ ev.answerId ||
238
+ ev.itemId ||
239
+ ev.memberToken ||
240
+ null;
241
+
242
+ yield {
243
+ adapter: NAME,
244
+ kind,
245
+ originalId: stableOriginalId(kind, id),
246
+ capturedAt,
247
+ payload: { ...ev, account },
248
+ };
249
+ emitted += 1;
250
+ }
251
+ }
252
+
253
+ async *_syncViaCookie(opts = {}) {
254
+ if (!this.account || !this.account.urlToken) {
255
+ throw new Error(
256
+ "social-zhihu._syncViaCookie: account.urlToken required (set via new ZhihuAdapter({ account: { urlToken, cookies } }))",
257
+ );
258
+ }
259
+ if (!(await this._cookieAuth.validate())) return;
260
+ const token = encodeURIComponent(this.account.urlToken);
261
+ const include = opts.include || {};
262
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
263
+ const maxPages =
264
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
265
+
266
+ const plan = [
267
+ { kind: KIND_ANSWER, url: this._urls.answers, idOf: (it) => it.id, mapId: (it) => `answer-${it.id}` },
268
+ { kind: KIND_FOLLOW, url: this._urls.followees, idOf: (it) => it.url_token || it.id, mapId: (it) => `follow-${it.url_token || it.id}` },
269
+ { kind: KIND_FAVOURITE, url: this._urls.collections, idOf: (it) => it.id, mapId: (it) => `fav-${it.id}` },
270
+ ];
271
+
272
+ let emitted = 0;
273
+ for (const step of plan) {
274
+ if (include[step.kind] === false) continue;
275
+ const baseUrl = step.url.replace("{token}", token);
276
+ let offset = 0;
277
+ let page = 0;
278
+ while (page < maxPages) {
279
+ const query = { limit: PAGE_LIMIT, offset };
280
+ let sign = null;
281
+ if (this._signProvider) {
282
+ sign = await this._signProvider({
283
+ url: baseUrl,
284
+ query,
285
+ cookies: this._cookieAuth.toHeader(),
286
+ });
287
+ }
288
+ const resp = await this._fetchFn({
289
+ url: baseUrl,
290
+ cookies: this._cookieAuth.toHeader(),
291
+ query,
292
+ sign,
293
+ });
294
+ const items = extractData(resp);
295
+ if (!items.length) break;
296
+ for (const it of items) {
297
+ if (!it || typeof it !== "object") continue;
298
+ if (emitted >= limit) return;
299
+ yield {
300
+ adapter: NAME,
301
+ kind: step.kind,
302
+ originalId: stableOriginalId(step.kind, step.mapId(it)),
303
+ capturedAt: cookieItemTime(step.kind, it),
304
+ payload: { item: it, kind: step.kind, cookie: true },
305
+ };
306
+ emitted += 1;
307
+ }
308
+ if (isEnd(resp) || items.length < PAGE_LIMIT) break;
309
+ offset += items.length;
310
+ page += 1;
311
+ }
312
+ }
313
+ }
314
+
315
+ normalize(raw) {
316
+ if (!raw || !raw.payload) {
317
+ throw new Error("ZhihuAdapter.normalize: payload missing");
318
+ }
319
+ const ingestedAt = Date.now();
320
+ const kind = raw.kind || raw.payload.kind;
321
+ if (kind === KIND_ANSWER) return normalizeAnswer(raw, ingestedAt);
322
+ if (kind === KIND_FAVOURITE) return normalizeFavourite(raw, ingestedAt);
323
+ if (kind === KIND_FOLLOW) return normalizeFollow(raw, ingestedAt);
324
+ throw new Error(`ZhihuAdapter.normalize: unknown kind ${kind}`);
325
+ }
326
+ }
327
+
328
+ // ─── cookie-api response helpers ─────────────────────────────────────────────
329
+
330
+ /** Pull the data array from a zhihu /api/v4 paginated response. */
331
+ function extractData(resp) {
332
+ if (!resp || typeof resp !== "object") return [];
333
+ if (Array.isArray(resp.data)) return resp.data;
334
+ if (Array.isArray(resp.items)) return resp.items;
335
+ return [];
336
+ }
337
+
338
+ function isEnd(resp) {
339
+ return !!(resp && resp.paging && resp.paging.is_end === true);
340
+ }
341
+
342
+ function cookieItemTime(kind, it) {
343
+ if (kind === KIND_ANSWER) {
344
+ return parseTime(it.created_time || it.updated_time) || Date.now();
345
+ }
346
+ if (kind === KIND_FAVOURITE) {
347
+ return parseTime(it.created || it.updated_time) || Date.now();
348
+ }
349
+ return Date.now();
350
+ }
351
+
352
+ // ─── per-kind normalizers (snapshot direct fields OR cookie payload.item) ─────
353
+
354
+ function buildSource(raw, occurredAt) {
355
+ return {
356
+ adapter: NAME,
357
+ adapterVersion: VERSION,
358
+ originalId: raw.originalId,
359
+ capturedAt: raw.capturedAt || occurredAt,
360
+ capturedBy: CAPTURED_BY.API,
361
+ };
362
+ }
363
+
364
+ function normalizeAnswer(raw, ingestedAt) {
365
+ const p = raw.payload;
366
+ const it = p.cookie ? p.item : p;
367
+ const questionTitle = p.cookie
368
+ ? (it.question && it.question.title) || it.title || ""
369
+ : it.questionTitle || it.title || "";
370
+ const excerpt = it.excerpt || it.excerpt_new || it.content || "";
371
+ const answerId = it.answerId || it.id || null;
372
+ const occurredAt =
373
+ parseTime(it.createdTime || it.created_time || it.created || raw.capturedAt) || ingestedAt;
374
+ const source = buildSource(raw, occurredAt);
375
+ return {
376
+ events: [
377
+ {
378
+ id: newId(),
379
+ type: ENTITY_TYPES.EVENT,
380
+ subtype: EVENT_SUBTYPES.POST,
381
+ occurredAt,
382
+ actor: "person-self",
383
+ content: {
384
+ title: (questionTitle || excerpt || "").slice(0, 80) || "(空)",
385
+ text: stripHtml(excerpt),
386
+ },
387
+ ingestedAt,
388
+ source,
389
+ extra: {
390
+ platform: "zhihu",
391
+ zhihuAnswerId: answerId != null ? String(answerId) : null,
392
+ questionTitle: questionTitle || null,
393
+ voteupCount: it.voteupCount != null ? it.voteupCount : it.voteup_count || 0,
394
+ commentCount: it.commentCount != null ? it.commentCount : it.comment_count || 0,
395
+ url:
396
+ it.url ||
397
+ (answerId ? `https://www.zhihu.com/answer/${answerId}` : null),
398
+ },
399
+ },
400
+ ],
401
+ persons: [],
402
+ places: [],
403
+ items: [],
404
+ topics: [],
405
+ };
406
+ }
407
+
408
+ function normalizeFavourite(raw, ingestedAt) {
409
+ const p = raw.payload;
410
+ const it = p.cookie ? p.item : p;
411
+ const title = it.title || it.collectionName || "";
412
+ const occurredAt =
413
+ parseTime(it.capturedAt || it.created || raw.capturedAt) || ingestedAt;
414
+ const source = buildSource(raw, occurredAt);
415
+ return {
416
+ events: [
417
+ {
418
+ id: newId(),
419
+ type: ENTITY_TYPES.EVENT,
420
+ subtype: EVENT_SUBTYPES.LIKE,
421
+ occurredAt,
422
+ actor: "person-self",
423
+ content: {
424
+ title: (title || "").slice(0, 80) || "(空)",
425
+ text: title,
426
+ },
427
+ ingestedAt,
428
+ source,
429
+ extra: {
430
+ platform: "zhihu",
431
+ zhihuItemId: (it.itemId || it.id) != null ? String(it.itemId || it.id) : null,
432
+ collectionName: it.collectionName || it.title || null,
433
+ isPublic: it.is_public != null ? it.is_public : undefined,
434
+ url: it.url || null,
435
+ },
436
+ },
437
+ ],
438
+ persons: [],
439
+ places: [],
440
+ items: [],
441
+ topics: [],
442
+ };
443
+ }
444
+
445
+ function normalizeFollow(raw, ingestedAt) {
446
+ const p = raw.payload;
447
+ const it = p.cookie ? p.item : p;
448
+ const memberToken = it.memberToken || it.url_token || it.id || `unknown-${newId()}`;
449
+ const name = it.name || "(unnamed)";
450
+ const occurredAt = parseTime(it.capturedAt || raw.capturedAt) || ingestedAt;
451
+ const source = buildSource(raw, occurredAt);
452
+ const person = {
453
+ id: `person-zhihu-${memberToken}`,
454
+ type: ENTITY_TYPES.PERSON,
455
+ subtype: PERSON_SUBTYPES.CONTACT,
456
+ names: [name],
457
+ ingestedAt,
458
+ source,
459
+ identifiers: {
460
+ "zhihu-token": [String(memberToken)],
461
+ },
462
+ extra: {
463
+ platform: "zhihu",
464
+ headline: it.headline || null,
465
+ avatarUrl: it.avatarUrl || it.avatar_url || null,
466
+ followedAt: occurredAt,
467
+ },
468
+ };
469
+ return { events: [], persons: [person], places: [], items: [], topics: [] };
470
+ }
471
+
472
+ function stripHtml(s) {
473
+ if (typeof s !== "string") return "";
474
+ return s.replace(/<[^>]+>/g, "").trim();
475
+ }
476
+
477
+ async function defaultFetch(_opts) {
478
+ throw new Error("social-zhihu: no fetchFn configured for cookie-api mode");
479
+ }
480
+
481
+ module.exports = {
482
+ ZhihuAdapter,
483
+ extractData,
484
+ NAME,
485
+ VERSION,
486
+ SNAPSHOT_SCHEMA_VERSION,
487
+ VALID_SNAPSHOT_KINDS,
488
+ };