@nitpicker/crawler 0.18.1 → 0.20.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 (141) 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 +140 -56
  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/handle-scrape-end.js +19 -10
  115. package/lib/crawler/resolve-result-went-off-host.d.ts +34 -0
  116. package/lib/crawler/resolve-result-went-off-host.js +39 -0
  117. package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
  118. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  119. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  120. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  121. package/lib/crawler/types.d.ts +34 -1
  122. package/lib/crawler-orchestrator.d.ts +53 -9
  123. package/lib/crawler-orchestrator.js +223 -46
  124. package/lib/crawler.d.ts +11 -1
  125. package/lib/crawler.js +9 -0
  126. package/lib/inventory-setup-phases.d.ts +32 -0
  127. package/lib/inventory-setup-phases.js +45 -0
  128. package/lib/resume-setup-phases.d.ts +16 -0
  129. package/lib/resume-setup-phases.js +24 -0
  130. package/lib/retry-failed-setup-phases.d.ts +19 -0
  131. package/lib/retry-failed-setup-phases.js +24 -0
  132. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  133. package/lib/setup-recovery-phase-labels.js +24 -0
  134. package/lib/types.d.ts +145 -0
  135. package/package.json +11 -8
  136. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  137. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  138. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  139. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  140. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  141. package/lib/archive/meta/summarize-tags.js +0 -33
@@ -1,21 +1,34 @@
1
1
  import type { Knex } from 'knex';
2
2
  /**
3
3
  * Rewrites the FK declarations of the adjunct tables from the legacy
4
- * `pages(id)` to `content_items(id)` by rebuilding each table. SQLite has
5
- * no `ALTER TABLE DROP CONSTRAINT`, so the only way to change an FK
6
- * target is to recreate the table:
4
+ * `pages(id)` to `content_items(id)` by rebuilding each table, AND converts
5
+ * `page_tags` (if present) into `technology_signals` / `page_technologies`.
6
+ * SQLite has no `ALTER TABLE … DROP CONSTRAINT`, so the only way to change
7
+ * an FK target is to recreate the table:
7
8
  *
8
- * 1. Stage each table's rows into a constraint-free `<table>__retarget`
9
- * copy (`CREATE TABLE … AS SELECT *`) and drop the original, freeing
10
- * its index names.
11
- * 2. Recreate every table through {@link createAdjunctTables} the same
12
- * DDL fresh archives get, so migrated archives end up identical in
13
- * shape and index naming. Rebuilding the DDL inline here instead
14
- * would recreate the exact drift this function repairs.
15
- * 3. Copy the staged rows back using the recreated table's own column
16
- * list (read via `pragma_table_info`). A column the canonical DDL has
17
- * but the input archive lacks aborts the INSERT with a clear error —
18
- * never a silent data drop.
9
+ * 1. Stage each {@link RETARGET_TABLES} table's rows into a constraint-free
10
+ * `<table>__retarget` copy (`CREATE TABLE … AS SELECT *`) and drop the
11
+ * original, freeing its index names. `page_tags`, if present, is read
12
+ * into memory here too (converted via `convertLegacyPageTagsToInserts`
13
+ * the same helper `migrate-page-tags-to-page-technologies.ts` uses for
14
+ * already-0.13+ archives) but NOT staged the same way: it has no
15
+ * current-schema table to retarget onto, so there is nothing to copy
16
+ * back only something to drop, once its data lives in the tables
17
+ * from step 2.
18
+ * 2. Recreate every {@link RETARGET_TABLES} table through
19
+ * {@link createAdjunctTables} the same DDL fresh archives get, so
20
+ * migrated archives end up identical in shape and index naming.
21
+ * Rebuilding the DDL inline here instead would recreate the exact
22
+ * drift this function repairs. This same call also creates
23
+ * `technology_signals` / `page_technologies` (idempotent
24
+ * `hasTable`-guarded DDL — a no-op if a prior partial run already
25
+ * created them).
26
+ * 3. Copy the staged {@link RETARGET_TABLES} rows back using the recreated
27
+ * table's own column list (read via `pragma_table_info`). A column the
28
+ * canonical DDL has but the input archive lacks aborts the INSERT with
29
+ * a clear error — never a silent data drop. The converted `page_tags`
30
+ * signals/technologies are inserted here too, then `page_tags` itself
31
+ * is dropped (not recreated).
19
32
  *
20
33
  * A rename-based recipe (`ALTER TABLE x RENAME TO x__retarget`, skip
21
34
  * copy #1) is deliberately NOT used: SQLite keeps the renamed table's
@@ -1,4 +1,6 @@
1
+ import { eachSplitted } from '../utils/array/each-splitted.js';
1
2
  import { createAdjunctTables } from './create-adjunct-tables.js';
3
+ import { convertLegacyPageTagsToInserts } from './meta/technologies/convert-legacy-page-tags-to-inserts.js';
2
4
  /**
3
5
  * The adjunct tables whose FK declarations may still point at the legacy
4
6
  * `pages(id)` on migrated archives. Only names are listed — the column
@@ -6,10 +8,15 @@ import { createAdjunctTables } from './create-adjunct-tables.js';
6
8
  * {@link createAdjunctTables}, so a future column addition there cannot
7
9
  * silently drift from a hardcoded list (a missing column in the staged
8
10
  * copy fails the copy-back INSERT loudly instead of dropping data).
11
+ *
12
+ * `page_tags` is deliberately NOT here — it has no current-schema
13
+ * equivalent to retarget onto (removed in favor of `technology_signals` /
14
+ * `page_technologies`). {@link retargetLegacyFkTables} handles it as a
15
+ * special case: convert its rows, then drop it outright rather than
16
+ * recreating it. See that function's JSDoc.
9
17
  */
