@zucker-framework/ext-kit 1.0.0 → 1.0.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.
package/dist/index.mjs CHANGED
@@ -1,31 +1,67 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
3
7
 
4
- // src/util.ts
5
- function delay(ms) {
6
- return new Promise((resolve) => setTimeout(resolve, ms));
8
+ // src/util.js
9
+ function cancelled() {
10
+ return new Error("Cancelled");
7
11
  }
8
- __name(delay, "delay");
9
- function clamp(value, min, max) {
10
- return Math.min(Math.max(value, min), max);
12
+ __name(cancelled, "cancelled");
13
+ function throwIfAborted(signal) {
14
+ if (signal?.aborted) throw cancelled();
15
+ }
16
+ __name(throwIfAborted, "throwIfAborted");
17
+ function withAbort(promise, signal) {
18
+ if (!signal) return promise;
19
+ if (signal.aborted) {
20
+ promise.catch(() => {
21
+ });
22
+ return Promise.reject(cancelled());
23
+ }
24
+ return new Promise((resolve, reject) => {
25
+ const abort = /* @__PURE__ */ __name(() => reject(cancelled()), "abort");
26
+ signal.addEventListener("abort", abort, {
27
+ once: true
28
+ });
29
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
30
+ });
11
31
  }
12
- __name(clamp, "clamp");
32
+ __name(withAbort, "withAbort");
33
+ function delay(ms, signal) {
34
+ throwIfAborted(signal);
35
+ return new Promise((resolve, reject) => {
36
+ const finish = /* @__PURE__ */ __name(() => {
37
+ signal?.removeEventListener("abort", abort);
38
+ resolve();
39
+ }, "finish");
40
+ const timer = setTimeout(finish, ms);
41
+ const abort = /* @__PURE__ */ __name(() => {
42
+ clearTimeout(timer);
43
+ reject(cancelled());
44
+ }, "abort");
45
+ signal?.addEventListener("abort", abort, {
46
+ once: true
47
+ });
48
+ });
49
+ }
50
+ __name(delay, "delay");
13
51
  function normalizeError(error) {
14
52
  return error instanceof Error ? error.message : String(error);
15
53
  }
16
54
  __name(normalizeError, "normalizeError");
17
- function storageGet(keys) {
18
- return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
19
- }
20
- __name(storageGet, "storageGet");
21
- function storageSet(value) {
22
- return new Promise((resolve) => chrome.storage.local.set(value, () => resolve()));
23
- }
24
- __name(storageSet, "storageSet");
25
- function storageRemove(keys) {
26
- return new Promise((resolve) => chrome.storage.local.remove(keys, () => resolve()));
55
+ function storageCall(area, method, value) {
56
+ return new Promise((resolve, reject) => chrome.storage[area][method](value, (result) => {
57
+ const error = chrome.runtime.lastError;
58
+ error ? reject(new Error(error.message)) : resolve(result);
59
+ }));
27
60
  }
28
- __name(storageRemove, "storageRemove");
61
+ __name(storageCall, "storageCall");
62
+ var storageGet = /* @__PURE__ */ __name((keys) => storageCall("local", "get", keys), "storageGet");
63
+ var storageSet = /* @__PURE__ */ __name((value) => storageCall("local", "set", value), "storageSet");
64
+ var storageRemove = /* @__PURE__ */ __name((keys) => storageCall("local", "remove", keys), "storageRemove");
29
65
 
30
66
  // src/naming.ts
31
67
  function sanitize(value) {
@@ -43,7 +79,7 @@ __name(hostOf, "hostOf");
43
79
  function normalizeUrls(urls) {
44
80
  const out = [];
45
81
  const seen = /* @__PURE__ */ new Set();
46
- for (const raw of urls || []) {
82
+ for (const raw of Array.isArray(urls) ? urls : []) {
47
83
  const value = String(raw || "").trim();
48
84
  if (!/^https?:\/\//i.test(value)) {
49
85
  continue;
@@ -51,7 +87,6 @@ function normalizeUrls(urls) {
51
87
  let key = value;
52
88
  try {
53
89
  const parsed = new URL(value);
54
- parsed.hash = "";
55
90
  key = parsed.toString();
56
91
  } catch {
57
92
  continue;
@@ -131,25 +166,67 @@ function buildQuickFileName(brand, quickFolder, pageUrl, extension) {
131
166
  }
132
167
  __name(buildQuickFileName, "buildQuickFileName");
133
168
 
134
- // src/licensing.ts
135
- var CACHE_TTL_MS = 12 * 60 * 60 * 1e3;
136
- function unwrap(payload) {
137
- const obj = payload;
138
- return obj && typeof obj === "object" && "result" in obj ? obj.result : obj || {};
139
- }
140
- __name(unwrap, "unwrap");
169
+ // src/licensing.js
141
170
  function createLicensing(product, brand, config) {
171
+ for (const key of [
172
+ "cacheTtlMs",
173
+ "offlineGraceMs",
174
+ "requestTimeoutMs",
175
+ "retryDelayMs"
176
+ ]) {
177
+ if (!Number.isFinite(config[key]) || config[key] < 0 || key === "requestTimeoutMs" && !config[key]) throw new RangeError(`Invalid licensing ${key}`);
178
+ }
142
179
  const TOKEN_KEY = `${product}_license_token`;
143
180
  const ENTITLEMENT_KEY = `${product}_entitlement`;
144
181
  const DEVICE_KEY = `${product}_device_id`;
145
- async function getDeviceId() {
146
- const data = await storageGet(DEVICE_KEY);
147
- if (data[DEVICE_KEY]) {
148
- return data[DEVICE_KEY];
182
+ let refresh = null;
183
+ let nextRefreshAt = 0;
184
+ let generation = 0;
185
+ let mutation = false;
186
+ let lastStatus = null;
187
+ const unavailable = /* @__PURE__ */ __name(() => ({
188
+ state: "unavailable",
189
+ message: "Could not verify your saved Pro license. Check your connection and use Recheck Pro in License. Your activation is still saved."
190
+ }), "unavailable");
191
+ const proStatus = /* @__PURE__ */ __name((entitlement) => ({
192
+ state: "pro",
193
+ entitlement
194
+ }), "proStatus");
195
+ function fallbackStatus(cached) {
196
+ if (lastStatus?.state === "invalid") return lastStatus;
197
+ const cachedEntitlement = usableCache(cached);
198
+ return cachedEntitlement ? proStatus(cachedEntitlement) : unavailable();
199
+ }
200
+ __name(fallbackStatus, "fallbackStatus");
201
+ function normalize(value) {
202
+ if (!value || value.invalid === true) return null;
203
+ const normalized = config.normalizeEntitlement(value);
204
+ if (!normalized || typeof normalized.expiresAt !== "string") return null;
205
+ const expires = Date.parse(normalized.expiresAt);
206
+ return Number.isFinite(expires) && expires > Date.now() ? normalized : null;
207
+ }
208
+ __name(normalize, "normalize");
209
+ function usableCache(cached) {
210
+ const age = Date.now() - Number(cached?.checkedAt);
211
+ return Number.isFinite(age) && age >= 0 && age < config.offlineGraceMs ? normalize(cached) : null;
212
+ }
213
+ __name(usableCache, "usableCache");
214
+ async function request(operation, input = {}) {
215
+ const controller = new AbortController();
216
+ const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);
217
+ try {
218
+ return await withAbort(Promise.resolve().then(() => config.request(operation, input, controller.signal)), controller.signal);
219
+ } catch {
220
+ throw new Error("License service unreachable. Check your connection and retry.");
221
+ } finally {
222
+ clearTimeout(timer);
149
223
  }
150
- const bytes = new Uint8Array(16);
151
- crypto.getRandomValues(bytes);
152
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
224
+ }
225
+ __name(request, "request");
226
+ async function getDeviceId() {
227
+ const stored = await storageGet(DEVICE_KEY);
228
+ if (stored[DEVICE_KEY]) return stored[DEVICE_KEY];
229
+ const id = crypto.randomUUID();
153
230
  await storageSet({
154
231
  [DEVICE_KEY]: id
155
232
  });
@@ -157,256 +234,730 @@ function createLicensing(product, brand, config) {
157
234
  }
158
235
  __name(getDeviceId, "getDeviceId");
159
236
  async function activate(code) {
160
- const deviceId = await getDeviceId();
161
- let response;
237
+ if (mutation) throw new Error("A license change is already in progress");
238
+ mutation = true;
239
+ generation += 1;
162
240
  try {
163
- const platform = navigator.userAgentData?.platform || "Chrome";
164
- response = await fetch(`${config.apiBase}/extension/licenses/activate`, {
165
- method: "POST",
166
- headers: {
167
- "Content-Type": "application/json"
168
- },
169
- body: JSON.stringify({
170
- code: String(code || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, ""),
171
- deviceId,
172
- deviceName: `${brand} \xB7 ${platform}`
173
- })
241
+ const deviceId = await getDeviceId();
242
+ const data = await request("activate", {
243
+ code,
244
+ deviceId,
245
+ brand
174
246
  });
175
- } catch {
176
- throw new Error("License service unreachable. Check your connection and retry.");
177
- }
178
- const payload = await response.json().catch(() => ({}));
179
- if (!response.ok) {
180
- if (response.status === 403) {
181
- throw new Error("Invalid code, or its device limit is reached.");
182
- }
183
- throw new Error("Activation failed. Please retry later.");
247
+ if (!data.accepted) throw new Error(data.denied ? "Invalid or expired code, or its device limit is reached." : "Activation failed. Please retry later.");
248
+ const entitlement = normalize(data?.entitlement);
249
+ if (!entitlement || typeof data?.token !== "string" || !data.token) throw new Error("License service returned invalid activation data. Please retry.");
250
+ await storageSet({
251
+ [TOKEN_KEY]: data.token,
252
+ [ENTITLEMENT_KEY]: {
253
+ ...entitlement,
254
+ checkedAt: Date.now()
255
+ }
256
+ });
257
+ nextRefreshAt = 0;
258
+ lastStatus = proStatus(entitlement);
259
+ return entitlement;
260
+ } finally {
261
+ mutation = false;
184
262
  }
185
- const data = unwrap(payload);
186
- await storageSet({
187
- [TOKEN_KEY]: data.token || "",
188
- [ENTITLEMENT_KEY]: {
189
- ...data.entitlement || {},
190
- checkedAt: Date.now()
191
- }
192
- });
193
- return data.entitlement;
194
263
  }
195
264
  __name(activate, "activate");
196
- function normalize(value) {
197
- const v = value;
198
- if (!v) {
199
- return null;
265
+ async function refreshEntitlement(token, cached, version) {
266
+ try {
267
+ const data = await request("me", {
268
+ token
269
+ });
270
+ if (version !== generation) return null;
271
+ if (data.denied === true) {
272
+ const denied = {
273
+ state: "invalid",
274
+ message: "Your saved Pro license could not be validated. Use Recheck Pro or enter your original activation code in License to restore it."
275
+ };
276
+ lastStatus = denied;
277
+ try {
278
+ await storageRemove(ENTITLEMENT_KEY);
279
+ } catch {
280
+ await storageSet({
281
+ [ENTITLEMENT_KEY]: {
282
+ invalid: true,
283
+ active: false,
284
+ checkedAt: Date.now()
285
+ }
286
+ }).catch(() => {
287
+ console.warn(`${brand}: rejected license cache could not be invalidated`);
288
+ });
289
+ }
290
+ return denied;
291
+ }
292
+ const entitlement = data.accepted ? normalize(data.entitlement) : null;
293
+ if (entitlement) {
294
+ await storageSet({
295
+ [ENTITLEMENT_KEY]: {
296
+ ...entitlement,
297
+ checkedAt: Date.now()
298
+ }
299
+ });
300
+ return proStatus(entitlement);
301
+ }
302
+ return fallbackStatus(cached);
303
+ } catch {
304
+ if (version !== generation) return null;
305
+ return fallbackStatus(cached);
200
306
  }
201
- const expired = v.expiresAt && new Date(v.expiresAt).getTime() <= Date.now();
202
- return expired ? null : {
203
- plan: v.plan || "pro",
204
- expiresAt: v.expiresAt || ""
205
- };
206
307
  }
207
- __name(normalize, "normalize");
208
- async function getEntitlement() {
308
+ __name(refreshEntitlement, "refreshEntitlement");
309
+ async function getStatus({ cachedOnly = false, force = false } = {}) {
209
310
  const data = await storageGet([
210
311
  TOKEN_KEY,
211
312
  ENTITLEMENT_KEY
212
313
  ]);
213
- const token = data[TOKEN_KEY];
214
- const cached = data[ENTITLEMENT_KEY];
215
- if (!token) {
216
- return null;
217
- }
218
- if (cached?.checkedAt && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
219
- return normalize(cached);
220
- }
221
- let response;
222
- try {
223
- response = await fetch(`${config.apiBase}/extension/licenses/me`, {
224
- headers: {
225
- Authorization: `Bearer ${token}`
226
- }
314
+ const token = data[TOKEN_KEY], cached = data[ENTITLEMENT_KEY];
315
+ if (!token) return {
316
+ state: "free"
317
+ };
318
+ const valid = lastStatus?.state === "invalid" ? null : usableCache(cached);
319
+ if (!force && valid && Date.now() - cached.checkedAt < config.cacheTtlMs) return proStatus(valid);
320
+ if (mutation) return {
321
+ state: "checking",
322
+ refreshing: true
323
+ };
324
+ if (!refresh && (force || Date.now() >= nextRefreshAt)) {
325
+ const version = generation;
326
+ nextRefreshAt = Date.now() + config.retryDelayMs;
327
+ refresh = refreshEntitlement(token, cached, version).then((status2) => {
328
+ if (version === generation && status2) lastStatus = status2;
329
+ return status2;
330
+ }).finally(() => {
331
+ refresh = null;
227
332
  });
228
- } catch {
229
- return cached ? normalize(cached) : null;
230
333
  }
231
- if (!response.ok) {
232
- if (response.status === 401 || response.status === 403) {
233
- await clear();
234
- return null;
235
- }
236
- return cached ? normalize(cached) : null;
334
+ if (refresh && !cachedOnly) {
335
+ const status2 = await refresh;
336
+ return status2 || getStatus();
237
337
  }
238
- const payload = await response.json().catch(() => ({}));
239
- const entitlement = unwrap(payload)?.entitlement;
240
- await storageSet({
241
- [ENTITLEMENT_KEY]: {
242
- ...entitlement || {},
243
- checkedAt: Date.now()
244
- }
245
- });
246
- return normalize(entitlement);
338
+ const remembered = lastStatus?.state === "pro" ? null : lastStatus;
339
+ const status = valid ? proStatus(valid) : refresh ? {
340
+ state: "checking"
341
+ } : remembered || unavailable();
342
+ return {
343
+ ...status,
344
+ refreshing: Boolean(refresh)
345
+ };
247
346
  }
248
- __name(getEntitlement, "getEntitlement");
249
- async function isPro() {
250
- return Boolean(await getEntitlement());
347
+ __name(getStatus, "getStatus");
348
+ async function getEntitlement(options) {
349
+ return (await getStatus(options)).entitlement || null;
251
350
  }
252
- __name(isPro, "isPro");
253
- async function clear() {
254
- await storageRemove([
255
- TOKEN_KEY,
256
- ENTITLEMENT_KEY
257
- ]);
351
+ __name(getEntitlement, "getEntitlement");
352
+ async function deactivate() {
353
+ if (mutation) throw new Error("A license change is already in progress");
354
+ mutation = true;
355
+ generation += 1;
356
+ try {
357
+ const data = await storageGet(TOKEN_KEY);
358
+ if (data[TOKEN_KEY]) {
359
+ const result = await request("deactivate", {
360
+ token: data[TOKEN_KEY]
361
+ });
362
+ if (!result.accepted || result.deactivated !== true) throw new Error("Could not release this device. Your license is still saved; please retry.");
363
+ }
364
+ await storageRemove([
365
+ TOKEN_KEY,
366
+ ENTITLEMENT_KEY
367
+ ]);
368
+ nextRefreshAt = 0;
369
+ lastStatus = null;
370
+ } finally {
371
+ mutation = false;
372
+ }
258
373
  }
259
- __name(clear, "clear");
374
+ __name(deactivate, "deactivate");
260
375
  return {
261
376
  activate,
262
377
  getEntitlement,
263
- isPro,
264
- clear,
378
+ getStatus,
379
+ deactivate,
265
380
  purchaseUrl: config.purchaseUrl
266
381
  };
267
382
  }
268
383
  __name(createLicensing, "createLicensing");
269
384
 
270
- // src/download.ts
271
- function createDownloader(justification) {
272
- const pendingDownloadNames = /* @__PURE__ */ new Map();
385
+ // src/download.js
386
+ function createDownloader(justification, options = {}) {
387
+ const deadlineMs = options.deadlineMs ?? 12e4;
388
+ const blobLeaseMs = options.blobLeaseMs ?? 18e4;
389
+ const pollMs = options.pollMs ?? 1e3;
390
+ if (![
391
+ deadlineMs,
392
+ blobLeaseMs,
393
+ pollMs
394
+ ].every((n) => Number.isFinite(n) && n > 0) || blobLeaseMs <= deadlineMs) throw new RangeError("Invalid download deadline, polling or blob lease");
395
+ const names = /* @__PURE__ */ new Map();
396
+ const waiters = /* @__PURE__ */ new Map();
273
397
  let offscreenSetup = null;
274
398
  chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
275
- const fileName = pendingDownloadNames.get(item.url) || pendingDownloadNames.get(item.finalUrl || "");
276
- if (!fileName) {
277
- return;
278
- }
279
- suggest({
280
- filename: fileName,
399
+ const filename = names.get(item.url) || names.get(item.finalUrl);
400
+ if (filename) suggest({
401
+ filename,
281
402
  conflictAction: "uniquify"
282
403
  });
283
- return true;
404
+ });
405
+ chrome.downloads.onChanged.addListener((delta) => {
406
+ if (delta.state?.current === "complete" || delta.state?.current === "interrupted") waiters.get(delta.id)?.();
284
407
  });
285
408
  async function ensureOffscreen() {
286
- if (await chrome.offscreen.hasDocument()) {
287
- return;
288
- }
289
- if (!offscreenSetup) {
290
- offscreenSetup = chrome.offscreen.createDocument({
291
- url: "offscreen.html",
409
+ if (!offscreenSetup) offscreenSetup = (async () => {
410
+ if (!await chrome.offscreen.hasDocument()) await chrome.offscreen.createDocument({
411
+ url: options.offscreenUrl || "offscreen.html",
292
412
  reasons: [
293
413
  chrome.offscreen.Reason.BLOBS
294
414
  ],
295
415
  justification
296
- }).catch((error) => {
297
- if (!String(error?.message || "").includes("Only a single")) {
298
- throw error;
299
- }
300
- }).finally(() => {
301
- offscreenSetup = null;
302
416
  });
303
- }
417
+ })().finally(() => {
418
+ offscreenSetup = null;
419
+ });
304
420
  return offscreenSetup;
305
421
  }
306
422
  __name(ensureOffscreen, "ensureOffscreen");
307
- async function downloadViaBlob(base64, mime, filename) {
423
+ async function cleanup(url) {
424
+ names.delete(url);
425
+ if (url) await chrome.runtime.sendMessage({
426
+ target: "offscreen",
427
+ type: "REVOKE_BLOB_URL",
428
+ url
429
+ }).catch(() => {
430
+ });
431
+ }
432
+ __name(cleanup, "cleanup");
433
+ async function waitForDownload(id, signal) {
434
+ return new Promise((resolve, reject) => {
435
+ let finished = false;
436
+ let deadlineError;
437
+ const finish = /* @__PURE__ */ __name((error, item) => {
438
+ if (finished) return;
439
+ finished = true;
440
+ clearInterval(poll);
441
+ clearTimeout(timeout);
442
+ signal?.removeEventListener("abort", abort);
443
+ waiters.delete(id);
444
+ error ? reject(error) : resolve(item);
445
+ }, "finish");
446
+ const inspect = /* @__PURE__ */ __name(async () => {
447
+ try {
448
+ const [item] = await chrome.downloads.search({
449
+ id
450
+ });
451
+ if (!item) return finish(new Error("Download no longer exists"));
452
+ if (item.state === "complete") finish(null, item);
453
+ else if (item.state === "interrupted") finish(deadlineError || new Error(item.error === "USER_CANCELED" ? "Cancelled" : `Download interrupted: ${item.error || "unknown error"}`));
454
+ } catch (error) {
455
+ finish(error);
456
+ }
457
+ }, "inspect");
458
+ const abort = /* @__PURE__ */ __name(async () => {
459
+ await chrome.downloads.cancel(id).catch(() => {
460
+ });
461
+ const [item] = await chrome.downloads.search({
462
+ id
463
+ }).catch(() => []);
464
+ item?.state === "complete" ? finish(null, item) : finish(cancelled());
465
+ }, "abort");
466
+ const poll = setInterval(inspect, pollMs);
467
+ const timeout = setTimeout(async () => {
468
+ deadlineError = new Error("Download completion timed out");
469
+ await chrome.downloads.cancel(id).catch(() => {
470
+ });
471
+ finish(deadlineError);
472
+ }, deadlineMs);
473
+ waiters.set(id, inspect);
474
+ signal?.addEventListener("abort", abort, {
475
+ once: true
476
+ });
477
+ if (signal?.aborted) void abort();
478
+ else void inspect();
479
+ });
480
+ }
481
+ __name(waitForDownload, "waitForDownload");
482
+ async function recover(item, signal) {
483
+ const matches = item.downloadId != null ? await chrome.downloads.search({
484
+ id: item.downloadId
485
+ }) : await chrome.downloads.search({
486
+ url: `^${item.downloadUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`
487
+ });
488
+ const download = matches.find((d) => d.byExtensionId === chrome.runtime.id && (!item.downloadUrl || d.url === item.downloadUrl));
489
+ if (!download) {
490
+ await cleanup(item.downloadUrl);
491
+ throw new Error("Interrupted download could not be confirmed. Retry this page.");
492
+ }
493
+ try {
494
+ return await waitForDownload(download.id, signal);
495
+ } finally {
496
+ await cleanup(item.downloadUrl);
497
+ }
498
+ }
499
+ __name(recover, "recover");
500
+ async function downloadViaBlob(base64, mime, filename, { signal, onPrepared, onCreated } = {}) {
501
+ throwIfAborted(signal);
308
502
  await ensureOffscreen();
503
+ throwIfAborted(signal);
309
504
  const created = await chrome.runtime.sendMessage({
310
505
  target: "offscreen",
311
506
  type: "CREATE_BLOB_URL",
312
507
  base64,
313
- mime
508
+ mime,
509
+ leaseMs: blobLeaseMs
314
510
  });
315
- if (!created?.ok || !created.url) {
316
- throw new Error(created?.error || "Failed to prepare download");
317
- }
511
+ if (!created?.ok || !created.url) throw new Error(created?.error || "Failed to prepare download");
318
512
  const url = created.url;
319
- pendingDownloadNames.set(url, filename);
320
- const cleanup = /* @__PURE__ */ __name(() => {
321
- pendingDownloadNames.delete(url);
322
- chrome.runtime.sendMessage({
323
- target: "offscreen",
324
- type: "REVOKE_BLOB_URL",
325
- url
326
- }).catch(() => {
327
- });
328
- }, "cleanup");
513
+ names.set(url, filename);
514
+ let downloadId;
329
515
  try {
330
- const downloadId = await new Promise((resolve, reject) => {
331
- chrome.downloads.download({
332
- url,
333
- filename,
334
- conflictAction: "uniquify",
335
- saveAs: false
336
- }, (id) => {
337
- const error = chrome.runtime.lastError;
338
- error ? reject(new Error(error.message)) : resolve(id);
339
- });
516
+ await onPrepared?.(url);
517
+ throwIfAborted(signal);
518
+ downloadId = await chrome.downloads.download({
519
+ url,
520
+ filename,
521
+ conflictAction: "uniquify",
522
+ saveAs: false
340
523
  });
341
- setTimeout(cleanup, 6e4);
524
+ await onCreated?.(downloadId);
525
+ await waitForDownload(downloadId, signal);
342
526
  return downloadId;
343
527
  } catch (error) {
344
- cleanup();
528
+ if (downloadId != null) await chrome.downloads.cancel(downloadId).catch(() => {
529
+ });
345
530
  throw error;
531
+ } finally {
532
+ await cleanup(url);
346
533
  }
347
534
  }
348
535
  __name(downloadViaBlob, "downloadViaBlob");
349
536
  return {
350
- downloadViaBlob
537
+ downloadViaBlob,
538
+ recover
351
539
  };
352
540
  }
353
541
  __name(createDownloader, "createDownloader");
354
542
 
355
- // src/session.ts
356
- function debuggerAttach(target, version) {
357
- return new Promise((resolve, reject) => {
358
- chrome.debugger.attach(target, version, () => {
359
- const error = chrome.runtime.lastError;
360
- error ? reject(new Error(error.message)) : resolve();
543
+ // src/scroll-capture.js
544
+ var scroll_capture_exports = {};
545
+ __export(scroll_capture_exports, {
546
+ prepare: () => prepare,
547
+ restore: () => restore
548
+ });
549
+ var sessions = /* @__PURE__ */ new WeakMap();
550
+ function pageTask(action, id, index, leaseMs) {
551
+ const key = "__zuckerScrollCapture";
552
+ let state = window[key];
553
+ if (action === "restore") {
554
+ if (state?.id === id) state.restore();
555
+ return {};
556
+ }
557
+ if (action === "init") {
558
+ if (state) throw new Error("Another scrolling-content capture is active");
559
+ const elements = [
560
+ ...document.querySelectorAll("*")
561
+ ];
562
+ if (elements.length > 3e4) throw new Error("This page is too complex to expand its scrolling content safely.");
563
+ const panels = elements.filter((el) => {
564
+ if (el === document.body || el === document.documentElement || el === document.scrollingElement || el.closest('nav,aside,[role="navigation"],[role="menu"],textarea,select')) return false;
565
+ const rect = el.getBoundingClientRect(), css = getComputedStyle(el);
566
+ if (rect.width < 120 || rect.height < 80 || el.scrollHeight <= el.clientHeight + 2 || !/^(auto|scroll|overlay)$/.test(css.overflowY) || css.visibility !== "visible") return false;
567
+ for (let parent = el; parent; parent = parent.parentElement) {
568
+ const style = getComputedStyle(parent);
569
+ if (style.display === "none" || Number(style.opacity) === 0) return false;
570
+ }
571
+ return true;
361
572
  });
573
+ if (!panels.length) return {
574
+ count: 0
575
+ };
576
+ if (panels.length > 24) throw new Error("This page has too many independent scrolling areas. Use Visible window or export smaller sections.");
577
+ const virtual = '.rc-virtual-list-holder,.ant-table-tbody-virtual-holder,[data-virtuoso-scroller],.ReactVirtualized__Grid,.ReactVirtualized__List,[data-virtualized="true"]';
578
+ if (panels.some((el) => el.matches(virtual) || [
579
+ ...el.querySelectorAll(virtual)
580
+ ].some((list) => list.getClientRects().length && list.checkVisibility({
581
+ checkOpacity: true,
582
+ checkVisibilityCSS: true
583
+ })))) throw new Error("This page uses a virtual scrolling list. A complete full-page capture is not available; use the table's export or Visible window.");
584
+ const changes = /* @__PURE__ */ new Map(), scrolls = /* @__PURE__ */ new Map();
585
+ state = {
586
+ id,
587
+ panels,
588
+ changes,
589
+ scrolls,
590
+ boxes: /* @__PURE__ */ new Map(),
591
+ watchdog: null,
592
+ leaseMs
593
+ };
594
+ state.set = (el, property, value) => {
595
+ if (!changes.has(el)) changes.set(el, {
596
+ hadStyle: el.hasAttribute("style"),
597
+ properties: /* @__PURE__ */ new Map()
598
+ });
599
+ const saved = changes.get(el).properties;
600
+ if (!saved.has(property)) saved.set(property, {
601
+ value: el.style.getPropertyValue(property),
602
+ priority: el.style.getPropertyPriority(property)
603
+ });
604
+ el.style.setProperty(property, value, "important");
605
+ };
606
+ state.restore = () => {
607
+ clearTimeout(state.watchdog);
608
+ const scrollingProperties = /* @__PURE__ */ new Set([
609
+ "scroll-behavior",
610
+ "scroll-snap-type",
611
+ "overflow-anchor"
612
+ ]);
613
+ const restoreProperty = /* @__PURE__ */ __name((el, property, old) => old.value ? el.style.setProperty(property, old.value, old.priority) : el.style.removeProperty(property), "restoreProperty");
614
+ for (const [el, saved] of [
615
+ ...changes
616
+ ].reverse()) for (const [property, old] of saved.properties) if (!scrollingProperties.has(property)) restoreProperty(el, property, old);
617
+ for (const [el, saved] of scrolls) {
618
+ el.scrollLeft = saved.x;
619
+ el.scrollTop = saved.y;
620
+ }
621
+ for (const [el, saved] of changes) {
622
+ for (const [property, old] of saved.properties) if (scrollingProperties.has(property)) restoreProperty(el, property, old);
623
+ if (!saved.hadStyle && !el.style.length) {
624
+ el.getAttribute("style");
625
+ el.removeAttribute("style");
626
+ }
627
+ }
628
+ if (window[key]?.id === id) delete window[key];
629
+ };
630
+ window[key] = state;
631
+ state.watchdog = setTimeout(state.restore, state.leaseMs);
632
+ for (const el of elements) {
633
+ if (!el.scrollTop && !el.scrollLeft) continue;
634
+ scrolls.set(el, {
635
+ x: el.scrollLeft,
636
+ y: el.scrollTop
637
+ });
638
+ state.set(el, "scroll-behavior", "auto");
639
+ state.set(el, "scroll-snap-type", "none");
640
+ state.set(el, "overflow-anchor", "none");
641
+ }
642
+ for (const panel of panels) {
643
+ const rect = panel.getBoundingClientRect();
644
+ state.boxes.set(panel, {
645
+ width: rect.width,
646
+ x: rect.x
647
+ });
648
+ scrolls.set(panel, {
649
+ x: panel.scrollLeft,
650
+ y: panel.scrollTop
651
+ });
652
+ state.set(panel, "scroll-behavior", "auto");
653
+ state.set(panel, "scroll-snap-type", "none");
654
+ state.set(panel, "overflow-anchor", "none");
655
+ panel.scrollTop = 0;
656
+ }
657
+ return {
658
+ count: panels.length
659
+ };
660
+ }
661
+ if (!state || state.id !== id) throw new Error("The page changed while preparing its scrolling content.");
662
+ clearTimeout(state.watchdog);
663
+ state.watchdog = setTimeout(state.restore, state.leaseMs);
664
+ if (action === "scroll" || action === "position") {
665
+ const panel = state.panels[index];
666
+ if (!panel.isConnected) throw new Error("A scrolling area changed during capture. Please retry.");
667
+ if (action === "scroll") panel.scrollTop += Math.max(80, panel.clientHeight * 0.8);
668
+ return {
669
+ bottom: panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 1,
670
+ height: panel.scrollHeight
671
+ };
672
+ }
673
+ if (action === "expand") {
674
+ for (const panel of [
675
+ ...state.panels
676
+ ].reverse()) {
677
+ if (!panel.isConnected) throw new Error("A scrolling area changed during capture. Please retry.");
678
+ let el = panel;
679
+ while (el) {
680
+ const rect = el.getBoundingClientRect(), css = getComputedStyle(el);
681
+ if (!state.scrolls.has(el)) state.scrolls.set(el, {
682
+ x: el.scrollLeft,
683
+ y: el.scrollTop
684
+ });
685
+ if (!state.boxes.has(el)) state.boxes.set(el, {
686
+ width: rect.width,
687
+ x: rect.x
688
+ });
689
+ state.set(el, "scroll-behavior", "auto");
690
+ state.set(el, "overflow-anchor", "none");
691
+ state.set(el, "transition", "none");
692
+ state.set(el, "box-sizing", "border-box");
693
+ state.set(el, "width", `${state.boxes.get(el).width}px`);
694
+ state.set(el, "max-height", "none");
695
+ state.set(el, "flex-shrink", "0");
696
+ if (css.position === "fixed") {
697
+ state.set(el, "position", "absolute");
698
+ state.set(el, "top", `${rect.top + scrollY}px`);
699
+ state.set(el, "left", `${rect.left + scrollX}px`);
700
+ state.set(el, "right", "auto");
701
+ }
702
+ if (css.position === "fixed" || css.position === "absolute") state.set(el, "bottom", "auto");
703
+ if (state.panels.includes(el)) {
704
+ const height = el.scrollHeight + el.offsetHeight - el.clientHeight;
705
+ state.set(el, "height", `${height}px`);
706
+ state.set(el, "min-height", `${height}px`);
707
+ state.set(el, "scrollbar-gutter", "stable");
708
+ state.set(el, "overflow-y", "hidden");
709
+ el.scrollTop = 0;
710
+ } else {
711
+ state.set(el, "height", "auto");
712
+ state.set(el, "min-height", `${Math.max(rect.height, el.clientHeight)}px`);
713
+ if (/hidden|clip|auto|scroll/.test(css.overflowY)) {
714
+ state.set(el, "overflow-x", "clip");
715
+ state.set(el, "overflow-y", "visible");
716
+ }
717
+ }
718
+ if (css.contain !== "none") state.set(el, "contain", "none");
719
+ el = el.parentElement;
720
+ }
721
+ }
722
+ return {
723
+ height: document.documentElement.scrollHeight
724
+ };
725
+ }
726
+ if (action === "measure") {
727
+ for (const panel of state.panels) {
728
+ const rect = panel.getBoundingClientRect(), original = state.boxes.get(panel);
729
+ if (Math.abs(rect.width - original.width) > 1 || Math.abs(rect.x - original.x) > 1) throw new Error("This scrolling layout cannot be expanded without changing its width. Use Visible window or the website's export.");
730
+ }
731
+ return {
732
+ height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
733
+ panels: state.panels.map((el) => [
734
+ el.scrollHeight,
735
+ el.clientHeight
736
+ ]),
737
+ ready: document.fonts.status === "loaded" && [
738
+ ...document.images
739
+ ].every((image) => image.complete || !state.panels.some((panel) => panel.contains(image)) || !image.getClientRects().length || image.checkVisibility?.({
740
+ checkOpacity: true,
741
+ checkVisibilityCSS: true
742
+ }) === false)
743
+ };
744
+ }
745
+ return {};
746
+ }
747
+ __name(pageTask, "pageTask");
748
+ async function command(ctx, id, action, index, leaseMs) {
749
+ const response = await ctx[action === "restore" ? "cleanup" : "send"]("Runtime.evaluate", {
750
+ expression: `(${pageTask.toString()})(${JSON.stringify(action)},${JSON.stringify(id)},${JSON.stringify(index)},${JSON.stringify(leaseMs)})`,
751
+ returnByValue: true
362
752
  });
753
+ if (response?.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description?.split("\n")[0] || "Could not prepare scrolling content");
754
+ return response?.result?.value || {};
755
+ }
756
+ __name(command, "command");
757
+ async function prepare(ctx, waitForContent = async () => {
758
+ }) {
759
+ const id = crypto.randomUUID();
760
+ sessions.set(ctx, id);
761
+ const readinessMs = Math.min(6e4, Math.max(0, Number(ctx.options?.readyTimeoutMs) || 25e3));
762
+ const initial = await command(ctx, id, "init", null, Math.max(6e4, readinessMs + 15e3));
763
+ if (!initial.count) {
764
+ sessions.delete(ctx);
765
+ return;
766
+ }
767
+ let deadline = Date.now() + readinessMs + 15e3;
768
+ for (let index = 0; index < initial.count; index++) {
769
+ let bottom = false;
770
+ for (let step = 0; step < 60 && Date.now() < deadline; step++) {
771
+ const position = await command(ctx, id, "scroll", index);
772
+ await new Promise((resolve) => setTimeout(resolve, 120));
773
+ if (position.bottom) {
774
+ await waitForContent();
775
+ const loaded = await command(ctx, id, "position", index);
776
+ if (loaded.bottom && loaded.height === position.height) {
777
+ bottom = true;
778
+ break;
779
+ }
780
+ }
781
+ }
782
+ if (!bottom) throw new Error("Scrolling content keeps growing or exceeds the capture limit. Use Visible window or a smaller section.");
783
+ }
784
+ await waitForContent();
785
+ await command(ctx, id, "expand");
786
+ await waitForContent();
787
+ deadline = Date.now() + 15e3;
788
+ let previous = "", stable = 0;
789
+ while (Date.now() < deadline) {
790
+ await command(ctx, id, "expand");
791
+ await new Promise((resolve) => setTimeout(resolve, 250));
792
+ const measured = await command(ctx, id, "measure");
793
+ const signature = JSON.stringify([
794
+ measured.height,
795
+ measured.panels
796
+ ]);
797
+ stable = measured.ready && signature === previous && measured.panels.every(([full, view]) => full <= view + 2) ? stable + 1 : 0;
798
+ if (stable >= 2) return;
799
+ previous = signature;
800
+ }
801
+ throw new Error("Scrolling content did not settle in time. Wait for it to finish loading and retry.");
802
+ }
803
+ __name(prepare, "prepare");
804
+ async function restore(ctx) {
805
+ const id = sessions.get(ctx);
806
+ if (!id) return;
807
+ sessions.delete(ctx);
808
+ await command(ctx, id, "restore");
809
+ }
810
+ __name(restore, "restore");
811
+
812
+ // src/session.js
813
+ function debuggerAttach(target, version) {
814
+ return new Promise((resolve, reject) => chrome.debugger.attach(target, version, () => {
815
+ const error = chrome.runtime.lastError;
816
+ error ? reject(new Error(error.message)) : resolve();
817
+ }));
363
818
  }
364
819
  __name(debuggerAttach, "debuggerAttach");
365
820
  function debuggerDetach(target) {
366
- return new Promise((resolve, reject) => {
367
- chrome.debugger.detach(target, () => {
368
- const error = chrome.runtime.lastError;
369
- error ? reject(new Error(error.message)) : resolve();
370
- });
371
- });
821
+ return new Promise((resolve, reject) => chrome.debugger.detach(target, () => {
822
+ const error = chrome.runtime.lastError;
823
+ error ? reject(new Error(error.message)) : resolve();
824
+ }));
372
825
  }
373
826
  __name(debuggerDetach, "debuggerDetach");
374
- function sendCommand(target, method, params) {
375
- return new Promise((resolve, reject) => {
376
- chrome.debugger.sendCommand(target, method, params || {}, (result) => {
377
- const error = chrome.runtime.lastError;
378
- error ? reject(new Error(error.message)) : resolve(result || {});
379
- });
380
- });
827
+ function sendCommand(target, method, params, signal) {
828
+ throwIfAborted(signal);
829
+ return withAbort(new Promise((resolve, reject) => chrome.debugger.sendCommand(target, method, params || {}, (result) => {
830
+ const error = chrome.runtime.lastError;
831
+ if (error) reject(new Error(error.message));
832
+ else if (result?.exceptionDetails) reject(new Error("Page script could not be evaluated"));
833
+ else resolve(result || {});
834
+ })), signal);
381
835
  }
382
836
  __name(sendCommand, "sendCommand");
383
- function waitForLoad(tabId, timeoutMs) {
384
- return new Promise((resolve, reject) => {
385
- let settled = false;
386
- const finish = /* @__PURE__ */ __name((fn, value) => {
387
- if (!settled) {
388
- settled = true;
389
- chrome.tabs.onUpdated.removeListener(listener);
390
- clearTimeout(timer);
391
- fn(value);
392
- }
393
- }, "finish");
394
- const listener = /* @__PURE__ */ __name((updatedTabId, changeInfo) => {
395
- if (updatedTabId === tabId && changeInfo.status === "complete") {
396
- finish(resolve);
397
- }
398
- }, "listener");
399
- const timer = setTimeout(() => finish(reject, new Error("Page load timeout")), timeoutMs);
400
- chrome.tabs.onUpdated.addListener(listener);
401
- chrome.tabs.get(tabId).then((tab) => {
402
- if (tab.status === "complete") {
403
- finish(resolve);
404
- }
405
- }).catch(() => {
406
- });
407
- });
837
+ async function waitForLoad(tabId, timeoutMs, signal) {
838
+ const start = Date.now();
839
+ while (Date.now() - start < timeoutMs) {
840
+ throwIfAborted(signal);
841
+ const tab = await chrome.tabs.get(tabId);
842
+ if (tab.status === "complete") return;
843
+ await delay(250, signal);
844
+ }
845
+ throw new Error("Page load timeout");
408
846
  }
409
847
  __name(waitForLoad, "waitForLoad");
848
+ async function evaluatePage(target, fn, args, signal) {
849
+ const result = await sendCommand(target, "Runtime.evaluate", {
850
+ expression: `(${fn.toString()})(${JSON.stringify(args ?? null)})`,
851
+ returnByValue: true
852
+ }, signal);
853
+ return result.result?.value;
854
+ }
855
+ __name(evaluatePage, "evaluatePage");
856
+ function watchNetwork(target) {
857
+ const pending = /* @__PURE__ */ new Set();
858
+ let changedAt = Date.now();
859
+ const listener = /* @__PURE__ */ __name((source, method, params) => {
860
+ if (source.tabId !== target.tabId) return;
861
+ if (method === "Network.requestWillBeSent" && [
862
+ "Document",
863
+ "XHR",
864
+ "Fetch",
865
+ "Stylesheet",
866
+ "Font"
867
+ ].includes(params.type)) {
868
+ pending.add(params.requestId);
869
+ changedAt = Date.now();
870
+ } else if ([
871
+ "Network.loadingFinished",
872
+ "Network.loadingFailed"
873
+ ].includes(method) && pending.delete(params.requestId)) changedAt = Date.now();
874
+ }, "listener");
875
+ chrome.debugger.onEvent.addListener(listener);
876
+ return {
877
+ busy: /* @__PURE__ */ __name(() => pending.size > 0 || Date.now() - changedAt < 750, "busy"),
878
+ reset: /* @__PURE__ */ __name(() => {
879
+ pending.clear();
880
+ changedAt = Date.now();
881
+ }, "reset"),
882
+ dispose: /* @__PURE__ */ __name(() => chrome.debugger.onEvent.removeListener(listener), "dispose")
883
+ };
884
+ }
885
+ __name(watchNetwork, "watchNetwork");
886
+ async function preparePageForCapture(target, signal) {
887
+ await sendCommand(target, "Emulation.setFocusEmulationEnabled", {
888
+ enabled: true
889
+ }, signal).catch((error) => {
890
+ throwIfAborted(signal);
891
+ });
892
+ }
893
+ __name(preparePageForCapture, "preparePageForCapture");
894
+ function pageMetrics({ maxSeenY = 0 } = {}) {
895
+ function visible(el) {
896
+ if (!el?.getBoundingClientRect) return false;
897
+ const rect = el.getBoundingClientRect();
898
+ if (rect.width <= 2 || rect.height <= 2 || rect.top > maxSeenY + innerHeight) return false;
899
+ for (let node = el; node instanceof Element; node = node.parentElement) {
900
+ const style = getComputedStyle(node);
901
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) < 0.05) return false;
902
+ }
903
+ return true;
904
+ }
905
+ __name(visible, "visible");
906
+ const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim();
907
+ const strong = '[aria-busy="true"],.ant-spin-spinning,.ant-skeleton,.mantine-Loader-root,.MuiCircularProgress-root,.v-progress-circular--indeterminate,.el-loading-mask,.chakra-spinner,[role="progressbar"]:not([aria-valuenow])';
908
+ let loading = [
909
+ ...document.querySelectorAll(strong)
910
+ ].some(visible);
911
+ if (!loading) loading = [
912
+ ...document.querySelectorAll(".loading,.loader,.spinner,.skeleton")
913
+ ].some((el) => {
914
+ if (!visible(el)) return false;
915
+ const words = (el.innerText || "").trim();
916
+ const rect = el.getBoundingClientRect();
917
+ return words.length < 160 && /^(loading\b|please wait\b|加载中|正在加载|読み込み中|로딩)/i.test(words) || !words && rect.width <= 256 && rect.height <= 256 && getComputedStyle(el).animationName !== "none";
918
+ });
919
+ const pendingImages = [
920
+ ...document.images
921
+ ].filter((img) => !img.complete && visible(img) && img.width * img.height > 4e3).length;
922
+ const fontsPending = document.fonts?.status === "loading";
923
+ let hash = 0;
924
+ for (let i = 0; i < text.length; i += Math.max(1, Math.floor(text.length / 2e3))) hash = Math.imul(hash, 31) + text.charCodeAt(i) | 0;
925
+ const height = Math.max(document.body?.scrollHeight || 0, document.documentElement.scrollHeight);
926
+ return {
927
+ complete: document.readyState === "complete",
928
+ loading,
929
+ pendingImages,
930
+ fontsPending,
931
+ signature: [
932
+ location.href,
933
+ text.length,
934
+ hash,
935
+ height,
936
+ pendingImages,
937
+ fontsPending
938
+ ].join("|")
939
+ };
940
+ }
941
+ __name(pageMetrics, "pageMetrics");
942
+ async function waitForPageStable(target, options, signal, network, maxSeenY = 0) {
943
+ const start = Date.now();
944
+ let signature = "", stableSince = start, last;
945
+ while (Date.now() - start < options.readyTimeoutMs) {
946
+ throwIfAborted(signal);
947
+ last = await evaluatePage(target, pageMetrics, {
948
+ maxSeenY
949
+ }, signal);
950
+ if (!last) throw new Error("Could not inspect page readiness");
951
+ if (last.signature !== signature || !last.complete || last.loading || last.pendingImages || last.fontsPending || network?.busy()) stableSince = Date.now();
952
+ signature = last.signature;
953
+ if (Date.now() - stableSince >= 1e3) return "";
954
+ await delay(250, signal);
955
+ }
956
+ const reason = last?.loading ? "Page still shows loading UI" : "Page content is still changing or loading";
957
+ if (options.allowUnstable) return `${reason}; captured after the readiness timeout at your request.`;
958
+ throw new Error(`${reason}. Increase readiness wait, or choose Capture after timeout in Settings if this page is ready.`);
959
+ }
960
+ __name(waitForPageStable, "waitForPageStable");
410
961
  function challengeError(name) {
411
962
  const error = new Error(`Blocked by ${name} verification`);
412
963
  error.isChallenge = true;
@@ -417,135 +968,100 @@ function isChallengeError(error) {
417
968
  return Boolean(error?.isChallenge);
418
969
  }
419
970
  __name(isChallengeError, "isChallengeError");
420
- var CHALLENGE_TITLE_RE = /just a moment|checking your browser|attention required|verifying|security checkpoint|正在验证|安全检查点|verify you are human|are you a robot|请完成验证|人机验证|captcha/;
421
- async function probePage(target) {
422
- const r = await sendCommand(target, "Runtime.evaluate", {
423
- returnByValue: true,
424
- expression: `(function(){
425
- var uri=document.documentURI||"";
426
- if(uri.indexOf("chrome-error://")===0) return {error:"Page failed to load (DNS or network error)",challenge:""};
427
- var t=(document.title||"").toLowerCase();
428
- var b=(document.body?document.body.innerText:"").slice(0,500).toLowerCase();
429
- var u=(location.href||"").toLowerCase();
430
- var s=t+" "+b+" "+u;
431
- var c="";
432
- if(/just a moment|checking your browser|attention required|cf-browser-verification|challenges\\.cloudflare/.test(s)) c="Cloudflare";
433
- else if(/verifying your browser|\u6B63\u5728\u9A8C\u8BC1\u60A8\u7684\u6D4F\u89C8\u5668|security checkpoint|\u5B89\u5168\u68C0\u67E5\u70B9/.test(s)) c="Vercel";
434
- else if(/verify you are human|are you a robot|\u8BF7\u5B8C\u6210\u9A8C\u8BC1|\u4EBA\u673A\u9A8C\u8BC1|captcha/.test(s)) c="bot-check";
435
- return {error:"",challenge:c};
436
- })()`
437
- });
438
- const value = r.result?.value;
439
- return value || {
971
+ function pageProbe() {
972
+ if (document.documentURI.startsWith("chrome-error://")) return {
973
+ error: "Page failed to load (DNS or network error)",
974
+ challenge: ""
975
+ };
976
+ const shown = /* @__PURE__ */ __name((selector) => [
977
+ ...document.querySelectorAll(selector)
978
+ ].some((el) => {
979
+ const r = el.getBoundingClientRect();
980
+ return r.width > 0 && r.height > 0 && el.checkVisibility({
981
+ checkOpacity: true,
982
+ checkVisibilityCSS: true
983
+ });
984
+ }), "shown");
985
+ const title = document.title.trim().toLowerCase();
986
+ let challenge = "";
987
+ if (shown("#challenge-form,#challenge-stage,#cf-challenge-running") && /just a moment|checking your browser|attention required|verify/.test(title)) challenge = "Cloudflare";
988
+ else if (shown("#vercel-security-checkpoint,#challenge-container") && /security checkpoint|verifying your browser|安全检查点/.test(title)) challenge = "Vercel";
989
+ else if (/^(verify you are human|human verification|are you a robot|captcha|人机验证)[\s.!?—-]*$/i.test(title) && shown('iframe[src*="recaptcha"],iframe[src*="hcaptcha"],iframe[src*="challenges.cloudflare.com"],input[name*="captcha"],[data-sitekey]')) challenge = "bot-check";
990
+ return {
991
+ error: "",
992
+ challenge
993
+ };
994
+ }
995
+ __name(pageProbe, "pageProbe");
996
+ async function probePage(target, signal) {
997
+ return await evaluatePage(target, pageProbe, null, signal) || {
440
998
  error: "",
441
999
  challenge: ""
442
1000
  };
443
1001
  }
444
1002
  __name(probePage, "probePage");
445
- async function waitForVerification(tabId, shouldStop, timeoutMs = 18e4) {
1003
+ async function waitForVerification(tabId, shouldStop, signal, timeoutMs = 18e4) {
446
1004
  const start = Date.now();
447
1005
  while (Date.now() - start < timeoutMs) {
448
- if (shouldStop()) {
449
- return false;
450
- }
451
- await delay(2500);
1006
+ throwIfAborted(signal);
1007
+ if (shouldStop()) return false;
1008
+ await delay(2500, signal);
452
1009
  const tab = await chrome.tabs.get(tabId).catch(() => null);
453
- if (!tab) {
454
- return false;
455
- }
456
- const title = (tab.title || "").toLowerCase();
457
- const url = (tab.url || "").toLowerCase();
458
- if (url.startsWith("chrome-error")) {
459
- continue;
460
- }
461
- if (title && !CHALLENGE_TITLE_RE.test(title)) {
462
- return true;
1010
+ if (!tab) return false;
1011
+ if (tab.status !== "complete") continue;
1012
+ const target = {
1013
+ tabId
1014
+ };
1015
+ let attached = false;
1016
+ try {
1017
+ await debuggerAttach(target, "1.3");
1018
+ attached = true;
1019
+ const result = await probePage(target, signal);
1020
+ if (!result.challenge && !result.error) return true;
1021
+ } catch {
1022
+ throwIfAborted(signal);
1023
+ } finally {
1024
+ if (attached) await debuggerDetach(target).catch(() => {
1025
+ });
463
1026
  }
464
1027
  }
465
1028
  return false;
466
1029
  }
467
1030
  __name(waitForVerification, "waitForVerification");
468
1031
  async function focusTab(tabId) {
469
- await chrome.tabs.update(tabId, {
1032
+ const tab = await chrome.tabs.update(tabId, {
470
1033
  active: true
471
- }).catch(() => {
472
1034
  });
473
- try {
474
- const tab = await chrome.tabs.get(tabId);
475
- if (tab.windowId != null) {
476
- await chrome.windows.update(tab.windowId, {
477
- focused: true
478
- });
479
- }
480
- } catch {
481
- }
1035
+ await chrome.windows.update(tab.windowId, {
1036
+ focused: true
1037
+ });
482
1038
  }
483
1039
  __name(focusTab, "focusTab");
484
- function autoScroll(target) {
485
- return sendCommand(target, "Runtime.evaluate", {
486
- awaitPromise: true,
487
- expression: `new Promise(function(resolve){
488
- var distance=600, scrolled=0, guard=0;
489
- var maxHeight=Math.min(document.body?document.body.scrollHeight:0, 20000);
490
- var timer=setInterval(function(){
491
- window.scrollBy(0, distance);
492
- scrolled+=distance; guard+=1;
493
- var reached=scrolled>=Math.max(maxHeight, document.body?document.body.scrollHeight:0);
494
- if(reached || guard>60){
495
- clearInterval(timer);
496
- setTimeout(function(){ window.scrollTo(0,0); setTimeout(resolve, 400); }, 300);
497
- }
498
- }, 120);
499
- })`
500
- }).catch(() => void 0);
1040
+ async function autoScroll(target, signal) {
1041
+ let maxSeenY = 0;
1042
+ for (let step = 0; step < 60; step++) {
1043
+ const position = await evaluatePage(target, () => {
1044
+ window.scrollBy({
1045
+ top: 600,
1046
+ behavior: "instant"
1047
+ });
1048
+ return {
1049
+ y: scrollY,
1050
+ bottom: scrollY + innerHeight >= Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) - 1
1051
+ };
1052
+ }, null, signal);
1053
+ maxSeenY = Math.max(maxSeenY, position.y);
1054
+ await delay(120, signal);
1055
+ if (position.bottom) break;
1056
+ }
1057
+ await evaluatePage(target, () => window.scrollTo({
1058
+ left: 0,
1059
+ top: 0,
1060
+ behavior: "instant"
1061
+ }), null, signal);
1062
+ return maxSeenY;
501
1063
  }
502
1064
  __name(autoScroll, "autoScroll");
503
- function revealAnimations(target, reversible = false) {
504
- return sendCommand(target, "Runtime.evaluate", {
505
- expression: `(function(){
506
- try{
507
- var reversible=${reversible ? "true" : "false"};
508
- var state=reversible?{style:null,aos:[],inline:[]}:null;
509
- var css=document.createElement('style');
510
- css.textContent=[
511
- '[data-aos]{opacity:1!important;transform:none!important;transition:none!important;}',
512
- '.wow{visibility:visible!important;opacity:1!important;animation-name:none!important;}',
513
- '.animate__animated,.animated{opacity:1!important;}',
514
- '[data-scroll]{opacity:1!important;transform:none!important;}',
515
- '.reveal,[data-sr],[data-sr-id],.sr-reveal{opacity:1!important;transform:none!important;visibility:visible!important;}'
516
- ].join('');
517
- (document.head||document.documentElement).appendChild(css);
518
- if(state) state.style=css;
519
- document.querySelectorAll('[data-aos]').forEach(function(el){
520
- if(state&&!el.classList.contains('aos-animate')) state.aos.push(el);
521
- el.classList.add('aos-animate');
522
- });
523
- // \u901A\u7528\u515C\u5E95\uFF1A\u88AB\u52A8\u753B\u85CF\u8D77\u6765\u7684\u5185\u5BB9\u5757\uFF08opacity\u22480 \u4E14\u5E26 transition/animation\uFF09\u5F3A\u5236\u663E\u793A\u3002
524
- // \u53EA\u5904\u7406 static/relative\uFF08\u666E\u901A\u5185\u5BB9\u6D41\uFF09\uFF0C\u8DF3\u8FC7 fixed/absolute\uFF08\u591A\u4E3A\u5F39\u7A97/\u83DC\u5355/\u8F6E\u64AD\uFF09\uFF0C\u907F\u514D\u8BEF\u4F24
525
- var all=document.body?document.body.querySelectorAll('*'):[];
526
- for(var i=0;i<all.length;i++){
527
- var el=all[i], s=getComputedStyle(el);
528
- if(parseFloat(s.opacity)<0.05 && (s.transitionDuration!=='0s'||s.animationName!=='none')){
529
- var p=s.position;
530
- if(p==='static'||p==='relative'){
531
- if(state) state.inline.push({
532
- el:el,
533
- opacity:el.style.getPropertyValue('opacity'),
534
- opacityPriority:el.style.getPropertyPriority('opacity'),
535
- transform:el.style.getPropertyValue('transform'),
536
- transformPriority:el.style.getPropertyPriority('transform')
537
- });
538
- el.style.setProperty('opacity','1','important');
539
- el.style.setProperty('transform','none','important');
540
- }
541
- }
542
- }
543
- if(state) window.__zuckerExtRevealState=state;
544
- }catch(e){}
545
- })()`
546
- }).catch(() => void 0);
547
- }
548
- __name(revealAnimations, "revealAnimations");
549
1065
  async function getScrollPosition(target) {
550
1066
  const result = await sendCommand(target, "Runtime.evaluate", {
551
1067
  returnByValue: true,
@@ -558,76 +1074,89 @@ async function getScrollPosition(target) {
558
1074
  };
559
1075
  }
560
1076
  __name(getScrollPosition, "getScrollPosition");
1077
+ function hidePageCaptureDecorations() {
1078
+ const hosts = [
1079
+ ...document.querySelectorAll("browser-mcp-container")
1080
+ ];
1081
+ window.__zuckerExtCaptureDecorations = hosts.map((el) => ({
1082
+ el,
1083
+ display: el.style.getPropertyValue("display"),
1084
+ priority: el.style.getPropertyPriority("display")
1085
+ }));
1086
+ hosts.forEach((el) => el.style.setProperty("display", "none", "important"));
1087
+ return {
1088
+ width: innerWidth,
1089
+ height: innerHeight,
1090
+ dpr: devicePixelRatio || 1
1091
+ };
1092
+ }
1093
+ __name(hidePageCaptureDecorations, "hidePageCaptureDecorations");
1094
+ function showPageCaptureDecorations() {
1095
+ for (const saved of window.__zuckerExtCaptureDecorations || []) {
1096
+ if (saved.display) saved.el.style.setProperty("display", saved.display, saved.priority);
1097
+ else saved.el.style.removeProperty("display");
1098
+ }
1099
+ delete window.__zuckerExtCaptureDecorations;
1100
+ }
1101
+ __name(showPageCaptureDecorations, "showPageCaptureDecorations");
1102
+ function suppressCaptureDecorations(target, signal) {
1103
+ return sendCommand(target, "Runtime.evaluate", {
1104
+ expression: `(${hidePageCaptureDecorations.toString()})()`
1105
+ }, signal);
1106
+ }
1107
+ __name(suppressCaptureDecorations, "suppressCaptureDecorations");
1108
+ function restoreCaptureDecorations(target) {
1109
+ return sendCommand(target, "Runtime.evaluate", {
1110
+ expression: `(${showPageCaptureDecorations.toString()})()`
1111
+ });
1112
+ }
1113
+ __name(restoreCaptureDecorations, "restoreCaptureDecorations");
561
1114
  function restorePageAfterQuick(target, position) {
562
1115
  const x = Number(position?.x) || 0;
563
1116
  const y = Number(position?.y) || 0;
564
1117
  return sendCommand(target, "Runtime.evaluate", {
565
- expression: `(function(){
566
- try{
567
- var state=window.__zuckerExtRevealState;
568
- if(state){
569
- if(state.style&&state.style.remove) state.style.remove();
570
- (state.aos||[]).forEach(function(el){ el.classList.remove('aos-animate'); });
571
- (state.inline||[]).forEach(function(saved){
572
- if(saved.opacity) saved.el.style.setProperty('opacity',saved.opacity,saved.opacityPriority||'');
573
- else saved.el.style.removeProperty('opacity');
574
- if(saved.transform) saved.el.style.setProperty('transform',saved.transform,saved.transformPriority||'');
575
- else saved.el.style.removeProperty('transform');
576
- });
577
- delete window.__zuckerExtRevealState;
578
- }
579
- }catch(e){}
580
- window.scrollTo(${x}, ${y});
581
- })()`
1118
+ expression: `window.scrollTo({ left: ${x}, top: ${y}, behavior: "instant" })`
582
1119
  });
583
1120
  }
584
1121
  __name(restorePageAfterQuick, "restorePageAfterQuick");
585
1122
 
586
- // src/engine.ts
587
- var LOAD_TIMEOUT_MS = 3e4;
588
- var SAME_HOST_MIN_GAP_MS = 1800;
589
- var DEFAULT_TEXTS = {
590
- alreadyRunning: "Another capture is already running",
591
- cannotQuick: "This page cannot be captured. Open an http(s) website first.",
592
- challengeBeforeQuick: /* @__PURE__ */ __name((challenge) => `Complete the ${challenge} verification before capturing this page.`, "challengeBeforeQuick"),
593
- quickFolder: "Quick Captures"
594
- };
1123
+ // src/engine.js
595
1124
  function initExtension(config) {
1125
+ if (!Number.isSafeInteger(config.freeDailyLimit) || config.freeDailyLimit < 0) throw new RangeError("freeDailyLimit must be an explicit non-negative integer");
1126
+ const normalizeSettings = /* @__PURE__ */ __name((defaults, input) => {
1127
+ const settings = config.normalizeSettings ? config.normalizeSettings(defaults, input) : {
1128
+ ...defaults,
1129
+ ...input && typeof input === "object" && !Array.isArray(input) ? input : {}
1130
+ };
1131
+ return {
1132
+ ...settings,
1133
+ delayMs: Math.min(1e4, Math.max(0, Number(settings.delayMs) || 0)),
1134
+ readyTimeoutMs: Math.min(6e4, Math.max(5e3, Number(settings.readyTimeoutMs) || 25e3)),
1135
+ allowUnstable: settings.allowUnstable === true,
1136
+ ignoreChallenge: settings.ignoreChallenge === true,
1137
+ expandScrollAreas: settings.expandScrollAreas !== false
1138
+ };
1139
+ }, "normalizeSettings");
1140
+ const scrollCapture = config.scrollCapture || scroll_capture_exports;
596
1141
  const texts = {
597
- ...DEFAULT_TEXTS,
598
- ...config.texts || {}
1142
+ alreadyRunning: "Another capture is already running",
1143
+ cannotQuick: "Open an http(s) website first.",
1144
+ challengeBeforeQuick: /* @__PURE__ */ __name((name) => `Complete the ${name} verification, or enable Capture verification page in Settings if this is a normal page.`, "challengeBeforeQuick"),
1145
+ quickFolder: "Quick Captures",
1146
+ ...config.texts
599
1147
  };
600
- const freeDailyLimit = config.freeDailyLimit ?? 30;
601
- const USAGE_KEY = `${config.product}_free_usage`;
602
- const BATCH_KEY = `${config.product}_batch_state`;
603
1148
  const prefix = config.messagePrefix;
1149
+ const BATCH_KEY = `${config.product}_batch_state`, QUICK_KEY = `${config.product}_quick_state`;
1150
+ const USAGE_KEY = `${config.product}_free_usage`, TAB_KEY = `${config.product}_owned_tab`;
604
1151
  const licensing = createLicensing(config.product, config.brand, config.licensing);
605
- const downloader = createDownloader(`Convert large ${config.brand} output into a downloadable blob URL.`);
1152
+ const downloader = createDownloader(`Prepare local ${config.brand} downloads.`, config.downloads);
1153
+ let activeRun = null;
606
1154
  let batch = idleBatch();
607
- let restorePromise = restoreBatch();
608
- let quickRunning = false;
609
- chrome.runtime.onInstalled.addListener(() => {
610
- const previousRestore = restorePromise;
611
- restorePromise = (async () => {
612
- await previousRestore.catch(() => {
613
- });
614
- batch = idleBatch();
615
- await storageRemove(BATCH_KEY);
616
- setBadge("");
617
- })();
618
- });
619
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
620
- if (message?.target === "offscreen") {
621
- return false;
622
- }
623
- handleMessage(message).then(sendResponse).catch((error) => sendResponse({
624
- ok: false,
625
- error: normalizeError(error)
626
- }));
627
- return true;
628
- });
1155
+ let restorePromise;
629
1156
  function idleBatch() {
630
1157
  return {
1158
+ version: 2,
1159
+ id: crypto.randomUUID(),
631
1160
  running: false,
632
1161
  cancelRequested: false,
633
1162
  startedAt: null,
@@ -636,529 +1165,762 @@ function initExtension(config) {
636
1165
  done: 0,
637
1166
  success: 0,
638
1167
  failed: 0,
1168
+ cancelled: 0,
639
1169
  current: "",
640
1170
  freeLimited: false,
641
1171
  needVerify: null,
642
- skipVerify: false,
643
- settings: {
644
- ...config.defaultSettings
645
- },
1172
+ settings: normalizeSettings(config.defaultSettings),
646
1173
  pro: false,
647
1174
  items: []
648
1175
  };
649
1176
  }
650
1177
  __name(idleBatch, "idleBatch");
651
- async function handleMessage(message) {
652
- await restorePromise;
653
- switch (message.type) {
654
- case `${prefix}_START`:
655
- return startBatch(message.urls, message.settings);
656
- // 两个后缀都接受:CAPTURE_CURRENT(截图类)/ CONVERT_CURRENT(转换类),兼容既有 popup
657
- case `${prefix}_CAPTURE_CURRENT`:
658
- case `${prefix}_CONVERT_CURRENT`:
659
- return quickCapture(message.tabId, message.settings);
660
- case `${prefix}_CANCEL`:
661
- return cancelBatch();
662
- case `${prefix}_CLEAR_BATCH`:
663
- return clearBatch();
664
- case `${prefix}_SKIP_VERIFY`:
665
- batch.skipVerify = true;
666
- await persistBatch();
667
- return {
668
- ok: true
669
- };
670
- case `${prefix}_STATE`:
671
- return {
672
- ok: true,
673
- batch: publicBatch(),
674
- quota: await getQuota()
675
- };
676
- case `${prefix}_ACTIVATE`:
677
- await licensing.activate(message.code);
678
- return {
679
- ok: true,
680
- quota: await getQuota()
681
- };
682
- case `${prefix}_CLEAR_LICENSE`:
683
- await licensing.clear();
684
- return {
685
- ok: true,
686
- quota: await getQuota()
687
- };
688
- default:
689
- return {
690
- ok: false,
691
- error: "Unknown message"
692
- };
693
- }
1178
+ function reserveRun(restoring = false) {
1179
+ if (activeRun || !restoring && batch.running) return null;
1180
+ activeRun = {
1181
+ controller: new AbortController(),
1182
+ job: null,
1183
+ skipVerify: false,
1184
+ windowId: null,
1185
+ idleTabId: null
1186
+ };
1187
+ return activeRun;
694
1188
  }
695
- __name(handleMessage, "handleMessage");
696
- async function getQuota() {
697
- const entitlement = await licensing.getEntitlement().catch(() => null);
698
- if (entitlement) {
699
- return {
700
- pro: true,
701
- expiresAt: entitlement.expiresAt,
702
- limit: null,
703
- used: 0,
704
- remaining: 999999
705
- };
1189
+ __name(reserveRun, "reserveRun");
1190
+ function releaseRun(run) {
1191
+ if (activeRun === run) {
1192
+ activeRun = null;
1193
+ setBadge("");
706
1194
  }
707
- const data = await storageGet(USAGE_KEY);
1195
+ }
1196
+ __name(releaseRun, "releaseRun");
1197
+ function recount(job) {
1198
+ job.success = job.items.filter((i) => i.status === "success").length;
1199
+ job.failed = job.items.filter((i) => i.status === "failed").length;
1200
+ job.cancelled = job.items.filter((i) => i.status === "cancelled").length;
1201
+ job.done = job.success + job.failed + job.cancelled;
1202
+ }
1203
+ __name(recount, "recount");
1204
+ function publicBatch() {
1205
+ return {
1206
+ ...batch,
1207
+ busy: Boolean(activeRun),
1208
+ // Marked running with no live controller: a final write failed and left
1209
+ // downloads unconfirmed. The popup must stay usable in this state.
1210
+ stalled: Boolean(batch.running && !activeRun),
1211
+ items: batch.items.map(({ url, index, status, fileName, error, warning }) => ({
1212
+ url,
1213
+ index,
1214
+ status,
1215
+ fileName,
1216
+ error,
1217
+ warning
1218
+ }))
1219
+ };
1220
+ }
1221
+ __name(publicBatch, "publicBatch");
1222
+ const persist = /* @__PURE__ */ __name((job) => storageSet({
1223
+ [BATCH_KEY]: job
1224
+ }), "persist");
1225
+ function usageFrom(data) {
708
1226
  const usage = data[USAGE_KEY];
709
- const today = todayString();
710
- const used = usage && usage.date === today ? usage.used || 0 : 0;
1227
+ return usage?.date === todayString() && Number.isSafeInteger(usage.used) && usage.used >= 0 ? usage.used : 0;
1228
+ }
1229
+ __name(usageFrom, "usageFrom");
1230
+ async function getQuota(cachedOnly = false, force = false) {
1231
+ const status = await licensing.getStatus({
1232
+ cachedOnly,
1233
+ force
1234
+ });
1235
+ const license = {
1236
+ licenseStatus: status.state,
1237
+ licenseMessage: status.message || "",
1238
+ licenseRefreshing: Boolean(status.refreshing),
1239
+ hasLicense: status.state !== "free"
1240
+ };
1241
+ if (status.entitlement) return {
1242
+ ...license,
1243
+ pro: true,
1244
+ expiresAt: status.entitlement.expiresAt,
1245
+ limit: null,
1246
+ used: 0,
1247
+ remaining: null
1248
+ };
1249
+ if (status.state !== "free") return {
1250
+ ...license,
1251
+ pro: false,
1252
+ limit: null,
1253
+ used: 0,
1254
+ remaining: null
1255
+ };
1256
+ const used = usageFrom(await storageGet(USAGE_KEY));
1257
+ const limit = config.freeDailyLimit;
711
1258
  return {
1259
+ ...license,
712
1260
  pro: false,
713
- limit: freeDailyLimit,
1261
+ limit,
714
1262
  used,
715
- remaining: Math.max(0, freeDailyLimit - used)
1263
+ remaining: Math.max(0, limit - used)
716
1264
  };
717
1265
  }
718
1266
  __name(getQuota, "getQuota");
719
- async function recordUsage(count) {
720
- const data = await storageGet(USAGE_KEY);
721
- const today = todayString();
722
- const usage = data[USAGE_KEY];
723
- const used = usage && usage.date === today ? usage.used || 0 : 0;
724
- await storageSet({
725
- [USAGE_KEY]: {
726
- date: today,
727
- used: used + count
728
- }
729
- });
1267
+ function assertQuota(quota) {
1268
+ if (quota.pro) return;
1269
+ if (quota.hasLicense) throw new Error(quota.licenseMessage || "Checking your Pro license. Use Recheck Pro in License and retry.");
1270
+ if (!quota.remaining) throw new Error(`Daily free limit reached (${quota.limit} pages/day). Already purchased Pro? Enter your original activation code in License to restore unlimited exports.`);
730
1271
  }
731
- __name(recordUsage, "recordUsage");
732
- async function startBatch(urls, settings) {
733
- if (batch.running || quickRunning) {
734
- return {
735
- ok: false,
736
- error: texts.alreadyRunning
737
- };
738
- }
739
- const normalized = normalizeUrls(urls);
740
- if (!normalized.length) {
741
- return {
742
- ok: false,
743
- error: "No valid http(s) URLs"
744
- };
745
- }
746
- const quota = await getQuota();
747
- if (!quota.pro && quota.remaining <= 0) {
748
- return {
749
- ok: false,
750
- error: `Daily free limit reached (${quota.limit} pages/day). Go Pro for unlimited pages.`
751
- };
752
- }
753
- const freeLimited = !quota.pro && normalized.length > quota.remaining;
754
- const accepted = quota.pro ? normalized : normalized.slice(0, quota.remaining);
755
- const options = {
756
- ...config.defaultSettings,
757
- ...settings || {}
1272
+ __name(assertQuota, "assertQuota");
1273
+ async function commitResult(job, item, status, error = "", quick = false) {
1274
+ const previous = {
1275
+ status: item.status,
1276
+ error: item.error,
1277
+ usageRecorded: item.usageRecorded
758
1278
  };
759
- batch = {
760
- ...idleBatch(),
761
- running: true,
762
- startedAt: Date.now(),
763
- folder: `${config.brand}/${folderNameFor(accepted)}`,
764
- total: accepted.length,
765
- freeLimited,
766
- settings: options,
767
- pro: quota.pro,
768
- items: accepted.map((url, index) => ({
769
- url,
770
- index: index + 1,
771
- status: "pending",
772
- fileName: "",
773
- error: "",
774
- warning: "",
775
- usageRecorded: false
776
- }))
777
- };
778
- await persistBatch();
779
- runBatch(options).catch((error) => {
780
- batch.running = false;
781
- batch.current = normalizeError(error);
782
- persistBatch();
783
- });
1279
+ let used;
1280
+ if (status === "success" && item.chargeFree && !item.usageRecorded) {
1281
+ used = usageFrom(await storageGet(USAGE_KEY)) + 1;
1282
+ }
1283
+ item.status = status;
1284
+ item.error = error;
1285
+ if (used !== void 0) item.usageRecorded = true;
1286
+ if (!quick) recount(job);
1287
+ try {
1288
+ await storageSet({
1289
+ [quick ? QUICK_KEY : BATCH_KEY]: quick ? null : job,
1290
+ ...used !== void 0 ? {
1291
+ [USAGE_KEY]: {
1292
+ date: todayString(),
1293
+ used
1294
+ }
1295
+ } : {}
1296
+ });
1297
+ } catch (error2) {
1298
+ Object.assign(item, previous);
1299
+ if (!quick) recount(job);
1300
+ throw error2;
1301
+ }
1302
+ }
1303
+ __name(commitResult, "commitResult");
1304
+ function makeItem(url, index) {
784
1305
  return {
785
- ok: true,
786
- batch: publicBatch(),
787
- quota: await getQuota()
1306
+ url,
1307
+ index,
1308
+ status: "pending",
1309
+ fileName: "",
1310
+ error: "",
1311
+ warning: "",
1312
+ usageRecorded: false
788
1313
  };
789
1314
  }
790
- __name(startBatch, "startBatch");
791
- async function quickCapture(tabId, settings) {
792
- if (batch.running || quickRunning) {
793
- return {
794
- ok: false,
795
- error: texts.alreadyRunning
796
- };
1315
+ __name(makeItem, "makeItem");
1316
+ async function recoverQuick(signal) {
1317
+ const quick = (await storageGet(QUICK_KEY))[QUICK_KEY];
1318
+ if (!quick) return;
1319
+ if (quick.downloadId == null && !quick.downloadUrl) {
1320
+ await storageRemove(QUICK_KEY);
1321
+ return;
797
1322
  }
798
- if (!Number.isInteger(tabId)) {
799
- return {
800
- ok: false,
801
- error: "No active web page"
802
- };
1323
+ let recovered = false;
1324
+ try {
1325
+ await downloader.recover(quick, signal);
1326
+ recovered = true;
1327
+ } catch {
1328
+ await storageRemove(QUICK_KEY);
803
1329
  }
804
- const tab = await chrome.tabs.get(tabId).catch(() => null);
805
- if (!tab?.url || !/^https?:\/\//i.test(tab.url)) {
806
- return {
807
- ok: false,
808
- error: texts.cannotQuick
1330
+ if (recovered) await commitResult(null, quick, "success", "", true);
1331
+ }
1332
+ __name(recoverQuick, "recoverQuick");
1333
+ async function startBatch(urls, settings) {
1334
+ const run = reserveRun();
1335
+ if (!run) return {
1336
+ ok: false,
1337
+ error: texts.alreadyRunning
1338
+ };
1339
+ try {
1340
+ await recoverQuick(run.controller.signal);
1341
+ const normalized = normalizeUrls(urls);
1342
+ if (!normalized.length) throw new Error("No valid http(s) URLs");
1343
+ const quota = await withAbort(getQuota(), run.controller.signal);
1344
+ throwIfAborted(run.controller.signal);
1345
+ assertQuota(quota);
1346
+ const accepted = quota.pro ? normalized : normalized.slice(0, quota.remaining);
1347
+ batch = {
1348
+ ...idleBatch(),
1349
+ running: true,
1350
+ startedAt: Date.now(),
1351
+ folder: `${config.brand}/${folderNameFor(accepted)}-${crypto.randomUUID().slice(0, 6)}`,
1352
+ total: accepted.length,
1353
+ freeLimited: accepted.length < normalized.length,
1354
+ settings: normalizeSettings(config.defaultSettings, settings),
1355
+ pro: quota.pro,
1356
+ items: accepted.map((url, index) => makeItem(url, index + 1))
809
1357
  };
810
- }
811
- const quota = await getQuota();
812
- if (!quota.pro && quota.remaining <= 0) {
1358
+ run.job = batch;
1359
+ await persist(batch);
1360
+ void runBatch(batch, run);
813
1361
  return {
814
- ok: false,
815
- error: `Daily free limit reached (${quota.limit} pages/day). Go Pro for unlimited pages.`
1362
+ ok: true,
1363
+ batch: publicBatch(),
1364
+ quota
816
1365
  };
1366
+ } catch (error) {
1367
+ releaseRun(run);
1368
+ throw error;
817
1369
  }
818
- quickRunning = true;
819
- setBadge("1");
1370
+ }
1371
+ __name(startBatch, "startBatch");
1372
+ async function runBatch(job, run, { keepLock = false } = {}) {
1373
+ const signal = run.controller.signal;
1374
+ const lastHostAt = /* @__PURE__ */ new Map();
820
1375
  try {
821
- const options = {
822
- ...config.defaultSettings,
823
- ...settings || {}
824
- };
825
- const result = await captureExistingTab(tabId, tab.url, options);
826
- if (!quota.pro) {
827
- await recordUsage(1);
1376
+ for (const item of job.items) {
1377
+ if ([
1378
+ "success",
1379
+ "failed",
1380
+ "cancelled"
1381
+ ].includes(item.status)) continue;
1382
+ if (item.downloadId != null || item.downloadUrl) {
1383
+ let error2;
1384
+ try {
1385
+ await downloader.recover(item, signal);
1386
+ } catch (failure) {
1387
+ error2 = failure;
1388
+ }
1389
+ await commitResult(job, item, error2 ? signal.aborted || error2.message === "Cancelled" ? "cancelled" : "failed" : "success", error2 ? normalizeError(error2) : "");
1390
+ continue;
1391
+ }
1392
+ throwIfAborted(signal);
1393
+ const quota = await withAbort(getQuota(), signal);
1394
+ assertQuota(quota);
1395
+ const host = hostOf(item.url), previous = lastHostAt.get(host) || 0;
1396
+ await delay(Math.max(0, (config.sameHostGapMs ?? 1800) - (Date.now() - previous)), signal);
1397
+ lastHostAt.set(host, Date.now());
1398
+ item.chargeFree = !quota.pro;
1399
+ item.status = "running";
1400
+ job.current = item.url;
1401
+ setBadge(`${job.done + 1}/${job.total}`);
1402
+ await persist(job);
1403
+ let error;
1404
+ try {
1405
+ await captureUrl(item, job, run);
1406
+ } catch (failure) {
1407
+ error = failure;
1408
+ }
1409
+ await commitResult(job, item, error ? signal.aborted || error.message === "Cancelled" ? "cancelled" : "failed" : "success", error ? normalizeError(error) : "");
828
1410
  }
829
- setBadge("OK");
830
- setTimeout(() => setBadge(""), 4e3);
831
- return {
832
- ok: true,
833
- result,
834
- quota: await getQuota()
835
- };
836
1411
  } catch (error) {
837
- setBadge("ERR");
838
- setTimeout(() => setBadge(""), 4e3);
839
- return {
840
- ok: false,
841
- error: normalizeError(error),
842
- quota: await getQuota()
843
- };
1412
+ const message = normalizeError(error);
1413
+ job.current = message;
1414
+ if (!signal.aborted && !job.cancelRequested) {
1415
+ for (const item of job.items) if (item.status === "pending") {
1416
+ item.status = "failed";
1417
+ item.error = message;
1418
+ }
1419
+ }
844
1420
  } finally {
845
- quickRunning = false;
1421
+ if (signal.aborted || job.cancelRequested) {
1422
+ job.cancelRequested = true;
1423
+ for (const item of job.items) if ([
1424
+ "pending",
1425
+ "running"
1426
+ ].includes(item.status)) item.status = "cancelled";
1427
+ job.current = "Stopped";
1428
+ } else if (job.items.every((i) => [
1429
+ "success",
1430
+ "failed",
1431
+ "cancelled"
1432
+ ].includes(i.status))) job.current = "Done";
1433
+ job.running = job.items.some((item) => item.status === "downloading");
1434
+ if (job.running) job.current = "Some downloads could not be confirmed. Press Stop to reconcile them.";
1435
+ job.needVerify = null;
1436
+ recount(job);
1437
+ try {
1438
+ await persist(job);
1439
+ } catch {
1440
+ console.warn(`${config.brand}: could not save final task state`);
1441
+ }
1442
+ try {
1443
+ await closeBatchWindow(run);
1444
+ } finally {
1445
+ if (!keepLock) releaseRun(run);
1446
+ }
846
1447
  }
847
1448
  }
848
- __name(quickCapture, "quickCapture");
849
- async function captureExistingTab(tabId, pageUrl, options) {
850
- const target = {
851
- tabId
852
- };
853
- let attached = false;
854
- let scrollPosition = {
855
- x: 0,
856
- y: 0
1449
+ __name(runBatch, "runBatch");
1450
+ async function quickCapture(tabId, settings) {
1451
+ const run = reserveRun();
1452
+ if (!run) return {
1453
+ ok: false,
1454
+ error: texts.alreadyRunning
857
1455
  };
1456
+ const signal = run.controller.signal;
858
1457
  try {
859
- await debuggerAttach(target, "1.3");
860
- attached = true;
861
- await sendCommand(target, "Page.enable");
862
- await sendCommand(target, "Runtime.enable");
863
- const probe = await probePage(target);
864
- if (probe.error) {
865
- throw new Error(probe.error);
866
- }
867
- if (probe.challenge) {
868
- throw new Error(texts.challengeBeforeQuick(probe.challenge));
1458
+ await recoverQuick(signal);
1459
+ if (!Number.isInteger(tabId)) throw new Error("No active web page");
1460
+ const tab = await chrome.tabs.get(tabId);
1461
+ if (!/^https?:\/\//i.test(tab?.url || "")) throw new Error(texts.cannotQuick);
1462
+ const quota = await withAbort(getQuota(), signal);
1463
+ assertQuota(quota);
1464
+ const item = {
1465
+ ...makeItem(tab.url, 1),
1466
+ chargeFree: !quota.pro
1467
+ };
1468
+ const options = normalizeSettings(config.defaultSettings, settings);
1469
+ await storageSet({
1470
+ [QUICK_KEY]: item
1471
+ });
1472
+ const visibleOutput = await config.captureQuick?.({
1473
+ tab,
1474
+ options,
1475
+ signal
1476
+ });
1477
+ if (visibleOutput) return await deliverQuick(item, visibleOutput, signal);
1478
+ const target = {
1479
+ tabId
1480
+ };
1481
+ let attached = false, position = null;
1482
+ const network = watchNetwork(target);
1483
+ const ctx = makeContext("quick", tabId, item.url, options, target, signal);
1484
+ try {
1485
+ await debuggerAttach(target, "1.3");
1486
+ attached = true;
1487
+ if ((config.captureScope?.(options) || "full") === "full") position = await getScrollPosition(target);
1488
+ await config.beforeQuick?.(ctx, tab);
1489
+ await sendCommand(target, "Network.enable", {}, signal);
1490
+ if ((config.captureScope?.(options) || "full") === "full") {
1491
+ await preparePageForCapture(target, signal);
1492
+ }
1493
+ await suppressCaptureDecorations(target, signal);
1494
+ await prepareCapture(target, item, options, run, network, "quick");
1495
+ const output = await capturePrepared(ctx, options, run, network);
1496
+ throwIfAborted(signal);
1497
+ return await deliverQuick(item, output, signal);
1498
+ } finally {
1499
+ network.dispose();
1500
+ if (attached) {
1501
+ await config.afterQuick?.(ctx).catch(() => {
1502
+ });
1503
+ await restoreCaptureDecorations(target).catch(() => {
1504
+ });
1505
+ if (position && await isCurrentQuickPage(ctx)) await restorePageAfterQuick(target, position).catch(() => {
1506
+ });
1507
+ await debuggerDetach(target).catch(() => {
1508
+ });
1509
+ }
869
1510
  }
870
- scrollPosition = await getScrollPosition(target);
871
- await delay(clamp(Number(options.delayMs) || Number(config.defaultSettings.delayMs) || 1500, 0, 1e4));
872
- await autoScroll(target);
873
- await revealAnimations(target, true);
874
- await delay(400);
875
- const output = await config.capture(makeContext("quick", tabId, pageUrl, options, target));
876
- const fileName = buildQuickFileName(config.brand, texts.quickFolder, pageUrl, output.extension);
877
- await downloader.downloadViaBlob(output.base64, output.mime, fileName);
1511
+ } catch (error) {
1512
+ const saved = (await storageGet(QUICK_KEY))[QUICK_KEY];
1513
+ if (!saved?.downloadId && !saved?.downloadUrl) await storageRemove(QUICK_KEY);
878
1514
  return {
879
- fileName,
880
- warning: output.warning || ""
1515
+ ok: false,
1516
+ error: normalizeError(error),
1517
+ quota: await getQuota(true)
881
1518
  };
882
1519
  } finally {
883
- if (attached) {
884
- await restorePageAfterQuick(target, scrollPosition).catch(() => {
885
- });
886
- await debuggerDetach(target).catch(() => {
887
- });
888
- }
1520
+ releaseRun(run);
889
1521
  }
890
1522
  }
891
- __name(captureExistingTab, "captureExistingTab");
892
- async function cancelBatch() {
893
- if (batch.running) {
894
- batch.cancelRequested = true;
895
- batch.current = "Stopping\u2026";
896
- await persistBatch();
897
- }
1523
+ __name(quickCapture, "quickCapture");
1524
+ async function deliverQuick(item, output, signal) {
1525
+ throwIfAborted(signal);
1526
+ item.warning = [
1527
+ item.warning,
1528
+ output.warning
1529
+ ].filter(Boolean).join(" ");
1530
+ item.fileName = buildQuickFileName(config.brand, texts.quickFolder, item.url, output.extension);
1531
+ await saveOutput(item, output, signal, () => storageSet({
1532
+ [QUICK_KEY]: item
1533
+ }));
1534
+ await commitResult(null, item, "success", "", true);
898
1535
  return {
899
1536
  ok: true,
900
- batch: publicBatch()
1537
+ result: {
1538
+ fileName: item.fileName,
1539
+ warning: item.warning
1540
+ },
1541
+ quota: await getQuota(true)
901
1542
  };
902
1543
  }
903
- __name(cancelBatch, "cancelBatch");
904
- async function clearBatch() {
905
- if (batch.running) {
1544
+ __name(deliverQuick, "deliverQuick");
1545
+ async function isCurrentQuickPage(ctx) {
1546
+ if (ctx.mode !== "quick") return true;
1547
+ try {
1548
+ const current = await chrome.tabs.get(ctx.tabId);
1549
+ return current.url === ctx.url && (!current.pendingUrl || current.pendingUrl === ctx.url);
1550
+ } catch {
1551
+ return false;
1552
+ }
1553
+ }
1554
+ __name(isCurrentQuickPage, "isCurrentQuickPage");
1555
+ async function assertCurrentQuickPage(ctx) {
1556
+ if (!await isCurrentQuickPage(ctx)) throw new Error("The page changed during capture. Return to the requested page and try again.");
1557
+ }
1558
+ __name(assertCurrentQuickPage, "assertCurrentQuickPage");
1559
+ async function capturePrepared(ctx, options, run, network) {
1560
+ const expand = (config.captureScope?.(options) || "full") === "full" && options.expandScrollAreas;
1561
+ try {
1562
+ await assertCurrentQuickPage(ctx);
1563
+ const warnings = /* @__PURE__ */ new Set();
1564
+ if (expand) await scrollCapture?.prepare(ctx, async () => {
1565
+ await assertCurrentQuickPage(ctx);
1566
+ const warning = await waitForPageStable({
1567
+ tabId: ctx.tabId
1568
+ }, options, run.controller.signal, network);
1569
+ if (warning) warnings.add(warning);
1570
+ });
1571
+ await assertCurrentQuickPage(ctx);
1572
+ const output = await config.capture(ctx);
1573
+ await assertCurrentQuickPage(ctx);
1574
+ if (output.warning) warnings.add(output.warning);
906
1575
  return {
907
- ok: false,
908
- error: "Stop the running batch before clearing it"
1576
+ ...output,
1577
+ warning: [
1578
+ ...warnings
1579
+ ].join(" ")
909
1580
  };
1581
+ } finally {
1582
+ if (expand) await scrollCapture?.restore(ctx);
910
1583
  }
911
- batch = idleBatch();
912
- await storageRemove(BATCH_KEY);
913
- setBadge("");
914
- return {
915
- ok: true,
916
- batch: publicBatch()
917
- };
918
1584
  }
919
- __name(clearBatch, "clearBatch");
920
- async function runBatch(options) {
921
- const lastHostAt = {};
922
- for (const item of batch.items) {
923
- if (item.status !== "pending") {
924
- continue;
925
- }
926
- if (batch.cancelRequested) {
927
- break;
928
- }
929
- const host = hostOf(item.url);
930
- const since = Date.now() - (lastHostAt[host] || 0);
931
- if (lastHostAt[host] && since < SAME_HOST_MIN_GAP_MS) {
932
- batch.current = "Pacing to avoid rate-limits\u2026";
933
- await delay(SAME_HOST_MIN_GAP_MS - since);
934
- if (batch.cancelRequested) {
935
- break;
936
- }
937
- }
938
- lastHostAt[host] = Date.now();
939
- item.status = "running";
940
- batch.current = item.url;
941
- setBadge(`${batch.done + 1}/${batch.total}`);
942
- await persistBatch();
943
- let shouldCharge = false;
944
- try {
945
- const result = await captureUrl(item, options);
946
- item.fileName = result.fileName;
947
- item.warning = result.warning;
948
- item.status = "success";
949
- batch.success += 1;
950
- if (!batch.pro && !item.usageRecorded) {
951
- item.usageRecorded = true;
952
- shouldCharge = true;
953
- }
954
- } catch (error) {
955
- item.status = "failed";
956
- item.error = normalizeError(error);
957
- batch.failed += 1;
958
- }
959
- batch.done = batch.success + batch.failed;
960
- await persistBatch();
961
- if (shouldCharge) {
962
- await recordUsage(1);
963
- }
964
- }
965
- batch.running = false;
966
- batch.current = batch.cancelRequested ? "Stopped" : "Done";
967
- batch.needVerify = null;
968
- await persistBatch();
969
- setBadge(batch.failed ? "ERR" : "OK");
970
- setTimeout(() => setBadge(""), 4e3);
1585
+ __name(capturePrepared, "capturePrepared");
1586
+ async function prepareCapture(target, item, options, run, network, mode = "batch") {
1587
+ const signal = run.controller.signal;
1588
+ const probe = await probePage(target, signal);
1589
+ if (probe.error) throw new Error(probe.error);
1590
+ if (probe.challenge && !options.ignoreChallenge) throw challengeError(probe.challenge);
1591
+ if (probe.challenge) item.warning = `Captured a ${probe.challenge} verification page at your request.`;
1592
+ const scope = config.captureScope?.(options) || "full";
1593
+ if (scope === "viewport" && mode === "quick") return;
1594
+ await delay(options.delayMs, signal);
1595
+ const before = await waitForPageStable(target, options, signal, network);
1596
+ const maxSeenY = scope === "full" ? await autoScroll(target, signal) : 0;
1597
+ const after = await waitForPageStable(target, options, signal, network, maxSeenY);
1598
+ item.warning = [
1599
+ ...new Set([
1600
+ item.warning,
1601
+ before,
1602
+ after
1603
+ ].filter(Boolean))
1604
+ ].join(" ");
971
1605
  }
972
- __name(runBatch, "runBatch");
973
- async function captureUrl(item, options) {
974
- const maxChallengeRetry = 2;
975
- let lastError;
976
- for (let attempt = 0; attempt <= maxChallengeRetry; attempt += 1) {
977
- if (batch.cancelRequested) {
978
- throw new Error("Cancelled");
979
- }
1606
+ __name(prepareCapture, "prepareCapture");
1607
+ async function saveOutput(item, output, signal, save) {
1608
+ throwIfAborted(signal);
1609
+ await downloader.downloadViaBlob(output.base64, output.mime, item.fileName, {
1610
+ signal,
1611
+ onPrepared: /* @__PURE__ */ __name(async (url) => {
1612
+ item.downloadUrl = url;
1613
+ item.status = "downloading";
1614
+ await save();
1615
+ }, "onPrepared"),
1616
+ onCreated: /* @__PURE__ */ __name(async (id) => {
1617
+ item.downloadId = id;
1618
+ await save();
1619
+ }, "onCreated")
1620
+ });
1621
+ }
1622
+ __name(saveOutput, "saveOutput");
1623
+ async function captureUrl(item, job, run) {
1624
+ const signal = run.controller.signal;
1625
+ for (let attempt = 0; attempt <= 3; attempt++) {
1626
+ throwIfAborted(signal);
980
1627
  try {
981
- return await captureOnce(item, options, false);
982
- } catch (error) {
983
- lastError = error;
984
- if (!isChallengeError(error)) {
985
- throw error;
986
- }
987
- if (attempt < maxChallengeRetry) {
988
- const backoff = 8e3 * (attempt + 1);
989
- batch.current = `Rate-limited \u2014 cooling down ${backoff / 1e3}s (retry ${attempt + 1}/${maxChallengeRetry})`;
990
- await delay(backoff);
991
- }
1628
+ return await captureOnce(item, job, run, attempt === 3);
1629
+ } catch (error) {
1630
+ if (!isChallengeError(error) || attempt === 3) throw error;
1631
+ job.current = "Waiting before retrying verification\u2026";
1632
+ await persist(job);
1633
+ await delay(8e3 * (attempt + 1), signal);
992
1634
  }
993
1635
  }
994
- if (isChallengeError(lastError) && !batch.cancelRequested) {
995
- return await captureOnce(item, options, true);
996
- }
997
- throw lastError;
998
1636
  }
999
1637
  __name(captureUrl, "captureUrl");
1000
- async function captureOnce(item, options, interactive) {
1638
+ async function ensureBatchWindow(run) {
1639
+ if (Number.isInteger(run.windowId)) {
1640
+ const live = await chrome.windows.get(run.windowId).catch(() => null);
1641
+ if (live) return run.windowId;
1642
+ run.windowId = null;
1643
+ run.idleTabId = null;
1644
+ }
1645
+ const created = await chrome.windows.create({
1646
+ url: "about:blank",
1647
+ focused: false
1648
+ }).catch(() => null);
1649
+ run.windowId = Number.isInteger(created?.id) ? created.id : null;
1650
+ run.idleTabId = created?.tabs?.[0]?.id ?? null;
1651
+ await saveTabLease(run);
1652
+ return run.windowId;
1653
+ }
1654
+ __name(ensureBatchWindow, "ensureBatchWindow");
1655
+ function saveTabLease(run, tabId = null) {
1656
+ return storageCall("session", "set", {
1657
+ [TAB_KEY]: {
1658
+ tabId,
1659
+ jobId: run.job?.id,
1660
+ windowId: run.windowId,
1661
+ idleTabId: run.idleTabId
1662
+ }
1663
+ });
1664
+ }
1665
+ __name(saveTabLease, "saveTabLease");
1666
+ async function closeIdleTab(lease) {
1667
+ if (!Number.isInteger(lease?.idleTabId)) return;
1668
+ const tab = await chrome.tabs.get(lease.idleTabId).catch(() => null);
1669
+ if (tab?.windowId === lease.windowId && tab.url === "about:blank" && (!tab.pendingUrl || tab.pendingUrl === "about:blank")) await chrome.tabs.remove(tab.id).catch(() => {
1670
+ });
1671
+ }
1672
+ __name(closeIdleTab, "closeIdleTab");
1673
+ async function closeBatchWindow(run) {
1674
+ await closeIdleTab(run);
1675
+ run.windowId = null;
1676
+ run.idleTabId = null;
1677
+ await storageCall("session", "remove", TAB_KEY).catch(() => {
1678
+ });
1679
+ }
1680
+ __name(closeBatchWindow, "closeBatchWindow");
1681
+ async function captureOnce(item, job, run, interactive) {
1682
+ const signal = run.controller.signal, options = job.settings;
1683
+ const windowId = await ensureBatchWindow(run);
1001
1684
  const tab = await chrome.tabs.create({
1002
- url: item.url,
1003
- active: Boolean(interactive)
1685
+ url: "about:blank",
1686
+ active: interactive,
1687
+ ...Number.isInteger(windowId) ? {
1688
+ windowId
1689
+ } : {}
1004
1690
  });
1005
- const tabId = tab.id;
1006
- if (!tabId) {
1007
- throw new Error("Could not open tab");
1008
- }
1009
- const target = {
1691
+ const tabId = tab.id, target = {
1010
1692
  tabId
1011
1693
  };
1012
1694
  let attached = false;
1695
+ const network = watchNetwork(target);
1013
1696
  try {
1014
- await waitForLoad(tabId, LOAD_TIMEOUT_MS);
1015
- await delay(Number(options.delayMs) || 0);
1016
- if (batch.cancelRequested) {
1017
- throw new Error("Cancelled");
1018
- }
1697
+ await saveTabLease(run, tabId);
1698
+ throwIfAborted(signal);
1019
1699
  await debuggerAttach(target, "1.3");
1020
1700
  attached = true;
1021
- await sendCommand(target, "Page.enable");
1022
- await sendCommand(target, "Runtime.enable");
1023
- const probe = await probePage(target);
1024
- if (probe.error) {
1025
- throw new Error(probe.error);
1026
- }
1027
- if (probe.challenge) {
1028
- if (!interactive) {
1029
- throw challengeError(probe.challenge);
1030
- }
1031
- await debuggerDetach(target).catch(() => {
1032
- });
1701
+ await sendCommand(target, "Page.enable", {}, signal);
1702
+ await sendCommand(target, "Network.enable", {}, signal);
1703
+ const ctx = makeContext("batch", tabId, item.url, options, target, signal);
1704
+ if (config.beforeScroll) await config.beforeScroll(ctx);
1705
+ await preparePageForCapture(target, signal);
1706
+ await chrome.tabs.update(tabId, {
1707
+ url: item.url
1708
+ });
1709
+ await waitForLoad(tabId, 3e4, signal);
1710
+ await suppressCaptureDecorations(target, signal);
1711
+ const probe = await probePage(target, signal);
1712
+ if (probe.challenge && !options.ignoreChallenge && interactive) {
1713
+ await debuggerDetach(target);
1033
1714
  attached = false;
1034
1715
  await focusTab(tabId);
1035
- batch.needVerify = {
1716
+ job.needVerify = {
1036
1717
  url: item.url,
1037
1718
  challenge: probe.challenge
1038
1719
  };
1039
- batch.current = `Complete ${probe.challenge} verification in the opened tab\u2026`;
1040
- await persistBatch();
1041
- batch.skipVerify = false;
1042
- const passed = await waitForVerification(tabId, () => batch.cancelRequested || batch.skipVerify);
1043
- batch.needVerify = null;
1044
- batch.current = item.url;
1045
- await persistBatch();
1046
- if (!passed) {
1047
- throw challengeError(`${probe.challenge} verification not completed`);
1048
- }
1049
- await delay(800);
1720
+ job.current = `Complete ${probe.challenge} verification in the opened tab\u2026`;
1721
+ run.skipVerify = false;
1722
+ await persist(job);
1723
+ const passed = await waitForVerification(tabId, () => run.skipVerify, signal);
1724
+ job.needVerify = null;
1725
+ job.current = item.url;
1726
+ await persist(job);
1727
+ if (!passed) throw challengeError(`${probe.challenge} verification not completed`);
1050
1728
  await debuggerAttach(target, "1.3");
1051
1729
  attached = true;
1052
- await sendCommand(target, "Page.enable");
1053
- await sendCommand(target, "Runtime.enable");
1730
+ network.reset();
1731
+ await sendCommand(target, "Network.enable", {}, signal);
1732
+ await preparePageForCapture(target, signal);
1054
1733
  }
1055
- const ctx = makeContext("batch", tabId, item.url, options, target);
1056
- if (config.beforeScroll) {
1057
- await config.beforeScroll(ctx);
1058
- }
1059
- await autoScroll(target);
1060
- await revealAnimations(target);
1061
- await delay(400);
1062
- const output = await config.capture(ctx);
1063
- const fileName = buildBatchFileName(batch.folder, item, output.extension);
1064
- await downloader.downloadViaBlob(output.base64, output.mime, fileName);
1065
- return {
1066
- fileName,
1067
- warning: output.warning || ""
1068
- };
1734
+ await prepareCapture(target, item, options, run, network);
1735
+ const output = await capturePrepared(ctx, options, run, network);
1736
+ throwIfAborted(signal);
1737
+ item.warning = [
1738
+ item.warning,
1739
+ output.warning
1740
+ ].filter(Boolean).join(" ");
1741
+ item.fileName = buildBatchFileName(job.folder, item, output.extension);
1742
+ await saveOutput(item, output, signal, () => persist(job));
1069
1743
  } finally {
1744
+ network.dispose();
1070
1745
  if (attached) {
1746
+ await restoreCaptureDecorations(target).catch(() => {
1747
+ });
1071
1748
  await debuggerDetach(target).catch(() => {
1072
1749
  });
1073
1750
  }
1074
1751
  await chrome.tabs.remove(tabId).catch(() => {
1075
1752
  });
1753
+ await saveTabLease(run);
1076
1754
  }
1077
1755
  }
1078
1756
  __name(captureOnce, "captureOnce");
1079
- function makeContext(mode, tabId, url, options, target) {
1757
+ function makeContext(mode, tabId, url, options, target, signal) {
1080
1758
  return {
1081
1759
  mode,
1082
1760
  tabId,
1083
1761
  url,
1084
1762
  options,
1085
- send: /* @__PURE__ */ __name((method, params) => sendCommand(target, method, params), "send")
1763
+ send: /* @__PURE__ */ __name((method, params) => sendCommand(target, method, params, signal), "send"),
1764
+ cleanup: /* @__PURE__ */ __name((method, params) => sendCommand(target, method, params), "cleanup")
1086
1765
  };
1087
1766
  }
1088
1767
  __name(makeContext, "makeContext");
1089
- function publicBatch() {
1090
- return {
1091
- running: batch.running,
1092
- total: batch.total,
1093
- done: batch.done,
1094
- success: batch.success,
1095
- failed: batch.failed,
1096
- current: batch.current,
1097
- folder: batch.folder,
1098
- freeLimited: batch.freeLimited,
1099
- needVerify: batch.needVerify,
1100
- items: batch.items.map((item) => ({
1101
- url: item.url,
1102
- index: item.index,
1103
- status: item.status,
1104
- fileName: item.fileName,
1105
- error: item.error,
1106
- warning: item.warning
1107
- }))
1108
- };
1109
- }
1110
- __name(publicBatch, "publicBatch");
1111
- async function persistBatch() {
1112
- await storageSet({
1113
- [BATCH_KEY]: batch
1114
- });
1115
- }
1116
- __name(persistBatch, "persistBatch");
1117
1768
  async function restoreBatch() {
1118
- const data = await storageGet(BATCH_KEY);
1119
- const saved = data[BATCH_KEY];
1120
- if (!saved?.items?.length) {
1121
- return;
1769
+ const data = await storageGet([
1770
+ BATCH_KEY,
1771
+ QUICK_KEY
1772
+ ]);
1773
+ const lease = (await storageCall("session", "get", TAB_KEY))[TAB_KEY];
1774
+ if (Number.isInteger(lease?.tabId) || Number.isInteger(lease?.windowId)) {
1775
+ if (Number.isInteger(lease.tabId)) await chrome.tabs.remove(lease.tabId).catch(() => {
1776
+ });
1777
+ await closeIdleTab(lease);
1778
+ await storageCall("session", "remove", TAB_KEY);
1122
1779
  }
1780
+ await recoverQuick();
1781
+ const saved = data[BATCH_KEY];
1782
+ if (!Array.isArray(saved?.items) || !saved.items.length) return;
1783
+ const items = saved.items.filter((item) => typeof item?.url === "string" && normalizeUrls([
1784
+ item.url
1785
+ ]).length).slice(0, 500);
1123
1786
  batch = {
1124
1787
  ...idleBatch(),
1125
- ...saved
1126
- };
1127
- batch.settings = {
1128
- ...config.defaultSettings,
1129
- ...saved.settings || {}
1788
+ ...saved,
1789
+ version: 2,
1790
+ settings: normalizeSettings(config.defaultSettings, config.migrateSettings ? config.migrateSettings(saved.settings) : saved.settings),
1791
+ needVerify: null,
1792
+ items: items.map((item, i) => ({
1793
+ ...makeItem(item.url, i + 1),
1794
+ ...item,
1795
+ chargeFree: item.chargeFree ?? !saved.pro,
1796
+ status: [
1797
+ "success",
1798
+ "failed",
1799
+ "cancelled",
1800
+ "downloading"
1801
+ ].includes(item.status) ? item.status : "pending"
1802
+ }))
1130
1803
  };
1131
- batch.items = saved.items.map((item) => ({
1132
- url: item.url || "",
1133
- index: item.index || 0,
1134
- status: item.status === "running" ? "pending" : item.status || "pending",
1135
- fileName: item.fileName || "",
1136
- error: item.error || "",
1137
- warning: item.warning || "",
1138
- usageRecorded: item.usageRecorded || false
1139
- }));
1140
- batch.done = batch.items.filter((item) => item.status === "success" || item.status === "failed").length;
1141
- batch.success = batch.items.filter((item) => item.status === "success").length;
1142
- batch.failed = batch.items.filter((item) => item.status === "failed").length;
1143
- batch.needVerify = null;
1144
- batch.skipVerify = false;
1145
- if (saved.running && batch.items.some((item) => item.status === "pending")) {
1146
- batch.running = true;
1147
- batch.cancelRequested = false;
1148
- batch.current = "Resuming interrupted batch\u2026";
1149
- await persistBatch();
1150
- runBatch(batch.settings).catch(async (error) => {
1151
- batch.running = false;
1152
- batch.current = normalizeError(error);
1153
- await persistBatch();
1154
- });
1155
- } else if (saved.running) {
1156
- batch.running = false;
1157
- batch.current = "Done";
1158
- await persistBatch();
1804
+ batch.total = batch.items.length;
1805
+ if (!batch.folder) batch.folder = `${config.brand}/${folderNameFor(batch.items.map((i) => i.url))}`;
1806
+ recount(batch);
1807
+ if (saved.running) {
1808
+ const run = reserveRun(true);
1809
+ run.job = batch;
1810
+ if (saved.cancelRequested) run.controller.abort();
1811
+ batch.current = saved.cancelRequested ? "Stopping\u2026" : "Resuming interrupted batch\u2026";
1812
+ await persist(batch);
1813
+ void runBatch(batch, run);
1159
1814
  }
1160
1815
  }
1161
1816
  __name(restoreBatch, "restoreBatch");
1817
+ async function handleMessage(message) {
1818
+ await restorePromise;
1819
+ switch (message?.type) {
1820
+ case `${prefix}_START`:
1821
+ return startBatch(message.urls, message.settings);
1822
+ case `${prefix}_CAPTURE_CURRENT`:
1823
+ case `${prefix}_CONVERT_CURRENT`:
1824
+ return quickCapture(message.tabId, message.settings);
1825
+ case `${prefix}_CANCEL`:
1826
+ if (activeRun) {
1827
+ activeRun.controller.abort();
1828
+ if (activeRun.job) {
1829
+ activeRun.job.cancelRequested = true;
1830
+ activeRun.job.current = "Stopping\u2026";
1831
+ await persist(activeRun.job);
1832
+ }
1833
+ } else if (batch.running) {
1834
+ const run = reserveRun(true);
1835
+ if (run) {
1836
+ run.job = batch;
1837
+ batch.cancelRequested = true;
1838
+ batch.current = "Stopping\u2026";
1839
+ run.controller.abort();
1840
+ await persist(batch).catch(() => {
1841
+ });
1842
+ void runBatch(batch, run);
1843
+ }
1844
+ }
1845
+ return {
1846
+ ok: true,
1847
+ batch: publicBatch()
1848
+ };
1849
+ case `${prefix}_CLEAR_BATCH`: {
1850
+ const run = reserveRun(true);
1851
+ if (!run) return {
1852
+ ok: false,
1853
+ error: "Stop the running task before clearing it"
1854
+ };
1855
+ try {
1856
+ run.job = batch;
1857
+ if (batch.running || batch.items.some((item) => item.status === "downloading")) {
1858
+ batch.cancelRequested = true;
1859
+ run.controller.abort();
1860
+ await runBatch(batch, run, {
1861
+ keepLock: true
1862
+ });
1863
+ if (batch.running) throw new Error("Downloads could not be confirmed. Results were kept; retry Stop or Clear.");
1864
+ }
1865
+ await storageRemove(BATCH_KEY);
1866
+ batch = idleBatch();
1867
+ } finally {
1868
+ releaseRun(run);
1869
+ }
1870
+ return {
1871
+ ok: true,
1872
+ batch: publicBatch(),
1873
+ quota: await getQuota(true)
1874
+ };
1875
+ }
1876
+ case `${prefix}_SKIP_VERIFY`:
1877
+ if (activeRun) activeRun.skipVerify = true;
1878
+ return {
1879
+ ok: true
1880
+ };
1881
+ case `${prefix}_STATE`:
1882
+ return {
1883
+ ok: true,
1884
+ batch: publicBatch(),
1885
+ quota: await getQuota(true)
1886
+ };
1887
+ case `${prefix}_ACTIVATE`:
1888
+ await licensing.activate(message.code);
1889
+ return {
1890
+ ok: true,
1891
+ quota: await getQuota(true)
1892
+ };
1893
+ case `${prefix}_REFRESH_LICENSE`:
1894
+ return {
1895
+ ok: true,
1896
+ quota: await getQuota(false, true)
1897
+ };
1898
+ case `${prefix}_CLEAR_LICENSE`:
1899
+ await licensing.deactivate();
1900
+ return {
1901
+ ok: true,
1902
+ quota: await getQuota(true)
1903
+ };
1904
+ default:
1905
+ return {
1906
+ ok: false,
1907
+ error: "Unknown message"
1908
+ };
1909
+ }
1910
+ }
1911
+ __name(handleMessage, "handleMessage");
1912
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1913
+ if (message?.target === "offscreen" || sender.id && sender.id !== chrome.runtime.id) return false;
1914
+ handleMessage(message).then(sendResponse).catch((error) => sendResponse({
1915
+ ok: false,
1916
+ error: normalizeError(error)
1917
+ }));
1918
+ return true;
1919
+ });
1920
+ chrome.runtime.onInstalled.addListener(() => {
1921
+ void restorePromise.catch(() => {
1922
+ });
1923
+ });
1162
1924
  function setBadge(text) {
1163
1925
  chrome.action.setBadgeText({
1164
1926
  text
@@ -1168,10 +1930,11 @@ function initExtension(config) {
1168
1930
  });
1169
1931
  }
1170
1932
  __name(setBadge, "setBadge");
1933
+ restorePromise = restoreBatch();
1171
1934
  }
1172
1935
  __name(initExtension, "initExtension");
1173
1936
 
1174
- // src/popup.ts
1937
+ // src/popup.js
1175
1938
  function initPopup(config) {
1176
1939
  const byId = /* @__PURE__ */ __name((id) => document.getElementById(id), "byId");
1177
1940
  const el = {
@@ -1207,20 +1970,66 @@ function initPopup(config) {
1207
1970
  licenseCode: byId("licenseCode"),
1208
1971
  activateBtn: byId("activateBtn"),
1209
1972
  deactivateBtn: byId("deactivateBtn"),
1973
+ recheckLicense: byId("recheckLicense"),
1974
+ licenseHint: byId("licenseHint"),
1210
1975
  status: byId("status")
1211
1976
  };
1212
1977
  let pollTimer = null;
1978
+ let disposed = false;
1979
+ window.addEventListener("pagehide", () => {
1980
+ disposed = true;
1981
+ pollGeneration++;
1982
+ clearTimeout(pollTimer);
1983
+ if (scanJob) {
1984
+ const job = scanJob;
1985
+ scanJob = null;
1986
+ void chrome.scripting.executeScript({
1987
+ target: {
1988
+ tabId: job.tabId
1989
+ },
1990
+ func: collectSameOriginLinks,
1991
+ args: [
1992
+ {
1993
+ phase: "cancel",
1994
+ id: job.id
1995
+ }
1996
+ ]
1997
+ }).catch(() => {
1998
+ });
1999
+ }
2000
+ }, {
2001
+ once: true
2002
+ });
1213
2003
  let queue = [];
1214
2004
  let queueLocked = false;
2005
+ let operationBusy = false;
2006
+ let editingIndex = -1;
2007
+ let scanJob = null;
2008
+ let pollGeneration = 0;
2009
+ const settingsKey = config.settingsKey || config.queueKey.replace(/_url_queue$/, "_settings");
2010
+ const settingFields = /* @__PURE__ */ __name(() => document.querySelectorAll(".settings select, .settings input[data-setting]"), "settingFields");
2011
+ let savedSettings = {};
1215
2012
  const prefix = config.messagePrefix;
1216
- void init();
2013
+ void init().catch((error) => setStatus(errText(error), true));
1217
2014
  async function init() {
1218
2015
  el.urls.addEventListener("input", updateUrlCount);
1219
2016
  el.addUrls.addEventListener("click", addInputUrls);
1220
2017
  el.scanSite.addEventListener("click", scanCurrentSite);
1221
2018
  el.selectAll.addEventListener("click", () => setAllSelected(true));
1222
2019
  el.selectNone.addEventListener("click", () => setAllSelected(false));
1223
- el.moreQueueActions.addEventListener("click", () => el.queueMenu.classList.toggle("hidden"));
2020
+ el.moreQueueActions.addEventListener("click", (event) => {
2021
+ event.stopPropagation();
2022
+ setQueueMenu(el.queueMenu.classList.contains("hidden"));
2023
+ });
2024
+ document.addEventListener("click", (event) => {
2025
+ if (!el.queueMenu.classList.contains("hidden") && !el.queueMenu.contains(event.target)) setQueueMenu(false);
2026
+ });
2027
+ document.addEventListener("keydown", (event) => {
2028
+ if (event.key === "Escape" && !el.queueMenu.classList.contains("hidden")) {
2029
+ setQueueMenu(false);
2030
+ el.moreQueueActions.focus();
2031
+ }
2032
+ });
1224
2033
  el.removeUnselected.addEventListener("click", removeUnselected);
1225
2034
  el.clearQueue.addEventListener("click", clearQueue);
1226
2035
  el.captureCurrent.addEventListener("click", quickCapture);
@@ -1233,12 +2042,28 @@ function initPopup(config) {
1233
2042
  el.haveCode.addEventListener("click", () => el.licensePanel.classList.toggle("hidden"));
1234
2043
  el.activateBtn.addEventListener("click", activateLicense);
1235
2044
  el.deactivateBtn.addEventListener("click", deactivateLicense);
1236
- await loadQueue();
2045
+ el.recheckLicense?.addEventListener("click", recheckLicense);
2046
+ el.start.disabled = true;
2047
+ el.captureCurrent.disabled = true;
2048
+ await Promise.all([
2049
+ loadQueue(),
2050
+ loadSettings()
2051
+ ]);
2052
+ config.onSettingsLoaded?.();
2053
+ settingFields().forEach((field) => {
2054
+ field.addEventListener("change", saveSettings);
2055
+ if (field.type === "number") field.addEventListener("input", saveSettings);
2056
+ });
1237
2057
  updateUrlCount();
1238
2058
  renderQueue();
1239
2059
  await refreshState();
1240
2060
  }
1241
2061
  __name(init, "init");
2062
+ function setQueueMenu(open) {
2063
+ el.queueMenu.classList.toggle("hidden", !open);
2064
+ el.moreQueueActions.setAttribute("aria-expanded", String(open));
2065
+ }
2066
+ __name(setQueueMenu, "setQueueMenu");
1242
2067
  async function activateLicense() {
1243
2068
  const code = el.licenseCode.value.trim();
1244
2069
  if (!code) {
@@ -1263,15 +2088,29 @@ function initPopup(config) {
1263
2088
  }
1264
2089
  __name(activateLicense, "activateLicense");
1265
2090
  async function deactivateLicense() {
2091
+ el.deactivateBtn.disabled = true;
1266
2092
  const response = await send({
1267
2093
  type: `${prefix}_CLEAR_LICENSE`
1268
2094
  });
2095
+ el.deactivateBtn.disabled = false;
1269
2096
  if (response.ok) {
1270
2097
  renderQuota(response.quota);
1271
- setStatus("License removed from this device");
1272
- }
2098
+ setStatus("Device released. Its license slot is now available.");
2099
+ } else setStatus(response.error || "Could not release this device", true);
1273
2100
  }
1274
2101
  __name(deactivateLicense, "deactivateLicense");
2102
+ async function recheckLicense() {
2103
+ el.recheckLicense.disabled = true;
2104
+ setStatus("Checking your Pro license\u2026");
2105
+ const response = await send({
2106
+ type: `${prefix}_REFRESH_LICENSE`
2107
+ });
2108
+ el.recheckLicense.disabled = false;
2109
+ if (!response.ok) return setStatus(response.error || "Could not check your license", true);
2110
+ renderQuota(response.quota);
2111
+ setStatus(response.quota.pro ? "Pro confirmed \u2014 unlimited exports." : response.quota.licenseMessage || "Enter your original activation code to restore Pro in this extension.", !response.quota.pro);
2112
+ }
2113
+ __name(recheckLicense, "recheckLicense");
1275
2114
  function parseUrls() {
1276
2115
  return normalizeInputUrls(el.urls.value.split(/\s+/));
1277
2116
  }
@@ -1289,7 +2128,6 @@ function initPopup(config) {
1289
2128
  if (!/^https?:$/.test(url.protocol)) {
1290
2129
  continue;
1291
2130
  }
1292
- url.hash = "";
1293
2131
  const normalized = url.toString();
1294
2132
  if (!seen.has(normalized)) {
1295
2133
  seen.add(normalized);
@@ -1334,35 +2172,80 @@ function initPopup(config) {
1334
2172
  }
1335
2173
  __name(addUrlsToQueue, "addUrlsToQueue");
1336
2174
  async function scanCurrentSite() {
1337
- setStatus("Scanning current tab\u2026");
2175
+ if (scanJob) {
2176
+ const stopped = scanJob;
2177
+ scanJob = null;
2178
+ await chrome.scripting.executeScript({
2179
+ target: {
2180
+ tabId: stopped.tabId
2181
+ },
2182
+ func: collectSameOriginLinks,
2183
+ args: [
2184
+ {
2185
+ phase: "cancel",
2186
+ id: stopped.id
2187
+ }
2188
+ ]
2189
+ }).catch(() => {
2190
+ });
2191
+ el.scanSite.textContent = "\u2316 Scan site";
2192
+ setStatus("Scan stopped; imported links are kept");
2193
+ return;
2194
+ }
2195
+ if (queueLocked || operationBusy) return;
2196
+ let job;
1338
2197
  try {
1339
2198
  const [tab] = await chrome.tabs.query({
1340
2199
  active: true,
1341
2200
  currentWindow: true
1342
2201
  });
1343
- if (!tab?.id) {
1344
- throw new Error("No active tab");
1345
- }
1346
- const [result] = await chrome.scripting.executeScript({
2202
+ if (!tab?.id || !/^https?:\/\//i.test(tab.url || "")) throw new Error("Open an http(s) website first");
2203
+ job = {
2204
+ tabId: tab.id,
2205
+ id: crypto.randomUUID()
2206
+ };
2207
+ scanJob = job;
2208
+ el.scanSite.textContent = "Stop scan";
2209
+ const [links] = await chrome.scripting.executeScript({
1347
2210
  target: {
1348
2211
  tabId: tab.id
1349
2212
  },
1350
- func: collectSameOriginLinks
2213
+ func: collectSameOriginLinks,
2214
+ args: [
2215
+ {
2216
+ phase: "links"
2217
+ }
2218
+ ]
1351
2219
  });
1352
- const scan = result?.result || {
1353
- urls: [],
1354
- sitemapCount: 0
1355
- };
1356
- const found = scan.urls || [];
1357
- if (!found.length) {
1358
- setStatus("No same-site links found on this page", true);
2220
+ if (scanJob !== job) return;
2221
+ const added = addUrlsToQueue(links?.result?.urls || []);
2222
+ setStatus(`Added ${added} page links; checking sitemap\u2026`);
2223
+ const [maps] = await chrome.scripting.executeScript({
2224
+ target: {
2225
+ tabId: tab.id
2226
+ },
2227
+ func: collectSameOriginLinks,
2228
+ args: [
2229
+ {
2230
+ phase: "sitemap",
2231
+ id: job.id
2232
+ }
2233
+ ]
2234
+ });
2235
+ if (scanJob !== job) return;
2236
+ if (queueLocked || operationBusy) {
2237
+ setStatus("Scan stopped when the task started; page links are kept");
1359
2238
  return;
1360
2239
  }
1361
- const added = addUrlsToQueue(found);
1362
- const sitemapNote = scan.sitemapCount ? `, including ${scan.sitemapCount} from sitemap` : "";
1363
- setStatus(`Found ${found.length} same-site URLs${sitemapNote}; added ${added} new`);
2240
+ const extra = addUrlsToQueue(maps?.result?.urls || []);
2241
+ setStatus(`Added ${added + extra} URLs${maps?.result?.incomplete ? "; some sitemaps could not be read" : ""}`);
1364
2242
  } catch (error) {
1365
2243
  setStatus(errText(error), true);
2244
+ } finally {
2245
+ if (scanJob === job) {
2246
+ scanJob = null;
2247
+ el.scanSite.textContent = "\u2316 Scan site";
2248
+ }
1366
2249
  }
1367
2250
  }
1368
2251
  __name(scanCurrentSite, "scanCurrentSite");
@@ -1372,14 +2255,24 @@ function initPopup(config) {
1372
2255
  setStatus("Select at least one URL from the queue", true);
1373
2256
  return;
1374
2257
  }
1375
- el.start.disabled = true;
2258
+ operationBusy = true;
2259
+ renderQueue();
1376
2260
  setStatus("Starting\u2026");
1377
- const response = await send({
1378
- type: `${prefix}_START`,
1379
- urls,
1380
- settings: config.collectSettings()
1381
- });
1382
- el.start.disabled = false;
2261
+ let response;
2262
+ try {
2263
+ response = await send({
2264
+ type: `${prefix}_START`,
2265
+ urls,
2266
+ settings: await config.collectSettings()
2267
+ });
2268
+ } catch (error) {
2269
+ response = {
2270
+ ok: false,
2271
+ error: errText(error)
2272
+ };
2273
+ }
2274
+ operationBusy = false;
2275
+ renderQueue();
1383
2276
  if (!response.ok) {
1384
2277
  setStatus(response.error || "Failed to start", true);
1385
2278
  return;
@@ -1399,15 +2292,25 @@ function initPopup(config) {
1399
2292
  setStatus("Open an http(s) website first", true);
1400
2293
  return;
1401
2294
  }
1402
- el.captureCurrent.disabled = true;
1403
- el.start.disabled = true;
2295
+ operationBusy = true;
2296
+ renderQueue();
2297
+ el.cancel.classList.remove("hidden");
1404
2298
  setStatus(config.texts.quickIng);
1405
- const response = await send({
1406
- type: `${prefix}_${config.quickType}`,
1407
- tabId: tab.id,
1408
- settings: config.collectQuickSettings()
1409
- });
1410
- el.captureCurrent.disabled = false;
2299
+ let response;
2300
+ try {
2301
+ response = await send({
2302
+ type: `${prefix}_${config.quickType}`,
2303
+ tabId: tab.id,
2304
+ settings: await config.collectQuickSettings()
2305
+ });
2306
+ } catch (error) {
2307
+ response = {
2308
+ ok: false,
2309
+ error: errText(error)
2310
+ };
2311
+ }
2312
+ operationBusy = false;
2313
+ await refreshState();
1411
2314
  renderQueue();
1412
2315
  if (!response.ok || !response.result) {
1413
2316
  setStatus(response.error || config.texts.quickFailed, true);
@@ -1436,6 +2339,7 @@ function initPopup(config) {
1436
2339
  }
1437
2340
  renderBatch(response.batch);
1438
2341
  setStatus("Batch results cleared");
2342
+ renderQuota(response.quota);
1439
2343
  }
1440
2344
  __name(clearBatchResults, "clearBatchResults");
1441
2345
  async function refreshState() {
@@ -1447,45 +2351,46 @@ function initPopup(config) {
1447
2351
  }
1448
2352
  renderQuota(response.quota);
1449
2353
  renderBatch(response.batch);
1450
- if (response.batch?.running) {
2354
+ if (response.batch?.running || response.batch?.busy || response.quota?.licenseRefreshing) {
1451
2355
  schedulePoll();
1452
2356
  }
1453
2357
  }
1454
2358
  __name(refreshState, "refreshState");
1455
2359
  function schedulePoll() {
1456
- if (pollTimer) {
1457
- clearInterval(pollTimer);
1458
- }
1459
- pollTimer = setInterval(async () => {
2360
+ if (disposed) return;
2361
+ clearTimeout(pollTimer);
2362
+ const generation = ++pollGeneration;
2363
+ async function poll() {
1460
2364
  const response = await send({
1461
2365
  type: `${prefix}_STATE`
1462
2366
  });
1463
- if (!response.ok) {
1464
- return;
2367
+ if (disposed || generation !== pollGeneration) return;
2368
+ if (response.ok) {
2369
+ renderQuota(response.quota);
2370
+ renderBatch(response.batch);
1465
2371
  }
1466
- renderQuota(response.quota);
1467
- renderBatch(response.batch);
1468
- if (!response.batch?.running && pollTimer) {
1469
- clearInterval(pollTimer);
1470
- }
1471
- }, 800);
2372
+ if (!response.ok || response.batch?.running || response.batch?.busy || response.quota?.licenseRefreshing) pollTimer = setTimeout(poll, 800);
2373
+ }
2374
+ __name(poll, "poll");
2375
+ pollTimer = setTimeout(poll, 800);
1472
2376
  }
1473
2377
  __name(schedulePoll, "schedulePoll");
1474
2378
  function renderQuota(quota) {
1475
2379
  if (!quota) {
1476
2380
  return;
1477
2381
  }
2382
+ if (el.licenseHint) el.licenseHint.textContent = quota.licenseMessage || (quota.pro ? "Your Pro license includes unlimited exports." : "Already purchased? Enter your original activation code to restore Pro in this extension and browser.");
2383
+ el.recheckLicense?.classList.toggle("hidden", !quota.hasLicense);
2384
+ el.proLink.classList.toggle("hidden", quota.pro || quota.hasLicense);
2385
+ el.deactivateBtn.classList.toggle("hidden", !quota.hasLicense);
2386
+ el.activateBtn.classList.toggle("hidden", quota.pro);
1478
2387
  if (quota.pro) {
1479
2388
  const until = quota.expiresAt ? ` \xB7 ${String(quota.expiresAt).slice(0, 10)}` : "";
1480
2389
  el.quotaText.textContent = `unlimited${until}`;
1481
- el.proLink.classList.add("hidden");
1482
- el.deactivateBtn.classList.remove("hidden");
1483
- el.activateBtn.classList.add("hidden");
2390
+ } else if (quota.hasLicense) {
2391
+ el.quotaText.textContent = quota.licenseStatus === "checking" ? "Checking Pro\u2026" : "Pro needs verification";
1484
2392
  } else {
1485
2393
  el.quotaText.textContent = `${quota.remaining}/${quota.limit} today`;
1486
- el.proLink.classList.remove("hidden");
1487
- el.deactivateBtn.classList.add("hidden");
1488
- el.activateBtn.classList.remove("hidden");
1489
2394
  }
1490
2395
  }
1491
2396
  __name(renderQuota, "renderQuota");
@@ -1493,13 +2398,15 @@ function initPopup(config) {
1493
2398
  if (!batch) {
1494
2399
  return;
1495
2400
  }
1496
- el.start.classList.toggle("hidden", batch.running);
1497
- el.captureCurrent.classList.toggle("hidden", batch.running);
1498
- el.cancel.classList.toggle("hidden", !batch.running);
1499
- queueLocked = batch.running;
1500
- renderQueue();
2401
+ const busy = batch.running || batch.busy;
2402
+ const stalled = Boolean(batch.stalled);
2403
+ el.start.classList.toggle("hidden", busy);
2404
+ el.captureCurrent.classList.toggle("hidden", busy);
2405
+ el.cancel.classList.toggle("hidden", !busy);
2406
+ queueLocked = busy && !stalled;
2407
+ renderQueue(true);
1501
2408
  el.progressWrap.classList.toggle("hidden", !batch.total);
1502
- el.clearBatch.classList.toggle("hidden", batch.running || !batch.total);
2409
+ el.clearBatch.classList.toggle("hidden", busy && !stalled || !batch.total);
1503
2410
  if (batch.total) {
1504
2411
  el.progressText.textContent = `${batch.done} / ${batch.total}`;
1505
2412
  el.progressCurrent.textContent = batch.current || "";
@@ -1519,6 +2426,8 @@ function initPopup(config) {
1519
2426
  state.textContent = {
1520
2427
  pending: "\xB7",
1521
2428
  running: "\u2026",
2429
+ downloading: "\u2193",
2430
+ cancelled: "\u2013",
1522
2431
  success: "\u2713",
1523
2432
  failed: "\u2717"
1524
2433
  }[item.status] || "\xB7";
@@ -1540,7 +2449,7 @@ function initPopup(config) {
1540
2449
  }
1541
2450
  el.resultList.append(rowEl);
1542
2451
  }
1543
- if (batch.folder && batch.done > 0) {
2452
+ if (batch.folder && batch.success > 0) {
1544
2453
  el.folderNote.classList.remove("hidden");
1545
2454
  el.folderNote.textContent = `Saved to Downloads/${batch.folder}/`;
1546
2455
  } else {
@@ -1549,6 +2458,35 @@ function initPopup(config) {
1549
2458
  }
1550
2459
  }
1551
2460
  __name(renderBatch, "renderBatch");
2461
+ async function loadSettings() {
2462
+ const data = await chrome.storage.local.get(settingsKey);
2463
+ const saved = data[settingsKey];
2464
+ if (!saved || typeof saved !== "object") return;
2465
+ settingFields().forEach((field) => {
2466
+ const value = String(saved[field.id]);
2467
+ if (field.tagName === "SELECT" && [
2468
+ ...field.options
2469
+ ].some((option) => option.value === value)) field.value = value;
2470
+ if (field.type === "number" && value.trim() && Number.isFinite(Number(value))) {
2471
+ const fallback = field.value;
2472
+ field.value = value;
2473
+ if (!field.checkValidity()) field.value = fallback;
2474
+ }
2475
+ savedSettings[field.id] = field.value;
2476
+ });
2477
+ }
2478
+ __name(loadSettings, "loadSettings");
2479
+ function saveSettings() {
2480
+ settingFields().forEach((field) => {
2481
+ if (field.type !== "number" || field.value.trim() && Number.isFinite(field.valueAsNumber) && field.checkValidity()) savedSettings[field.id] = field.value;
2482
+ });
2483
+ void chrome.storage.local.set({
2484
+ [settingsKey]: {
2485
+ ...savedSettings
2486
+ }
2487
+ }).catch((error) => setStatus(errText(error), true));
2488
+ }
2489
+ __name(saveSettings, "saveSettings");
1552
2490
  async function loadQueue() {
1553
2491
  const data = await chrome.storage.local.get(config.queueKey);
1554
2492
  const saved = Array.isArray(data[config.queueKey]) ? data[config.queueKey] : [];
@@ -1561,20 +2499,23 @@ function initPopup(config) {
1561
2499
  function saveQueue() {
1562
2500
  void chrome.storage.local.set({
1563
2501
  [config.queueKey]: queue
1564
- });
2502
+ }).catch((error) => setStatus(errText(error), true));
1565
2503
  }
1566
2504
  __name(saveQueue, "saveQueue");
1567
- function renderQueue() {
2505
+ function renderQueue(preserveEditor = false) {
2506
+ if (queueLocked || operationBusy) editingIndex = -1;
2507
+ if (editingIndex >= queue.length) editingIndex = -1;
1568
2508
  const selected = queue.filter((item) => item.selected).length;
1569
2509
  el.queueSummary.textContent = `${selected}/${queue.length} selected`;
1570
2510
  el.start.textContent = selected ? config.texts.startSome(selected) : config.texts.startNone;
1571
- el.start.disabled = selected === 0;
1572
- el.selectAll.disabled = queueLocked || !queue.length;
1573
- el.selectNone.disabled = queueLocked || !queue.length;
1574
- el.moreQueueActions.disabled = queueLocked || !queue.length;
1575
- el.addUrls.disabled = queueLocked;
1576
- el.scanSite.disabled = queueLocked;
1577
- el.captureCurrent.disabled = queueLocked;
2511
+ el.start.disabled = operationBusy || queueLocked || selected === 0;
2512
+ el.selectAll.disabled = queueLocked || operationBusy || !queue.length;
2513
+ el.selectNone.disabled = queueLocked || operationBusy || !queue.length;
2514
+ el.moreQueueActions.disabled = queueLocked || operationBusy || !queue.length;
2515
+ el.addUrls.disabled = queueLocked || operationBusy;
2516
+ el.scanSite.disabled = queueLocked || operationBusy;
2517
+ el.captureCurrent.disabled = queueLocked || operationBusy;
2518
+ if (preserveEditor && editingIndex >= 0 && el.queueList.querySelector(".queueEdit")) return;
1578
2519
  el.queueList.replaceChildren();
1579
2520
  if (!queue.length) {
1580
2521
  const empty = document.createElement("p");
@@ -1589,28 +2530,23 @@ function initPopup(config) {
1589
2530
  const checkbox = document.createElement("input");
1590
2531
  checkbox.type = "checkbox";
1591
2532
  checkbox.checked = item.selected;
1592
- checkbox.disabled = queueLocked;
2533
+ checkbox.disabled = queueLocked || operationBusy;
1593
2534
  checkbox.setAttribute("aria-label", `Include ${item.url}`);
1594
2535
  checkbox.addEventListener("change", () => {
1595
2536
  item.selected = checkbox.checked;
1596
2537
  saveQueue();
1597
2538
  renderQueue();
1598
2539
  });
1599
- const url = document.createElement("button");
1600
- url.type = "button";
1601
- url.className = "queueUrl";
1602
- url.textContent = item.url.replace(/^https?:\/\//, "");
1603
- url.title = item.url;
1604
- url.disabled = queueLocked;
1605
- url.addEventListener("click", () => editQueueItem(index));
2540
+ const url = index === editingIndex ? buildQueueEditor(item, index) : buildQueueLabel(item, index);
1606
2541
  const remove = document.createElement("button");
1607
2542
  remove.type = "button";
1608
2543
  remove.className = "queueRemove";
1609
2544
  remove.textContent = "\xD7";
1610
2545
  remove.title = "Remove URL";
1611
- remove.disabled = queueLocked;
2546
+ remove.disabled = queueLocked || operationBusy;
1612
2547
  remove.addEventListener("click", () => {
1613
2548
  queue.splice(index, 1);
2549
+ editingIndex = -1;
1614
2550
  saveQueue();
1615
2551
  renderQueue();
1616
2552
  });
@@ -1619,30 +2555,82 @@ function initPopup(config) {
1619
2555
  });
1620
2556
  }
1621
2557
  __name(renderQueue, "renderQueue");
1622
- function editQueueItem(index) {
1623
- const current = queue[index];
1624
- const value = prompt("Edit URL", current.url);
1625
- if (value === null) {
1626
- return;
1627
- }
1628
- const [normalized] = normalizeInputUrls([
1629
- value
1630
- ]);
1631
- if (!normalized) {
1632
- setStatus("Enter a valid http(s) URL", true);
1633
- return;
1634
- }
1635
- if (queue.some((item, itemIndex) => itemIndex !== index && item.url === normalized)) {
1636
- setStatus("That URL is already in the queue", true);
1637
- return;
1638
- }
1639
- current.url = normalized;
1640
- saveQueue();
1641
- renderQueue();
2558
+ function buildQueueLabel(item, index) {
2559
+ const url = document.createElement("button");
2560
+ url.type = "button";
2561
+ url.className = "queueUrl";
2562
+ url.textContent = item.url.replace(/^https?:\/\//, "");
2563
+ url.title = `${item.url}
2564
+ Click to edit`;
2565
+ url.disabled = queueLocked || operationBusy;
2566
+ url.addEventListener("click", () => {
2567
+ if (queueLocked || operationBusy) return;
2568
+ editingIndex = index;
2569
+ renderQueue();
2570
+ });
2571
+ return url;
2572
+ }
2573
+ __name(buildQueueLabel, "buildQueueLabel");
2574
+ function buildQueueEditor(item, index) {
2575
+ const input = document.createElement("input");
2576
+ input.type = "text";
2577
+ input.className = "queueEdit";
2578
+ input.value = item.url;
2579
+ input.spellcheck = false;
2580
+ input.setAttribute("aria-label", `Edit ${item.url}`);
2581
+ const leaveEditor = /* @__PURE__ */ __name(() => {
2582
+ editingIndex = -1;
2583
+ const label = buildQueueLabel(item, index);
2584
+ input.parentElement?.querySelector('input[type="checkbox"]')?.setAttribute("aria-label", `Include ${item.url}`);
2585
+ input.replaceWith(label);
2586
+ return label;
2587
+ }, "leaveEditor");
2588
+ const finish = /* @__PURE__ */ __name((fromBlur) => {
2589
+ if (editingIndex !== index) return;
2590
+ const [normalized] = normalizeInputUrls([
2591
+ input.value
2592
+ ]);
2593
+ const duplicate = normalized && queue.some((other, otherIndex) => otherIndex !== index && other.url === normalized);
2594
+ if (!normalized || duplicate) {
2595
+ const message = duplicate ? "That URL is already in the queue" : "Enter a valid http(s) URL";
2596
+ if (fromBlur) {
2597
+ setStatus(`${message} \u2014 edit discarded`, true);
2598
+ leaveEditor();
2599
+ } else {
2600
+ setStatus(message, true);
2601
+ input.focus();
2602
+ input.select();
2603
+ }
2604
+ return;
2605
+ }
2606
+ if (normalized !== item.url) {
2607
+ item.url = normalized;
2608
+ saveQueue();
2609
+ setStatus("URL updated");
2610
+ }
2611
+ const label = leaveEditor();
2612
+ if (!fromBlur) label.focus();
2613
+ }, "finish");
2614
+ input.addEventListener("keydown", (event) => {
2615
+ if (event.key === "Enter") {
2616
+ event.preventDefault();
2617
+ finish(false);
2618
+ } else if (event.key === "Escape") {
2619
+ event.preventDefault();
2620
+ event.stopPropagation();
2621
+ leaveEditor().focus();
2622
+ }
2623
+ });
2624
+ input.addEventListener("blur", () => finish(true));
2625
+ queueMicrotask(() => {
2626
+ input.focus();
2627
+ input.select();
2628
+ });
2629
+ return input;
1642
2630
  }
1643
- __name(editQueueItem, "editQueueItem");
2631
+ __name(buildQueueEditor, "buildQueueEditor");
1644
2632
  function setAllSelected(selected) {
1645
- if (queueLocked) {
2633
+ if (queueLocked || operationBusy) {
1646
2634
  return;
1647
2635
  }
1648
2636
  queue.forEach((item) => {
@@ -1653,15 +2641,19 @@ function initPopup(config) {
1653
2641
  }
1654
2642
  __name(setAllSelected, "setAllSelected");
1655
2643
  function removeUnselected() {
2644
+ if (queueLocked || operationBusy) return;
1656
2645
  queue = queue.filter((item) => item.selected);
1657
- el.queueMenu.classList.add("hidden");
2646
+ editingIndex = -1;
2647
+ setQueueMenu(false);
1658
2648
  saveQueue();
1659
2649
  renderQueue();
1660
2650
  }
1661
2651
  __name(removeUnselected, "removeUnselected");
1662
2652
  function clearQueue() {
2653
+ if (queueLocked || operationBusy) return;
1663
2654
  queue = [];
1664
- el.queueMenu.classList.add("hidden");
2655
+ editingIndex = -1;
2656
+ setQueueMenu(false);
1665
2657
  saveQueue();
1666
2658
  renderQueue();
1667
2659
  }
@@ -1682,6 +2674,7 @@ function initPopup(config) {
1682
2674
  }
1683
2675
  __name(send, "send");
1684
2676
  function setStatus(text, isError = false) {
2677
+ if (disposed) return;
1685
2678
  el.status.textContent = text;
1686
2679
  el.status.classList.toggle("error", isError);
1687
2680
  }
@@ -1692,90 +2685,210 @@ function initPopup(config) {
1692
2685
  __name(errText, "errText");
1693
2686
  }
1694
2687
  __name(initPopup, "initPopup");
1695
- async function collectSameOriginLinks() {
1696
- const seen = /* @__PURE__ */ new Set();
1697
- const out = [];
1698
- const origin = location.origin;
1699
- let sitemapCount = 0;
2688
+ async function collectSameOriginLinks(options = {}) {
2689
+ const origin = location.origin, seen = /* @__PURE__ */ new Set(), urls = [];
1700
2690
  const add = /* @__PURE__ */ __name((value) => {
1701
2691
  try {
1702
2692
  const url = new URL(value || "", location.href);
1703
- url.hash = "";
1704
- if (url.origin === origin && /^https?:$/.test(url.protocol) && !seen.has(url.toString()) && out.length < 500) {
1705
- seen.add(url.toString());
1706
- out.push(url.toString());
2693
+ if (url.origin === origin && /^https?:$/.test(url.protocol) && !seen.has(url.href) && urls.length < 500) {
2694
+ seen.add(url.href);
2695
+ urls.push(url.href);
1707
2696
  return true;
1708
2697
  }
1709
2698
  } catch {
1710
2699
  }
1711
2700
  return false;
1712
2701
  }, "add");
1713
- add(location.href);
1714
- for (const a of document.querySelectorAll("a[href]")) {
1715
- add(a.getAttribute("href"));
1716
- if (out.length >= 500) {
1717
- break;
2702
+ if (options.phase === "cancel") {
2703
+ const active = globalThis.__zuckerExtScan;
2704
+ if (active?.id === options.id) {
2705
+ active.cancelled = true;
2706
+ active.controller?.abort();
1718
2707
  }
2708
+ return {
2709
+ urls: [],
2710
+ cancelled: true
2711
+ };
1719
2712
  }
1720
- const sitemapQueue = [
1721
- new URL("/sitemap.xml", origin).toString()
1722
- ];
1723
- const visitedSitemaps = /* @__PURE__ */ new Set();
1724
- while (sitemapQueue.length && visitedSitemaps.size < 8 && out.length < 500) {
1725
- const sitemapUrl = sitemapQueue.shift();
1726
- if (visitedSitemaps.has(sitemapUrl)) {
1727
- continue;
2713
+ if (options.phase !== "sitemap") {
2714
+ add(location.href);
2715
+ for (const a of document.querySelectorAll("a[href]")) add(a.getAttribute("href"));
2716
+ }
2717
+ if (options.phase === "links") return {
2718
+ urls,
2719
+ sitemapCount: 0
2720
+ };
2721
+ const scan = {
2722
+ id: options.id,
2723
+ cancelled: false,
2724
+ controller: null
2725
+ };
2726
+ globalThis.__zuckerExtScan?.controller?.abort();
2727
+ globalThis.__zuckerExtScan = scan;
2728
+ const deadline = Date.now() + 6e3;
2729
+ let incomplete = false, sitemapCount = 0;
2730
+ const queue = [
2731
+ new URL("/sitemap.xml", origin).href
2732
+ ], visited = /* @__PURE__ */ new Set();
2733
+ const sameOrigin = /* @__PURE__ */ __name((value) => {
2734
+ try {
2735
+ const url = new URL(value, origin);
2736
+ return url.origin === origin && /^https?:$/.test(url.protocol) ? url.href : null;
2737
+ } catch {
2738
+ return null;
2739
+ }
2740
+ }, "sameOrigin");
2741
+ async function read(url) {
2742
+ if (scan.cancelled || Date.now() >= deadline) {
2743
+ incomplete = true;
2744
+ return null;
1728
2745
  }
1729
- visitedSitemaps.add(sitemapUrl);
2746
+ const controller = new AbortController();
2747
+ scan.controller = controller;
2748
+ const timer = setTimeout(() => controller.abort(), Math.min(1500, deadline - Date.now()));
1730
2749
  try {
1731
- const response = await fetch(sitemapUrl, {
1732
- credentials: "same-origin"
2750
+ const response = await fetch(url, {
2751
+ credentials: "same-origin",
2752
+ signal: controller.signal
1733
2753
  });
1734
- if (!response.ok) {
1735
- continue;
1736
- }
1737
- const xml = new DOMParser().parseFromString(await response.text(), "application/xml");
1738
- for (const loc of xml.querySelectorAll("loc")) {
1739
- const value = loc.textContent?.trim();
1740
- if (!value) {
1741
- continue;
1742
- }
1743
- if (/\.xml(?:\.gz)?(?:$|\?)/i.test(value)) {
1744
- try {
1745
- const nested = new URL(value, sitemapUrl);
1746
- if (nested.origin === origin && !visitedSitemaps.has(nested.toString())) {
1747
- sitemapQueue.push(nested.toString());
1748
- }
1749
- } catch {
1750
- }
1751
- } else if (add(value)) {
1752
- sitemapCount += 1;
2754
+ if (!response.ok) return null;
2755
+ if (Number(response.headers.get("content-length")) > 2 * 1024 * 1024) throw new Error("Sitemap too large");
2756
+ let stream = response.body;
2757
+ const reader = stream.getReader();
2758
+ const first = await reader.read();
2759
+ const gzip = first.value?.[0] === 31 && first.value?.[1] === 139;
2760
+ stream = new ReadableStream({
2761
+ start(ctrl) {
2762
+ if (first.value) ctrl.enqueue(first.value);
2763
+ if (first.done) ctrl.close();
2764
+ },
2765
+ async pull(ctrl) {
2766
+ const value = await reader.read();
2767
+ value.done ? ctrl.close() : ctrl.enqueue(value.value);
2768
+ },
2769
+ cancel(reason) {
2770
+ return reader.cancel(reason);
1753
2771
  }
1754
- if (out.length >= 500) {
1755
- break;
2772
+ });
2773
+ if (gzip) stream = stream.pipeThrough(new DecompressionStream("gzip"));
2774
+ const decoded = stream.getReader(), chunks = [];
2775
+ let size = 0;
2776
+ try {
2777
+ while (true) {
2778
+ const next = await decoded.read();
2779
+ if (next.done) break;
2780
+ size += next.value.length;
2781
+ if (size > 2 * 1024 * 1024) throw new Error("Sitemap too large");
2782
+ chunks.push(next.value);
1756
2783
  }
2784
+ } finally {
2785
+ await decoded.cancel().catch(() => {
2786
+ });
1757
2787
  }
2788
+ return await new Blob(chunks).text();
1758
2789
  } catch {
2790
+ incomplete = true;
2791
+ return null;
2792
+ } finally {
2793
+ clearTimeout(timer);
2794
+ }
2795
+ }
2796
+ __name(read, "read");
2797
+ const robots = await read(new URL("/robots.txt", origin).href);
2798
+ for (const match of (robots || "").matchAll(/^sitemap:\s*(\S+)/gim)) {
2799
+ const url = sameOrigin(match[1]);
2800
+ if (url) queue.push(url);
2801
+ }
2802
+ while (queue.length && visited.size < 8 && urls.length < 500 && !scan.cancelled && Date.now() < deadline) {
2803
+ const url = queue.shift();
2804
+ if (visited.has(url)) continue;
2805
+ visited.add(url);
2806
+ const content = await read(url);
2807
+ if (!content) continue;
2808
+ const xml = new DOMParser().parseFromString(content, "application/xml");
2809
+ if (xml.querySelector("parsererror")) {
2810
+ incomplete = true;
2811
+ continue;
2812
+ }
2813
+ const index = xml.documentElement.localName === "sitemapindex";
2814
+ for (const loc of xml.querySelectorAll("loc")) {
2815
+ const value = sameOrigin(loc.textContent?.trim());
2816
+ if (!value) continue;
2817
+ if (index) {
2818
+ if (!visited.has(value)) queue.push(value);
2819
+ } else if (add(value)) sitemapCount++;
1759
2820
  }
1760
2821
  }
2822
+ if (queue.length) incomplete = true;
2823
+ if (globalThis.__zuckerExtScan === scan) delete globalThis.__zuckerExtScan;
1761
2824
  return {
1762
- urls: out,
1763
- sitemapCount
2825
+ urls,
2826
+ sitemapCount,
2827
+ incomplete,
2828
+ cancelled: scan.cancelled
1764
2829
  };
1765
2830
  }
1766
2831
  __name(collectSameOriginLinks, "collectSameOriginLinks");
2832
+
2833
+ // src/capture-budget.js
2834
+ var CAPTURE_LIMITS = {
2835
+ maxEdgeDevicePx: 16384,
2836
+ maxDevicePixels: 64 * 1024 * 1024
2837
+ };
2838
+ function captureScale(dpr) {
2839
+ return Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
2840
+ }
2841
+ __name(captureScale, "captureScale");
2842
+ function captureFits(width, height, dpr) {
2843
+ const scale = captureScale(dpr);
2844
+ return [
2845
+ width,
2846
+ height
2847
+ ].every((n) => Number.isFinite(n) && n > 0) && width * scale <= CAPTURE_LIMITS.maxEdgeDevicePx && height * scale <= CAPTURE_LIMITS.maxEdgeDevicePx && width * height * scale ** 2 <= CAPTURE_LIMITS.maxDevicePixels;
2848
+ }
2849
+ __name(captureFits, "captureFits");
2850
+ function assertCaptureFits(width, height, dpr, message) {
2851
+ if (!captureFits(width, height, dpr)) throw new Error(message);
2852
+ }
2853
+ __name(assertCaptureFits, "assertCaptureFits");
2854
+ function captureBudget(dpr) {
2855
+ const scale = captureScale(dpr);
2856
+ return {
2857
+ maxEdge: Math.floor(CAPTURE_LIMITS.maxEdgeDevicePx / scale),
2858
+ maxArea: Math.floor(CAPTURE_LIMITS.maxDevicePixels / scale ** 2)
2859
+ };
2860
+ }
2861
+ __name(captureBudget, "captureBudget");
2862
+ async function readDevicePixelRatio(ctx) {
2863
+ const result = await ctx.send("Runtime.evaluate", {
2864
+ expression: "devicePixelRatio || 1",
2865
+ returnByValue: true
2866
+ });
2867
+ return captureScale(result?.result?.value);
2868
+ }
2869
+ __name(readDevicePixelRatio, "readDevicePixelRatio");
1767
2870
  export {
2871
+ CAPTURE_LIMITS,
2872
+ assertCaptureFits,
1768
2873
  buildBatchFileName,
1769
2874
  buildQuickFileName,
2875
+ captureBudget,
2876
+ captureFits,
2877
+ collectSameOriginLinks,
2878
+ createDownloader,
1770
2879
  createLicensing,
1771
2880
  fileStamp,
1772
2881
  folderNameFor,
2882
+ hidePageCaptureDecorations,
1773
2883
  hostOf,
1774
2884
  initExtension,
1775
2885
  initPopup,
1776
2886
  normalizeUrls,
2887
+ readDevicePixelRatio,
1777
2888
  sanitize,
2889
+ scroll_capture_exports as scrollCapture,
1778
2890
  sessionStamp,
2891
+ showPageCaptureDecorations,
1779
2892
  todayString,
1780
2893
  urlBaseName
1781
2894
  };