@nitpicker/crawler 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +15 -0
  2. package/lib/append-retry-failed-common-setup-phases.js +21 -0
  3. package/lib/append-setup-phases.d.ts +17 -0
  4. package/lib/append-setup-phases.js +22 -0
  5. package/lib/archive/archive-accessor.d.ts +31 -5
  6. package/lib/archive/archive-accessor.js +46 -24
  7. package/lib/archive/archive.d.ts +115 -37
  8. package/lib/archive/archive.js +118 -52
  9. package/lib/archive/cache/extract-archive-to-cache.d.ts +10 -1
  10. package/lib/archive/cache/extract-archive-to-cache.js +14 -4
  11. package/lib/archive/create-adjunct-tables.d.ts +12 -2
  12. package/lib/archive/create-adjunct-tables.js +91 -29
  13. package/lib/archive/create-entity-tables.d.ts +5 -1
  14. package/lib/archive/create-entity-tables.js +6 -1
  15. package/lib/archive/database.d.ts +39 -9
  16. package/lib/archive/database.js +63 -18
  17. package/lib/archive/db-ops/lifecycle/init.d.ts +14 -2
  18. package/lib/archive/db-ops/lifecycle/init.js +29 -11
  19. package/lib/archive/db-ops/meta/get-custom-elements-of-page.d.ts +11 -0
  20. package/lib/archive/db-ops/meta/get-custom-elements-of-page.js +15 -0
  21. package/lib/archive/db-ops/meta/get-page-technologies-of-page.d.ts +11 -0
  22. package/lib/archive/db-ops/meta/get-page-technologies-of-page.js +15 -0
  23. package/lib/archive/db-ops/meta/get-technology-signals-of-page.d.ts +11 -0
  24. package/lib/archive/db-ops/meta/get-technology-signals-of-page.js +15 -0
  25. package/lib/archive/db-ops/pages/order/set-url-order.d.ts +6 -1
  26. package/lib/archive/db-ops/pages/order/set-url-order.js +7 -1
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +6 -1
  29. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +12 -3
  30. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +6 -1
  31. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +10 -2
  32. package/lib/archive/db-ops/pages/write/insert-custom-elements.d.ts +27 -0
  33. package/lib/archive/db-ops/pages/write/insert-custom-elements.js +40 -0
  34. package/lib/archive/db-ops/pages/write/insert-page.d.ts +24 -4
  35. package/lib/archive/db-ops/pages/write/insert-page.js +40 -4
  36. package/lib/archive/db-ops/pages/write/insert-technologies.d.ts +25 -0
  37. package/lib/archive/db-ops/pages/write/insert-technologies.js +45 -0
  38. package/lib/archive/db-ops/pages/write/link-redirect-sources.d.ts +7 -1
  39. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +8 -2
  40. package/lib/archive/db-ops/pages/write/update-page.d.ts +4 -1
  41. package/lib/archive/db-ops/pages/write/update-page.js +38 -16
  42. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
  43. package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
  44. package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
  45. package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
  46. package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
  47. package/lib/archive/filesystem/parse-pax-path.js +47 -0
  48. package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
  49. package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
  50. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
  51. package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
  52. package/lib/archive/filesystem/tar.d.ts +13 -1
  53. package/lib/archive/filesystem/tar.js +63 -7
  54. package/lib/archive/filesystem/untar.d.ts +13 -0
  55. package/lib/archive/filesystem/untar.js +68 -8
  56. package/lib/archive/init-schema.d.ts +1 -1
  57. package/lib/archive/init-schema.js +6 -4
  58. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
  59. package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
  60. package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
  61. package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
  62. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
  63. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
  64. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
  65. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
  66. package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
  67. package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
  68. package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
  69. package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
  70. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
  71. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
  72. package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
  73. package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
  74. package/lib/archive/meta/technologies/types.d.ts +68 -0
  75. package/lib/archive/meta/technologies/types.js +13 -0
  76. package/lib/archive/meta/types.d.ts +97 -30
  77. package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
  78. package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
  79. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
  80. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
  81. package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
  82. package/lib/archive/migrate-info-main-content-selector.js +14 -3
  83. package/lib/archive/migrate-info-roots.d.ts +6 -1
  84. package/lib/archive/migrate-info-roots.js +14 -3
  85. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
  86. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
  87. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
  88. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
  89. package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
  90. package/lib/archive/migrate-main-contents-columns.js +14 -3
  91. package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
  92. package/lib/archive/migrate-page-meta-body-hash.js +14 -3
  93. package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
  94. package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
  95. package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
  96. package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
  97. package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
  98. package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
  99. package/lib/archive/page.d.ts +37 -13
  100. package/lib/archive/page.js +45 -17
  101. package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
  102. package/lib/archive/retarget-legacy-fk-tables.js +61 -15
  103. package/lib/archive/types.d.ts +18 -0
  104. package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
  105. package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
  106. package/lib/crawler/capture-custom-elements.d.ts +33 -0
  107. package/lib/crawler/capture-custom-elements.js +39 -0
  108. package/lib/crawler/close-browser-safely.d.ts +3 -3
  109. package/lib/crawler/close-browser-safely.js +8 -17
  110. package/lib/crawler/collect-custom-elements.d.ts +33 -0
  111. package/lib/crawler/collect-custom-elements.js +110 -0
  112. package/lib/crawler/crawler.js +101 -46
  113. package/lib/crawler/fetch-destination.js +6 -13
  114. package/lib/crawler/find-package-dir.d.ts +24 -0
  115. package/lib/crawler/find-package-dir.js +39 -0
  116. package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
  117. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  118. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  119. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  120. package/lib/crawler/types.d.ts +23 -0
  121. package/lib/crawler-orchestrator.d.ts +53 -9
  122. package/lib/crawler-orchestrator.js +223 -46
  123. package/lib/crawler.d.ts +11 -1
  124. package/lib/crawler.js +9 -0
  125. package/lib/inventory-setup-phases.d.ts +32 -0
  126. package/lib/inventory-setup-phases.js +45 -0
  127. package/lib/resume-setup-phases.d.ts +16 -0
  128. package/lib/resume-setup-phases.js +24 -0
  129. package/lib/retry-failed-setup-phases.d.ts +19 -0
  130. package/lib/retry-failed-setup-phases.js +24 -0
  131. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  132. package/lib/setup-recovery-phase-labels.js +24 -0
  133. package/lib/types.d.ts +145 -0
  134. package/package.json +12 -9
  135. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  136. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  137. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  138. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  139. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  140. package/lib/archive/meta/summarize-tags.js +0 -33