10
18
  const RETARGET_TABLES = [
11
19
  'page_html_ref',
12
- 'page_tags',
13
20
  'page_jsonld',
14
21
  'page_errors',
15
22
  'analysis_violations',
@@ -32,21 +39,34 @@ const NULLABLE_ON_RETARGET = {
32
39
  };
33
40
  /**
34
41
  * Rewrites the FK declarations of the adjunct tables from the legacy
35
- * `pages(id)` to `content_items(id)` by rebuilding each table. SQLite has
36
- * no `ALTER TABLE DROP CONSTRAINT`, so the only way to change an FK
37
- * target is to recreate the table:
42
+ * `pages(id)` to `content_items(id)` by rebuilding each table, AND converts
43
+ * `page_tags` (if present) into `technology_signals` / `page_technologies`.
44
+ * SQLite has no `ALTER TABLE … DROP CONSTRAINT`, so the only way to change
45
+ * an FK target is to recreate the table:
38
46
  *
39
- * 1. Stage each table's rows into a constraint-free `<table>__retarget`
40
- * copy (`CREATE TABLE … AS SELECT *`) and drop the original, freeing
41
- * its index names.
42
- * 2. Recreate every table through {@link createAdjunctTables} the same
43
- * DDL fresh archives get, so migrated archives end up identical in
44
- * shape and index naming. Rebuilding the DDL inline here instead
45
- * would recreate the exact drift this function repairs.
46
- * 3. Copy the staged rows back using the recreated table's own column
47
- * list (read via `pragma_table_info`). A column the canonical DDL has
48
- * but the input archive lacks aborts the INSERT with a clear error —
49
- * never a silent data drop.
47
+ * 1. Stage each {@link RETARGET_TABLES} table's rows into a constraint-free
48
+ * `<table>__retarget` copy (`CREATE TABLE … AS SELECT *`) and drop the
49
+ * original, freeing its index names. `page_tags`, if present, is read
50
+ * into memory here too (converted via `convertLegacyPageTagsToInserts`
51
+ * the same helper `migrate-page-tags-to-page-technologies.ts` uses for
52
+ * already-0.13+ archives) but NOT staged the same way: it has no
53
+ * current-schema table to retarget onto, so there is nothing to copy
54
+ * back only something to drop, once its data lives in the tables
55
+ * from step 2.
56
+ * 2. Recreate every {@link RETARGET_TABLES} table through
57
+ * {@link createAdjunctTables} the same DDL fresh archives get, so
58
+ * migrated archives end up identical in shape and index naming.
59
+ * Rebuilding the DDL inline here instead would recreate the exact
60
+ * drift this function repairs. This same call also creates
61
+ * `technology_signals` / `page_technologies` (idempotent
62
+ * `hasTable`-guarded DDL — a no-op if a prior partial run already
63
+ * created them).
64
+ * 3. Copy the staged {@link RETARGET_TABLES} rows back using the recreated
65
+ * table's own column list (read via `pragma_table_info`). A column the
66
+ * canonical DDL has but the input archive lacks aborts the INSERT with
67
+ * a clear error — never a silent data drop. The converted `page_tags`
68
+ * signals/technologies are inserted here too, then `page_tags` itself
69
+ * is dropped (not recreated).
50
70
  *
51
71
  * A rename-based recipe (`ALTER TABLE x RENAME TO x__retarget`, skip
52
72
  * copy #1) is deliberately NOT used: SQLite keeps the renamed table's
@@ -85,6 +105,18 @@ export async function retargetLegacyFkTables(trx) {
85
105
  await trx.raw(`DROP TABLE "${table}"`);
86
106
  staged.push(table);
87
107
  }
108
+ // `page_tags` has no current-schema table to retarget onto (removed —
109
+ // see the module JSDoc), so its rows are converted to
110
+ // `technology_signals` / `page_technologies` shape HERE, while
111
+ // `page_tags` still exists, then only dropped (not staged/recreated)
112
+ // after `createAdjunctTables` below provisions the tables that will
113
+ // receive them.
114
+ const hasLegacyPageTags = await trx.schema.hasTable('page_tags');
115
+ const legacyTagRows = hasLegacyPageTags
116
+ ? await trx
117
+ .select('pageId', 'provider', 'version', 'confidence', 'categories')
118
+ .from('page_tags')
119
+ : [];
88
120
  await createAdjunctTables(trx);
89
121
  for (const table of staged) {
90
122
  const columns = await trx
@@ -104,4 +136,18 @@ export async function retargetLegacyFkTables(trx) {
104
136
  await trx.raw(`INSERT INTO "${table}" (${insertColumnList}) SELECT ${selectColumnList} FROM "${table}__retarget"`);
105
137
  await trx.raw(`DROP TABLE "${table}__retarget"`);
106
138
  }
139
+ if (hasLegacyPageTags) {
140
+ const { signalInserts, technologyInserts } = convertLegacyPageTagsToInserts(legacyTagRows);
141
+ if (signalInserts.length > 0) {
142
+ await eachSplitted(signalInserts, 100, async (chunk) => {
143
+ await trx('technology_signals').insert(chunk);
144
+ });
145
+ }
146
+ if (technologyInserts.length > 0) {
147
+ await eachSplitted(technologyInserts, 100, async (chunk) => {
148
+ await trx('page_technologies').insert(chunk);
149
+ });
150
+ }
151
+ await trx.raw('DROP TABLE "page_tags"');
152
+ }
107
153
  }
@@ -1,4 +1,5 @@
1
1
  import type { DomPathCandidate } from './populate-entity-tables/types.js';
2
+ import type { MainContentCustomElementCandidate } from '../crawler/types.js';
2
3
  import type { PageData } from '../utils/types/types.js';
3
4
  import type { ParseURLOptions } from '@d-zero/shared/parse-url';
4
5
  /**
@@ -13,6 +14,13 @@ import type { ParseURLOptions } from '@d-zero/shared/parse-url';
13
14
  export type PageDataWithDomPaths = PageData & {
14
15
  /** In-browser dom-path capture for the rendered document's images. */
15
16
  imageDomPaths?: readonly DomPathCandidate[];
17
+ /**
18
+ * In-browser Web Component (custom element) capture for the rendered
19
+ * document's main-content region (`crawler/capture-custom-elements.ts`).
20
+ * `undefined` means capture was not attempted or failed — a distinct
21
+ * state from "capture succeeded, zero elements found" (empty array).
22
+ */
23
+ mainContentCustomElements?: readonly MainContentCustomElementCandidate[];
16
24
  };
