@nitpicker/crawler 0.18.1 → 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 (138) 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 +4 -4
  35. package/lib/archive/db-ops/pages/write/insert-page.js +3 -2
  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/update-page.d.ts +4 -1
  39. package/lib/archive/db-ops/pages/write/update-page.js +38 -16
  40. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
  41. package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
  42. package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
  43. package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
  44. package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
  45. package/lib/archive/filesystem/parse-pax-path.js +47 -0
  46. package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
  47. package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
  48. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
  49. package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
  50. package/lib/archive/filesystem/tar.d.ts +13 -1
  51. package/lib/archive/filesystem/tar.js +63 -7
  52. package/lib/archive/filesystem/untar.d.ts +13 -0
  53. package/lib/archive/filesystem/untar.js +68 -8
  54. package/lib/archive/init-schema.d.ts +1 -1
  55. package/lib/archive/init-schema.js +6 -4
  56. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
  57. package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
  58. package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
  59. package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
  60. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
  61. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
  62. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
  63. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
  64. package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
  65. package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
  66. package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
  67. package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
  68. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
  69. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
  70. package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
  71. package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
  72. package/lib/archive/meta/technologies/types.d.ts +68 -0
  73. package/lib/archive/meta/technologies/types.js +13 -0
  74. package/lib/archive/meta/types.d.ts +97 -30
  75. package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
  76. package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
  77. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
  78. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
  79. package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
  80. package/lib/archive/migrate-info-main-content-selector.js +14 -3
  81. package/lib/archive/migrate-info-roots.d.ts +6 -1
  82. package/lib/archive/migrate-info-roots.js +14 -3
  83. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
  84. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
  85. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
  86. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
  87. package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
  88. package/lib/archive/migrate-main-contents-columns.js +14 -3
  89. package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
  90. package/lib/archive/migrate-page-meta-body-hash.js +14 -3
  91. package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
  92. package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
  93. package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
  94. package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
  95. package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
  96. package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
  97. package/lib/archive/page.d.ts +37 -13
  98. package/lib/archive/page.js +45 -17
  99. package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
  100. package/lib/archive/retarget-legacy-fk-tables.js +61 -15
  101. package/lib/archive/types.d.ts +18 -0
  102. package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
  103. package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
  104. package/lib/crawler/capture-custom-elements.d.ts +33 -0
  105. package/lib/crawler/capture-custom-elements.js +39 -0
  106. package/lib/crawler/close-browser-safely.d.ts +3 -3
  107. package/lib/crawler/close-browser-safely.js +8 -17
  108. package/lib/crawler/collect-custom-elements.d.ts +33 -0
  109. package/lib/crawler/collect-custom-elements.js +110 -0
  110. package/lib/crawler/crawler.js +101 -46
  111. package/lib/crawler/fetch-destination.js +6 -13
  112. package/lib/crawler/find-package-dir.d.ts +24 -0
  113. package/lib/crawler/find-package-dir.js +39 -0
  114. package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
  115. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  116. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  117. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  118. package/lib/crawler/types.d.ts +23 -0
  119. package/lib/crawler-orchestrator.d.ts +53 -9
  120. package/lib/crawler-orchestrator.js +223 -46
  121. package/lib/crawler.d.ts +11 -1
  122. package/lib/crawler.js +9 -0
  123. package/lib/inventory-setup-phases.d.ts +32 -0
  124. package/lib/inventory-setup-phases.js +45 -0
  125. package/lib/resume-setup-phases.d.ts +16 -0
  126. package/lib/resume-setup-phases.js +24 -0
  127. package/lib/retry-failed-setup-phases.d.ts +19 -0
  128. package/lib/retry-failed-setup-phases.js +24 -0
  129. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  130. package/lib/setup-recovery-phase-labels.js +24 -0
  131. package/lib/types.d.ts +145 -0
  132. package/package.json +11 -8
  133. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  134. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  135. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  136. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  137. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  138. package/lib/archive/meta/summarize-tags.js +0 -33
@@ -62,9 +62,14 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
62
62
  * SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
63
63
  * `SQLITE_LIMIT_VARIABLE_NUMBER`.
64
64
  * @param knex - Knex query builder connected to the archive DB.
65
+ * @param onProgress - Called after each chunk's DELETE/UPDATE statements
66
+ * complete, with the pages processed so far and the total to reset (issue
67
+ * #294: a large `--retry-failed` can reset thousands of pages across 13
68
+ * tables, running for seconds to minutes with no other signal it hasn't
69
+ * hung). Omit for no reporting (the default; e.g. tests).
65
70
  * @returns The URLs of the pages that were reset to pending.
