@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
@@ -0,0 +1,140 @@
1
+ import redirects from 'follow-redirects';
2
+ import { TECHNOLOGY_SIGNAL_DEFINITIONS } from '../archive/meta/technologies/technology-signal-definitions.js';
3
+ /** Default cap on bytes read from a JS resource before giving up on a match. */
4
+ const DEFAULT_BYTE_LIMIT = 8192;
5
+ /** Default network timeout, shorter than `fetchDestination`'s HEAD budget — this is a best-effort enrichment pass, not the crawl's critical path. */
6
+ const DEFAULT_TIMEOUT_MS = 8000;
7
+ const EVIDENCE_MAX_LENGTH = 200;
8
+ /** Definitions this scan tests against — only the `js-license-comment` signal type applies to JS resource bodies (the rest match HTML). */
9
+ const JS_LICENSE_COMMENT_DEFINITIONS = TECHNOLOGY_SIGNAL_DEFINITIONS.filter((def) => def.signalType === 'js-license-comment');
10
+ /**
11
+ * Reads up to `byteLimit` bytes from a JS resource's leading bytes and tests
12
+ * them against {@link TECHNOLOGY_SIGNAL_DEFINITIONS}' `js-license-comment`
13
+ * patterns (e.g. Vue's leading license-banner comment). Best-effort: any network
14
+ * failure, timeout, or non-2xx response resolves `null` rather than
15
+ * throwing — a single unreachable JS resource must not abort the enrichment
16
+ * pass over the rest of the archive's resources (see
17
+ * `scanJsResourcesForTechnologySignals`, this function's only caller).
18
+ *
19
+ * Not cached and not routed through `destinationCache` (unlike
20
+ * `fetchDestination`): callers are expected to persist the outcome in
21
+ * `technology_js_scan_cache`, keyed by `resourceId`, so a resource is never
22
+ * scanned twice across the archive's lifetime.
23
+ * @param url - The JS resource's absolute URL.
24
+ * @param options - Byte cap, timeout, and User-Agent overrides.
25
+ * @returns The first matching signal, or `null` when nothing matched (or
26
+ * the fetch failed).
27
+ * @example
28
+ * const signal = await scanJsResourceForLicenseComment('https://example.com/_astro/app.js');
29
+ * // { technology: 'Vue', signalType: 'js-license-comment', evidence: 'Vue.js license banner text', weight: 55, category: 'JavaScript frameworks' }
30
+ */
31
+ export async function scanJsResourceForLicenseComment(url, options = {}) {
32
+ const byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
33
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
34
+ const body = await readLeadingBytes(url, byteLimit, timeout, options.userAgent).catch(() => null);
35
+ if (body == null)
36
+ return null;
37
+ for (const def of JS_LICENSE_COMMENT_DEFINITIONS) {
38
+ const match = def.pattern.exec(body);
39
+ if (match) {
40
+ return {
41
+ technology: def.technology,
42
+ signalType: def.signalType,
43
+ evidence: match[0].slice(0, EVIDENCE_MAX_LENGTH),
44
+ weight: def.weight,
45
+ category: def.category ?? null,
46
+ };
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Streams up to `byteLimit` bytes of a GET response body, destroying the
53
+ * connection once the cap is reached (or the stream ends first). Mirrors
54
+ * `fetchDestination`'s `titleBytesLimit` byte-cap technique (same
55
+ * `follow-redirects` + manual chunk accumulation + `req.destroy()` shape),
56
+ * but generic over "leading bytes as text" rather than title extraction —
57
+ * this module has no `PageData` to build.
58
+ * @param url - The absolute URL to fetch.
59
+ * @param byteLimit - Byte cap on the accumulated body.
60
+ * @param timeout - Milliseconds before the request is aborted.
61
+ * @param userAgent - Optional User-Agent header.
62
+ * @returns The accumulated body text, or `null` on a non-2xx response.
63
+ */
64
+ async function readLeadingBytes(url, byteLimit, timeout, userAgent) {
65
+ return new Promise((resolve, reject) => {
66
+ const parsed = new URL(url);
67
+ const request = {
68
+ protocol: parsed.protocol,
69
+ hostname: parsed.hostname,
70
+ port: parsed.port || undefined,
71
+ path: `${parsed.pathname}${parsed.search}`,
72
+ method: 'GET',
73
+ trackRedirects: true,
74
+ headers: {
75
+ host: parsed.host,
76
+ ...(userAgent ? { 'User-Agent': userAgent } : {}),
77
+ Accept: '*/*',
78
+ 'Accept-Encoding': 'identity',
79
+ },
80
+ };
81
+ let req;
82
+ let destroyed = false;
83
+ let settled = false;
84
+ const timeoutHandle = setTimeout(() => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ destroyed = true;
89
+ req.destroy();
90
+ resolve(null);
91
+ }, timeout);
92
+ const response = (res) => {
93
+ const status = res.statusCode ?? 0;
94
+ if (status < 200 || status >= 300) {
95
+ settled = true;
96
+ clearTimeout(timeoutHandle);
97
+ destroyed = true;
98
+ req.destroy();
99
+ resolve(null);
100
+ return;
101
+ }
102
+ const chunks = [];
103
+ let totalBytes = 0;
104
+ res.on('data', (chunk) => {
105
+ if (settled)
106
+ return;
107
+ chunks.push(chunk);
108
+ totalBytes += chunk.length;
109
+ if (totalBytes >= byteLimit) {
110
+ settled = true;
111
+ clearTimeout(timeoutHandle);
112
+ destroyed = true;
113
+ req.destroy();
114
+ resolve(Buffer.concat(chunks).toString('utf8'));
115
+ }
116
+ });
117
+ res.on('end', () => {
118
+ if (settled)
119
+ return;
120
+ settled = true;
121
+ clearTimeout(timeoutHandle);
122
+ resolve(Buffer.concat(chunks).toString('utf8'));
123
+ });
124
+ };
125
+ if (parsed.protocol === 'https:') {
126
+ req = redirects.https.request(request, response);
127
+ }
128
+ else {
129
+ req = redirects.http.request(request, response);
130
+ }
131
+ req.on('error', (error) => {
132
+ clearTimeout(timeoutHandle);
133
+ // Ignore errors caused by our own intentional req.destroy() above.
134
+ if (destroyed)
135
+ return;
136
+ reject(error);
137
+ });
138
+ req.end();
139
+ });
140
+ }
@@ -0,0 +1,58 @@
1
+ import type { ArchiveAccessor } from '../archive/archive-accessor.js';
2
+ /** Options for {@link scanJsResourcesForTechnologySignals}. */
3
+ export interface ScanJsResourcesForTechnologySignalsOptions {
4
+ /** Maximum concurrent network fetches. Defaults to {@link DEFAULT_CONCURRENCY}. */
5
+ concurrency?: number;
6
+ /** Forwarded to `scanJsResourceForLicenseComment`. */
7
+ byteLimit?: number;
8
+ /** Forwarded to `scanJsResourceForLicenseComment`. */
9
+ timeout?: number;
10
+ /** Forwarded to `scanJsResourceForLicenseComment`. */
11
+ userAgent?: string;
12
+ /** Called after each resource finishes scanning (matched or not). */
13
+ onProgress?: (done: number, total: number) => void;
14
+ }
15
+ /** Outcome counters returned by {@link scanJsResourcesForTechnologySignals}. */
16
+ export interface ScanJsResourcesForTechnologySignalsResult {
17
+ /** JS resources eligible for scanning (internal, not yet cached). */
18
+ candidateCount: number;
19
+ /** Resources actually scanned this run (equals `candidateCount` barring a mid-run crash). */
20
+ scannedCount: number;
21
+ /** Resources whose leading bytes matched a known license comment. */
22
+ matchedCount: number;
23
+ /** Distinct pages whose `technology_signals` / `page_technologies` were updated as a result. */
24
+ pagesUpdatedCount: number;
25
+ }
26
+ /**
27
+ * Post-crawl network enrichment (distinct from crawl-time extraction and
28
+ * from read-model-time backfill — see ARCHITECTURE.md): re-fetches the
29
+ * leading bytes of every not-yet-scanned internal JS resource, tests them
30
+ * for a known technology's license comment, and folds any match into the
31
+ * referencing pages' `technology_signals` / `page_technologies`.
32
+ *
33
+ * Each resource is scanned at most once ever, across the archive's whole
34
+ * lifetime — outcomes (including non-matches) are recorded in
35
+ * `technology_js_scan_cache` keyed by `resourceId`, so a later
36
+ * `--append`/`--retry-failed` run only pays the network cost for resources
37
+ * discovered since the last run.
38
+ *
39
+ * A single resource can be referenced by many pages (a shared bundle); a
40
+ * match is applied to every one of them independently. Network scanning
41
+ * runs at bounded concurrency; the per-page DB recombination that follows
42
+ * runs after every scan has settled, never concurrently for the same page,
43
+ * so two resources that both resolve to the same page cannot race each
44
+ * other's read-modify-write.
45
+ *
46
+ * Best-effort like `scanJsResourceForLicenseComment`: an unreachable
47
+ * resource is recorded as a non-match (cached as scanned, `technology:
48
+ * null`) rather than retried or surfaced as an error — a flaky CDN must not
49
+ * block the rest of the archive's enrichment, and the resource will not be
50
+ * retried until `technology_js_scan_cache` itself is cleared.
51
+ * @param accessor - The archive to enrich.
52
+ * @param options - Concurrency, byte-cap, timeout, and progress overrides.
53
+ * @returns Counters describing what was scanned, matched, and updated.
54
+ * @example
55
+ * const result = await scanJsResourcesForTechnologySignals(archive);
56
+ * // { candidateCount: 42, scannedCount: 42, matchedCount: 3, pagesUpdatedCount: 57 }
57
+ */
58
+ export declare function scanJsResourcesForTechnologySignals(accessor: ArchiveAccessor, options?: ScanJsResourcesForTechnologySignalsOptions): Promise<ScanJsResourcesForTechnologySignalsResult>;
@@ -0,0 +1,196 @@
1
+ import { combineTechnologyConfidence } from '../archive/meta/technologies/combine-technology-confidence.js';
2
+ import { scanJsResourceForLicenseComment } from './scan-js-resource-for-license-comment.js';
3
+ /**
4
+ * Content-Type strings this scan treats as JavaScript. Deliberately
5
+ * duplicated (not imported) from `@nitpicker/query`'s `content-type-rules.ts`
6
+ * `'javascript'` category rule: the crawler package must not depend back on
7
+ * query (see `ensure-viewer-read-model-quietly.ts`'s docs for the same
8
+ * boundary), and this list is small and stable enough that duplication is
9
+ * cheaper than introducing a shared third package for it.
10
+ */
11
+ const JS_CONTENT_TYPES = [
12
+ 'text/javascript',
13
+ 'application/javascript',
14
+ 'application/x-javascript',
15
+ 'application/ecmascript',
16
+ ];
17
+ const DEFAULT_CONCURRENCY = 4;
18
+ /**
19
+ * Runs `worker` over `items` with at most `concurrency` calls in flight at
20
+ * once. A minimal worker-pool, not a chunk-then-parallelize helper like
21
+ * `eachSplitted` (which parallelizes across chunks, not within one) — this
22
+ * module needs a true cap on simultaneous outbound HTTP connections.
23
+ * @param items - The items to process.
24
+ * @param concurrency - Maximum simultaneous `worker` calls.
25
+ * @param worker - Called once per item; errors propagate to the caller.
26
+ */
27
+ async function runWithConcurrency(items, concurrency, worker) {
28
+ let nextIndex = 0;
29
+ /**
30
+ *
31
+ */
32
+ async function runNext() {
33
+ const index = nextIndex++;
34
+ if (index >= items.length)
35
+ return;
36
+ await worker(items[index]);
37
+ await runNext();
38
+ }
39
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => runNext()));
40
+ }
41
+ /**
42
+ * Re-derives one page's `technology_signals` + `page_technologies` rows from
43
+ * its existing persisted signals plus newly-discovered `js-license-comment`
44
+ * signals, then scoped-replaces both tables — the same full-per-page-replace
45
+ * invariant `insertTechnologies` follows (see its docs: the two tables are
46
+ * never updated independently).
47
+ *
48
+ * `technology_signals` does not persist `category`/`version` (only
49
+ * `page_technologies` does — see `create-adjunct-tables.ts`), so a technology
50
+ * whose category/version came from a signal not re-derivable from the
51
+ * persisted rows (a `wappalyzer` or `meta-generator` signal, both computed at
52
+ * crawl time from data this function does not have) would otherwise regress
53
+ * to `null` here. The existing `page_technologies` row is read first and its
54
+ * `category`/`version` fall back in wherever the freshly combined result has
55
+ * none.
56
+ * @param knex - The archive's Knex instance.
57
+ * @param pageId - The page to update.
58
+ * @param newSignals - Newly-discovered signals for this page (from JS
59
+ * resources it references).
60
+ */
61
+ async function applyNewSignalsToPage(knex, pageId, newSignals) {
62
+ await knex.transaction(async (trx) => {
63
+ const existingSignalRows = await trx('technology_signals')
64
+ .where('pageId', pageId)
65
+ .select('technology', 'signalType', 'evidence', 'weight');
66
+ const existingTechnologyRows = await trx('page_technologies')
67
+ .where('pageId', pageId)
68
+ .select('technology', 'category', 'version');
69
+ const existingMetaByTechnology = new Map(existingTechnologyRows.map((row) => [row.technology, row]));
70
+ const allSignals = [...existingSignalRows, ...newSignals];
71
+ const technologies = combineTechnologyConfidence(allSignals).map((t) => {
72
+ const previous = existingMetaByTechnology.get(t.technology);
73
+ return {
74
+ ...t,
75
+ category: t.category ?? previous?.category ?? null,
76
+ version: t.version ?? previous?.version ?? null,
77
+ };
78
+ });
79
+ await trx('technology_signals').where('pageId', pageId).delete();
80
+ await trx('page_technologies').where('pageId', pageId).delete();
81
+ await trx('technology_signals').insert(allSignals.map((s) => ({
82
+ pageId,
83
+ technology: s.technology,
84
+ signalType: s.signalType,
85
+ evidence: s.evidence,
86
+ weight: s.weight,
87
+ })));
88
+ if (technologies.length > 0) {
89
+ await trx('page_technologies').insert(technologies.map((t) => ({ pageId, ...t })));
90
+ }
91
+ });
92
+ }
93
+ /**
94
+ * Post-crawl network enrichment (distinct from crawl-time extraction and
95
+ * from read-model-time backfill — see ARCHITECTURE.md): re-fetches the
96
+ * leading bytes of every not-yet-scanned internal JS resource, tests them
97
+ * for a known technology's license comment, and folds any match into the
98
+ * referencing pages' `technology_signals` / `page_technologies`.
99
+ *
100
+ * Each resource is scanned at most once ever, across the archive's whole
101
+ * lifetime — outcomes (including non-matches) are recorded in
102
+ * `technology_js_scan_cache` keyed by `resourceId`, so a later
103
+ * `--append`/`--retry-failed` run only pays the network cost for resources
104
+ * discovered since the last run.
105
+ *
106
+ * A single resource can be referenced by many pages (a shared bundle); a
107
+ * match is applied to every one of them independently. Network scanning
108
+ * runs at bounded concurrency; the per-page DB recombination that follows
109
+ * runs after every scan has settled, never concurrently for the same page,
110
+ * so two resources that both resolve to the same page cannot race each
111
+ * other's read-modify-write.
112
+ *
113
+ * Best-effort like `scanJsResourceForLicenseComment`: an unreachable
114
+ * resource is recorded as a non-match (cached as scanned, `technology:
115
+ * null`) rather than retried or surfaced as an error — a flaky CDN must not
116
+ * block the rest of the archive's enrichment, and the resource will not be
117
+ * retried until `technology_js_scan_cache` itself is cleared.
118
+ * @param accessor - The archive to enrich.
119
+ * @param options - Concurrency, byte-cap, timeout, and progress overrides.
120
+ * @returns Counters describing what was scanned, matched, and updated.
121
+ * @example
122
+ * const result = await scanJsResourcesForTechnologySignals(archive);
123
+ * // { candidateCount: 42, scannedCount: 42, matchedCount: 3, pagesUpdatedCount: 57 }
124
+ */
125
+ export async function scanJsResourcesForTechnologySignals(accessor, options = {}) {
126
+ const knex = accessor.getKnex();
127
+ const concurrency = Math.max(options.concurrency ?? DEFAULT_CONCURRENCY, 1);
128
+ const candidates = await knex('resource_items as ri')
129
+ .join('url_refs as ur', 'ur.id', 'ri.url_id')
130
+ .leftJoin('content_type_refs as ctr', 'ctr.id', 'ri.content_type_id')
131
+ .leftJoin('technology_js_scan_cache as cache', 'cache.resourceId', 'ri.id')
132
+ .where('ri.is_external', 0)
133
+ .whereNull('cache.resourceId')
134
+ .where((qb) => {
135
+ qb.whereIn('ctr.raw', JS_CONTENT_TYPES)
136
+ .orWhere('ur.url', 'like', '%.js')
137
+ .orWhere('ur.url', 'like', '%.js?%')
138
+ .orWhere('ur.url', 'like', '%.mjs')
139
+ .orWhere('ur.url', 'like', '%.mjs?%');
140
+ })
141
+ .select('ri.id as resourceId', 'ur.url as url');
142
+ let scannedCount = 0;
143
+ const matchesByResourceId = new Map();
144
+ await runWithConcurrency(candidates, concurrency, async (candidate) => {
145
+ const signal = await scanJsResourceForLicenseComment(candidate.url, {
146
+ byteLimit: options.byteLimit,
147
+ timeout: options.timeout,
148
+ userAgent: options.userAgent,
149
+ });
150
+ scannedCount++;
151
+ options.onProgress?.(scannedCount, candidates.length);
152
+ await knex('technology_js_scan_cache').insert({
153
+ resourceId: candidate.resourceId,
154
+ scannedAt: Date.now(),
155
+ technology: signal?.technology ?? null,
156
+ evidence: signal?.evidence ?? null,
157
+ });
158
+ if (signal) {
159
+ matchesByResourceId.set(candidate.resourceId, signal);
160
+ }
161
+ });
162
+ if (matchesByResourceId.size === 0) {
163
+ return {
164
+ candidateCount: candidates.length,
165
+ scannedCount,
166
+ matchedCount: 0,
167
+ pagesUpdatedCount: 0,
168
+ };
169
+ }
170
+ const edges = await knex('resource_ref_edges')
171
+ .whereIn('resource_id', [...matchesByResourceId.keys()])
172
+ .select('resource_id', 'page_id');
173
+ const newSignalsByPageId = new Map();
174
+ for (const edge of edges) {
175
+ const signal = matchesByResourceId.get(edge.resource_id);
176
+ if (!signal)
177
+ continue;
178
+ const list = newSignalsByPageId.get(edge.page_id);
179
+ if (list) {
180
+ list.push(signal);
181
+ }
182
+ else {
183
+ newSignalsByPageId.set(edge.page_id, [signal]);
184
+ }
185
+ }
186
+ const affectedPageIds = [...newSignalsByPageId.keys()];
187
+ await runWithConcurrency(affectedPageIds, concurrency, async (pageId) => {
188
+ await applyNewSignalsToPage(knex, pageId, newSignalsByPageId.get(pageId));
189
+ });
190
+ return {
191
+ candidateCount: candidates.length,
192
+ scannedCount,
193
+ matchedCount: matchesByResourceId.size,
194
+ pagesUpdatedCount: affectedPageIds.length,
195
+ };
196
+ }
@@ -42,8 +42,18 @@ export interface RedirectEdgeResult {
42
42
  * The outcome of {@link Crawler.#scrapePage}: either a normal scrape result from
43
43
  * the browser/HEAD pipeline, or a {@link RedirectEdgeResult} when the URL's
44
44
  * redirect destination was already rendered and only the edge needs recording.
45
+ *
46
+ * The non-redirect-edge member is {@link BrowserScrapeResult}, not bare
47
+ * beholder `ScrapeResult` — every `#scrapePage` return site is either a
48
+ * `_launchBrowserAndScrape` call (which returns `BrowserScrapeResult`) or a
49
+ * plain `ScrapeResult`-shaped literal (structurally compatible, since
50
+ * `postNavigationUrl` is optional). Callers that reach a `type: 'error'`
51
+ * result and need to know whether the browser ended up off-host before
52
+ * failing (`pageData` is absent on error, so `pageData.isExternal` is not
53
+ * available) read `postNavigationUrl` for that signal — see
54
+ * `resolveResultWentOffHost`.
45
55
  */
46
- export type ScrapeOutcome = ScrapeResult | RedirectEdgeResult;
56
+ export type ScrapeOutcome = BrowserScrapeResult | RedirectEdgeResult;
47
57
  /**
48
58
  * Internal envelope returned by {@link Crawler.#launchBrowserAndScrape} that
49
59
  * augments beholder's {@link ScrapeResult} with the puppeteer-side
@@ -301,6 +311,14 @@ export interface CrawlerEventTypes {
301
311
  * common case outside `crawl --inventory`. See {@link PageSource}.
302
312
  */
303
313
  source?: PageSource;
314
+ /**
315
+ * This page's body hash, precomputed by the crawler from `result.html`
316
+ * (non-null whenever `result.html.length > 0`, since this event is only
317
+ * emitted for internal pages). Forwarded through `Archive.setPage` to
318
+ * `update-page.ts` so `page_meta.body_hash` is written from this value
319
+ * instead of hashing the same html a second time.
320
+ */
321
+ bodyHash?: Buffer | null;
304
322
  };
305
323
  /**
306
324
  * Emitted when an external page (outside the crawl scope) has been scraped.
@@ -475,6 +493,21 @@ export interface CrawlerEventTypes {
475
493
  observedCount: number;
476
494
  };
477
495
  }
496
+ /**
497
+ * One Web Component (custom element) found inside a page's main-content
498
+ * region by {@link ./collect-custom-elements.ts}. Unlike beholder's eight
499
+ * `MainContentsData` categories (headings/images/tables/buttons/iframes/
500
+ * videos/audios/canvases), this is captured independently by nitpicker
501
+ * itself — see {@link ./capture-custom-elements.ts} for why.
502
+ */
503
+ export interface MainContentCustomElementCandidate {
504
+ /** The element's `nodeName` (always upper-cased in an HTML document, e.g. `MY-WIDGET`). */
505
+ nodeName: string;
506
+ /** The element's `id` attribute, or `null` when absent. */
507
+ elementId: string | null;
508
+ /** The element's class list, in DOM order. */
509
+ classList: string[];
510
+ }
478
511
  /**
479
512
  * Tunables for `NetworkOutageDetector`.
480
513
  */
@@ -1,7 +1,7 @@
1
1
  import type { Config } from './archive/types.js';
2
2
  import type { NetworkProbe } from './crawler/probe-network.js';
3
3
  import type { InventoryMode } from './crawler/types.js';
4
- import type { CrawlEvent } from './types.js';
4
+ import type { CrawlEvent, SetupProgressCallbacks } from './types.js';
5
5
  import type { ExURL } from '@d-zero/shared/parse-url';
6
6
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
7
7
  import Archive from './archive/archive.js';
@@ -111,10 +111,13 @@ interface InventorySource {
111
111
  * writes the final archive file. It emits events defined by {@link CrawlEvent}.
112
112
  *
113
113
  * Instances are created via the static factory methods {@link CrawlerOrchestrator.crawling}
114
- * or {@link CrawlerOrchestrator.resume}; the constructor is private.
114
+ * or {@link CrawlerOrchestrator.resume}; the constructor is private. Implements
115
+ * `Symbol.asyncDispose` so callers can use `await using` to close the archive
116
+ * and reap zombie Chromium processes on scope exit instead of a manual
117
+ * `try`/`finally` around `archive.close()` + `garbageCollect()`.
115
118
  * @example
116
119
  * ```ts
117
- * const orchestrator = await CrawlerOrchestrator.crawling(['https://example.com'], { recursive: true });
120
+ * await using orchestrator = await CrawlerOrchestrator.crawling(['https://example.com'], { recursive: true });
118
121
  * await orchestrator.write();
119
122
  * ```
120
123
  */
@@ -125,6 +128,23 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
125
128
  */
126
129
  get archive(): Archive;
127
130
  private constructor();
131
+ /**
132
+ * Enables `await using orchestrator = ...`. Closes the archive (write
133
+ * or remove tmpDir + release the lock, per {@link Archive.close}) and
134
+ * then reaps any zombie Chromium processes via {@link garbageCollect} —
135
+ * the same two-step teardown every CLI crawl command previously
136
+ * repeated by hand in a `finally` block.
137
+ *
138
+ * Relays `Archive.close()`'s recovery-write progress (issue #294) as
139
+ * `recoveringArchiveWrite`/`writeStep`/`writeTarProgress` — the same
140
+ * events `write()` emits — for the rare case where the file doesn't
141
+ * exist on disk yet at dispose time (e.g. an explicit `write()` call
142
+ * threw partway through). A CLI listener whose display is still open at
143
+ * that point (it hadn't yet seen `writeFileEnd`) picks these up for
144
+ * free; one that already tore down after the earlier failure silently
145
+ * drops them, same as any other post-close display update.
146
+ */
147
+ [Symbol.asyncDispose](): Promise<void>;
128
148
  /**
129
149
  * Abort the current crawl operation.
130
150
  *
@@ -173,8 +193,11 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
173
193
  * the time `write()` is called those tables are already populated.
174
194
  * This method just tars.
175
195
  *
176
- * Emits `writeFileStart` before writing and `writeFileEnd` after
177
- * the write completes successfully.
196
+ * Emits `writeFileStart` before writing and `writeFileEnd` after the
197
+ * write completes successfully. Also relays `Archive.write()`'s
198
+ * per-step (`writeStep`) and tar-byte (`writeTarProgress`) progress
199
+ * (issue #294) — tarring a 15 GB+ archive can take minutes, and without
200
+ * these events a CLI listener has no way to show it isn't hung.
178
201
  */
179
202
  write(): Promise<void>;
180
203
  /**
@@ -211,10 +234,14 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
211
234
  * @param newUrls - New root URLs to add and crawl.
212
235
  * @param options - Optional config overrides applied on top of the archived config.
213
236
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
237
+ * @param setupProgress - Optional progress callbacks for the setup phase
238
+ * (untar, `.bak` copy, repromote, state rebuild) that runs before
239
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
240
+ * this can't go through the orchestrator's event emitter (issue #294).
214
241
  * @returns The orchestrator instance after the append crawl completes.
215
242
  * @throws {Error} When `newUrls` is empty, the archive is in list mode, or it cannot be parsed.
216
243
  */
217
- static append(archivePath: string, newUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback): Promise<CrawlerOrchestrator>;
244
+ static append(archivePath: string, newUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, setupProgress?: SetupProgressCallbacks): Promise<CrawlerOrchestrator>;
218
245
  /**
219
246
  * Inventory mode: cross-reference a user-supplied URL list against an
220
247
  * existing `.nitpicker` archive and import ONLY the URLs that are not yet
@@ -292,10 +319,15 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
292
319
  * URLs. Pass `null` for programmatic callers that built
293
320
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
294
321
  * column will be `NULL` and no source list is archived.
322
+ * @param setupProgress - Optional progress callbacks for the setup phase
323
+ * (untar, scope classification, bulk inserts, state rebuild) that runs
324
+ * before `initializedCallback` — see {@link SetupProgressCallbacks} for
325
+ * why this can't go through the orchestrator's event emitter (issue
326
+ * #294).
295
327
  * @returns The orchestrator instance after a successful inventory pass.
296
328
  * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
297
329
  */
298
- static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null): Promise<CrawlerOrchestrator>;
330
+ static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null, setupProgress?: SetupProgressCallbacks): Promise<CrawlerOrchestrator>;
299
331
  /**
300
332
  * Re-fetch previously-failed pages in an existing `.nitpicker` archive.
301
333
  *
@@ -326,10 +358,14 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
326
358
  * @param archivePath - Absolute or relative path to the existing `.nitpicker`.
327
359
  * @param options - Optional config overrides applied on top of the archived config.
328
360
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
361
+ * @param setupProgress - Optional progress callbacks for the setup phase
362
+ * (untar, `.bak` copy, reset, state rebuild) that runs before
363
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
364
+ * this can't go through the orchestrator's event emitter (issue #294).
329
365
  * @returns The orchestrator instance after the retry crawl completes.
330
366
  * @throws {Error} When the archive is in list mode or has no parseable roots.
331
367
  */