17
25
  /**
18
26
  * Event map for database-related events emitted by the Database and ArchiveAccessor classes.
@@ -388,6 +396,8 @@ export interface DB_Page {
388
396
  main_content_audio_count: number | null;
389
397
  /** Number of canvases within the main region, or null. */
390
398
  main_content_canvas_count: number | null;
399
+ /** Number of Web Components (custom elements) within the main region, or null. */
400
+ main_content_custom_element_count: number | null;
391
401
  /** `document.body.scrollHeight` at the desktop-compact preset, or null. */
392
402
  scroll_height_desktop: number | null;
393
403
  /** `document.body.scrollHeight` at the mobile-small preset, or null. */
@@ -637,4 +647,12 @@ export interface DatabaseOption {
637
647
  * tmpDir, where any write would race the live crawler.
638
648
  */
639
649
  readOnly?: boolean;
650
+ /**
651
+ * Called instead of `console.error` for self-healing schema migration
652
+ * notices (issue #294) — see
653
+ * {@link import('./db-ops/lifecycle/init.js').init}'s `onLog` param for
654
+ * why a bare `console.error` at this point is unsafe. Ignored when
655
+ * `readOnly` is `true` (migrations never run there).
656
+ */
657
+ onLog?: (message: string) => void;
640
658
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Verifies that crawler's own `puppeteer` install and the `puppeteer` used
3
+ * internally by `@d-zero/beholder` resolve to the same directory on disk.
4
+ *
5
+ * `Crawler#scrapePage` launches the browser through crawler's own
6
+ * `puppeteer` import and hands the resulting `Page` across the module
7
+ * boundary into `Scraper.scrapeStart()` (`@d-zero/beholder`). If crawler's
8
+ * pinned `puppeteer` version drifts from whatever version `@d-zero/beholder`
9
+ * depends on internally, yarn installs two separate `puppeteer` copies and
10
+ * the `Page` instances are structurally similar but not the same class —
11
+ * this surfaces as a `TS2345` deep inside application code with no
12
+ * indication that a version mismatch is the actual cause. Comparing the
13
+ * resolved directories catches the drift directly, at the boundary where it
14
+ * actually matters.
15
+ * @throws {Error} When crawler's and beholder's `puppeteer` resolve to
16
+ * different directories, naming both.
17
+ * @example
18
+ * ```ts
19
+ * import { assertPuppeteerSharedWithBeholder } from '@nitpicker/crawler';
20
+ *
21
+ * assertPuppeteerSharedWithBeholder();
22
+ * ```
23
+ */
24
+ export declare function assertPuppeteerSharedWithBeholder(): void;
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { findPackageDir } from './find-package-dir.js';
4
+ /**
5
+ * Verifies that crawler's own `puppeteer` install and the `puppeteer` used
6
+ * internally by `@d-zero/beholder` resolve to the same directory on disk.
7
+ *
8
+ * `Crawler#scrapePage` launches the browser through crawler's own
9
+ * `puppeteer` import and hands the resulting `Page` across the module
10
+ * boundary into `Scraper.scrapeStart()` (`@d-zero/beholder`). If crawler's
11
+ * pinned `puppeteer` version drifts from whatever version `@d-zero/beholder`
12
+ * depends on internally, yarn installs two separate `puppeteer` copies and
13
+ * the `Page` instances are structurally similar but not the same class —
14
+ * this surfaces as a `TS2345` deep inside application code with no
15
+ * indication that a version mismatch is the actual cause. Comparing the
16
+ * resolved directories catches the drift directly, at the boundary where it
17
+ * actually matters.
18
+ * @throws {Error} When crawler's and beholder's `puppeteer` resolve to
19
+ * different directories, naming both.
20
+ * @example
21
+ * ```ts
22
+ * import { assertPuppeteerSharedWithBeholder } from '@nitpicker/crawler';
23
+ *
24
+ * assertPuppeteerSharedWithBeholder();
25
+ * ```
26
+ */
27
+ export function assertPuppeteerSharedWithBeholder() {
28
+ const here = path.dirname(fileURLToPath(import.meta.url));
29
+ const crawlerPuppeteerDir = findPackageDir(here, 'puppeteer');
30
+ const beholderDir = findPackageDir(here, '@d-zero/beholder');
31
+ const beholderPuppeteerDir = findPackageDir(beholderDir, 'puppeteer');
32
+ if (crawlerPuppeteerDir !== beholderPuppeteerDir) {
33
+ throw new Error(`crawler's puppeteer (${crawlerPuppeteerDir}) and @d-zero/beholder's puppeteer (${beholderPuppeteerDir}) resolve to different installs. ` +
34
+ `Pin crawler's own "puppeteer" version in package.json to match the version @d-zero/beholder depends on internally.`);
35
+ }
36
+ }
@@ -0,0 +1,33 @@
1
+ import type { MainContentCustomElementCandidate } from './types.js';
2
+ import type { Page as PuppeteerPage } from 'puppeteer';
3
+ /**
4
+ * Captures every Web Component (custom element) inside a page's
5
+ * main-content region, in document order.
6
+ *
7
+ * `@d-zero/beholder`'s `MainContentsData` has no `customElements` category
8
+ * (confirmed by reading its `types.ts` / `get-main-contents.ts`), so the
9
+ * crawler runs this one extra `page.evaluate` after `scrapeStart` returns,
10
+ * while the page is still alive — the exact same pattern as
11
+ * {@link ./capture-image-dom-paths.ts}, which fills a different gap in
12
+ * beholder's image metadata for the same reason (beholder does not own
13
+ * this detection and does not need to; nitpicker already owns the live
14
+ * `page` object at this point in the scrape).
15
+ *
16
+ * Returns `undefined` on any evaluation failure (page context died,
17
+ * navigation raced the call) — custom-element capture is best-effort
18
+ * enrichment and a capture failure must not fail the scrape. Callers
19
+ * treat `undefined` as "unknown" (a distinct state from "zero elements
20
+ * found"), matching
21
+ * {@link ../archive/meta/compute-main-contents-denormalized.ts}'s
22
+ * three-value handling of `main_content_custom_element_count`.
23
+ * @param page - The live puppeteer page, after the scrape completed and
24
+ * before the browser closes.
25
+ * @param mainContentSelector - Optional selector override, forwarded
26
+ * verbatim to {@link ./collect-custom-elements.ts}.
27
+ * @returns Candidates in document order, or `undefined` when the page
28
+ * could not be evaluated.
29
+ * @example
30
+ * const result = await scraper.scrapeStart(page, url, options);
31
+ * const customElements = await captureCustomElements(page, options.mainContentSelector);
32
+ */
33
+ export declare function captureCustomElements(page: PuppeteerPage, mainContentSelector?: string | null): Promise<MainContentCustomElementCandidate[] | undefined>;
@@ -0,0 +1,39 @@
1
+ import { collectCustomElements } from './collect-custom-elements.js';
2
+ /**
3
+ * Captures every Web Component (custom element) inside a page's
4
+ * main-content region, in document order.
5
+ *
6
+ * `@d-zero/beholder`'s `MainContentsData` has no `customElements` category
7
+ * (confirmed by reading its `types.ts` / `get-main-contents.ts`), so the
8
+ * crawler runs this one extra `page.evaluate` after `scrapeStart` returns,
9
+ * while the page is still alive — the exact same pattern as
10
+ * {@link ./capture-image-dom-paths.ts}, which fills a different gap in
11
+ * beholder's image metadata for the same reason (beholder does not own
12
+ * this detection and does not need to; nitpicker already owns the live
13
+ * `page` object at this point in the scrape).
14
+ *
15
+ * Returns `undefined` on any evaluation failure (page context died,
16
+ * navigation raced the call) — custom-element capture is best-effort
17
+ * enrichment and a capture failure must not fail the scrape. Callers
18
+ * treat `undefined` as "unknown" (a distinct state from "zero elements
19
+ * found"), matching
20
+ * {@link ../archive/meta/compute-main-contents-denormalized.ts}'s
21
+ * three-value handling of `main_content_custom_element_count`.
22
+ * @param page - The live puppeteer page, after the scrape completed and
23
+ * before the browser closes.
24
+ * @param mainContentSelector - Optional selector override, forwarded
25
+ * verbatim to {@link ./collect-custom-elements.ts}.
26
+ * @returns Candidates in document order, or `undefined` when the page
27
+ * could not be evaluated.
28
+ * @example
29
+ * const result = await scraper.scrapeStart(page, url, options);
30
+ * const customElements = await captureCustomElements(page, options.mainContentSelector);
31
+ */
32
+ export async function captureCustomElements(page, mainContentSelector) {
33
+ try {
34
+ return await page.evaluate(collectCustomElements, mainContentSelector ?? null);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
@@ -44,9 +44,9 @@ export interface CloseBrowserSafelyDeps {
44
44
  * expiry guarantees the worker always completes and no orphan subprocesses are
45
45
  * left behind.
46
46
  *
47
- * The losing timer is cleared explicitly in `.finally()` so it never keeps the
48
- * event loop alive after the race settles (a plain `delay()` in `Promise.race`
49
- * would leak the timer until it fires).
47
+ * Timer cleanup is delegated to `raceWithTimeout` (`@d-zero/shared`), which
48
+ * clears the losing timer internally so it never keeps the event loop alive
49
+ * after the race settles.
50
50
  *
51
51
  * The tree-kill happens via {@link killProcessTree}, which enumerates
52
52
  * descendants through `ps` (POSIX) or delegates to `taskkill /T /F` (Windows).
@@ -1,3 +1,4 @@
1
+ import { raceWithTimeout } from '@d-zero/shared/race-with-timeout';
1
2
  import { crawlerLog } from '../debug.js';
2
3
  import { killProcessTree } from './kill-process-tree.js';
3
4
  /**
@@ -18,9 +19,9 @@ const DEFAULT_CLOSE_TIMEOUT_MS = 30 * 1000;
18
19
  * expiry guarantees the worker always completes and no orphan subprocesses are
19
20
  * left behind.
20
21
  *
21
- * The losing timer is cleared explicitly in `.finally()` so it never keeps the
22
- * event loop alive after the race settles (a plain `delay()` in `Promise.race`
23
- * would leak the timer until it fires).
22
+ * Timer cleanup is delegated to `raceWithTimeout` (`@d-zero/shared`), which
23
+ * clears the losing timer internally so it never keeps the event loop alive
24
+ * after the race settles.
24
25
  *
25
26
  * The tree-kill happens via {@link killProcessTree}, which enumerates
26
27
  * descendants through `ps` (POSIX) or delegates to `taskkill /T /F` (Windows).
@@ -40,20 +41,10 @@ export async function closeBrowserSafely(browser, timeoutMs = DEFAULT_CLOSE_TIME
40
41
  // releases its internal reference and process() returns null, so we would
41
42
  // have no handle to tree-kill on timeout.
42
43
  const childProcess = browser.process();
43
- let timeoutHandle;
44
- const timedOut = await Promise.race([
45
- browser
46
- .close()
47
- .then(() => false)
48
- .catch(() => false),
49
- new Promise((resolve) => {
50
- timeoutHandle = setTimeout(() => resolve(true), timeoutMs);
51
- }),
52
- ]).finally(() => {
53
- if (timeoutHandle) {
54
- clearTimeout(timeoutHandle);
55
- }
56
- });
44
+ const { timeout: timedOut } = await raceWithTimeout(() => browser
45
+ .close()
46
+ .then(() => { })
47
+ .catch(() => { }), timeoutMs);
57
48
  if (timedOut && childProcess && !childProcess.killed) {
58
49
  // Mark the Node ChildProcess as killed so Node's reaping logic treats
59
50
  // it correctly; then walk the OS process tree.
@@ -0,0 +1,33 @@
1
+ import type { MainContentCustomElementCandidate } from './types.js';
2
+ /**
3
+ * Collects every Web Component (custom element) inside a page's main-content
4
+ * region — its `nodeName`, `id`, and `classList` — in document order.
5
+ *
6
+ * **Self-contained by contract.** Passed verbatim to Puppeteer's
7
+ * `page.evaluate`, which serialises the function source and executes it
8
+ * inside the browser: it must not reference imports, module-scope bindings,
9
+ * or any closure state. This is why the main-content selector priority
10
+ * lists below are inlined rather than imported from
11
+ * `@d-zero/beholder`'s `main-content-selectors.ts` — the same
12
+ * closure-free constraint that forces beholder's own
13
+ * `extractMainContentsFromDocument` to inline them. The two copies (this
14
+ * file's and beholder's) are kept in sync by
15
+ * {@link ./collect-custom-elements.spec.ts}, which asserts the resolved
16
+ * main element matches beholder's own resolution for the same fixture.
17
+ *
18
+ * A "custom element" here means: the tag name (case-insensitively)
19
+ * contains a hyphen, and is not one of the eight hyphenated SVG/MathML
20
+ * element names the Custom Elements spec reserves as non-registrable
21
+ * (`annotation-xml`, `color-profile`, `font-face`, `font-face-src`,
22
+ * `font-face-uri`, `font-face-format`, `font-face-name`, `missing-glyph`).
23
+ * @param mainContentSelector - Optional selector prepended to the default
24
+ * list, mirroring beholder's `getMainContents` option of the same name.
25
+ * @param doc - The document to walk. Defaults to the global `document`,
26
+ * which is how the in-browser `page.evaluate(collectCustomElements, sel)`
27
+ * call resolves it; Node callers (specs) pass a jsdom document.
28
+ * @returns Candidates in document order, or an empty array when no
29
+ * main-content region can be resolved.
30
+ * @example
31
+ * const candidates = await page.evaluate(collectCustomElements, mainContentSelector);
32
+ */
33
+ export declare function collectCustomElements(mainContentSelector?: string | null, doc?: Document): MainContentCustomElementCandidate[];
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Collects every Web Component (custom element) inside a page's main-content
3
+ * region — its `nodeName`, `id`, and `classList` — in document order.
4
+ *
5
+ * **Self-contained by contract.** Passed verbatim to Puppeteer's
6
+ * `page.evaluate`, which serialises the function source and executes it
7
+ * inside the browser: it must not reference imports, module-scope bindings,
8
+ * or any closure state. This is why the main-content selector priority
9
+ * lists below are inlined rather than imported from
10
+ * `@d-zero/beholder`'s `main-content-selectors.ts` — the same
11
+ * closure-free constraint that forces beholder's own
12
+ * `extractMainContentsFromDocument` to inline them. The two copies (this
13
+ * file's and beholder's) are kept in sync by
14
+ * {@link ./collect-custom-elements.spec.ts}, which asserts the resolved
15
+ * main element matches beholder's own resolution for the same fixture.
16
+ *
17
+ * A "custom element" here means: the tag name (case-insensitively)
18
+ * contains a hyphen, and is not one of the eight hyphenated SVG/MathML
19
+ * element names the Custom Elements spec reserves as non-registrable
20
+ * (`annotation-xml`, `color-profile`, `font-face`, `font-face-src`,
21
+ * `font-face-uri`, `font-face-format`, `font-face-name`, `missing-glyph`).
22
+ * @param mainContentSelector - Optional selector prepended to the default
23
+ * list, mirroring beholder's `getMainContents` option of the same name.
24
+ * @param doc - The document to walk. Defaults to the global `document`,
25
+ * which is how the in-browser `page.evaluate(collectCustomElements, sel)`
26
+ * call resolves it; Node callers (specs) pass a jsdom document.
27
+ * @returns Candidates in document order, or an empty array when no
28
+ * main-content region can be resolved.
29
+ * @example
30
+ * const candidates = await page.evaluate(collectCustomElements, mainContentSelector);
31
+ */
32
+ export function collectCustomElements(mainContentSelector = null, doc = document) {
33
+ // Kept in sync with beholder's MAIN_CONTENT_SELECTORS
34
+ // (main-content-selectors.ts) — see the module JSDoc. Inlined rather
35
+ // than imported because this function must stay closure-free.
36
+ const selectors = [
37
+ 'main',
38
+ '[role="main"]',
39
+ '#main',
40
+ '.main',
41
+ '#content',
42
+ '.content',
43
+ '#contents',
44
+ '.contents',
45
+ '#main-content',
46
+ '.main-content',
47
+ '#main_content',
48
+ '.main_content',
49
+ '#mainContent',
50
+ '.mainContent',
51
+ ];
52
+ if (mainContentSelector) {
53
+ selectors.unshift(mainContentSelector);
54
+ }
55
+ let $main = null;
56
+ for (const sel of selectors) {
57
+ try {
58
+ $main = doc.querySelector(sel);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ if ($main) {
64
+ break;
65
+ }
66
+ }
67
+ if (!$main) {
68
+ // Kept in sync with beholder's MAIN_CONTENT_FALLBACK_SELECTORS.
69
+ const fallbackSelectors = [
70
+ '[id*="main" i]',
71
+ '[class*="main" i]',
72
+ '[id*="content" i]',
73
+ '[class*="content" i]',
74
+ ];
75
+ for (const sel of fallbackSelectors) {
76
+ const candidate = doc.querySelector(sel);
77
+ if (candidate && candidate !== doc.body && candidate !== doc.documentElement) {
78
+ $main = candidate;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ if (!$main)
84
+ return [];
85
+ // The eight hyphenated element names the Custom Elements spec reserves
86
+ // as non-registrable (SVG/MathML legacy names) — the sole exception to
87
+ // "a hyphenated tag name is a custom element".
88
+ const reservedHyphenatedNames = new Set([
89
+ 'annotation-xml',
90
+ 'color-profile',
91
+ 'font-face',
92
+ 'font-face-src',
93
+ 'font-face-uri',
94
+ 'font-face-format',
95
+ 'font-face-name',
96
+ 'missing-glyph',
97
+ ]);
98
+ const candidates = [];
99
+ for (const el of $main.querySelectorAll('*')) {
100
+ const tagName = el.nodeName.toLowerCase();
101
+ if (!tagName.includes('-') || reservedHyphenatedNames.has(tagName))
102
+ continue;
103
+ candidates.push({
104
+ nodeName: el.nodeName,
105
+ elementId: el.id || null,
106
+ classList: [...el.classList],
107
+ });
108
+ }
109
+ return candidates;
110
+ }