@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
@@ -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
  /**
@@ -15,6 +14,26 @@ import type { Knex } from 'knex';
15
14
  * anything reachable via the crawled chain must be labelled `'crawled'`
16
15
  * even if previously labelled `'inventory-*'`.
17
16
  *
17
+ * `is_external`, by contrast, IS overwritten on every call — but demotion is
18
+ * guarded. `updatePage` keys the row by the redirect DESTINATION url while
19
+ * passing the REQUESTING url's `isExternal`, so the value written here
20
+ * describes the requester, not necessarily this row. Inheriting it is
21
+ * deliberate when PROMOTING (an out-of-scope soft-404 page reached from an
22
+ * in-scope request counts as covered by the crawl, and the viewer relies on
23
+ * that — see `@nitpicker/query`'s `build-directory-tree-rows.ts`). It would be
24
+ * wrong when DEMOTING: an out-of-scope url redirecting to an in-scope page
25
+ * that was already taken on as a target must not flip that page to
26
+ * `is_external = 1` — no reading of the column justifies erasing a real
27
+ * observation with an inherited one. `crawler.ts`'s `#scrapedDestinations`
28
+ * blocks this within one run, but that is per-`#runDeal` memory, so a later
29
+ * `--append` / `--retry-failed` process starts blind to what the DB already
30
+ * knows — hence the CASE below, which checks the ROW's own prior state
31
+ * instead: once `scraped = 1 AND is_external = 0` is true, no later call can
32
+ * flip it back to `1`. Promotion (`0 → 1` before the row has been scraped, or
33
+ * `1 → 0` at any time) is untouched. This mirrors how `first_crawled_at`'s `COALESCE` below
34
+ * protects an established value — deliberately NOT by re-deriving scope from
35
+ * the destination url, which would also kill the wanted promoting case.
36
+ *
18
37
  * The page's response headers are decomposed and written into the
19
38
  * header dictionary tables here — per response, not deferred to
20
39
  * crawl-end — and the resulting `header_set_id` lands on the same
@@ -26,11 +45,12 @@ import type { Knex } from 'knex';
26
45
  * @param knex - Knex query builder connected to the archive DB. Used as the
27
46
  * fallback when `trx` is not provided.
28
47
  * @param caches - The connection's write-side id caches.
29
- * @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}).
30
50
  * @param isTarget - Whether this page is a crawl target.
31
51
  * @param trx - Optional transaction all statements run through.
32
52
  * @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
33
53
  * except through the priority lattice described above.
34
54
  * @returns The `content_items.id` of the inserted or updated row.
35
55
  */
36
- 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>;
@@ -24,6 +24,26 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
24
24
  * anything reachable via the crawled chain must be labelled `'crawled'`
25
25
  * even if previously labelled `'inventory-*'`.
26
26
  *
27
+ * `is_external`, by contrast, IS overwritten on every call — but demotion is
28
+ * guarded. `updatePage` keys the row by the redirect DESTINATION url while
29
+ * passing the REQUESTING url's `isExternal`, so the value written here
30
+ * describes the requester, not necessarily this row. Inheriting it is
31
+ * deliberate when PROMOTING (an out-of-scope soft-404 page reached from an
32
+ * in-scope request counts as covered by the crawl, and the viewer relies on
33
+ * that — see `@nitpicker/query`'s `build-directory-tree-rows.ts`). It would be
34
+ * wrong when DEMOTING: an out-of-scope url redirecting to an in-scope page
35
+ * that was already taken on as a target must not flip that page to
36
+ * `is_external = 1` — no reading of the column justifies erasing a real
37
+ * observation with an inherited one. `crawler.ts`'s `#scrapedDestinations`
38
+ * blocks this within one run, but that is per-`#runDeal` memory, so a later
39
+ * `--append` / `--retry-failed` process starts blind to what the DB already
40
+ * knows — hence the CASE below, which checks the ROW's own prior state
41
+ * instead: once `scraped = 1 AND is_external = 0` is true, no later call can
42
+ * flip it back to `1`. Promotion (`0 → 1` before the row has been scraped, or
43
+ * `1 → 0` at any time) is untouched. This mirrors how `first_crawled_at`'s `COALESCE` below
44
+ * protects an established value — deliberately NOT by re-deriving scope from
45
+ * the destination url, which would also kill the wanted promoting case.
46
+ *
27
47
  * The page's response headers are decomposed and written into the
28
48
  * header dictionary tables here — per response, not deferred to
29
49
  * crawl-end — and the resulting `header_set_id` lands on the same
@@ -35,7 +55,8 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
35
55
  * @param knex - Knex query builder connected to the archive DB. Used as the
36
56
  * fallback when `trx` is not provided.
37
57
  * @param caches - The connection's write-side id caches.
38
- * @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}).
39
60
  * @param isTarget - Whether this page is a crawl target.
40
61
  * @param trx - Optional transaction all statements run through.
41
62
  * @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
