@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,4 +1,13 @@
1
+ import { open } from 'node:fs/promises';
1
2
  import { list } from 'tar';
3
+ import { parsePaxPath } from './parse-pax-path.js';
4
+ import { parseTarSizeField } from './parse-tar-size-field.js';
5
+ /** Bytes per tar header/data block (fixed by the tar format). */
6
+ const BLOCK_SIZE = 512;
7
+ /** Defensive cap on entries scanned before giving up and falling back to `list()`. */
8
+ const MAX_ENTRIES = 100_000;
9
+ /** Cap on PAX/GNU-longname payload size read into memory; larger is treated as malformed. */
10
+ const MAX_EXTENDED_PAYLOAD_SIZE = 1_000_000;
2
11
  /**
3
12
  * Reads the top-level directory name from a `.nitpicker` tar archive.
4
13
  *
@@ -13,9 +22,16 @@ import { list } from 'tar';
13
22
  * directory it sees, so callers (`Archive.open`, the migration script) can
14
23
  * use the actual inner name regardless of what the outer file is called.
15
24
  *
16
- * Reads the tar's central directory only; does not extract any data. Runs
17
- * in O(number of entries) but stops at the first match, so a typical
18
- * archive (1–2 top-level entries) completes in a single read.
25
+ * Parses raw tar headers directly (ustar fixed fields, PAX extended headers,
26
+ * GNU longname/longlink) instead of using the `tar` package's `list()`
27
+ * `list()` reads the entire archive stream to completion even after
28
+ * `onReadEntry` has found what it needs (no internal early-abort), so on a
29
+ * 15 GB+ archive it was a second full read on top of `untar()`'s own read
30
+ * (issue #294). The fast path here stops at the first top-level directory
31
+ * entry, typically reading well under 1 KB. Falls back to the original
32
+ * `list()`-based scan on anything it doesn't recognize (unknown typeflag
33
+ * sequence, truncated read, non-tar content) — safety over speed for
34
+ * archives this function has never been exercised against.
19
35
  * @param tarFilePath - Path to the `.nitpicker` tar file.
20
36
  * @returns The first top-level directory name found in the archive.
21
37
  * @throws {Error} When the tar contains no top-level directory entry
@@ -27,6 +43,151 @@ import { list } from 'tar';
27
43
  * await peekTarTopDir('renamed.nitpicker'); // → 'original'
28
44
  */
