@zucker-framework/ext-kit 1.0.2 → 1.0.6

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