@@ -47,7 +68,7 @@ export async function insertPage(knex, caches, page, isTarget, trx, source) {
47
68
  const pageId = await resolveContentItemId(qb, caches, page.url.withoutHashAndAuth, undefined, source);
48
69
  const flat = deriveFlatFromMeta(page.meta, page.url.href);
49
70
  const denorm = computePageDenormalized(page.meta);
50
- const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight);
71
+ const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length);
51
72
  const extras = deriveMetaExtras(page.meta);
52
73
  const now = Date.now();
53
74
  // Canonicalize so the stored dictionary value matches the exact-string
@@ -86,8 +107,23 @@ export async function insertPage(knex, caches, page, isTarget, trx, source) {
86
107
  .where('id', pageId)
87
108
  .update({
88
109
  scraped: 1,
89
- is_target: isTarget ? 1 : 0,
90
- is_external: page.isExternal ? 1 : 0,
110
+ // Once a row has been scraped as a real crawl target, no later call
111
+ // may flip it back off — same inheritance-from-the-requester bug as
112
+ // `is_external` below (`setExternalPage` always passes `isTarget:
113
+ // false`), and the same fix: guard on the row's own prior state
114
+ // instead of per-run memory. Demoting is_target away from an
115
+ // established value would under-count `getScrapedHtmlPageCount`'s
116
+ // resume offset and silently break the "isTarget=1 means covered by
117
+ // the crawl" contract `accessor.getPages('page')` documents.
118
+ is_target: qb.raw('CASE WHEN scraped = 1 AND is_target = 1 THEN 1 ELSE ? END', [
119
+ isTarget ? 1 : 0,
120
+ ]),
121
+ // Once a row has been scraped as internal, no later call may flip it
122
+ // back to external — see this function's docs for why the write this
123
+ // guards against happens at all.
124
+ is_external: qb.raw('CASE WHEN scraped = 1 AND is_external = 0 THEN 0 ELSE ? END', [
125
+ page.isExternal ? 1 : 0,
126
+ ]),
91
127
  status: page.status,
92
128
  status_text: page.statusText,
93
129
  content_type_id: contentTypeId,
@@ -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
+ }
@@ -20,7 +20,13 @@ import type { Knex } from 'knex';
20
20
  * @param destId - `content_items.id` of the redirect destination page.
21
21
  * @param destUrlNormalized - Normalised destination URL, used to detect and
22
22
  * skip self-redirects.
23
- * @param isExternal - Whether the sources are external to the crawl scope.
23
+ * @param isExternal - Whether the sources are external to the crawl scope, as
24
+ * a single value applied to every hop in `sources` — the same
25
+ * requester-describes-a-different-row shape `insertPage` guards against
26
+ * (see its docs in `insert-page.ts`), since a hop can independently be a
27
+ * real, already-scraped internal page in its own right (reached earlier as
28
+ * a direct crawl target) before it is later observed as an intermediate hop
29
+ * in some other chain. The UPDATE below carries the same CASE guard.
24
30
  * @param chainLineageSource - Lineage label propagated to each intermediate
25
31
  * hop's row (passed through to {@link resolveContentItemId}). Derived by
26
32
  * the caller from the **originating** page's source (`page.url`), not from
@@ -19,7 +19,13 @@ import { resolveContentItemId } from '../../_shared/resolve-content-item-id.js';
19
19
  * @param destId - `content_items.id` of the redirect destination page.
20
20
  * @param destUrlNormalized - Normalised destination URL, used to detect and
21
21
  * skip self-redirects.
22
- * @param isExternal - Whether the sources are external to the crawl scope.
22
+ * @param isExternal - Whether the sources are external to the crawl scope, as
23
+ * a single value applied to every hop in `sources` — the same
24
+ * requester-describes-a-different-row shape `insertPage` guards against
25
+ * (see its docs in `insert-page.ts`), since a hop can independently be a
26
+ * real, already-scraped internal page in its own right (reached earlier as
27
+ * a direct crawl target) before it is later observed as an intermediate hop
28
+ * in some other chain. The UPDATE below carries the same CASE guard.
23
29
  * @param chainLineageSource - Lineage label propagated to each intermediate
24
30
  * hop's row (passed through to {@link resolveContentItemId}). Derived by
25
31
  * the caller from the **originating** page's source (`page.url`), not from
@@ -53,7 +59,7 @@ export async function linkRedirectSources(trx, caches, sources, destId, destUrlN
53
59
  .update({
54
60
  scraped: 1,
55
61
  redirect_dest_id: destId,
56
- is_external: isExternal ? 1 : 0,
62
+ is_external: trx.raw('CASE WHEN scraped = 1 AND is_external = 0 THEN 0 ELSE ? END', [isExternal ? 1 : 0]),
57
63
  });
58
64
  // Conditional `301 Moved Permanently` stamp — applied ONLY when the
59
65
  // row carries no definitive status yet (NULL or the `-1`
@@ -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;