@chainlesschain/personal-data-hub 0.4.3 → 0.4.5

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 (58) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/messaging-whatsapp.test.js +289 -0
  9. package/__tests__/adapters/netease-music-live.test.js +244 -0
  10. package/__tests__/adapters/shopping-base.test.js +179 -0
  11. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  12. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  13. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +64 -0
  14. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +11 -0
  15. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  16. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  17. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  18. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  19. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +431 -0
  20. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  21. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +200 -0
  22. package/__tests__/adapters/travel-12306.test.js +279 -0
  23. package/__tests__/adapters/travel-amap.test.js +219 -0
  24. package/__tests__/adapters/travel-baidu-map.test.js +305 -0
  25. package/__tests__/adapters/travel-base.test.js +205 -0
  26. package/__tests__/adapters/travel-ctrip.test.js +203 -0
  27. package/__tests__/adapters/travel-tencent-map.test.js +207 -0
  28. package/lib/adapters/_live-json-helpers.js +50 -0
  29. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  30. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  31. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  32. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  33. package/lib/adapters/finance-alipay/api-client.js +268 -6
  34. package/lib/adapters/finance-alipay/index.js +85 -9
  35. package/lib/adapters/game-genshin/api-client.js +207 -6
  36. package/lib/adapters/game-genshin/index.js +90 -9
  37. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  38. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  39. package/lib/adapters/netease-music/api-client.js +284 -0
  40. package/lib/adapters/netease-music/index.js +85 -9
  41. package/lib/adapters/social-douyin/index.js +2 -0
  42. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  43. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  44. package/lib/adapters/social-douyin-adb/index.js +18 -1
  45. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  46. package/lib/adapters/social-kuaishou/index.js +7 -2
  47. package/lib/adapters/social-kuaishou-adb/api-client.js +38 -18
  48. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +16 -15
  49. package/lib/adapters/social-toutiao/index.js +8 -4
  50. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  51. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  52. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  53. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  54. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  55. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  56. package/lib/adapters/travel-base/index.js +9 -2
  57. package/lib/index.js +1 -1
  58. package/package.json +1 -1
@@ -1,17 +1,83 @@
1
1
  /**
2
- * HonorOfKingsApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
2
+ * HonorOfKingsApiClient — 王者荣耀 (Honor of Kings) 采集客户端。
3
3
  *
4
- * 王者荣耀走腾讯系登录(微信/QQ openid 或营地 gamehelper cookie);v0.1 仅从
5
- * cookie uid(extractUid),战绩/对局时长 v0.2(营地接口 + 腾讯签名)。
6
- * Cookie key 优先级: openid > uin(QQ号) > tencent_uid。
4
+ * v0.1 cookie-scrape(extractUid)。**v0.2 接通 live 营地战绩 fetcher**:
5
+ * 王者营地 (KOH Camp / gamehelper) 接口拉取个人资料 + 最近对局(含对局时长,
6
+ * 即家长关心的 "玩多久")。
7
+ *
8
+ * ⚠️ 鉴权与 genshin/netease 不同:营地走 QQ/微信 OAuth,需 access_token + openid
9
+ * + acctype("qc"=QQ / "wx"=微信)+ 游戏角色 (areaId / roleId),**不是 web
10
+ * cookie**——这些 token 由营地 App 登录态产出(手机端 collector 取得后回传)。
11
+ * ⚠️ **best-effort**:营地接口路径/字段无公开稳定文档,下方端点与字段名按社区
12
+ * 逆向常见形态实现,且做了多字段名兼容(pick 回退)。**未经真实营地登录态
13
+ * 实地验证**,端点/字段漂移时按需调整(同 SignProvider 轮转思路,端点/字段
14
+ * 都集中在常量与 pick 列表里便于改)。
15
+ *
16
+ * Cookie key 优先级 (extractUid,仅 v0.1 探测用): openid > uin(QQ号) > tencent_uid。
7
17
  */
8
18
  "use strict";
9
19
 