29
45
  export async function peekTarTopDir(tarFilePath) {
46
+ const fast = await peekTarTopDirFast(tarFilePath);
47
+ if (fast !== null) {
48
+ return fast;
49
+ }
50
+ return await peekTarTopDirViaList(tarFilePath);
51
+ }
52
+ /**
53
+ * Returns `true` for a top-level directory name that should be skipped: an
54
+ * AppleDouble resource-fork sidecar (`._foo`, from macOS BSD tar) or a PAX
55
+ * header directory (`PaxHeaders.NNN`/`@PaxHeader`). Real `.nitpicker`
56
+ * archives never name their actual top dir with these prefixes. Shared by
57
+ * both the fast path and the `list()` fallback so the two can't drift into
58
+ * accepting different names for the same archive.
59
+ * @param name - Candidate top-level directory name.
60
+ */
61
+ function isSkippableTopDirName(name) {
62
+ return (name.startsWith('._') ||
63
+ name.startsWith('PaxHeaders') ||
64
+ name.startsWith('@PaxHeader'));
65
+ }
66
+ /**
67
+ * Extracts the first path segment from a tar entry path, after stripping a
68
+ * leading `./` some tar producers prepend.
69
+ * @param entryPath - The raw path recorded in (or resolved for) a tar entry.
70
+ */
71
+ function topSegmentOf(entryPath) {
72
+ return entryPath.replace(/^\.\//, '').split('/')[0] ?? '';
73
+ }
74
+ /**
75
+ * Reads a NUL-terminated (or full-width, if no NUL) string field out of a
76
+ * tar header block.
77
+ * @param header - The 512-byte header block.
78
+ * @param start - Field start offset.
79
+ * @param length - Field length in bytes.
80
+ */
81
+ function readHeaderString(header, start, length) {
82
+ const field = header.subarray(start, start + length);
83
+ const nulIndex = field.indexOf(0);
84
+ const raw = nulIndex === -1 ? field : field.subarray(0, nulIndex);
85
+ return raw.toString('utf8');
86
+ }
87
+ /**
88
+ * Fast path for {@link peekTarTopDir}: reads raw 512-byte tar header blocks
89
+ * directly from the file, stopping at the first top-level directory entry.
90
+ * Understands ustar fixed headers, PAX extended headers (`path=`, the form
91
+ * `tar`/node-tar emits for UTF-8 or >100-byte names — verified empirically:
92
+ * see this function's test fixtures), and GNU longname (`typeflag 'L'`,
93
+ * treated the same as a PAX path override for the next entry — this
94
+ * codebase's own `tar()` never emits it, but a `.nitpicker` produced by a
95
+ * different tar implementation should still resolve correctly). GNU
96
+ * longlink (`typeflag 'K'`, a symlink target — irrelevant to a directory
97
+ * name lookup) is skipped without being applied to anything.
98
+ *
99
+ * Returns `null` — never throws — on anything unrecognized: truncated read,
100
+ * unparseable size field, entry count past {@link MAX_ENTRIES}, or reaching
101
+ * the end-of-archive marker without finding a directory. The caller falls
102
+ * back to the exhaustive `list()`-based scan in every `null` case.
103
+ * @param tarFilePath - Path to the `.nitpicker` tar file.
104
+ * @returns The first top-level directory name, or `null` to fall back.
105
+ */
106
+ async function peekTarTopDirFast(tarFilePath) {
107
+ let fileHandle;
108
+ try {
109
+ fileHandle = await open(tarFilePath, 'r');
110
+ const header = Buffer.alloc(BLOCK_SIZE);
111
+ let position = 0;
112
+ // Set by a preceding PAX ('x') or GNU longname ('L') entry; applies
113
+ // to the very next entry only, then is cleared.
114
+ let pendingPath = null;
115
+ for (let entryIndex = 0; entryIndex < MAX_ENTRIES; entryIndex++) {
116
+ const { bytesRead } = await fileHandle.read(header, 0, BLOCK_SIZE, position);
117
+ if (bytesRead < BLOCK_SIZE) {
118
+ return null;
119
+ }
120
+ position += BLOCK_SIZE;
121
+ if (header.every((byte) => byte === 0)) {
122
+ // End-of-archive marker reached with no directory found —
123
+ // genuinely absent, not a parse failure, but the caller's
124
+ // error message is clearer coming from the exhaustive path.
125
+ return null;
126
+ }
127
+ const typeflag = String.fromCodePoint(header[156] ?? 0);
128
+ const size = parseTarSizeField(header.subarray(124, 136));
129
+ if (size === null) {
130
+ return null;
131
+ }
132
+ const dataBlockCount = Math.ceil(size / BLOCK_SIZE);
133
+ const dataStart = position;
134
+ position += dataBlockCount * BLOCK_SIZE;
135
+ if (typeflag === 'x' || typeflag === 'L') {
136
+ if (size <= 0 || size > MAX_EXTENDED_PAYLOAD_SIZE) {
137
+ return null;
138
+ }
139
+ const payload = Buffer.alloc(size);
140
+ const { bytesRead: payloadBytesRead } = await fileHandle.read(payload, 0, size, dataStart);
141
+ if (payloadBytesRead !== size) {
142
+ return null;
143
+ }
144
+ pendingPath =
145
+ typeflag === 'x'
146
+ ? parsePaxPath(payload)
147
+ : payload.toString('utf8').replace(/\0.*$/, '');
148
+ continue;
149
+ }
150
+ if (typeflag === 'g' || typeflag === 'K') {
151
+ // PAX global header (applies archive-wide, never carries a
152
+ // per-entry path) / GNU longlink (symlink target, irrelevant
153
+ // to a directory name lookup) — skip without consuming
154
+ // `pendingPath`.
155
+ continue;
156
+ }
157
+ const magic = header.toString('latin1', 257, 263);
158
+ const nameField = readHeaderString(header, 0, 100);
159
+ const prefixField = magic.startsWith('ustar')
160
+ ? readHeaderString(header, 345, 155)
161
+ : '';
162
+ const entryPath = pendingPath ?? (prefixField ? `${prefixField}/${nameField}` : nameField);
163
+ pendingPath = null;
164
+ if (typeflag === '5') {
165
+ const top = topSegmentOf(entryPath);
166
+ if (top && !isSkippableTopDirName(top)) {
167
+ return top;
168
+ }
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ finally {
177
+ await fileHandle?.close();
178
+ }
179
+ }
180
+ /**
181
+ * Exhaustive fallback for {@link peekTarTopDir}: scans the tar's entry list
182
+ * via the `tar` package. Reads the archive to completion regardless of when
183
+ * a match is found (no early-abort in the `tar` package's `list()` API),
184
+ * which is exactly the cost {@link peekTarTopDirFast} exists to avoid — this
185
+ * path only runs when the fast path can't make sense of the header stream.
186
+ * @param tarFilePath - Path to the `.nitpicker` tar file.
187
+ * @returns The first top-level directory name found in the archive.
188
+ * @throws {Error} When the tar contains no top-level directory entry.
189
+ */
190
+ async function peekTarTopDirViaList(tarFilePath) {
30
191
  let found = null;
31
192
  await list({
32
193
  file: tarFilePath,
@@ -41,19 +202,8 @@ export async function peekTarTopDir(tarFilePath) {
41
202
  // of the real top-level dir would win instead of the dir itself.
42
203
  if (entry.type !== 'Directory')
43
204
  return;
44
- // `entry.path` is the path inside the tar. Strip any leading
45
- // `./` (some tar producers prepend it) before splitting.
46
- const cleaned = entry.path.replace(/^\.\//, '');
47
- const top = cleaned.split('/')[0];
48
- if (!top || top === '')
49
- return;
50
- // Defense in depth: skip AppleDouble directory entries
51
- // (`._foo/`) and pax extended header entries
52
- // (`PaxHeaders.NNN/...` / `@PaxHeader`). Real `.nitpicker`
53
- // archives never name their top dir with these prefixes.
54
- if (top.startsWith('._'))
55
- return;
56
- if (top.startsWith('PaxHeaders') || top.startsWith('@PaxHeader'))
205
+ const top = topSegmentOf(entry.path);
206
+ if (!top || isSkippableTopDirName(top))
57
207
  return;
58
208
  found = top;
59
209
  },
@@ -4,8 +4,20 @@
4
4
  * The archive preserves the relative directory structure.
5
5
  * The `dir` parameter is resolved relative to its parent directory
6
6
  * so only the target directory name appears in the archive.
7
+ *
8
+ * When `onProgress` is given, the tar is produced through a manual stream
9
+ * pipe instead of tar's own `file` mode so the written byte count can be
10
+ * observed — a `.nitpicker` tar is dominated by the single giant `db.sqlite`
11
+ * entry (15 GB+ on large crawls), so per-entry callbacks would fire once and
12
+ * report nothing for minutes (issue #294). `totalBytes` is an estimate (the
13
+ * sum of the directory's file sizes; tar adds per-entry headers and padding
14
+ * on top), so `writtenBytes` is clamped to it and a final
15
+ * `(totalBytes, totalBytes)` call marks completion.
7
16
  * @param dir - The absolute path of the directory to archive.
8
17
  * @param outputPath - The file path where the TAR archive will be written.
18
+ * @param onProgress - Called as archive bytes are written, with the bytes
19
+ * written so far (clamped to the estimate) and the estimated total. Omit
20
+ * for the original silent, tar-managed-file creation.
9
21
  * @returns A promise that resolves when the TAR archive has been created.
10
22
  */
11
- export declare function tar(dir: string, outputPath: string): Promise<void>;
23
+ export declare function tar(dir: string, outputPath: string, onProgress?: (writtenBytes: number, totalBytes: number) => void): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
1
3
  import path from 'node:path';
2
4
  import { create } from 'tar';
3
5
  /**
@@ -6,17 +8,71 @@ import { create } from 'tar';
6
8
  * The archive preserves the relative directory structure.
7
9
  * The `dir` parameter is resolved relative to its parent directory
8
10
  * so only the target directory name appears in the archive.
11
+ *
12
+ * When `onProgress` is given, the tar is produced through a manual stream
13
+ * pipe instead of tar's own `file` mode so the written byte count can be
14
+ * observed — a `.nitpicker` tar is dominated by the single giant `db.sqlite`
15
+ * entry (15 GB+ on large crawls), so per-entry callbacks would fire once and
16
+ * report nothing for minutes (issue #294). `totalBytes` is an estimate (the
17
+ * sum of the directory's file sizes; tar adds per-entry headers and padding
18
+ * on top), so `writtenBytes` is clamped to it and a final
19
+ * `(totalBytes, totalBytes)` call marks completion.
9
20
  * @param dir - The absolute path of the directory to archive.
10
21
  * @param outputPath - The file path where the TAR archive will be written.
22
+ * @param onProgress - Called as archive bytes are written, with the bytes
23
+ * written so far (clamped to the estimate) and the estimated total. Omit
24
+ * for the original silent, tar-managed-file creation.
11
25
  * @returns A promise that resolves when the TAR archive has been created.
12
26
  */
13
- export function tar(dir, outputPath) {
27
+ export async function tar(dir, outputPath, onProgress) {
14
28
  const baseDir = path.dirname(dir);
15
29
  const targetDir = path.relative(baseDir, dir);
16
- return create({
17
- gzip: false,
18
- cwd: baseDir,
19
- file: outputPath,
20
- preservePaths: false,
21
- }, [targetDir]);
30
+ if (!onProgress) {
31
+ await create({
32
+ gzip: false,
33
+ cwd: baseDir,
34
+ file: outputPath,
35
+ preservePaths: false,
36
+ }, [targetDir]);
37
+ return;
38
+ }
39
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
40
+ let totalBytes = 0;
41
+ for (const entry of entries) {
42
+ if (!entry.isFile()) {
43
+ continue;
44
+ }
45
+ const { size } = await stat(path.join(entry.parentPath, entry.name));
46
+ totalBytes += size;
47
+ }
48
+ await new Promise((resolve, reject) => {
49
+ const source = create({
50
+ gzip: false,
51
+ cwd: baseDir,
52
+ preservePaths: false,
53
+ }, [targetDir]);
54
+ const sink = createWriteStream(outputPath);
55
+ let writtenBytes = 0;
56
+ source.on('data', (chunk) => {
57
+ writtenBytes += chunk.length;
58
+ onProgress(Math.min(writtenBytes, totalBytes), totalBytes);
59
+ });
60
+ // .pipe() does not destroy the other side on error, so an error on
61
+ // either stream would otherwise leave the write side dangling open.
62
+ source.on('error', (error) => {
63
+ const err = error instanceof Error ? error : new Error(String(error));
64
+ sink.destroy(err);
65
+ reject(err);
66
+ });
67
+ sink.on('error', (error) => {
68
+ const err = error instanceof Error ? error : new Error(String(error));
69
+ source.destroy(err);
70
+ reject(err);
71
+ });
72
+ sink.on('close', () => {
73
+ onProgress(totalBytes, totalBytes);
74
+ resolve();
75
+ });
76
+ source.pipe(sink);
77
+ });
22
78
  }
@@ -4,12 +4,23 @@
4
4
  * Only files newer than existing files in the target directory are extracted
5
5
  * (uses the `newer` option). Optionally restricts extraction to a specific
6
6
  * working directory and/or a subset of files.
7
+ *
8
+ * When `onProgress` is given, the tar is read through a manual
9
+ * `createReadStream` pipe instead of tar's own `file` mode so the consumed
10
+ * byte count can be observed — a `.nitpicker` tar is dominated by the single
11
+ * giant `db.sqlite` entry (15 GB+ on large crawls), so per-entry callbacks
12
+ * would fire once and report nothing for minutes (issue #294). Byte
13
+ * granularity is the read-stream chunk size (~64 KB); callers wanting
14
+ * coarser updates (e.g. one per percent) throttle in their own callback.
7
15
  * @param tarFilePath - The path to the TAR archive to extract.
8
16
  * @param options - Optional extraction settings.
9
17
  * @param options.cwd - The working directory to extract files into.
10
18
  * If omitted, the current working directory is used.
11
19
  * @param options.fileList - An array of specific file paths within the archive
12
20
  * to extract. If omitted, all files in the archive are extracted.
21
+ * @param options.onProgress - Called as archive bytes are consumed, with the
22
+ * bytes read so far and the archive's total size. Omit for the original
23
+ * silent, tar-managed-file extraction.
13
24
  * @returns A promise that resolves when extraction is complete.
14
25
  */
15
26
  export declare function untar(tarFilePath: string, options?: {
@@ -17,4 +28,6 @@ export declare function untar(tarFilePath: string, options?: {
17
28
  cwd?: string;
18
29
  /** An array of specific file paths within the archive to extract. */
19
30
  fileList?: string[];
31
+ /** Byte-level progress callback — see the function docs. */
32
+ onProgress?: (readBytes: number, totalBytes: number) => void;
20
33
  }): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
1
3
  import { extract } from 'tar';
2
4
  /**
3
5
  * Extracts files from a TAR archive.
@@ -5,20 +7,78 @@ import { extract } from 'tar';
5
7
  * Only files newer than existing files in the target directory are extracted
6
8
  * (uses the `newer` option). Optionally restricts extraction to a specific
7
9
  * working directory and/or a subset of files.
10
+ *
11
+ * When `onProgress` is given, the tar is read through a manual
12
+ * `createReadStream` pipe instead of tar's own `file` mode so the consumed
13
+ * byte count can be observed — a `.nitpicker` tar is dominated by the single
14
+ * giant `db.sqlite` entry (15 GB+ on large crawls), so per-entry callbacks
15
+ * would fire once and report nothing for minutes (issue #294). Byte
16
+ * granularity is the read-stream chunk size (~64 KB); callers wanting
17
+ * coarser updates (e.g. one per percent) throttle in their own callback.
8
18
  * @param tarFilePath - The path to the TAR archive to extract.
9
19
  * @param options - Optional extraction settings.
10
20
  * @param options.cwd - The working directory to extract files into.
11
21
  * If omitted, the current working directory is used.
12
22
  * @param options.fileList - An array of specific file paths within the archive
13
23
  * to extract. If omitted, all files in the archive are extracted.
24
+ * @param options.onProgress - Called as archive bytes are consumed, with the
25
+ * bytes read so far and the archive's total size. Omit for the original
26
+ * silent, tar-managed-file extraction.
14
27
  * @returns A promise that resolves when extraction is complete.
15
28
  */
16
- export function untar(tarFilePath, options) {
17
- return extract({
18
- file: tarFilePath,
19
- newer: true,
20
- cwd: options?.cwd,
21
- preservePaths: false,
22
- noMtime: true,
23
- }, options?.fileList ?? []);
29
+ export async function untar(tarFilePath, options) {
30
+ const { cwd, fileList, onProgress } = options ?? {};
31
+ if (!onProgress) {
32
+ await extract({
33
+ file: tarFilePath,
34
+ newer: true,
35
+ cwd,
36
+ preservePaths: false,
37
+ noMtime: true,
38
+ }, fileList ?? []);
39
+ return;
40
+ }
41
+ const { size: totalBytes } = await stat(tarFilePath);
42
+ await new Promise((resolve, reject) => {
43
+ const source = createReadStream(tarFilePath);
44
+ let readBytes = 0;
45
+ source.on('data', (chunk) => {
46
+ readBytes += chunk.length;
47
+ onProgress(readBytes, totalBytes);
48
+ });
49
+ const sink = extract({
50
+ newer: true,
51
+ cwd,
52
+ preservePaths: false,
53
+ noMtime: true,
54
+ }, fileList ?? []);
55
+ // .pipe() does not destroy the other side on error, so an error on
56
+ // either stream would otherwise leave the write side dangling open.
57
+ // `Unpack` (`extends Parser extends EE`, a plain `EventEmitter`, NOT
58
+ // a Minipass/stream base class despite behaving like a writable
59
+ // stream) has no `destroy()` — `abort()` is its own equivalent:
60
+ // idempotent (guarded internally), and it emits its own 'error'
61
+ // (confirmed via `tar`'s `warnMethod` with `recoverable: false`),
62
+ // which re-enters the `sink.on('error', ...)` handler below —
63
+ // harmless, since `source.destroy()`/`reject()` are already
64
+ // idempotent no-ops on a second call.
65
+ source.on('error', (error) => {
66
+ const err = error instanceof Error ? error : new Error(String(error));
67
+ sink.abort(err);
68
+ reject(err);
69
+ });
70
+ sink.on('error', (error) => {
71
+ const err = error instanceof Error ? error : new Error(String(error));
72
+ source.destroy(err);
73
+ reject(err);
74
+ });
75
+ // node-tar's Unpack stream signals completion via 'close' (all entry
76
+ // writes flushed), not 'finish' (input fully consumed) — resolving on
77
+ // 'finish' could hand the caller a tmpDir whose db.sqlite is still
78
+ // being written.
79
+ sink.on('close', () => {
80
+ resolve();
81
+ });
82
+ source.pipe(sink);
83
+ });
24
84
  }
@@ -30,7 +30,7 @@ export declare function applyConnectionPragmas(instance: Knex): Promise<void>;
30
30
  * writes during a crawl and every reader queries. Must run AFTER
31
31
  * `createRefTables` because most entity tables reference ref-table PKs.
32
32
  * - **Adjunct tables** ({@link createAdjunctTables}): `page_errors`,
33
- * `crawl_errors`, `page_tags`, `page_jsonld`, `inventory_runs`,
33
+ * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `inventory_runs`,
34
34
  * `analysis_text_refs` + `analysis_violations`, `page_html_blobs` +
35
35
  * `page_html_ref`. Must run AFTER `createEntityTables` because the
36
36
  * page-scoped tables FK into `content_items(id)`.
@@ -14,7 +14,8 @@ import { createRefTables } from './create-ref-tables.js';
14
14
  export async function applyConnectionPragmas(instance) {
15
15
  // Foreign-key enforcement defaults to OFF on every new SQLite
16
16
  // connection. Required for ON DELETE CASCADE on `page_html_ref`,
17
- // `page_tags`, `page_jsonld`, and the `page_main_content_*` tables to fire.
17
+ // `technology_signals`, `page_technologies`, `page_jsonld`, and the
18
+ // `page_main_content_*` tables to fire.
18
19
  await instance.raw('PRAGMA foreign_keys = ON');
19
20
  await instance.raw('PRAGMA wal_autocheckpoint = 1000');
20
21
  // Negative value = KiB of memory (64 MiB). Helps large BLOB scans.
@@ -54,7 +55,7 @@ export async function applyConnectionPragmas(instance) {
54
55
  * writes during a crawl and every reader queries. Must run AFTER
55
56
  * `createRefTables` because most entity tables reference ref-table PKs.
56
57
  * - **Adjunct tables** ({@link createAdjunctTables}): `page_errors`,
57
- * `crawl_errors`, `page_tags`, `page_jsonld`, `inventory_runs`,
58
+ * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `inventory_runs`,
58
59
  * `analysis_text_refs` + `analysis_violations`, `page_html_blobs` +
59
60
  * `page_html_ref`. Must run AFTER `createEntityTables` because the
60
61
  * page-scoped tables FK into `content_items(id)`.
@@ -131,8 +132,9 @@ export async function initSchema(instance) {
131
132
  // the ref tables (`url_refs`, `content_type_refs`, `text_refs`,
132
133
  // `json_refs`, `blob_refs`, `header_sets`) via FK clauses.
133
134
  await createEntityTables(instance);
134
- // Adjunct tables that FK into `content_items` (page_errors / page_tags /
135
- // page_jsonld / analysis_* / page_html_*) plus the standalone log tables
135
+ // Adjunct tables that FK into `content_items` (page_errors /
136
+ // technology_signals / page_technologies / page_jsonld / analysis_* /
137
+ // page_html_*) plus the standalone log tables
136
138
  // (crawl_errors / inventory_runs). MUST run after
137
139
  // {@link createEntityTables} so the FK targets exist. DDL +
138
140
  // column-level rationale lives in {@link createAdjunctTables}, which is
@@ -15,8 +15,19 @@ import type { MainContentsData, ScrollHeightData } from '@d-zero/beholder';
15
15
  * omit it (`.spec.ts` files are excluded from the `tsc` build, so this goes
16
16
  * uncaught at compile time) — `== null` tolerates both without forcing every
17
17
  * fixture to be updated.
18
+ *
19
+ * `customElementCount` is a separate parameter, not read off `mainContents`,
20
+ * because it is not one of beholder's `MainContentsData` categories —
21
+ * nitpicker captures Web Components itself (`crawler/capture-custom-elements.ts`)
22
+ * after `scrapeStart` returns. It carries three distinct states:
23
+ * `undefined`/capture-not-attempted and capture-failure both collapse to
24
+ * `null` (unknown — NOT the same as "captured, zero found"), while any
25
+ * number (including `0`) means capture succeeded.
18
26
  * @param mainContents - Beholder's per-page main-content metrics, or `null`/`undefined`.
19
27
  * @param scrollHeight - Beholder's per-page scroll-height measurements, or `null`/`undefined`.
20
- * @returns The seventeen denormalised columns.
28
+ * @param customElementCount - Count of Web Components nitpicker captured in
29
+ * the main-content region, or `null`/`undefined` when capture was not
30
+ * attempted or failed.
31
+ * @returns The eighteen denormalised columns.
21
32
  */
22
- export declare function computeMainContentsDenormalized(mainContents: MainContentsData | null | undefined, scrollHeight: ScrollHeightData | null | undefined): MainContentsDenormalizedColumns;
33
+ export declare function computeMainContentsDenormalized(mainContents: MainContentsData | null | undefined, scrollHeight: ScrollHeightData | null | undefined, customElementCount?: number | null): MainContentsDenormalizedColumns;
@@ -13,11 +13,22 @@
13
13
  * omit it (`.spec.ts` files are excluded from the `tsc` build, so this goes
14
14
  * uncaught at compile time) — `== null` tolerates both without forcing every
15
15
  * fixture to be updated.
16
+ *
17
+ * `customElementCount` is a separate parameter, not read off `mainContents`,
18
+ * because it is not one of beholder's `MainContentsData` categories —
19
+ * nitpicker captures Web Components itself (`crawler/capture-custom-elements.ts`)
20
+ * after `scrapeStart` returns. It carries three distinct states:
21
+ * `undefined`/capture-not-attempted and capture-failure both collapse to
22
+ * `null` (unknown — NOT the same as "captured, zero found"), while any
23
+ * number (including `0`) means capture succeeded.
16
24
  * @param mainContents - Beholder's per-page main-content metrics, or `null`/`undefined`.
17
25
  * @param scrollHeight - Beholder's per-page scroll-height measurements, or `null`/`undefined`.
18
- * @returns The seventeen denormalised columns.
26
+ * @param customElementCount - Count of Web Components nitpicker captured in
27
+ * the main-content region, or `null`/`undefined` when capture was not
28
+ * attempted or failed.
29
+ * @returns The eighteen denormalised columns.
19
30
  */
20
- export function computeMainContentsDenormalized(mainContents, scrollHeight) {
31
+ export function computeMainContentsDenormalized(mainContents, scrollHeight, customElementCount) {
21
32
  if (mainContents == null) {
22
33
  return {
23
34
  main_content_node_name: null,
@@ -35,6 +46,7 @@ export function computeMainContentsDenormalized(mainContents, scrollHeight) {
35
46
  main_content_video_count: null,
36
47
  main_content_audio_count: null,
37
48
  main_content_canvas_count: null,
49
+ main_content_custom_element_count: null,
38
50
  scroll_height_desktop: null,
39
51
  scroll_height_mobile: null,
40
52
  };
@@ -57,6 +69,7 @@ export function computeMainContentsDenormalized(mainContents, scrollHeight) {
57
69
  main_content_video_count: mainContents.videos.length,
58
70
  main_content_audio_count: mainContents.audios.length,
59
71
  main_content_canvas_count: mainContents.canvases.length,
72
+ main_content_custom_element_count: customElementCount ?? null,
60
73
  scroll_height_desktop: scrollHeight?.desktop ?? null,
61
74
  scroll_height_mobile: scrollHeight?.mobile ?? null,
62
75
  };
@@ -0,0 +1,46 @@
1
+ import type { PageTechnologyPartial, TechnologySignalPartial } from './types.js';
2
+ /**
3
+ * Options for {@link combineTechnologyConfidence}.
4
+ */
5
+ export interface CombineTechnologyConfidenceOptions {
6
+ /**
7
+ * Skip the `TECHNOLOGY_CONFIDENCE_THRESHOLD` drop. Set by the `page_tags`
8
+ * legacy-migration callers (`migrate-page-tags-to-page-technologies.ts`,
9
+ * `retarget-legacy-fk-tables.ts`): the threshold exists to filter noisy
10
+ * NEW detections at crawl time, but migration is converting Wappalyzer
11
+ * detections the old `page_tags`-based API already returned
12
+ * unconditionally (including low-confidence ones) — dropping them here
13
+ * would silently and permanently lose data with no recovery path (v0.x
14
+ * policy is read-time migration only, no retroactive re-crawl).
15
+ */
16
+ skipThreshold?: boolean;
17
+ }
18
+ /**
19
+ * Combines every signal detected for one technology on one page into a
20
+ * single confidence score via noisy-OR:
21
+ *
22
+ * ```
23
+ * confidence = round(100 * (1 - Π (1 - weight/100)))
24
+ * ```
25
+ *
26
+ * over each DISTINCT `signalType` present (same signal type appearing
27
+ * twice — e.g. two different `url-pattern` definitions both matching —
28
+ * counts once, at its highest weight; compounding only happens ACROSS
29
+ * signal types, which is what makes this "multiple independent signals
30
+ * agree" rather than "one noisy signal repeated"). A single signal's
31
+ * result equals its own weight exactly (`1 - (1 - w/100) = w/100`).
32
+ *
33
+ * Technologies whose combined confidence falls below
34
+ * `TECHNOLOGY_CONFIDENCE_THRESHOLD` are dropped entirely — a lone weak
35
+ * signal (e.g. `id="app"`, weight 15) is noise, not a detection, and
36
+ * `page_technologies` has no `isDetected` column to hide it behind; the
37
+ * row simply is not written. Pass `options.skipThreshold` to keep every
38
+ * technology regardless of confidence (see its docs for why).
39
+ * @param signals - Every signal detected for a single page, across all
40
+ * technologies (this function groups by `technology` internally).
41
+ * @param options - See {@link CombineTechnologyConfidenceOptions}.
42
+ * @returns One {@link PageTechnologyPartial} per technology (filtered by
43
+ * the confidence threshold unless `options.skipThreshold` is set), in no
44
+ * particular order.
45
+ */
46
+ export declare function combineTechnologyConfidence(signals: readonly TechnologySignalPartial[], options?: CombineTechnologyConfidenceOptions): PageTechnologyPartial[];