@@ -31,16 +31,15 @@ import { safePath } from './safe-path.js';
31
31
  *
32
32
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
33
33
  * {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
34
- * The constructor is private.
34
+ * The constructor is private. Implements `Symbol.asyncDispose` (inherited
35
+ * from {@link ArchiveAccessor}) so callers can use `await using` instead of
36
+ * a manual `try`/`finally` around {@link close}.
35
37
  * @example
36
- * const archive = await Archive.create({ filePath: '/path/to/site.nitpicker' });
37
- * try {
38
- * await archive.setConfig(config);
39
- * const pageId = await archive.setPage(pageData);
40
- * } finally {
41
- * // Writes the `.nitpicker` tar (if absent), removes tmpDir, releases the lock.
42
- * await archive.close();
43
- * }
38
+ * await using archive = await Archive.create({ filePath: '/path/to/site.nitpicker' });
39
+ * await archive.setConfig(config);
40
+ * const pageId = await archive.setPage(pageData);
41
+ * // Writes the `.nitpicker` tar (if absent), removes tmpDir, releases the
42
+ * // lock — all on scope exit, whether by fallthrough or thrown error.
44
43
  */
45
44
  export default class Archive extends ArchiveAccessor {
46
45
  /**
@@ -414,22 +413,26 @@ export default class Archive extends ArchiveAccessor {
414
413
  * internal pages on the next pass.
415
414
  * @param scopes - Hostname-indexed scope map representing the new scope.
416
415
  * @param options - URL parsing options forwarded to the scope-entry lookup.
416
+ * @param onProgress - Forwarded to {@link Database.repromoteExternalPages}
417
+ * — see that method's docs.
417
418
  * @returns The URLs that were repromoted.
418
419
  */
419
- async repromoteExternalPages(scopes, options) {
420
+ async repromoteExternalPages(scopes, options, onProgress) {
420
421
  dbLog('Repromote external pages with %d hostnames in scope', scopes.size);
421
- return this.#db.repromoteExternalPages(scopes, options);
422
+ return this.#db.repromoteExternalPages(scopes, options, onProgress);
422
423
  }
423
424
  /**
424
425
  * Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
425
426
  *
426
427
  * Delegates to {@link Database.resetFailedPages}. See that method for the
427
428
  * exact failure criteria (missing status / content type, or a 5xx status).
429
+ * @param onProgress - Forwarded to {@link Database.resetFailedPages} —
430
+ * see that method's docs.
428
431
  * @returns The URLs of the pages that were reset to pending.
429
432
  */
430
- async resetFailedPages() {
433
+ async resetFailedPages(onProgress) {
431
434
  dbLog('Reset failed pages back to pending');
432
- return this.#db.resetFailedPages();
435
+ return this.#db.resetFailedPages(onProgress);
433
436
  }
434
437
  /**
435
438
  * Persists the raw bytes of an `--inventory` source URL list into the
@@ -498,11 +501,14 @@ export default class Archive extends ArchiveAccessor {
498
501
  * pages share a single `page_html_blobs` row.
499
502
  * @param pageInfo - The page data to store.
500
503
  * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
504
+ * @param bodyHash - Precomputed body hash for the page's HTML (see
505
+ * `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
506
+ * computing it from the HTML instead.
501
507
  * @returns The database ID of the stored page.
502
508
  */
503
- async setPage(pageInfo, source) {
509
+ async setPage(pageInfo, source, bodyHash) {
504
510
  dbLog('Set page: %s', pageInfo.url.href);
505
- return await this.#db.updatePage(pageInfo, true, pageInfo.isTarget, source);
511
+ return await this.#db.updatePage(pageInfo, true, pageInfo.isTarget, source, bodyHash);
506
512
  }
507
513
  /**
508
514
  * Records a redirect edge without re-storing the destination's content.
@@ -554,10 +560,12 @@ export default class Archive extends ArchiveAccessor {
554
560
  /**
555
561
  * Assigns natural URL sort order values to all pages in the database
556
562
  * that do not yet have an `order` field set.
563
+ * @param onProgress - Forwarded to {@link Database.setUrlOrder} — see that
564
+ * method's docs.
557
565
  */
558
- async setUrlOrder() {
566
+ async setUrlOrder(onProgress) {
559
567
  dbLog("Pages didn't have `order` field. So set URL order.");
560
- await this.#db.setUrlOrder();
568
+ await this.#db.setUrlOrder(onProgress);
561
569
  }
562
570
  /**
563
571
  * Updates a subset of fields on the archive's `info` row. Used by the append
@@ -584,16 +592,35 @@ export default class Archive extends ArchiveAccessor {
584
592
  * `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
585
593
  * opened without it would tar back a tmpDir missing those extra files,
586
594
  * silently dropping them from the rewritten archive.
587
- */
588
- async write() {
595
+ * @param options - Optional write settings.
596
+ * @param options.onTarProgress - Called as archive bytes are written
597
+ * during the tar step, with the bytes written so far and the estimated
598
+ * total (issue #294: tarring a large archive takes minutes, and
599
+ * without this the CLI shows nothing until `write` returns). Omit for
600
+ * a silent write (the default).
601
+ * @param options.onStep - Called once at the start of each of this
602
+ * method's four steps (issue #294): `checkpoint` (WAL fold-back —
603
+ * single synchronous PRAGMA, no countable progress) and `remove`
604
+ * (deleting the tarred-away tmpDir) have no progress signal of their
605
+ * own, so without this a large archive's write looks frozen between
606
+ * the `tar` step's byte updates and completion. `rename` is nearly
607
+ * instant (same-filesystem directory move) but included for
608
+ * completeness — a caller displaying phase labels shouldn't have a
609
+ * gap where the operation is silently between named steps.
610
+ */
611
+ async write(options) {
589
612
  saveLog('Starts: %s', this.#filePath);
613
+ options?.onStep?.('checkpoint');
590
614
  await this.#db.checkpoint();
591
615
  const filePathWithoutExt = path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
592
616
  saveLog('Rename temporary dir: %s to %s', this.#tmpDir, filePathWithoutExt);
617
+ options?.onStep?.('rename');
593
618
  await rename(this.#tmpDir, filePathWithoutExt, true);
594
619
  saveLog('Tar temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
595
- await tar(filePathWithoutExt, this.#filePath);
620
+ options?.onStep?.('tar');
621
+ await tar(filePathWithoutExt, this.#filePath, options?.onTarProgress);
596
622
  saveLog('Remove temporary dir: %s', filePathWithoutExt);
623
+ options?.onStep?.('remove');
597
624
  await remove(filePathWithoutExt);
598
625
  saveLog('Done: %s', this.#filePath);
599
626
  }
@@ -602,19 +629,25 @@ export default class Archive extends ArchiveAccessor {
602
629
  * (write or remove), drops the DB handle via the base class, then
603
630
  * releases the lock in a `finally` so the lock never leaks even on
604
631
  * partial failure.
632
+ * @param options - See {@link close}.
633
+ * @param options.timeoutMs
634
+ * @param options.onRecoveryStart
635
+ * @param options.onTarProgress
636
+ * @param options.onStep
605
637
  */
606
- async #runFullClose() {
638
+ async #runFullClose(options) {
607
639
  log('Closing');
608
640
  try {
609
641
  if (!exists(this.#filePath)) {
610
642
  log("Save the file because it doesn't exist");
611
- await this.write();
643
+ options?.onRecoveryStart?.();
644
+ await this.write(options);
612
645
  }
613
646
  else if (exists(this.#tmpDir)) {
614
647
  log('Remove temporary dir');
615
648
  await remove(this.#tmpDir);
616
649
  }
617
- await super.close();
650
+ await super.close({ timeoutMs: options?.timeoutMs });
618
651
  }
619
652
  finally {
620
653
  await this.#releaseLock();
@@ -654,27 +687,38 @@ export default class Archive extends ArchiveAccessor {
654
687
  * on the user's tmpDir.
655
688
  *
656
689
  * `options.readOnly: false` is a narrow escape hatch for opening a
657
- * second, writable connection to a `tmpDir` that {@link Archive.openCached}
658
- * already extracted (and migrated) into an OS-temp cache directory
659
- * never the caller's live/interrupted crawl tmpDir, which must stay
660
- * read-only. A read-only open (`Archive.openCached`/`ArchiveManager.open`)
661
- * must never take this path itselfblocking or writing during what
662
- * must be a read-only open is forbidden (issue #177). This escape
663
- * hatch has no current production caller; any future
664
- * one is responsible for its own cross-process coordination (see
665
- * `acquireArchiveLock`) this method does not acquire any lock itself.
690
+ * second, writable connection to a `tmpDir` the caller's own process
691
+ * already owns and extracted itself. The one production caller is the
692
+ * viewer-read-model worker thread (`@nitpicker/query`'s
693
+ * `viewer-read-model-worker-entry.ts`, issue #294): the parent thread
694
+ * holds the archive via `Archive.open` (lock included worker threads
695
+ * share the parent's PID, so the PID-based `acquireArchiveLock` guard
696
+ * stays valid), sits idle awaiting the worker, and re-tars the tmpDir
697
+ * afterward. What this hatch must NEVER target is a live/interrupted
698
+ * crawl tmpDir owned by a *different* process (the stub-mode
699
+ * `ArchiveManager.open` path attaches to exactly such directories, and
700
+ * must stay read-only): writable connects run the self-healing
701
+ * migrations, and mutating a directory out from under its owner is how
702
+ * archives corrupt. A read-only open (`Archive.openCached`/
703
+ * `ArchiveManager.open`) must never take this path itself — blocking or
704
+ * writing during what must be a read-only open is forbidden (issue
705
+ * #177). Any new caller is responsible for its own coordination with
706
+ * the tmpDir's owner (see `acquireArchiveLock` for the cross-process
707
+ * case) — this method does not acquire any lock itself.
666
708
  * @param tmpDir - The path to the temporary directory containing the database.
667
709
  * @param namespace - An optional namespace for scoping data access within the archive.
668
710
  * @param options - Connection options.
669
711
  * @param options.readOnly - Defaults to `true`. Pass `false` to obtain a
670
- * writable accessor against an already-extracted cache directory.
712
+ * writable accessor against a tmpDir the calling process itself owns.
671
713
  * @returns An ArchiveAccessor instance for querying the archive data.
672
714
  * @example
673
715
  * // Default (read-only) — safe for stub mode and cache reads:
674
716
  * const accessor = await Archive.connect(tmpDir);
675
717
  * @example
676
- * // Writable escape hatch — only against a tar-cache extraction:
677
- * const writable = await Archive.connect(cacheDir, null, { readOnly: false });
718
+ * // Writable escape hatch — only against a tmpDir this process owns
719
+ * // (e.g. the viewer-read-model worker thread reconnecting to the
720
+ * // parent's Archive.open extraction):
721
+ * const writable = await Archive.connect(ownTmpDir, null, { readOnly: false });
678
722
  */
679
723
  static async connect(tmpDir, namespace = null, options = {}) {
680
724
  const readOnly = options.readOnly ?? true;
@@ -715,23 +759,22 @@ export default class Archive extends ArchiveAccessor {
715
759
  * {@link Archive.open}.
716
760
  * @param filePath - Absolute path to the `.nitpicker` file.
717
761
  * @param namespace - Optional namespace forwarded to {@link ArchiveAccessor}.
762
+ * @param onExtractProgress - Forwarded to {@link extractArchiveToCache} —
763
+ * see that function's docs for the cache-hit/miss contract.
718
764
  * @returns A read-only {@link ArchiveAccessor} backed by the cache directory.
719
765
  * @example
720
766
  * ```ts
721
- * const accessor = await Archive.openCached('/path/to/site.nitpicker');
722
- * try {
723
- * const summary = await getSummary(accessor);
724
- * } finally {
725
- * await accessor.close(); // tears down DB handle, cacheDir persists.
726
- * }
767
+ * await using accessor = await Archive.openCached('/path/to/site.nitpicker');
768
+ * const summary = await getSummary(accessor);
769
+ * // tears down DB handle on scope exit; cacheDir persists.
727
770
  * ```
728
771
  */
729
- static async openCached(filePath, namespace = null) {
772
+ static async openCached(filePath, namespace = null, onExtractProgress) {
730
773
  const cacheRoot = getArchiveCacheRoot();
731
774
  const cacheKey = await computeArchiveCacheKey(filePath);
732
775
  const cacheDir = resolveArchiveCacheDir(cacheRoot, cacheKey, filePath);
733
776
  log('Open cached: %s (cacheDir=%s)', filePath, cacheDir);
734
- await extractArchiveToCache(filePath, cacheRoot, cacheDir, cacheKey);
777
+ await extractArchiveToCache(filePath, cacheRoot, cacheDir, cacheKey, onExtractProgress);
735
778
  return await Archive.connect(cacheDir, namespace);
736
779
  }
737
780
  /**
@@ -773,7 +816,7 @@ export default class Archive extends ArchiveAccessor {
773
816
  * @returns An Archive instance with the extracted data loaded.
774
817
  */
775
818
  static async open(options) {
776
- const { filePath, openPluginData } = options;
819
+ const { filePath, openPluginData, onExtractProgress, onLog } = options;
777
820
  const cwd = options.cwd ?? process.cwd();
778
821
  log('Open: %O', {
779
822
  filePath,
@@ -802,11 +845,12 @@ export default class Archive extends ArchiveAccessor {
802
845
  await untar(filePath, {
803
846
  cwd,
804
847
  fileList: openFiles.length > 0 ? openFiles : undefined,
848
+ onProgress: onExtractProgress,
805
849
  });
806
850
  const extractedDir = path.resolve(cwd, innerDirName);
807
851
  log('Move directory: %s to %s', extractedDir, tmpDir);
808
852
  await rename(extractedDir, tmpDir, true);
809
- return await Archive.#init(filePath, tmpDir, releaseLock);
853
+ return await Archive.#init(filePath, tmpDir, releaseLock, onLog);
810
854
  }
811
855
  catch (error) {
812
856
  await releaseLock();
@@ -817,16 +861,19 @@ export default class Archive extends ArchiveAccessor {
817
861
  * Resumes an archive from an existing temporary directory
818
862
  * (e.g., after an interrupted crawl session).
819
863
  * @param targetPath - The path to the temporary directory to resume from.
864
+ * @param onLog - Forwarded to {@link Database.connect} — see
865
+ * {@link ArchiveOpenOptions.onLog}'s docs (this writable reconnect
866
+ * runs the same self-healing migrations `Archive.open` does).
820
867
  * @returns An Archive instance reconnected to the existing data.
821
868
  * @throws {Error} If the specified path is not a directory.
822
869
  */
823
- static async resume(targetPath) {
870
+ static async resume(targetPath, onLog) {
824
871
  log('Resume: %s', targetPath);
825
872
  if (await isDir(targetPath)) {
826
873
  const tmpDir = targetPath;
827
874
  const releaseLock = await acquireArchiveLock(tmpDir);
828
875
  try {
829
- const db = await Archive.#connectDB(tmpDir);
876
+ const db = await Archive.#connectDB(tmpDir, { onLog });
830
877
  const name = (await db.getName()) ||
831
878
  path.basename(targetPath).replace(Archive.TMP_DIR_PREFIX, '');
832
879
  const filePath = path.resolve(process.cwd(), name + '.' + Archive.FILE_EXTENSION);
@@ -865,6 +912,7 @@ export default class Archive extends ArchiveAccessor {
865
912
  * `readOnly: true` so no migrations run and a missing tmpDir is not
866
913
  * resurrected.
867
914
  * @param options.readOnly
915
+ * @param options.onLog
868
916
  */
869
917
  static async #connectDB(tmpDir, options) {
870
918
  const dbPath = path.resolve(tmpDir, Archive.SQLITE_DB_FILE_NAME);
@@ -872,6 +920,7 @@ export default class Archive extends ArchiveAccessor {
872
920
  return await Database.connect({
873
921
  filename: dbPath,
874
922
  readOnly: options?.readOnly,
923
+ onLog: options?.onLog,
875
924
  });
876
925
  }
877
926
  /**
@@ -883,9 +932,11 @@ export default class Archive extends ArchiveAccessor {
883
932
  * @param filePath - Output `.nitpicker` file path
884
933
  * @param tmpDir - Temporary working directory path
885
934
  * @param releaseLock - Function returned by {@link acquireArchiveLock}.
935
+ * @param onLog - Forwarded to {@link Database.connect} — see
936
+ * {@link ArchiveOpenOptions.onLog}'s docs.
886
937
  */
887
- static async #init(filePath, tmpDir, releaseLock) {
888
- const db = await Archive.#connectDB(tmpDir);
938
+ static async #init(filePath, tmpDir, releaseLock, onLog) {
939
+ const db = await Archive.#connectDB(tmpDir, { onLog });
889
940
  const archive = new Archive(filePath, tmpDir, db, releaseLock);
890
941
  return archive;
891
942
  }
@@ -909,12 +960,27 @@ export default class Archive extends ArchiveAccessor {
909
960
  * {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
910
961
  * the safe base implementation — no `write()`, no `remove()`, no lock
911
962
  * release — leaving the tmpDir intact for the live crawler.
912
- */
913
- async close() {
963
+ * @param options - Optional close settings. `timeoutMs` is accepted for
964
+ * compatibility with {@link ArchiveAccessor.close}'s signature (forwarded
965
+ * to the base `super.close()` call below); the rest are progress
966
+ * callbacks (issue #294) forwarded to {@link write} when this call ends
967
+ * up taking the recovery-write branch (the archive file doesn't exist
968
+ * yet).
969
+ * @param options.timeoutMs - See {@link ArchiveAccessor.close}.
970
+ * @param options.onRecoveryStart - Called once, only when this `close()`
971
+ * is about to write the archive because the file doesn't exist on
972
+ * disk yet — e.g. a caller's own explicit `write()` threw before
973
+ * finishing, or was never called at all. Without this, a listener
974
+ * that already tore down its display after that earlier failure has
975
+ * no way to know a second, recovery write is happening.
976
+ * @param options.onTarProgress - See {@link write}.
977
+ * @param options.onStep - See {@link write}.
978
+ */
979
+ async close(options) {
914
980
  if (this.#closeOnce) {
915
981
  return this.#closeOnce;
916
982
  }
917
- this.#closeOnce = this.#runFullClose();
983
+ this.#closeOnce = this.#runFullClose(options);
918
984
  return this.#closeOnce;
919
985
  }
920
986
  /**
@@ -38,6 +38,15 @@
38
38
  * @param cacheDir - Absolute path the extracted contents should end up at.
39
39
  * @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
40
40
  * after extraction to detect concurrent writers; must match.
41
+ * @param onExtractProgress - Called during the untar step with bytes read
42
+ * so far and the archive's total size (issue #294: a cold cache on a
43
+ * large archive can take tens of seconds with no other signal it isn't
44
+ * hung). Never called on a cache hit — {@link isCacheDirReady} short-
45
+ * circuits before `untar` runs — so callers can treat "never invoked" as
46
+ * the definition of a hit and skip printing a phase label until the
47
+ * first call actually arrives. Only the first concurrent caller for a
48
+ * given `cacheDir` sees callbacks; same-`cacheDir` callers deduped
49
+ * through {@link inFlightByCacheDir} just await the shared promise.
41
50
  * @returns Resolves once `cacheDir` is ready to be opened read-only.
42
51
  */
43
- export declare function extractArchiveToCache(archivePath: string, cacheRoot: string, cacheDir: string, cacheKey: string): Promise<void>;
52
+ export declare function extractArchiveToCache(archivePath: string, cacheRoot: string, cacheDir: string, cacheKey: string, onExtractProgress?: (readBytes: number, totalBytes: number) => void): Promise<void>;
@@ -86,9 +86,18 @@ const inFlightByCacheDir = new Map();
86
86
  * @param cacheDir - Absolute path the extracted contents should end up at.
87
87
  * @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
88
88
  * after extraction to detect concurrent writers; must match.
89
+ * @param onExtractProgress - Called during the untar step with bytes read
90
+ * so far and the archive's total size (issue #294: a cold cache on a
91
+ * large archive can take tens of seconds with no other signal it isn't
92
+ * hung). Never called on a cache hit — {@link isCacheDirReady} short-
93
+ * circuits before `untar` runs — so callers can treat "never invoked" as
94
+ * the definition of a hit and skip printing a phase label until the
95
+ * first call actually arrives. Only the first concurrent caller for a
96
+ * given `cacheDir` sees callbacks; same-`cacheDir` callers deduped
97
+ * through {@link inFlightByCacheDir} just await the shared promise.
89
98
  * @returns Resolves once `cacheDir` is ready to be opened read-only.
90
99
  */
91
- export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, cacheKey) {
100
+ export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, cacheKey, onExtractProgress) {
92
101
  if (await isCacheDirReady(cacheDir)) {
93
102
  return;
94
103
  }
@@ -96,7 +105,7 @@ export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, ca
96
105
  if (existing) {
97
106
  return existing;
98
107
  }
99
- const promise = runExtraction(archivePath, cacheRoot, cacheDir, cacheKey).finally(() => {
108
+ const promise = runExtraction(archivePath, cacheRoot, cacheDir, cacheKey, onExtractProgress).finally(() => {
100
109
  inFlightByCacheDir.delete(cacheDir);
101
110
  });
102
111
  inFlightByCacheDir.set(cacheDir, promise);
@@ -110,8 +119,9 @@ export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, ca
110
119
  * @param cacheRoot - Absolute path to the cache root directory.
111
120
  * @param cacheDir - Absolute path the extracted contents should end up at.
112
121
  * @param cacheKey - Pre-extraction cache key, re-verified post-extraction.
122
+ * @param onExtractProgress - See {@link extractArchiveToCache}.
113
123
  */
114
- async function runExtraction(archivePath, cacheRoot, cacheDir, cacheKey) {
124
+ async function runExtraction(archivePath, cacheRoot, cacheDir, cacheKey, onExtractProgress) {
115
125
  await fs.mkdir(cacheRoot, { recursive: true });
116
126
  const releaseLock = await acquireLockWithPeerWait(cacheDir);
117
127
  try {
@@ -131,7 +141,7 @@ async function runExtraction(archivePath, cacheRoot, cacheDir, cacheKey) {
131
141
  // it (with `newer:true` cherrypicking entries) so wipe first.
132
142
  await fs.rm(stagingDir, { recursive: true, force: true });
133
143
  await fs.mkdir(stagingDir, { recursive: true });
134
- await untar(archivePath, { cwd: stagingDir });
144
+ await untar(archivePath, { cwd: stagingDir, onProgress: onExtractProgress });
135
145
  // Concurrent-writer detection: if the source archive changed
136
146
  // during our untar, the contents we just landed do NOT match the
137
147
  // `cacheKey` that named the directory. Drop the staging dir and
@@ -7,12 +7,22 @@ import type { Knex } from 'knex';
7
7
  * - `page_errors` — partial scrape failures, FK → `content_items(id)`
8
8
  * - `crawl_errors` — crawler-level error channel (no FK; the URL may be
9
9
  * an external link that failed DNS, or null for a process-level error)
10
- * - `page_tags` — Wappalyzer detections, FK → `content_items(id)`
10
+ * - `technology_signals` — one un-combined technology-detection signal
11
+ * (Wappalyzer or structural) per row, FK → `content_items(id)`
12
+ * - `page_technologies` — confidence-combined roll-up of
13
+ * `technology_signals`, one row per (page, technology), FK →
14
+ * `content_items(id)`. Supersedes the removed `page_tags` (Wappalyzer-only)
15
+ * - `technology_js_scan_cache` — resource-scoped cache for the post-crawl
16
+ * JS license-comment scan, FK → `resource_items(id)`
11
17
  * - `page_jsonld` — JSON-LD / SpeculationRules, FK → `content_items(id)`
12
18
  * - `page_main_content_headings` / `_images` / `_tables` / `_buttons` /
13
19
  * `_iframes` / `_videos` / `_audios` / `_canvases` — beholder
14
20
  * `MainContentsData` sub-entity arrays, one row per DOM element, FK →
15
21
  * `content_items(id)`
22
+ * - `page_main_content_custom_elements` — Web Components (custom elements)
23
+ * found in the main-content region, one row per DOM element, FK →
24
+ * `content_items(id)`. Unlike its eight siblings above, captured by
25
+ * nitpicker itself (`crawler/capture-custom-elements.ts`), not beholder
16
26
  * - `inventory_runs` — `--inventory` audit log (no FK; append-only)
17
27
  * - `network_outages` — operator-network-outage journal (no FK; append-only
18
28
  * except `ended_at`, which is written once on recovery)
@@ -50,7 +60,7 @@ import type { Knex } from 'knex';
50
60
  * Unlike `createRefTables` / `createEntityTables` (whose callers guard with
51
61
  * a single sentinel table), each table here is guarded individually because
52
62
  * the migration-script caller sees archives where any subset may already
53
- * exist (e.g. `page_tags` from the 0.10 migration but no `inventory_runs`).
63
+ * exist (e.g. `page_jsonld` from the 0.10 migration but no `inventory_runs`).
54
64
  * Index creation stays inside each guard: an existing table keeps whatever
55
65
  * indexes its creation path declared.
56
66
  * @param instance - The Knex query builder instance connected to the database.
@@ -6,12 +6,22 @@
6
6
  * - `page_errors` — partial scrape failures, FK → `content_items(id)`
7
7
  * - `crawl_errors` — crawler-level error channel (no FK; the URL may be
8
8
  * an external link that failed DNS, or null for a process-level error)
9
- * - `page_tags` — Wappalyzer detections, FK → `content_items(id)`
9
+ * - `technology_signals` — one un-combined technology-detection signal
10
+ * (Wappalyzer or structural) per row, FK → `content_items(id)`
11
+ * - `page_technologies` — confidence-combined roll-up of
12
+ * `technology_signals`, one row per (page, technology), FK →
13
+ * `content_items(id)`. Supersedes the removed `page_tags` (Wappalyzer-only)
14
+ * - `technology_js_scan_cache` — resource-scoped cache for the post-crawl
15
+ * JS license-comment scan, FK → `resource_items(id)`
10
16
  * - `page_jsonld` — JSON-LD / SpeculationRules, FK → `content_items(id)`
11
17
  * - `page_main_content_headings` / `_images` / `_tables` / `_buttons` /
12
18
  * `_iframes` / `_videos` / `_audios` / `_canvases` — beholder
13
19
  * `MainContentsData` sub-entity arrays, one row per DOM element, FK →
14
20
  * `content_items(id)`
21
+ * - `page_main_content_custom_elements` — Web Components (custom elements)
22
+ * found in the main-content region, one row per DOM element, FK →
23
+ * `content_items(id)`. Unlike its eight siblings above, captured by
24
+ * nitpicker itself (`crawler/capture-custom-elements.ts`), not beholder
15
25
  * - `inventory_runs` — `--inventory` audit log (no FK; append-only)
16
26
  * - `network_outages` — operator-network-outage journal (no FK; append-only
17
27
  * except `ended_at`, which is written once on recovery)
@@ -49,7 +59,7 @@
49
59
  * Unlike `createRefTables` / `createEntityTables` (whose callers guard with
50
60
  * a single sentinel table), each table here is guarded individually because
51
61
  * the migration-script caller sees archives where any subset may already
52
- * exist (e.g. `page_tags` from the 0.10 migration but no `inventory_runs`).
62
+ * exist (e.g. `page_jsonld` from the 0.10 migration but no `inventory_runs`).
53
63
  * Index creation stays inside each guard: an existing table keeps whatever
54
64
  * indexes its creation path declared.
55
65
  * @param instance - The Knex query builder instance connected to the database.
@@ -93,36 +103,69 @@ export async function createAdjunctTables(instance) {
93
103
  t.integer('createdAt').notNullable();
94
104
  });
95
105
  }
96
- if (!(await instance.schema.hasTable('page_tags'))) {
97
- await instance.schema.createTable('page_tags', (t) => {
98
- // Wappalyzer-derived technology detection. One row per
99
- // (provider × externalId) tuple per page. `category` is the first
100
- // element of `categories`; the full list lives in the JSON
101
- // `categories` column. `sources` records where the provider was
102
- // detected (script-src / inline / iframe-src / window-global / …).
106
+ if (!(await instance.schema.hasTable('technology_signals'))) {
107
+ await instance.schema.createTable('technology_signals', (t) => {
108
+ // One un-combined signal for one technology on one page — the
109
+ // source-of-truth granular evidence `page_technologies` rolls up.
110
+ // Supersedes `page_tags` (Wappalyzer-only; removed): `signalType`
111
+ // covers Wappalyzer AND nitpicker's own structural detections
112
+ // (URL patterns, HTML markers, scoped attributes, meta generator,
113
+ // JS license comments) in one unified model. See
114
+ // `archive/meta/technologies/`.
103
115
  t.increments('id');
104
116
  t.integer('pageId')
105
117
  .notNullable()
106
118
  .unsigned()
107
119
  .references('content_items.id')
108
120
  .onDelete('CASCADE');
109
- t.string('provider').notNullable();
121
+ t.string('technology').notNullable();
122
+ t.string('signalType').notNullable();
123
+ t.text('evidence');
124
+ t.integer('weight').notNullable();
125
+ t.index('pageId');
126
+ });
127
+ await instance.raw('CREATE INDEX technology_signals_tech_type ON technology_signals(technology, signalType)');
128
+ }
129
+ if (!(await instance.schema.hasTable('page_technologies'))) {
130
+ await instance.schema.createTable('page_technologies', (t) => {
131
+ // Confidence-combined roll-up of `technology_signals`, one row
132
+ // per (page, technology) — the read-optimised projection every
133
+ // query/viewer/MCP consumer reads instead of re-combining
134
+ // `technology_signals` on every request. Always written in the
135
+ // same transaction as its `technology_signals` rows; see
136
+ // ARCHITECTURE.md's invariant that the two tables are updated
137
+ // as a pair.
138
+ t.increments('id');
139
+ t.integer('pageId')
140
+ .notNullable()
141
+ .unsigned()
142
+ .references('content_items.id')
143
+ .onDelete('CASCADE');
144
+ t.string('technology').notNullable();
110
145
  t.string('category');
111
- t.string('externalId');
112
146
  t.string('version');
113
- t.integer('confidence');
114
- t.json('categories');
115
- t.json('sources');
116
- t.index('pageId');
117
- t.index('provider');
118
- t.index('externalId');
147
+ t.integer('confidence').notNullable();
148
+ t.integer('signalCount').notNullable();
149
+ t.unique(['pageId', 'technology']);
150
+ t.index('technology');
151
+ });
152
+ }
153
+ if (!(await instance.schema.hasTable('technology_js_scan_cache'))) {
154
+ await instance.schema.createTable('technology_js_scan_cache', (t) => {
155
+ // Resource-scoped cache preventing "JSスキャン・エンリッチメント"
156
+ // (post-crawl network enrichment, see ARCHITECTURE.md) from
157
+ // re-fetching the same JS resource on `--append`/`--retry-failed`.
158
+ // `resourceId` is the PK (one scan outcome per resource, ever);
159
+ // `technology`/`evidence` are `null` on a non-match.
160
+ t.integer('resourceId')
161
+ .primary()
162
+ .unsigned()
163
+ .references('resource_items.id')
164
+ .onDelete('CASCADE');
165
+ t.integer('scannedAt').notNullable();
166
+ t.string('technology');
167
+ t.text('evidence');
119
168
  });
120
- // Compound indexes for the "find duplicate IDs across pages" and
121
- // "list pages using provider X" hot paths. Knex's schema builder
122
- // can't express compound indexes inline in a way that round-trips
123
- // through libsql consistently, so raw SQL is used.
124
- await instance.raw('CREATE INDEX page_tags_provider_extId ON page_tags(provider, externalId)');
125
- await instance.raw('CREATE INDEX page_tags_provider_pageId ON page_tags(provider, pageId)');
126
169
  }
127
170
  if (!(await instance.schema.hasTable('page_jsonld'))) {
128
171
  await instance.schema.createTable('page_jsonld', (t) => {
@@ -151,12 +194,16 @@ export async function createAdjunctTables(instance) {
151
194
  await instance.raw('CREATE INDEX page_jsonld_type_pageId ON page_jsonld(type, pageId)');
152
195
  }
153
196
  // Beholder `MainContentsData` sub-entities, one adjunct table per array
154
- // (headings/images/tables/buttons/iframes/videos/audios/canvases). Same
155
- // shape as `page_tags` / `page_jsonld`: `pageId` FK → `content_items(id)`
156
- // ON DELETE CASCADE, individually guarded so any subset can pre-exist.
157
- // `order` preserves the DOM traversal order beholder returns the array
158
- // in (0-based); it is not itself an index target since these tables are
159
- // always read whole-page via `WHERE pageId = ? ORDER BY "order"`.
197
+ // (headings/images/tables/buttons/iframes/videos/audios/canvases), plus
198
+ // `page_main_content_custom_elements` (Web Components) the sole
199
+ // exception, captured by nitpicker itself rather than beholder (see
200
+ // `create-entity-tables.ts`'s `main_content_custom_element_count` JSDoc
201
+ // for why). Same shape as `page_tags` / `page_jsonld`: `pageId` FK
202
+ // `content_items(id)` ON DELETE CASCADE, individually guarded so any
203
+ // subset can pre-exist. `order` preserves the DOM traversal order the
204
+ // source array is captured in (0-based); it is not itself an index
205
+ // target since these tables are always read whole-page via
206
+ // `WHERE pageId = ? ORDER BY "order"`.
160
207
  if (!(await instance.schema.hasTable('page_main_content_headings'))) {
161
208
  await instance.schema.createTable('page_main_content_headings', (t) => {
162
209
  t.increments('id');
@@ -278,6 +325,21 @@ export async function createAdjunctTables(instance) {
278
325
  t.index('pageId');
279
326
  });
280
327
  }
328
+ if (!(await instance.schema.hasTable('page_main_content_custom_elements'))) {
329
+ await instance.schema.createTable('page_main_content_custom_elements', (t) => {
330
+ t.increments('id');
331
+ t.integer('pageId')
332
+ .notNullable()
333
+ .unsigned()
334
+ .references('content_items.id')
335
+ .onDelete('CASCADE');
336
+ t.integer('order').notNullable();
337
+ t.string('nodeName').notNullable();
338
+ t.string('elementId');
339
+ t.text('classList');
340
+ t.index('pageId');
341
+ });
342
+ }
281
343
  if (!(await instance.schema.hasTable('inventory_runs'))) {
282
344
  await instance.schema.createTable('inventory_runs', (t) => {
283
345
  // One row per successful `--inventory <list>` invocation. The
@@ -148,7 +148,11 @@ import type { Knex } from 'knex';
148
148
  * through `text_refs` — the values are page-specific diagnostics with low
149
149
  * cross-page reuse, so the ref-table dedup machinery would add write-path
150
150
  * cost without a corresponding storage win. `main_content_class_list` holds
151
- * a JSON-encoded string array.
151
+ * a JSON-encoded string array. `main_content_custom_element_count` is the
152
+ * one exception to "derived from beholder's `MainContentsData`" — beholder
153
+ * has no `customElements` category, so nitpicker captures Web Components
154
+ * itself (`crawler/capture-custom-elements.ts`) after `scrapeStart`
155
+ * returns, the same way it captures image dom-paths.
152
156
  *
153
157
  * ### Index rationale
154
158
  *
@@ -147,7 +147,11 @@
147
147
  * through `text_refs` — the values are page-specific diagnostics with low
148
148
  * cross-page reuse, so the ref-table dedup machinery would add write-path
149
149
  * cost without a corresponding storage win. `main_content_class_list` holds
150
- * a JSON-encoded string array.
150
+ * a JSON-encoded string array. `main_content_custom_element_count` is the
151
+ * one exception to "derived from beholder's `MainContentsData`" — beholder
152
+ * has no `customElements` category, so nitpicker captures Web Components
153
+ * itself (`crawler/capture-custom-elements.ts`) after `scrapeStart`
154
+ * returns, the same way it captures image dom-paths.
151
155
  *
152
156
  * ### Index rationale
153
157
  *
@@ -298,6 +302,7 @@ export async function createEntityTables(instance) {
298
302
  main_content_video_count INTEGER,
299
303
  main_content_audio_count INTEGER,
300
304
  main_content_canvas_count INTEGER,
305
+ main_content_custom_element_count INTEGER,
301
306
  scroll_height_desktop INTEGER,
302
307
  scroll_height_mobile INTEGER,
303
308
  body_hash BLOB,