66
71
  */
67
- export async function resetFailedPages(knex) {
72
+ export async function resetFailedPages(knex, onProgress) {
68
73
  const candidates = await knex('content_items')
69
74
  .join('url_refs', 'content_items.url_id', 'url_refs.id')
70
75
  .select('content_items.id as id', 'url_refs.url as url')
@@ -146,7 +151,8 @@ export async function resetFailedPages(knex) {
146
151
  await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
147
152
  await knex('page_errors').whereIn('pageId', chunk).delete();
148
153
  await knex('page_html_ref').whereIn('page_id', chunk).delete();
149
- await knex('page_tags').whereIn('pageId', chunk).delete();
154
+ await knex('technology_signals').whereIn('pageId', chunk).delete();
155
+ await knex('page_technologies').whereIn('pageId', chunk).delete();
150
156
  await knex('page_jsonld').whereIn('pageId', chunk).delete();
151
157
  await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
152
158
  await knex('page_main_content_images').whereIn('pageId', chunk).delete();
@@ -156,6 +162,8 @@ export async function resetFailedPages(knex) {
156
162
  await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
157
163
  await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
158
164
  await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
165
+ await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
166
+ onProgress?.(Math.min(i + chunkSize, ids.length), ids.length);
159
167
  }
160
168
  dbLog('Reset %d failed pages back to pending', urls.length);
161
169
  return urls;
@@ -0,0 +1,27 @@
1
+ import type { MainContentCustomElementCandidate } from '../../../../crawler/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Replaces the page's `page_main_content_custom_elements` rows with the
5
+ * freshly captured set. Called inside `updatePage`'s transaction, gated on
6
+ * `page.mainContentCustomElements !== undefined` by the caller — the caller
7
+ * must NOT call this with a defaulted `[]` when the capture itself failed
8
+ * (`undefined`), since this function cannot tell "capture found nothing"
9
+ * apart from "capture didn't run" once it receives a plain array.
10
+ *
11
+ * Unlike `insertButtons` / `insertHeadings` etc. (whose empty-array case is
12
+ * genuinely ambiguous — beholder's `MainContentsData` gives no way to tell
13
+ * "successfully found zero" from "degraded scrape" — this function always
14
+ * deletes existing rows once called, including for an empty array: because
15
+ * the caller already resolved that ambiguity via the `!== undefined` gate,
16
+ * an empty array here unambiguously means "capture succeeded, found none,"
17
+ * so stale rows from a previous crawl must be cleared.
18
+ *
19
+ * Unlike `insertButtons`, the input is nitpicker's own
20
+ * `MainContentCustomElementCandidate[]` (from `capture-custom-elements.ts`),
21
+ * not a slice of beholder's `MainContentsData` — there is no such category
22
+ * in `MainContentsData`.
23
+ * @param pageId - The owning `content_items.id`.
24
+ * @param customElements - The captured custom elements, in document order.
25
+ * @param trx - The active transaction.
26
+ */
27
+ export declare function insertCustomElements(pageId: number, customElements: readonly MainContentCustomElementCandidate[], trx: Knex.Transaction): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
+ /**
3
+ * Replaces the page's `page_main_content_custom_elements` rows with the
4
+ * freshly captured set. Called inside `updatePage`'s transaction, gated on
5
+ * `page.mainContentCustomElements !== undefined` by the caller — the caller
6
+ * must NOT call this with a defaulted `[]` when the capture itself failed
7
+ * (`undefined`), since this function cannot tell "capture found nothing"
8
+ * apart from "capture didn't run" once it receives a plain array.
9
+ *
10
+ * Unlike `insertButtons` / `insertHeadings` etc. (whose empty-array case is
11
+ * genuinely ambiguous — beholder's `MainContentsData` gives no way to tell
12
+ * "successfully found zero" from "degraded scrape" — this function always
13
+ * deletes existing rows once called, including for an empty array: because
14
+ * the caller already resolved that ambiguity via the `!== undefined` gate,
15
+ * an empty array here unambiguously means "capture succeeded, found none,"
16
+ * so stale rows from a previous crawl must be cleared.
17
+ *
18
+ * Unlike `insertButtons`, the input is nitpicker's own
19
+ * `MainContentCustomElementCandidate[]` (from `capture-custom-elements.ts`),
20
+ * not a slice of beholder's `MainContentsData` — there is no such category
21
+ * in `MainContentsData`.
22
+ * @param pageId - The owning `content_items.id`.
23
+ * @param customElements - The captured custom elements, in document order.
24
+ * @param trx - The active transaction.
25
+ */
26
+ export async function insertCustomElements(pageId, customElements, trx) {
27
+ await trx('page_main_content_custom_elements').where('pageId', pageId).delete();
28
+ if (customElements.length === 0)
29
+ return;
30
+ const rows = customElements.map((el, order) => ({
31
+ pageId,
32
+ order,
33
+ nodeName: el.nodeName,
34
+ elementId: el.elementId,
35
+ classList: JSON.stringify(el.classList),
36
+ }));
37
+ await eachSplitted(rows, 100, async (chunk) => {
38
+ await trx('page_main_content_custom_elements').insert(chunk);
39
+ });
40
+ }
@@ -1,5 +1,4 @@
1
- import type { PageData } from '../../../../utils/types/types.js';
2
- import type { PageSource } from '../../../types.js';
1
+ import type { PageDataWithDomPaths, PageSource } from '../../../types.js';
3
2
  import type { WriteRefCaches } from '../../_shared/types.js';
4
3
  import type { Knex } from 'knex';
5
4
  /**
@@ -46,11 +45,12 @@ import type { Knex } from 'knex';
46
45
  * @param knex - Knex query builder connected to the archive DB. Used as the
47
46
  * fallback when `trx` is not provided.
48
47
  * @param caches - The connection's write-side id caches.
49
- * @param page - The scraped page data.
48
+ * @param page - The scraped page data, optionally carrying nitpicker's own
49
+ * in-browser custom-element capture (see {@link PageDataWithDomPaths}).
50
50
  * @param isTarget - Whether this page is a crawl target.
51
51
  * @param trx - Optional transaction all statements run through.
52
52
  * @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
53
53
  * except through the priority lattice described above.
54
54
  * @returns The `content_items.id` of the inserted or updated row.
55
55
  */
56
- export declare function insertPage(knex: Knex, caches: WriteRefCaches, page: PageData, isTarget: boolean, trx?: Knex.Transaction, source?: PageSource): Promise<number>;
56
+ export declare function insertPage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, isTarget: boolean, trx?: Knex.Transaction, source?: PageSource): Promise<number>;
@@ -55,7 +55,8 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
55
55
  * @param knex - Knex query builder connected to the archive DB. Used as the
56
56
  * fallback when `trx` is not provided.
57
57
  * @param caches - The connection's write-side id caches.
58
- * @param page - The scraped page data.
58
+ * @param page - The scraped page data, optionally carrying nitpicker's own
59
+ * in-browser custom-element capture (see {@link PageDataWithDomPaths}).
59
60
  * @param isTarget - Whether this page is a crawl target.
60
61
  * @param trx - Optional transaction all statements run through.
61
62
  * @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
@@ -67,7 +68,7 @@ export async function insertPage(knex, caches, page, isTarget, trx, source) {
67
68
  const pageId = await resolveContentItemId(qb, caches, page.url.withoutHashAndAuth, undefined, source);
68
69
  const flat = deriveFlatFromMeta(page.meta, page.url.href);
69
70
  const denorm = computePageDenormalized(page.meta);
70
- const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight);
71
+ const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length);
71
72
  const extras = deriveMetaExtras(page.meta);
72
73
  const now = Date.now();
73
74
  // Canonicalize so the stored dictionary value matches the exact-string
@@ -0,0 +1,25 @@
1
+ import type { PageData } from '../../../../utils/types/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Replaces the page's `technology_signals` + `page_technologies` rows with
5
+ * the freshly extracted set. Called inside `updatePage`'s transaction
6
+ * unconditionally — the `wappalyzer` signal source draws on `<script src>` /
7
+ * `<iframe src>` / window globals / response headers, not the HTML body, so
8
+ * external pages that skip rendering still contribute technology signals
9
+ * (structural signals naturally find nothing against an empty `html`
10
+ * string, which is exactly what a skipped render leaves it as).
11
+ *
12
+ * Direct replacement for `insertTags` in the live crawl write path (that
13
+ * file is untouched — `scripts/migrate-to-0.10.mjs` still depends on it).
14
+ *
15
+ * Same empty-guard as `insertTags` / `insertJsonLd`: an empty array does
16
+ * not wipe prior rows on a degraded re-scrape. Both tables are always
17
+ * written together in this one call — see ARCHITECTURE.md's invariant
18
+ * that `technology_signals` and `page_technologies` are never updated
19
+ * independently.
20
+ * @param pageId
21
+ * @param html - The page's raw HTML string (empty for external/metadata-only scrapes).
22
+ * @param meta
23
+ * @param trx
24
+ */
25
+ export declare function insertTechnologies(pageId: number, html: string, meta: PageData['meta'], trx: Knex.Transaction): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
+ import { extractTechnologiesForArchive } from '../../../meta/technologies/extract-technologies-for-archive.js';
3
+ /**
4
+ * Replaces the page's `technology_signals` + `page_technologies` rows with
5
+ * the freshly extracted set. Called inside `updatePage`'s transaction
6
+ * unconditionally — the `wappalyzer` signal source draws on `<script src>` /
7
+ * `<iframe src>` / window globals / response headers, not the HTML body, so
8
+ * external pages that skip rendering still contribute technology signals
9
+ * (structural signals naturally find nothing against an empty `html`
10
+ * string, which is exactly what a skipped render leaves it as).
11
+ *
12
+ * Direct replacement for `insertTags` in the live crawl write path (that
13
+ * file is untouched — `scripts/migrate-to-0.10.mjs` still depends on it).
14
+ *
15
+ * Same empty-guard as `insertTags` / `insertJsonLd`: an empty array does
16
+ * not wipe prior rows on a degraded re-scrape. Both tables are always
17
+ * written together in this one call — see ARCHITECTURE.md's invariant
18
+ * that `technology_signals` and `page_technologies` are never updated
19
+ * independently.
20
+ * @param pageId
21
+ * @param html - The page's raw HTML string (empty for external/metadata-only scrapes).
22
+ * @param meta
23
+ * @param trx
24
+ */
25
+ export async function insertTechnologies(pageId, html, meta, trx) {
26
+ const { signals, technologies } = extractTechnologiesForArchive(html, meta);
27
+ if (signals.length === 0)
28
+ return;
29
+ await trx('technology_signals').where('pageId', pageId).delete();
30
+ await trx('page_technologies').where('pageId', pageId).delete();
31
+ await eachSplitted(signals, 100, async (chunk) => {
32
+ await trx('technology_signals').insert(chunk.map((s) => ({
33
+ pageId,
34
+ technology: s.technology,
35
+ signalType: s.signalType,
36
+ evidence: s.evidence,
37
+ weight: s.weight,
38
+ })));
39
+ });
40
+ if (technologies.length > 0) {
41
+ await eachSplitted(technologies, 100, async (chunk) => {
42
+ await trx('page_technologies').insert(chunk.map((t) => ({ pageId, ...t })));
43
+ });
44
+ }
45
+ }
@@ -24,6 +24,9 @@ import type { Knex } from 'knex';
24
24
  * inserted. Existing rows keep their original `source` (this is why a
25
25
  * second `crawl --inventory` does not "demote" an `'inventory-seed'` row
26
26
  * that was discovered earlier).
27
+ * @param bodyHash - Precomputed body hash for the page's HTML (see
28
+ * `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
29
+ * computing it from the HTML instead.
27
30
  * @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
28
31
  */
29
- export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource): Promise<number>;
32
+ export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource, bodyHash?: Buffer | null): Promise<number>;
@@ -13,13 +13,14 @@ import { resolveUrlOrBlob } from '../../_shared/resolve-url-or-blob.js';
13
13
  import { insertAudios } from './insert-audios.js';
14
14
  import { insertButtons } from './insert-buttons.js';
15
15
  import { insertCanvases } from './insert-canvases.js';
16
+ import { insertCustomElements } from './insert-custom-elements.js';
16
17
  import { insertHeadings } from './insert-headings.js';
17
18
  import { insertIframes } from './insert-iframes.js';
18
19
  import { insertJsonLd } from './insert-jsonld.js';
19
20
  import { insertMainContentImages } from './insert-main-content-images.js';
20
21
  import { insertMainContentTables } from './insert-main-content-tables.js';
21
22
  import { insertPage } from './insert-page.js';
22
- import { insertTags } from './insert-tags.js';
23
+ import { insertTechnologies } from './insert-technologies.js';
23
24
  import { insertVideos } from './insert-videos.js';
24
25
  import { linkRedirectSources } from './link-redirect-sources.js';
25
26
  import { writePageHtmlBlob } from './write-page-html-blob.js';
@@ -46,9 +47,12 @@ import { writePageHtmlBlob } from './write-page-html-blob.js';
46
47
  * inserted. Existing rows keep their original `source` (this is why a
47
48
  * second `crawl --inventory` does not "demote" an `'inventory-seed'` row
48
49
  * that was discovered earlier).
50
+ * @param bodyHash - Precomputed body hash for the page's HTML (see
51
+ * `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
52
+ * computing it from the HTML instead.
49
53
  * @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
50
54
  */
51
- export async function updatePage(knex, caches, page, writeHtml, isTarget, source) {
55
+ export async function updatePage(knex, caches, page, writeHtml, isTarget, source, bodyHash) {
52
56
  const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
53
57
  const destUrlObject = parseUrl(destUrl);
54
58
  if (!destUrlObject) {
@@ -56,7 +60,7 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
56
60
  }
57
61
  try {
58
62
  return await knex.transaction(async (trx) => {
59
- return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source);
63
+ return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash);
60
64
  });
61
65
  }
62
66
  catch (error) {
@@ -83,21 +87,24 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
83
87
  * @param writeHtml - See {@link updatePage}.
84
88
  * @param isTarget - See {@link updatePage}.
85
89
  * @param source - See {@link updatePage}.
90
+ * @param bodyHash - See {@link updatePage}.
86
91
  * @returns The `content_items.id` of the inserted or updated row.
87
92
  */
88
- async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source) {
93
+ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash) {
89
94
  const pageId = await insertPage(knex, caches, {
90
95
  ...page,
91
96
  url: destUrlObject,
92
97
  }, isTarget, trx, source);
93
- // Wappalyzer tag detection is HTML-body independent (relies on
94
- // `<script src>` / `<iframe src>` / window globals / response
95
- // headers) so it runs for every page including external /
96
- // metadata-only. JSON-LD on the other hand lives inside the
97
- // rendered HTML body, so we only write it when there is HTML to
98
- // scrape see the same `writeHtml` gate as `writePageHtmlBlob`
99
- // below.
100
- await insertTags(pageId, page.meta, trx);
98
+ // Technology detection combines a Wappalyzer signal source (HTML-body
99
+ // independent — relies on `<script src>` / `<iframe src>` / window
100
+ // globals / response headers, so it fires for every page including
101
+ // external / metadata-only) with structural signals that scan
102
+ // `page.html` (naturally a no-op against the empty string an
103
+ // external/metadata-only scrape leaves it as). Both run unconditionally,
104
+ // unlike JSON-LD below, which lives inside the rendered HTML body and
105
+ // is only written when there is HTML to scrape — see the same
106
+ // `writeHtml` gate as `writePageHtmlBlob` below.
107
+ await insertTechnologies(pageId, page.html, page.meta, trx);
101
108
  if (writeHtml) {
102
109
  await insertJsonLd(pageId, page.meta, trx);
103
110
  }
@@ -113,6 +120,16 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
113
120
  await insertVideos(pageId, page.mainContents, trx);
114
121
  await insertAudios(pageId, page.mainContents, trx);
115
122
  await insertCanvases(pageId, page.mainContents, trx);
123
+ // Unlike the eight calls above, the source here is nitpicker's own
124
+ // `capture-custom-elements.ts` capture, not `page.mainContents` — see
125
+ // `insert-custom-elements.ts`. That capture can fail independently
126
+ // of `page.mainContents` (a separate `page.evaluate()` call), so
127
+ // `undefined` (capture failed) must skip the call entirely — passing
128
+ // a defaulted `[]` would look identical to "found zero" and, per
129
+ // `insertCustomElements`'s docs, wipe stale-but-still-good rows.
130
+ if (page.mainContentCustomElements !== undefined) {
131
+ await insertCustomElements(pageId, page.mainContentCustomElements, trx);
132
+ }
116
133
  }
117
134
  // Chain lineage propagates FROM the originating URL
118
135
  // (`page.url`), NOT from the destination. See the matching
@@ -142,12 +159,17 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
142
159
  // content check alone expresses the intent without a redundant term.
143
160
  if (writeHtml && page.html.length > 0) {
144
161
  await writePageHtmlBlob(pageId, page.html, trx);
145
- // Computed from the same `page.html` written above, in the same
146
- // transaction, so `page_meta.body_hash` never observably lags the
147
- // snapshot it was derived from.
162
+ // Prefer the caller's precomputed hash (the crawler already hashed
163
+ // this exact `page.html` once, before this write path ever runs) —
164
+ // falling back to computing it here only when the caller has not
165
+ // (direct callers such as tests, or a future write path that has not
166
+ // adopted the precompute). Either way this stays derived from the
167
+ // same `page.html` written above, in the same transaction, so
168
+ // `page_meta.body_hash` never observably lags the snapshot it was
169
+ // derived from.
148
170
  await trx('page_meta')
149
171
  .where('page_id', pageId)
150
- .update({ body_hash: computeBodyHash(page.html) });
172
+ .update({ body_hash: bodyHash ?? computeBodyHash(page.html) });
151
173
  }
152
174
  else if (writeHtml &&
153
175
  page.contentType !== null &&
@@ -3,7 +3,18 @@ import type { Knex } from 'knex';
3
3
  * Retrieves a flat list of all resource URLs from the `resource_items`
4
4
  * table. URL text is normalised into `url_refs`, so the read joins the
5
5
  * two tables and returns the resolved strings.
6
+ *
7
+ * Read in `resource_items.id`-keyset chunks rather than a single SELECT
8
+ * (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
9
+ * hundreds of thousands) this was one unbounded, multi-second-to-minutes
10
+ * query with no way to report progress mid-scan. The accumulated result is
11
+ * identical to the previous single-SELECT read — chunking exists purely to
12
+ * make the scan observable, not to bound memory (the flat URL list is
13
+ * already fully materialised for the caller either way).
6
14
  * @param knex - Knex query builder connected to the archive DB.
15
+ * @param onProgress - Called after each chunk with the `resource_items.id`
16
+ * scanned up to so far and the max id. Omit for no reporting (the
17
+ * default; e.g. tests).
7
18
  * @returns An array of resource URL strings.
8
19
  */
9
- export declare function getResourceUrlList(knex: Knex): Promise<string[]>;
20
+ export declare function getResourceUrlList(knex: Knex, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<string[]>;
@@ -1,13 +1,51 @@
1
+ /** Rows read per `resource_items.id` keyset chunk. */
2
+ const READ_CHUNK_SIZE = 2000;
1
3
  /**
2
4
  * Retrieves a flat list of all resource URLs from the `resource_items`
3
5
  * table. URL text is normalised into `url_refs`, so the read joins the
4
6
  * two tables and returns the resolved strings.
7
+ *
8
+ * Read in `resource_items.id`-keyset chunks rather than a single SELECT
9
+ * (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
10
+ * hundreds of thousands) this was one unbounded, multi-second-to-minutes
11
+ * query with no way to report progress mid-scan. The accumulated result is
12
+ * identical to the previous single-SELECT read — chunking exists purely to
13
+ * make the scan observable, not to bound memory (the flat URL list is
14
+ * already fully materialised for the caller either way).
5
15
  * @param knex - Knex query builder connected to the archive DB.
16
+ * @param onProgress - Called after each chunk with the `resource_items.id`
17
+ * scanned up to so far and the max id. Omit for no reporting (the
18
+ * default; e.g. tests).
6
19
  * @returns An array of resource URL strings.
7
20
  */
8
- export async function getResourceUrlList(knex) {
9
- const res = await knex('resource_items')
10
- .join('url_refs', 'url_refs.id', 'resource_items.url_id')
11
- .select('url_refs.url as url');
12
- return res.map((r) => r.url);
21
+ export async function getResourceUrlList(knex, onProgress) {
22
+ // MAX() over the keyset column is an O(1) index-tail read; only fetched
23
+ // when someone is listening.
24
+ let maxId = 0;
25
+ if (onProgress) {
26
+ const [maxRow] = await knex('resource_items').max({
27
+ max: 'id',
28
+ });
29
+ maxId = maxRow?.max ?? 0;
30
+ }
31
+ const urls = [];
32
+ let lastId = 0;
33
+ for (;;) {
34
+ const rows = (await knex('resource_items')
35
+ .join('url_refs', 'url_refs.id', 'resource_items.url_id')
36
+ .where('resource_items.id', '>', lastId)
37
+ .orderBy('resource_items.id', 'asc')
38
+ .limit(READ_CHUNK_SIZE)
39
+ .select('resource_items.id as id', 'url_refs.url as url'));
40
+ if (rows.length === 0) {
41
+ onProgress?.(maxId, maxId);
42
+ break;
43
+ }
44
+ lastId = rows.at(-1).id;
45
+ for (const row of rows) {
46
+ urls.push(row.url);
47
+ }
48
+ onProgress?.(Math.min(lastId, maxId), maxId);
49
+ }
50
+ return urls;
13
51
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Copies a file while reporting byte progress — `fs.copyFile` offers no
3
+ * observability, and a `.nitpicker` archive's `.bak` safety copy (taken
4
+ * before every mutating `viewer-build`/`crawl --append`/`--inventory`/
5
+ * `--retry-failed` run, and copied back on failure) can be 15 GB+, running
6
+ * for tens of seconds with nothing on screen (issue #294). Byte granularity
7
+ * is the read-stream chunk size (~64 KB); callers wanting coarser updates
8
+ * throttle in their own callback.
9
+ * @param src - The file to copy.
10
+ * @param dest - The destination path, overwritten if present.
11
+ * @param onProgress - Called as bytes are copied, with the bytes copied so
12
+ * far and the source file's total size. Omit for a silent copy.
13
+ * @example
14
+ * ```ts
15
+ * await copyFileWithProgress(archivePath, `${archivePath}.bak`, (copied, total) => {
16
+ * console.error(`${copied}/${total}`);
17
+ * });
18
+ * ```
19
+ */
20
+ export declare function copyFileWithProgress(src: string, dest: string, onProgress?: (copiedBytes: number, totalBytes: number) => void): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { pipeline } from 'node:stream/promises';
4
+ /**
5
+ * Copies a file while reporting byte progress — `fs.copyFile` offers no
6
+ * observability, and a `.nitpicker` archive's `.bak` safety copy (taken
7
+ * before every mutating `viewer-build`/`crawl --append`/`--inventory`/
8
+ * `--retry-failed` run, and copied back on failure) can be 15 GB+, running
9
+ * for tens of seconds with nothing on screen (issue #294). Byte granularity
10
+ * is the read-stream chunk size (~64 KB); callers wanting coarser updates
11
+ * throttle in their own callback.
12
+ * @param src - The file to copy.
13
+ * @param dest - The destination path, overwritten if present.
14
+ * @param onProgress - Called as bytes are copied, with the bytes copied so
15
+ * far and the source file's total size. Omit for a silent copy.
16
+ * @example
17
+ * ```ts
18
+ * await copyFileWithProgress(archivePath, `${archivePath}.bak`, (copied, total) => {
19
+ * console.error(`${copied}/${total}`);
20
+ * });
21
+ * ```
22
+ */
23
+ export async function copyFileWithProgress(src, dest, onProgress) {
24
+ const { size: totalBytes } = await stat(src);
25
+ const source = createReadStream(src);
26
+ let copiedBytes = 0;
27
+ if (onProgress) {
28
+ source.on('data', (chunk) => {
29
+ copiedBytes += chunk.length;
30
+ onProgress(copiedBytes, totalBytes);
31
+ });
32
+ }
33
+ await pipeline(source, createWriteStream(dest));
34
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Parses a PAX extended header record block for the `path` key.
3
+ *
4
+ * Format: a sequence of `"<record-length> <key>=<value>\n"` records, where
5
+ * `<record-length>` is the decimal byte length of the ENTIRE record
6
+ * (including the length prefix itself and the trailing newline). `tar`
7
+ * (node-tar) emits one of these ahead of any entry whose name doesn't fit
8
+ * the ustar format's 100-byte field (UTF-8 multi-byte names, or names
9
+ * longer than 100 bytes) — verified empirically against this package's own
10
+ * `tar()`/`create()` output.
11
+ * @param data - The PAX header entry's raw data payload.
12
+ * @returns The `path` value if present, otherwise `null`.
13
+ * @example
14
+ * ```ts
15
+ * parsePaxPath(Buffer.from('20 path=日本語/\n', 'utf8')); // '日本語/'
16
+ * ```
17
+ */
18
+ export declare function parsePaxPath(data: Buffer): string | null;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Parses a PAX extended header record block for the `path` key.
3
+ *
4
+ * Format: a sequence of `"<record-length> <key>=<value>\n"` records, where
5
+ * `<record-length>` is the decimal byte length of the ENTIRE record
6
+ * (including the length prefix itself and the trailing newline). `tar`
7
+ * (node-tar) emits one of these ahead of any entry whose name doesn't fit
8
+ * the ustar format's 100-byte field (UTF-8 multi-byte names, or names
9
+ * longer than 100 bytes) — verified empirically against this package's own
10
+ * `tar()`/`create()` output.
11
+ * @param data - The PAX header entry's raw data payload.
12
+ * @returns The `path` value if present, otherwise `null`.
13
+ * @example
14
+ * ```ts
15
+ * parsePaxPath(Buffer.from('20 path=日本語/\n', 'utf8')); // '日本語/'
16
+ * ```
17
+ */
18
+ export function parsePaxPath(data) {
19
+ let offset = 0;
20
+ while (offset < data.length) {
21
+ const spaceIndex = data.indexOf(0x20 /* ' ' */, offset);
22
+ if (spaceIndex === -1) {
23
+ return null;
24
+ }
25
+ const lengthText = data.subarray(offset, spaceIndex).toString('latin1');
26
+ const recordLength = Number.parseInt(lengthText, 10);
27
+ if (!Number.isFinite(recordLength) || recordLength <= 0) {
28
+ return null;
29
+ }
30
+ const recordEnd = offset + recordLength;
31
+ if (recordEnd > data.length) {
32
+ return null;
33
+ }
34
+ // Decimal (not `0x3D`) to sidestep the Prettier/eslint hex-case
35
+ // conflict — see `parse-tar-size-field.ts` for the full explanation.
36
+ const equalsIndex = data.indexOf(61 /* '=' */, spaceIndex);
37
+ if (equalsIndex !== -1 && equalsIndex < recordEnd) {
38
+ const key = data.subarray(spaceIndex + 1, equalsIndex).toString('utf8');
39
+ if (key === 'path') {
40
+ // Value runs from after '=' to just before the trailing '\n'.
41
+ return data.subarray(equalsIndex + 1, recordEnd - 1).toString('utf8');
42
+ }
43
+ }
44
+ offset = recordEnd;
45
+ }
46
+ return null;
47
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Parses a tar header's size field (offset 124, 12 bytes), which is either
3
+ * a NUL/space-terminated octal ASCII string (standard) or, when the
4
+ * high bit of the first byte is set, a GNU base-256 big-endian binary
5
+ * encoding (used for sizes too large for the 11-digit octal field).
6
+ * @param field - The 12-byte size field.
7
+ * @returns The size in bytes, or `null` if the field cannot be parsed.
8
+ * @example
9
+ * ```ts
10
+ * parseTarSizeField(Buffer.from('00000000004\0', 'latin1')); // 4
11
+ * ```
12
+ */
13
+ export declare function parseTarSizeField(field: Buffer): number | null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Parses a tar header's size field (offset 124, 12 bytes), which is either
3
+ * a NUL/space-terminated octal ASCII string (standard) or, when the
4
+ * high bit of the first byte is set, a GNU base-256 big-endian binary
5
+ * encoding (used for sizes too large for the 11-digit octal field).
6
+ * @param field - The 12-byte size field.
7
+ * @returns The size in bytes, or `null` if the field cannot be parsed.
8
+ * @example
9
+ * ```ts
10
+ * parseTarSizeField(Buffer.from('00000000004\0', 'latin1')); // 4
11
+ * ```
12
+ */
13
+ export function parseTarSizeField(field) {
14
+ const first = field[0];
15
+ if (first === undefined) {
16
+ return null;
17
+ }
18
+ if ((first & 0x80) !== 0) {
19
+ // GNU base-256: the remaining bits of the first byte plus all
20
+ // following bytes form a big-endian unsigned integer. `.nitpicker`
21
+ // tars never approach sizes needing this (single-digit-terabyte
22
+ // range before it would even matter), but a correct read here still
23
+ // costs nothing.
24
+ // `0b0111_1111` (not `0x7F`) to sidestep the Prettier/eslint hex-case
25
+ // conflict (Prettier always lowercases hex digits; this repo's
26
+ // `unicorn/number-literal-case` requires uppercase) — a binary literal
27
+ // has no letter digits to disagree about.
28
+ let value = BigInt(first & 0b0111_1111);
29
+ for (let i = 1; i < field.length; i++) {
30
+ value = (value << 8n) | BigInt(field[i] ?? 0);
31
+ }
32
+ return Number(value);
33
+ }
34
+ const text = field.toString('latin1').replaceAll('\0', ' ').trim();
35
+ if (text === '') {
36
+ return 0;
37
+ }
38
+ const parsed = Number.parseInt(text, 8);
39
+ return Number.isNaN(parsed) ? null : parsed;
40
+ }
@@ -12,9 +12,16 @@
12
12
  * directory it sees, so callers (`Archive.open`, the migration script) can
13
13
  * use the actual inner name regardless of what the outer file is called.
14
14
  *
15
- * Reads the tar's central directory only; does not extract any data. Runs
16
- * in O(number of entries) but stops at the first match, so a typical
17
- * archive (1–2 top-level entries) completes in a single read.
15
+ * Parses raw tar headers directly (ustar fixed fields, PAX extended headers,
16
+ * GNU longname/longlink) instead of using the `tar` package's `list()`
17
+ * `list()` reads the entire archive stream to completion even after
18
+ * `onReadEntry` has found what it needs (no internal early-abort), so on a
19
+ * 15 GB+ archive it was a second full read on top of `untar()`'s own read
20
+ * (issue #294). The fast path here stops at the first top-level directory
21
+ * entry, typically reading well under 1 KB. Falls back to the original
22
+ * `list()`-based scan on anything it doesn't recognize (unknown typeflag
23
+ * sequence, truncated read, non-tar content) — safety over speed for
24
+ * archives this function has never been exercised against.
18
25
  * @param tarFilePath - Path to the `.nitpicker` tar file.
19
26
  * @returns The first top-level directory name found in the archive.
20
27
  * @throws {Error} When the tar contains no top-level directory entry