332
- static retryFailed(archivePath: string, options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback): Promise<CrawlerOrchestrator>;
368
+ static retryFailed(archivePath: string, options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, setupProgress?: SetupProgressCallbacks): Promise<CrawlerOrchestrator>;
333
369
  /**
334
370
  * Resume a previously interrupted crawl from an existing archive file.
335
371
  *
@@ -339,9 +375,17 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
339
375
  * @param stubPath - Path to the existing archive file to resume from.
340
376
  * @param options - Optional configuration overrides to apply on top of the archived config.
341
377
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
378
+ * @param setupProgress - Optional progress callbacks for the setup phase
379
+ * (self-healing migrations, state rebuild) that runs before
380
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
381
+ * this can't go through the orchestrator's event emitter (issue #294).
382
+ * No `onExtractProgress`/`onCopyProgress`: unlike `append`/`inventory`/
383
+ * `retryFailed`, `resume` reconnects to an existing tmpDir (no untar)
384
+ * and takes no `.bak` (nothing to restore — the interrupted crawl's
385
+ * tmpDir IS the source of truth).
342
386
  * @returns A promise that resolves to the CrawlerOrchestrator instance after crawling completes.
343
387
  * @throws {Error} If the archived URL is invalid.
344
388
  */
345
- static resume(stubPath: string, options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback): Promise<CrawlerOrchestrator>;
389
+ static resume(stubPath: string, options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, setupProgress?: SetupProgressCallbacks): Promise<CrawlerOrchestrator>;
346
390
  }
347
391
  export {};