20
+ const DEFAULT_BASE_URL = "https://ssl.kohsocialapp.qq.com:10001";
21
+
22
+ // 营地常见端点(best-effort,可经 opts 覆盖)。
23
+ const PATH_PROFILE = "/play/profildetail";
24
+ const PATH_BATTLE_LIST = "/game/getbattlelist";
25
+
26
+ const BROWSER_UA =
27
+ "Mozilla/5.0 (Linux; Android 12; gamehelper) AppleWebKit/537.36 " +
28
+ "(KHTML, like Gecko) Chrome/103.0.5060.129 Mobile Safari/537.36 GameHelper";
29
+
30
+ /** First present, non-empty value among `keys` on `obj`. */
31
+ function pick(obj, keys, fallback = null) {
32
+ if (!obj || typeof obj !== "object") return fallback;
33
+ for (const k of keys) {
34
+ const v = obj[k];
35
+ if (v !== undefined && v !== null && v !== "") return v;
36
+ }
37
+ return fallback;
38
+ }
39
+
40
+ /**
41
+ * Coerce a seconds-or-ms match duration to ms. Match lengths separate cleanly:
42
+ * seconds form is ≤ ~7200 (2h), ms form is ≥ ~300000 (5min) — so a 1e5 threshold
43
+ * disambiguates without overlap.
44
+ */
45
+ function toDurationMs(v) {
46
+ if (!Number.isFinite(v)) {
47
+ const n = typeof v === "string" && /^\d+$/.test(v) ? parseInt(v, 10) : NaN;
48
+ if (!Number.isFinite(n)) return 0;
49
+ v = n;
50
+ }
51
+ if (v <= 0) return 0;
52
+ return v < 1e5 ? v * 1000 : v;
53
+ }
54
+
55
+ /** Coerce epoch seconds-or-ms (or date string) to ms, else null. */
56
+ function toEpochMs(v) {
57
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
58
+ if (typeof v === "string") {
59
+ if (/^\d+$/.test(v)) {
60
+ const n = parseInt(v, 10);
61
+ return n > 1e12 ? n : n * 1000;
62
+ }
63
+ const t = Date.parse(v);
64
+ return Number.isFinite(t) ? t : null;
65
+ }
66
+ return null;
67
+ }
68
+
10
69
  class HonorOfKingsApiClient {
11
- constructor() {
70
+ constructor(opts = {}) {
71
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
72
+ this.profilePath = opts.profilePath || PATH_PROFILE;
73
+ this.battleListPath = opts.battleListPath || PATH_BATTLE_LIST;
74
+ this._fetch =
75
+ opts.fetch || (typeof globalThis.fetch === "function" ? globalThis.fetch : null);
76
+ this._now = opts.now || Date.now;
12
77
  this._lastErrorCode = 0;
13
78
  this._lastErrorMsg = "";
14
79
  }
80
+
15
81
  _setLastError(code, msg) {
16
82
  this._lastErrorCode = code;
17
83
  this._lastErrorMsg = msg;
@@ -24,13 +90,15 @@ class HonorOfKingsApiClient {
24
90
  return { code: this._lastErrorCode, message: this._lastErrorMsg };
25
91
  }
26
92
 
27
- /** @param {string} cookie @returns {string|null} */
93
+ /**
94
+ * 从 cookie 串抽 uid(v0.1 探测;live 模式用 credential.roleId)。
95
+ * @param {string} cookie @returns {string|null}
96
+ */
28
97
  extractUid(cookie) {
29
98
  if (typeof cookie !== "string" || cookie.length === 0) {
30
99
  this._setLastError(-1, "cookie 为空");
31
100
  return null;
32
101
  }
33
- // openid 是字母数字混合; uin / tencent_uid 是纯数字。
34
102
  const openid = /(?:^|; ?)openid=([A-Za-z0-9_-]+)/.exec(cookie);
35
103
  if (openid && openid[1] && openid[1].length >= 8) {
36
104
  this._clearLastError();
@@ -43,12 +111,167 @@ class HonorOfKingsApiClient {
43
111
  return m[1];
44
112
  }
45
113
  }
46
- this._setLastError(
47
- -7,
48
- "cookie 缺 openid / uin / tencent_uid — 营地/微信/QQ 未登录",
49
- );
114
+ this._setLastError(-7, "cookie 缺 openid / uin / tencent_uid — 营地/微信/QQ 未登录");
50
115
  return null;
51
116
  }
117
+
118
+ /** Auth fields every Camp request carries, derived from a credential bundle. */
119
+ _authFields(cred) {
120
+ return {
121
+ accessToken: cred.accessToken,
122
+ openid: cred.openid,
123
+ // Camp uses acctype "qc" (QQ) / "wx" (WeChat); login type mirrors it.
124
+ acctype: cred.acctype || "qc",
125
+ gameId: cred.gameId || "20001", // KOH gameId on the camp platform
126
+ areaId: cred.areaId != null ? String(cred.areaId) : undefined,
127
+ partition: cred.partition != null ? String(cred.partition) : undefined,
128
+ roleId: cred.roleId != null ? String(cred.roleId) : undefined,
129
+ serverId: cred.serverId != null ? String(cred.serverId) : undefined,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * POST a Camp endpoint with auth fields merged into the JSON body. Returns
135
+ * the envelope `data` on success, null on transport / API error.
136
+ */
137
+ async _post(path, extraBody, cred) {
138
+ if (typeof this._fetch !== "function") {
139
+ this._setLastError(-2, "HonorOfKingsApiClient: fetch not available — pass opts.fetch or run on Node 18+");
140
+ return null;
141
+ }
142
+ const body = JSON.stringify({ ...this._authFields(cred), ...extraBody });
143
+ let resp;
144
+ try {
145
+ resp = await this._fetch(`${this.baseUrl}${path}`, {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ "User-Agent": BROWSER_UA,
150
+ },
151
+ body,
152
+ });
153
+ } catch (e) {
154
+ this._setLastError(-4, "network: " + (e && e.message ? e.message : String(e)));
155
+ return null;
156
+ }
157
+ const txt = await resp.text();
158
+ if (!resp.ok) {
159
+ this._setLastError(resp.status, `HTTP ${resp.status}`);
160
+ return null;
161
+ }
162
+ let obj;
163
+ try {
164
+ obj = JSON.parse(txt);
165
+ } catch (e) {
166
+ this._setLastError(-3, "parse: " + (e && e.message ? e.message : String(e)));
167
+ return null;
168
+ }
169
+ // Camp envelope: { returnCode|result|ret, returnMsg|msg, data }. 0 = ok.
170
+ const code = pick(obj, ["returnCode", "result", "ret", "code"], 0);
171
+ if (Number(code) !== 0) {
172
+ this._setLastError(Number(code), pick(obj, ["returnMsg", "msg", "message"], `code ${code}`).toString());
173
+ return null;
174
+ }
175
+ this._clearLastError();
176
+ return obj.data !== undefined ? obj.data : obj;
177
+ }
178
+
179
+ /** 个人资料 → { uid, nickname, level, rank, avatarUrl } or null. */
180
+ async getProfile(cred) {
181
+ const data = await this._post(this.profilePath, {}, cred);
182
+ if (data === null) return null;
183
+ // Camp may wrap the role under data.role / data.roleInfo / data directly.
184
+ const role = pick(data, ["role", "roleInfo", "baseInfo"], data);
185
+ const uid =
186
+ pick(role, ["roleId", "uid", "roleIdStr"]) || (cred.roleId != null ? String(cred.roleId) : null);
187
+ return {
188
+ uid: uid != null ? String(uid) : null,
189
+ nickname: pick(role, ["roleName", "nickName", "nickname", "name"]),
190
+ level: (() => {
191
+ const lv = pick(role, ["level", "roleLevel"]);
192
+ return Number.isFinite(Number(lv)) ? Number(lv) : null;
193
+ })(),
194
+ rank: pick(role, ["rankName", "gradeName", "segmentName", "rank", "dengjib"]),
195
+ avatarUrl: pick(role, ["logo", "headUrl", "avatar", "iconUrl"]),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * 最近对局 → [{ matchId, startAt, durationMs, mode }]. null on error.
201
+ * @param {object} cred
202
+ * @param {object} [opts] { limit, offset }
203
+ */
204
+ async getBattleList(cred, opts = {}) {
205
+ const data = await this._post(
206
+ this.battleListPath,
207
+ { offset: opts.offset || 0, num: opts.limit || 20, count: opts.limit || 20 },
208
+ cred,
209
+ );
210
+ if (data === null) return null;
211
+ const list = pick(data, ["list", "battleList", "records", "data"], Array.isArray(data) ? data : []);
212
+ if (!Array.isArray(list)) return [];
213
+ return list.map((m) => ({
214
+ matchId: pick(m, ["gameSeq", "gameId", "relaySvrId", "battleId", "id"]),
215
+ startAt: toEpochMs(pick(m, ["startTime", "stTime", "gameTime", "battleTime"])),
216
+ durationMs: toDurationMs(pick(m, ["gametime", "usedTime", "gameDuration", "duration"], 0)),
217
+ mode: pick(m, ["mapName", "modeName", "gameName", "mode", "mapId"]),
218
+ }));
219
+ }
220
+
221
+ /**
222
+ * High-level: profile + recent battles → snapshot-shaped events so the
223
+ * adapter normalize path is unchanged.
224
+ * @returns {Promise<{account, events}|null>}
225
+ */
226
+ async fetchSnapshot(cred, opts = {}) {
227
+ if (!cred || typeof cred !== "object" || !cred.accessToken || !cred.openid) {
228
+ this._setLastError(-1, "credential 缺 accessToken / openid(营地未登录)");
229
+ return null;
230
+ }
231
+ const include = opts.include || {};
232
+ const events = [];
233
+ let account = null;
234
+
235
+ if (include.profile !== false) {
236
+ const profile = await this.getProfile(cred);
237
+ if (profile === null) return null;
238
+ account = { uid: profile.uid, displayName: profile.nickname };
239
+ events.push({
240
+ kind: "profile",
241
+ id: profile.uid ? `profile-${profile.uid}` : null,
242
+ uid: profile.uid,
243
+ nickname: profile.nickname,
244
+ level: profile.level,
245
+ rank: profile.rank,
246
+ avatarUrl: profile.avatarUrl,
247
+ });
248
+ }
249
+
250
+ if (include.play !== false) {
251
+ const battles = await this.getBattleList(cred, { limit: opts.limit, offset: opts.offset });
252
+ if (battles === null) return null;
253
+ for (const b of battles) {
254
+ events.push({
255
+ kind: "play",
256
+ id: b.matchId ? `play-${b.matchId}` : null,
257
+ durationMs: b.durationMs,
258
+ mode: b.mode,
259
+ startAt: b.startAt,
260
+ });
261
+ }
262
+ }
263
+
264
+ this._clearLastError();
265
+ return { account, events };
266
+ }
52
267
  }
53
268
 
54
- module.exports = { HonorOfKingsApiClient };
269
+ module.exports = {
270
+ HonorOfKingsApiClient,
271
+ // Exported for tests / endpoint introspection.
272
+ pick,
273
+ toDurationMs,
274
+ toEpochMs,
275
+ PATH_PROFILE,
276
+ PATH_BATTLE_LIST,
277
+ };
@@ -1,10 +1,15 @@
1
1
  /**
2
- * FAMILY-23 v0.1 — 王者荣耀 (Honor of Kings) adapter, snapshot mode.
2
+ * FAMILY-23 — 王者荣耀 (Honor of Kings) adapter.
3
3
  *
4
- * 家庭守护 telemetry:家长看孩子玩什么游戏/玩多久。v0.1 cookie-scrape 占位 —
5
- * [HonorOfKingsApiClient.extractUid] openid/uin;snapshot 模式消费手机端 collector
6
- * 快照 (profile + play-session)。战绩 HTTP fetcher(营地接口 + 腾讯签名)留 v0.2
7
- * 故无 inputPath sync NO_INPUT(同 social-kuaishou snapshot 先例)。
4
+ * 家庭守护 telemetry:家长看孩子玩什么游戏/玩多久。两路互补:
5
+ * - snapshot 模式(inputPath):手机端 collector 快照 (profile + play-session)。
6
+ * - **live 模式(credential,v0.2 接通)**:[HonorOfKingsApiClient.fetchSnapshot]
7
+ * 王者营地 (Camp) 接口拉个人资料 + 最近对局(含对局时长 "玩多久")。
8
+ * ⚠️ 营地走 QQ/微信 OAuth,需 credential={accessToken,openid,acctype,areaId,
9
+ * roleId,...}(手机端登录态取得后回传),不是 web cookie。营地端点/字段无公开
10
+ * 稳定文档,按社区逆向常见形态实现且做了多字段名兼容,**未实地验证**,漂移
11
+ * 时按 api-client 常量/pick 列表调整。
12
+ * 无 inputPath 且无 credential 时 sync 抛错。
8
13
  *
9
14
  * Snapshot schema (v1):
10
15
  * { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
@@ -26,7 +31,7 @@ const {
26
31
  const { HonorOfKingsApiClient } = require("./api-client");
27
32
 
28
33
  const NAME = "game-honor-of-kings";
29
- const VERSION = "0.1.0";
34
+ const VERSION = "0.2.0";
30
35
  const SNAPSHOT_SCHEMA_VERSION = 1;
31
36
  const KIND_PROFILE = "profile";
32
37
  const KIND_PLAY = "play";
@@ -60,6 +65,7 @@ class HonorOfKingsAdapter {
60
65
  this.version = VERSION;
61
66
  this.capabilities = [
62
67
  "sync:snapshot",
68
+ "sync:camp-token",
63
69
  "parse:hok-profile",
64
70
  "parse:hok-play-session",
65
71
  ];
@@ -74,7 +80,10 @@ class HonorOfKingsAdapter {
74
80
  legalGate: false,
75
81
  defaultInclude: { profile: true, play: true },
76
82
  };
77
- this.apiClient = new HonorOfKingsApiClient();
83
+ this.apiClient = new HonorOfKingsApiClient(opts);
84
+ // Test seam: override how the live client is built per-sync (inject fetch).
85
+ this._apiClientFactory =
86
+ typeof opts.apiClientFactory === "function" ? opts.apiClientFactory : null;
78
87
  this._deps = { fs };
79
88
  }
80
89
 
@@ -91,11 +100,15 @@ class HonorOfKingsAdapter {
91
100
  }
92
101
  return { ok: true, mode: "snapshot-file" };
93
102
  }
103
+ const cred = ctx && ctx.credential;
104
+ if (cred && typeof cred === "object" && cred.accessToken && cred.openid) {
105
+ return { ok: true, mode: "camp-token" };
106
+ }
94
107
  return {
95
108
  ok: false,
96
109
  reason: "NO_INPUT",
97
110
  message:
98
- "game-honor-of-kings.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher v0.2",
111
+ "game-honor-of-kings.authenticate: needs opts.inputPath (snapshot) or opts.credential {accessToken,openid,...} (营地 live)",
99
112
  };
100
113
  }
101
114
 
@@ -108,11 +121,69 @@ class HonorOfKingsAdapter {
108
121
  yield* this._syncViaSnapshot(opts);
109
122
  return;
110
123
  }
124
+ if (opts.credential && typeof opts.credential === "object") {
125
+ yield* this._syncViaLive(opts);
126
+ return;
127
+ }
111
128
  throw new Error(
112
- "game-honor-of-kings.sync: v0.1 needs opts.inputPath (snapshot mode); 营地战绩 HTTP fetcher v0.2",
129
+ "game-honor-of-kings.sync: needs opts.inputPath (snapshot mode) or opts.credential {accessToken,openid,...} (营地战绩 live)",
113
130
  );
114
131
  }
115
132
 
133
+ async *_syncViaLive(opts) {
134
+ const client = this._apiClientFactory
135
+ ? this._apiClientFactory(opts)
136
+ : new HonorOfKingsApiClient({
137
+ fetch: opts.fetch,
138
+ now: opts.now,
139
+ baseUrl: opts.baseUrl,
140
+ profilePath: opts.profilePath,
141
+ battleListPath: opts.battleListPath,
142
+ });
143
+ const emit = (phase, extra) => {
144
+ if (typeof opts.onProgress === "function") {
145
+ try {
146
+ opts.onProgress({ phase, adapter: NAME, ...extra });
147
+ } catch (_e) {
148
+ /* progress callback errors are best-effort */
149
+ }
150
+ }
151
+ };
152
+ const result = await client.fetchSnapshot(opts.credential, {
153
+ include: opts.include || {},
154
+ limit: opts.limit,
155
+ offset: opts.offset,
156
+ });
157
+ if (result === null) {
158
+ const e = client.lastError;
159
+ throw new Error(
160
+ `game-honor-of-kings.sync (live): ${e.message || "fetch failed"} (code ${e.code})`,
161
+ );
162
+ }
163
+ const account = result.account || null;
164
+ emit("fetched", { count: result.events.length });
165
+ const capturedAt = Date.now();
166
+ const limit =
167
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
168
+ const include = opts.include || {};
169
+ let emitted = 0;
170
+ for (const ev of result.events) {
171
+ if (emitted >= limit) return;
172
+ if (!ev || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
173
+ if (include[ev.kind] === false) continue;
174
+ const id =
175
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) || ev.uid || null;
176
+ yield {
177
+ adapter: NAME,
178
+ kind: ev.kind,
179
+ originalId: stableOriginalId(ev.kind, id),
180
+ capturedAt,
181
+ payload: { ...ev, capturedAt, account },
182
+ };
183
+ emitted += 1;
184
+ }
185
+ }
186
+
116
187
  async *_syncViaSnapshot(opts) {
117
188
  const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
118
189
  const snapshot = JSON.parse(raw);