@nitpicker/core 0.22.2 → 0.23.1

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.
@@ -6,7 +6,6 @@
6
6
  */
7
7
  const STANDARD_ANALYZE_PLUGINS = [
8
8
  '@nitpicker/analyze-axe',
9
- '@nitpicker/analyze-lighthouse',
10
9
  '@nitpicker/analyze-markuplint',
11
10
  '@nitpicker/analyze-search',
12
11
  '@nitpicker/analyze-textlint',
@@ -71,7 +71,7 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
71
71
  * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
72
72
  * enables per-plugin progress tracking via Lanes.
73
73
  *
74
- * The analysis proceeds in up to three phases:
74
+ * The analysis proceeds in up to two phases:
75
75
  *
76
76
  * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
77
77
  * page analysis tasks to the plugin's worker pool. Progress is
@@ -81,32 +81,10 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
81
81
  * sequentially in the main thread. These are lightweight checks
82
82
  * that don't need DOM access.
83
83
  *
84
- * 3. **Template classification phase** (opt-in via
85
- * {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
86
- * HTML page into a template group by DOM-structure similarity. Unlike
87
- * phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
88
- * runs once, globally, after every batch from phases 1-2 has been
89
- * accumulated (see `accumulatedPages` below for why it cannot run
90
- * per-batch).
91
- *
92
84
  * On completion, data entries are stored in the archive:
93
85
  * - `analysis/report` - Full {@link Report} with headers and data
94
86
  * - `analysis/table` - The raw {@link Table} instance (serialized)
95
87
  * - SQL-backed analysis violation tables - Flat {@link Violation} records
96
- * - `page_templates` SQL table - `templateKey` per page, written via
97
- * `Archive.replacePageTemplates` and never round-tripped through
98
- * `Table`/`Report`
99
- *
100
- * When `filter` resolves to zero plugins (only reachable when
101
- * `classifyTemplates` is the sole reason for this call — see
102
- * `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
103
- * contributes no plugin data of its own. To avoid silently wiping every
104
- * column and violation from a *previous* run's plugins, this case skips
105
- * `replaceAnalysisViolations` entirely and seeds the new `Table` from the
106
- * archive's existing `analysis/report` (if any). This does not apply when
107
- * one or more plugins ran: running a different plugin subset than a prior
108
- * call has always fully replaced the report/table/violations with that
109
- * subset's output, and that established contract is unchanged here.
110
88
  * @param filter - Optional list of plugin module names to run.
111
89
  * If omitted, all configured plugins are executed.
112
90
  * @param options - Optional settings for progress display.
package/lib/nitpicker.js CHANGED
@@ -8,7 +8,6 @@ import { getTableCacheRoot } from './get-table-cache-root.js';
8
8
  import { importModules } from './import-modules.js';
9
9
  import { loadPluginSettings } from './load-plugin-settings.js';
10
10
  import { Table } from './table.js';
11
- import { classifyPageTemplates } from './template-classification/classify-page-templates.js';
12
11
  import { UrlEventBus } from './url-event-bus.js';
13
12
  import { WorkerPool } from './worker/worker-pool.js';
14
13
  export { definePlugin } from './hooks/define-plugin.js';
@@ -31,34 +30,6 @@ const workerPath = path.resolve(__dirname, 'worker/worker.js');
31
30
  * Tracks the number of CPU cores so that worker pools scale with the host.
32
31
  */
33
32
  const DEFAULT_CONCURRENCY = Math.max(1, os.cpus().length);
34
- /**
35
- * Renders a `@d-zero/page-cluster` `ProgressEvent` as a short, human-readable
36
- * fragment for the Lanes template-classification lane, including a
37
- * done/total percentage wherever the event carries both halves of one
38
- * (matching the `N/M (X%)` convention every other lane in this file uses).
39
- * `pass0-signals` and `stage-b-start` carry only a running count with no
40
- * corpus-wide total, so those two render count-only.
41
- * @param event - Progress event forwarded from `resolvePageClusterKeys`.
42
- * @returns A short status fragment, e.g. `"42/100 blocks (42%)"`.
43
- */
44
- function formatTemplateClassificationProgress(event) {
45
- switch (event.phase) {
46
- case 'pass0-signals': {
47
- return `reading pages (${event.pagesSeen})`;
48
- }
49
- case 'pass1-block-complete': {
50
- const percent = Math.round((event.blocksProcessed / event.totalBlocks) * 100);
51
- return `${event.blocksProcessed}/${event.totalBlocks} blocks (${percent}%)`;
52
- }
53
- case 'pass1b-assign': {
54
- const percent = Math.round((event.pagesAssigned / event.pagesToAssign) * 100);
55
- return `${event.pagesAssigned}/${event.pagesToAssign} pages (${percent}%)`;
56
- }
57
- case 'stage-b-start': {
58
- return `merging ${event.unitCount} units`;
59
- }
60
- }
61
- }
62
33
  /**
63
34
  * Core orchestrator for running analyze plugins against a `.nitpicker` archive.
64
35
  *
@@ -149,7 +120,7 @@ export class Nitpicker extends EventEmitter {
149
120
  * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
150
121
  * enables per-plugin progress tracking via Lanes.
151
122
  *
152
- * The analysis proceeds in up to three phases:
123
+ * The analysis proceeds in up to two phases:
153
124
  *
154
125
  * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
155
126
  * page analysis tasks to the plugin's worker pool. Progress is
@@ -159,32 +130,10 @@ export class Nitpicker extends EventEmitter {
159
130
  * sequentially in the main thread. These are lightweight checks
160
131
  * that don't need DOM access.
161
132
  *
162
- * 3. **Template classification phase** (opt-in via
163
- * {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
164
- * HTML page into a template group by DOM-structure similarity. Unlike
165
- * phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
166
- * runs once, globally, after every batch from phases 1-2 has been
167
- * accumulated (see `accumulatedPages` below for why it cannot run
168
- * per-batch).
169
- *
170
133
  * On completion, data entries are stored in the archive:
171
134
  * - `analysis/report` - Full {@link Report} with headers and data
172
135
  * - `analysis/table` - The raw {@link Table} instance (serialized)
173
136
  * - SQL-backed analysis violation tables - Flat {@link Violation} records
174
- * - `page_templates` SQL table - `templateKey` per page, written via
175
- * `Archive.replacePageTemplates` and never round-tripped through
176
- * `Table`/`Report`
177
- *
178
- * When `filter` resolves to zero plugins (only reachable when
179
- * `classifyTemplates` is the sole reason for this call — see
180
- * `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
181
- * contributes no plugin data of its own. To avoid silently wiping every
182
- * column and violation from a *previous* run's plugins, this case skips
183
- * `replaceAnalysisViolations` entirely and seeds the new `Table` from the
184
- * archive's existing `analysis/report` (if any). This does not apply when
185
- * one or more plugins ran: running a different plugin subset than a prior
186
- * call has always fully replaced the report/table/violations with that
187
- * subset's output, and that established contract is unchanged here.
188
137
  * @param filter - Optional list of plugin module names to run.
189
138
  * If omitted, all configured plugins are executed.
190
139
  * @param options - Optional settings for progress display.
@@ -208,34 +157,6 @@ export class Nitpicker extends EventEmitter {
208
157
  const analyzeMods = await importModules(plugins);
209
158
  const lanes = options?.lanes;
210
159
  const table = new Table();
211
- if (plugins.length === 0) {
212
- // Only reachable via `--templates` with zero configured/selected
213
- // plugins (see this method's JSDoc). Seed from whatever
214
- // `analysis/report` already exists so this run — which contributes
215
- // no plugin data of its own — doesn't wipe a previous run's columns.
216
- try {
217
- const previousReport = await this.archive.getData('analysis/report');
218
- if (previousReport.pageData) {
219
- table.addHeaders(previousReport.pageData.headers);
220
- table.addData(previousReport.pageData.data);
221
- }
222
- }
223
- catch (error) {
224
- // ENOENT (no previous `analysis/report` — e.g. first analyze()
225
- // call ever on this archive) is expected and starting from an
226
- // empty table is correct. Anything else (corrupted JSON,
227
- // permission error) is not silently swallowed: emit it so the
228
- // user knows a previous run's data may not have been preserved,
229
- // while still proceeding rather than blocking this run entirely.
230
- if (error?.code !== 'ENOENT') {
231
- const message = error instanceof Error ? error.message : String(error);
232
- await this.emit('error', {
233
- message: `Failed to read previous analysis/report before a zero-plugin run: ${message}`,
234
- error: error instanceof Error ? error : null,
235
- });
236
- }
237
- }
238
- }
239
160
  for (const mod of analyzeMods) {
240
161
  if (!mod.headers) {
241
162
  continue;
@@ -267,32 +188,7 @@ export class Nitpicker extends EventEmitter {
267
188
  const label = pluginLabels.get(name) ?? name;
268
189
  lanes?.update(id, c.dim(`${label}: Waiting...`));
269
190
  }
270
- // Template classification (opt-in, `--templates`) is a corpus-wide batch
271
- // computation, not a per-page `AnalyzePlugin`, so it has its own lane
272
- // outside `pluginLaneIds` and does not go through `analyzeMods`/`Worker`.
273
- const templateClassificationLaneId = options?.classifyTemplates
274
- ? eachPagePlugins.length
275
- : null;
276
- if (templateClassificationLaneId != null) {
277
- lanes?.update(templateClassificationLaneId, c.dim('Template classification: Waiting...'));
278
- }
279
- // Accumulated across every `getPagesWithRefs` batch so template
280
- // classification runs once, globally, after the loop below completes —
281
- // never per-batch. Per-batch classification would produce template keys
282
- // that are only comparable within their own batch (see
283
- // `classifyPageTemplates`'s caller contract). `Page` instances are
284
- // lightweight handles (HTML is fetched lazily via `getHtml()`), so
285
- // accumulating every page across a 100,000-page batch size is not an
286
- // OOM risk even for archives with hundreds of thousands of pages.
287
- const accumulatedPages = [];
288
191
  await this.archive.getPagesWithRefs(100_000, async (pages) => {
289
- if (options?.classifyTemplates) {
290
- // Avoid `push(...pages)`: a 100,000-page batch can overflow V8's
291
- // argument-spread limit even though the data itself fits in memory.
292
- for (const page of pages) {
293
- accumulatedPages.push(page);
294
- }
295
- }
296
192
  const urlEmitter = new UrlEventBus();
297
193
  // Phase 1: eachPage plugins (sequentially, pages in parallel)
298
194
  for (const [pluginSeqIndex, { plugin, modIndex }] of eachPagePlugins.entries()) {
@@ -466,63 +362,13 @@ export class Nitpicker extends EventEmitter {
466
362
  }, {
467
363
  withRefs: false,
468
364
  });
469
- // Phase 3: template classification (opt-in). Deliberately outside the
470
- // `getPagesWithRefs` loop above — it must run once, globally, over every
471
- // accumulated page, not once per 100,000-page batch (see
472
- // `accumulatedPages`'s JSDoc comment).
473
- //
474
- // Wrapped in try/catch (unlike phases 1-2, whose failures are already
475
- // caught per-page/per-mod) because this phase has no finer-grained
476
- // unit to catch around: a failure here must not discard the
477
- // already-computed `table`/`allViolations` from phases 1-2, which are
478
- // persisted in the unconditional tail below regardless of this
479
- // phase's outcome.
480
- if (templateClassificationLaneId != null) {
481
- try {
482
- const classification = await classifyPageTemplates({
483
- archive: this.archive,
484
- pages: accumulatedPages,
485
- // Only worth paying for when there's a lane to update —
486
- // passing a defined callback at all (even a no-op one)
487
- // demotes `resolvePageClusterKeys` off its byte-identical,
488
- // yield-overhead-free sync path for corpora at or below its
489
- // inline threshold (see @d-zero/page-cluster's own docs).
490
- // `classifyPageTemplates` always requests cluster reasons too,
491
- // independent of this — see its own JSDoc for why that no
492
- // longer costs the sync-path demotion this comment warns about.
493
- onProgress: lanes
494
- ? (event) => {
495
- lanes.update(templateClassificationLaneId, `Template classification: ${formatTemplateClassificationProgress(event)}%braille%`);
496
- }
497
- : undefined,
498
- });
499
- if (classification.templateKeysByUrl.size > 0) {
500
- await this.archive.replacePageTemplates(classification.templateKeysByUrl, classification.clusterReasonsByTemplateKey);
501
- }
502
- lanes?.update(templateClassificationLaneId, c.green(`Template classification: Done (${classification.templateKeysByUrl.size} pages)`));
503
- }
504
- catch (error) {
505
- const message = error instanceof Error ? error.message : String(error);
506
- lanes?.update(templateClassificationLaneId, c.red(`Template classification: Failed (${message})`));
507
- await this.emit('error', {
508
- message: `[template classification] ${message}`,
509
- error: error instanceof Error ? error : null,
510
- });
511
- }
512
- }
513
365
  const report = {
514
366
  name: 'general',
515
367
  pageData: table.toJSON(),
516
368
  };
517
369
  await this.archive.setData('analysis/report', report);
518
370
  await this.archive.setData('analysis/table', table);
519
- // See this method's JSDoc: a zero-plugin call (only reachable via
520
- // `--templates` alone) contributes no violations of its own, so
521
- // replacing the whole table with an empty array would silently erase
522
- // every violation a previous run's plugins stored.
523
- if (plugins.length > 0) {
524
- await this.archive.replaceAnalysisViolations(allViolations);
525
- }
371
+ await this.archive.replaceAnalysisViolations(allViolations);
526
372
  }
527
373
  /**
528
374
  * Loads and caches the plugin configuration from the user's config file.
package/lib/types.d.ts CHANGED
@@ -44,17 +44,6 @@ export interface AnalyzeOptions {
44
44
  readonly lanes?: Lanes;
45
45
  /** When `true`, outputs plain-text progress lines instead of animated Lanes. */
46
46
  readonly verbose?: boolean;
47
- /**
48
- * When `true`, runs an additional, opt-in classification phase that
49
- * groups internal HTML pages by DOM-structure similarity (using
50
- * `@d-zero/page-cluster`), independent of the configured
51
- * `@nitpicker/analyze-*` plugins. Results are added to the report table
52
- * as a `templateKey` column. Defaults to `false` — the algorithm's
53
- * runtime on very large archives is not yet characterized, so it is
54
- * never run implicitly.
55
- * @see {@link ./template-classification/classify-page-templates.ts!classifyPageTemplates}
56
- */
57
- readonly classifyTemplates?: boolean;
58
47
  }
59
48
  /**
60
49
  * CLI-specified overrides for individual plugin settings.
@@ -129,7 +118,7 @@ export interface AnalyzePlugin<T extends string = string> {
129
118
  * Maximum number of pages this plugin processes in parallel.
130
119
  *
131
120
  * Each plugin is allocated its own {@link ../worker/worker-pool.ts!WorkerPool}
132
- * sized to this value. Heavy plugins (e.g. Lighthouse, which spawns a
121
+ * sized to this value. Heavy plugins (e.g. one that spawns a
133
122
  * full Chrome instance per page) should set a small number; lightweight
134
123
  * DOM-only plugins can leave it unset.
135
124
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/core",
3
- "version": "0.22.2",
3
+ "version": "0.23.1",
4
4
  "description": "Plugin-based page analysis engine for Nitpicker",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -31,13 +31,12 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@d-zero/dealer": "1.14.0",
34
- "@d-zero/page-cluster": "0.6.4",
35
34
  "@d-zero/shared": "0.23.0",
36
- "@nitpicker/crawler": "0.22.2",
37
- "@nitpicker/types": "0.22.0",
35
+ "@nitpicker/crawler": "0.23.0",
36
+ "@nitpicker/types": "0.23.0",
38
37
  "ansi-colors": "4.1.3",
39
38
  "cosmiconfig": "10.0.1",
40
39
  "jsdom": "30.0.1"
41
40
  },
42
- "gitHead": "27db72abdebd19dc6f83a1b5087d73d8b574f229"
41
+ "gitHead": "f86ab90a28332dc431324780d8298741739db847"
43
42
  }
@@ -1,49 +0,0 @@
1
- import type { ClassifyPageTemplatesOptions, PageTemplateClassification } from './types.js';
2
- /**
3
- * Classifies every internal HTML page in the archive into a template group
4
- * by DOM-structure similarity, using `@d-zero/page-cluster`.
5
- *
6
- * Result is cached, keyed by the archive file's path + size + mtime + page
7
- * count (see {@link buildArchiveCacheKey}). **This only helps one narrow
8
- * case**: a crash or Ctrl-C after this function completes but before
9
- * `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
10
- * itself take a while) — re-running on the same, not-yet-rewritten archive
11
- * file hits the cache instead of re-running the whole multi-pass clustering.
12
- * It does **not** help a *second, fully successful* `analyze --templates`
13
- * run on the same crawled data: `nitpicker.write()` always re-packages the
14
- * archive (new mtime, and usually a different size), which changes the key
15
- * on every completed run. There is no explicit `--force` escape hatch —
16
- * any change to the archive changes the key, which is the correctness
17
- * signal this cache relies on. The cache is **not** cleared at the start of
18
- * a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
19
- * purpose is surviving the one crash-before-write window described above.
20
- *
21
- * There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
22
- * holds state across its own internal multi-pass read and has no resume
23
- * API, so a crash *during* classification means the next run classifies
24
- * from scratch (this is a limitation of the upstream library, not something
25
- * this function works around).
26
- *
27
- * Internally: the external library's `resolvePageClusterKeys` returns a
28
- * `clusterKey` per page; this function's public naming is `templateKey`,
29
- * to avoid colliding with nitpicker's unrelated pre-existing "isolated
30
- * cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
31
- * link-reachability graph components — a completely different kind of
32
- * grouping from DOM-structure similarity).
33
- *
34
- * `onClusterReason` is always passed to `resolvePageClusterKeys` (not made
35
- * conditional the way `onProgress` is) — as of `@d-zero/page-cluster` 0.5.3,
36
- * requesting cluster reasons no longer forces small corpora off the
37
- * progress-emitting path (see that option's own JSDoc), so there is no
38
- * responsiveness cost to always capturing them.
39
- * @param options - See {@link ClassifyPageTemplatesOptions}.
40
- * @returns See {@link PageTemplateClassification}.
41
- * @example
42
- * ```ts
43
- * const { templateKeysByUrl } = await classifyPageTemplates({ archive, pages });
44
- * for (const [url, templateKey] of templateKeysByUrl) {
45
- * table.addData({ [url]: { templateKey: { value: templateKey } } });
46
- * }
47
- * ```
48
- */
49
- export declare function classifyPageTemplates(options: ClassifyPageTemplatesOptions): Promise<PageTemplateClassification>;
@@ -1,159 +0,0 @@
1
- import { stat } from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { resolvePageClusterKeys } from '@d-zero/page-cluster/resolve-page-cluster-keys';
5
- import { Cache } from '@d-zero/shared/cache';
6
- import { collectPageStylesheetUrls } from './collect-page-stylesheet-urls.js';
7
- import { createPageClusterFactory } from './create-page-cluster-factory.js';
8
- /**
9
- * Narrows a loaded cache value to the current {@link TemplateClassificationCacheEntry}
10
- * shape, so a pre-existing on-disk cache entry from before cluster-reason
11
- * capture was added (a bare `Record<string, string>`, no `templateKeys` key)
12
- * is treated as a miss instead of being misread as `{ templateKeys: undefined,
13
- * clusterReasons: undefined }`. No version counter is used for this — the
14
- * structural check is sufficient and avoids a magic number that would need
15
- * bumping on every future cache-shape change.
16
- * @param value - The value loaded from the persistent cache.
17
- */
18
- function isTemplateClassificationCacheEntry(value) {
19
- if (typeof value !== 'object' || value === null) {
20
- return false;
21
- }
22
- const candidate = value;
23
- return (typeof candidate.templateKeys === 'object' &&
24
- candidate.templateKeys !== null &&
25
- typeof candidate.clusterReasons === 'object' &&
26
- candidate.clusterReasons !== null);
27
- }
28
- /**
29
- * Classifies every internal HTML page in the archive into a template group
30
- * by DOM-structure similarity, using `@d-zero/page-cluster`.
31
- *
32
- * Result is cached, keyed by the archive file's path + size + mtime + page
33
- * count (see {@link buildArchiveCacheKey}). **This only helps one narrow
34
- * case**: a crash or Ctrl-C after this function completes but before
35
- * `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
36
- * itself take a while) — re-running on the same, not-yet-rewritten archive
37
- * file hits the cache instead of re-running the whole multi-pass clustering.
38
- * It does **not** help a *second, fully successful* `analyze --templates`
39
- * run on the same crawled data: `nitpicker.write()` always re-packages the
40
- * archive (new mtime, and usually a different size), which changes the key
41
- * on every completed run. There is no explicit `--force` escape hatch —
42
- * any change to the archive changes the key, which is the correctness
43
- * signal this cache relies on. The cache is **not** cleared at the start of
44
- * a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
45
- * purpose is surviving the one crash-before-write window described above.
46
- *
47
- * There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
48
- * holds state across its own internal multi-pass read and has no resume
49
- * API, so a crash *during* classification means the next run classifies
50
- * from scratch (this is a limitation of the upstream library, not something
51
- * this function works around).
52
- *
53
- * Internally: the external library's `resolvePageClusterKeys` returns a
54
- * `clusterKey` per page; this function's public naming is `templateKey`,
55
- * to avoid colliding with nitpicker's unrelated pre-existing "isolated
56
- * cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
57
- * link-reachability graph components — a completely different kind of
58
- * grouping from DOM-structure similarity).
59
- *
60
- * `onClusterReason` is always passed to `resolvePageClusterKeys` (not made
61
- * conditional the way `onProgress` is) — as of `@d-zero/page-cluster` 0.5.3,
62
- * requesting cluster reasons no longer forces small corpora off the
63
- * progress-emitting path (see that option's own JSDoc), so there is no
64
- * responsiveness cost to always capturing them.
65
- * @param options - See {@link ClassifyPageTemplatesOptions}.
66
- * @returns See {@link PageTemplateClassification}.
67
- * @example
68
- * ```ts
69
- * const { templateKeysByUrl } = await classifyPageTemplates({ archive, pages });
70
- * for (const [url, templateKey] of templateKeysByUrl) {
71
- * table.addData({ [url]: { templateKey: { value: templateKey } } });
72
- * }
73
- * ```
74
- */
75
- export async function classifyPageTemplates(options) {
76
- const { archive, pages, onProgress } = options;
77
- const cache = getTemplateCache();
78
- const cacheKey = await buildArchiveCacheKey(archive.filePath, pages.length);
79
- if (cacheKey) {
80
- const cached = await cache.load(cacheKey);
81
- if (cached && isTemplateClassificationCacheEntry(cached)) {
82
- return {
83
- templateKeysByUrl: new Map(Object.entries(cached.templateKeys)),
84
- clusterReasonsByTemplateKey: new Map(Object.entries(cached.clusterReasons)),
85
- };
86
- }
87
- }
88
- const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
89
- const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
90
- const clusterReasonsByTemplateKey = new Map();
91
- const clusterKeys = await resolvePageClusterKeys(factory, {
92
- ...(onProgress ? { onProgress } : {}),
93
- onClusterReason: (clusterKey, reason) => {
94
- clusterReasonsByTemplateKey.set(clusterKey, reason);
95
- },
96
- });
97
- // Safe only after `resolvePageClusterKeys` has resolved — see
98
- // `createPageClusterFactory`'s JSDoc for why.
99
- const yieldedUrls = getYieldedUrls();
100
- if (clusterKeys.length !== yieldedUrls.length) {
101
- // Would silently mis-map template keys to the wrong URLs if allowed
102
- // through — see createPageClusterFactory's JSDoc for why this should
103
- // be structurally impossible, but a loud failure here is far
104
- // preferable to a quiet mis-assignment. No equivalent check exists for
105
- // `clusterReasonsByTemplateKey` — see `PageTemplateClassification`'s
106
- // own JSDoc for why that map is a best-effort side channel, not a
107
- // per-page guarantee.
108
- throw new Error(`classifyPageTemplates: resolvePageClusterKeys returned ${clusterKeys.length} keys for ${yieldedUrls.length} yielded pages.`);
109
- }
110
- const templateKeysByUrl = new Map();
111
- for (const [index, url] of yieldedUrls.entries()) {
112
- templateKeysByUrl.set(url, clusterKeys[index]);
113
- }
114
- if (cacheKey) {
115
- await cache.store(cacheKey, {
116
- templateKeys: Object.fromEntries(templateKeysByUrl),
117
- clusterReasons: Object.fromEntries(clusterReasonsByTemplateKey),
118
- });
119
- }
120
- return { templateKeysByUrl, clusterReasonsByTemplateKey };
121
- }
122
- /**
123
- * Lazily constructs the persistent template-classification cache. Kept as a
124
- * function (rather than a module-level constant) so tests can point it at
125
- * an isolated tmpDir-per-test without module-mocking `os.tmpdir`.
126
- */
127
- function getTemplateCache() {
128
- return new Cache('nitpicker-templates', path.join(os.tmpdir(), 'nitpicker/cache/templates'));
129
- }
130
- /**
131
- * Derives a cache key from the archive file's identity: its absolute path,
132
- * byte size, mtime, and internal HTML page count. Byte size + mtime alone is
133
- * a coarse signal — a same-path overwrite that happens to land on the same
134
- * byte size within the filesystem's mtime tick (coarse mtime resolution,
135
- * or two writes close enough together) would otherwise be indistinguishable
136
- * from "unchanged". Page count is folded in as a cheap (already-computed,
137
- * no extra query), second independent signal that most re-crawls
138
- * (`--append` / `--inventory`) change; it does **not** catch every
139
- * possible content change (e.g. `--retry-failed` replacing a failed page's
140
- * content without changing the total page count), which is an accepted,
141
- * documented gap rather than an attempt at a full content hash — hashing
142
- * every page's HTML on every `analyze()` call would cost more than the
143
- * cache is meant to save.
144
- * @param archiveFilePath - `Archive.filePath` of the archive being analyzed.
145
- * @param pageCount - Number of pages passed to {@link classifyPageTemplates}
146
- * for this run (`pages.length`, before internal/HTML filtering).
147
- * @returns The cache key, or `null` if the file's stats cannot be read
148
- * (falls back to always recomputing rather than caching under the wrong
149
- * key).
150
- */
151
- async function buildArchiveCacheKey(archiveFilePath, pageCount) {
152
- try {
153
- const stats = await stat(archiveFilePath);
154
- return `${archiveFilePath}:${stats.size}:${stats.mtimeMs}:${pageCount}`;
155
- }
156
- catch {
157
- return null;
158
- }
159
- }
@@ -1,32 +0,0 @@
1
- import type { Archive } from '@nitpicker/crawler';
2
- /**
3
- * Collects, for every page that references at least one CSS stylesheet, the
4
- * set of stylesheet URLs it references.
5
- *
6
- * Reads in three narrow passes instead of one wide JOIN, to avoid
7
- * materializing the full page×stylesheet edge set at once — a real
8
- * 486,000-page archive has ~2.5 million such edges, and a single JOIN
9
- * projecting both URL strings for all of them is a transient allocation
10
- * spike well beyond what any single page's data actually needs:
11
- *
12
- * 1. Resolve which `resource_items` rows are classified `'css'`
13
- * (`content_type_refs.category`) and their stylesheet URL.
14
- * 2. Resolve `resource_ref_edges` for just those CSS resource ids, in
15
- * chunks, projecting only the two integer ids per row.
16
- * 3. Resolve the page ids collected in step 2 to their URLs.
17
- *
18
- * Identical stylesheet-URL arrays are interned across pages: pages sharing a
19
- * template share the same CSS set, so the number of distinct arrays
20
- * converges to a few hundred even across hundreds of thousands of pages.
21
- * @param archive - The archive to read from.
22
- * @returns Map from page URL (parseUrl-normalized `href`, matching
23
- * `Page.url.href`) to the stylesheet URLs it references. Pages that
24
- * reference no CSS resource have no entry — callers should default to an
25
- * empty array on a missed lookup.
26
- * @example
27
- * ```ts
28
- * const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
29
- * const hrefs = stylesheetsByUrl.get(page.url.href) ?? [];
30
- * ```
31
- */
32
- export declare function collectPageStylesheetUrls(archive: Archive): Promise<Map<string, readonly string[]>>;
@@ -1,130 +0,0 @@
1
- import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
2
- import { eachSplitted } from '@nitpicker/crawler';
3
- /**
4
- * SQLite has a hard cap of 999 bound variables per statement. A real archive
5
- * can have far more than 999 CSS-referencing edges or pages, so every
6
- * `whereIn` below is chunked at this size.
7
- *
8
- * Independently duplicated (not shared/imported) from `@nitpicker/query`'s
9
- * `compute-isolated-clusters.ts`, which defines the identical
10
- * `SQLITE_IN_CHUNK = 500` for the same reason. If this margin below 999 ever
11
- * needs revisiting, update both — there is no shared module either package
12
- * depends on to hold one copy.
13
- */
14
- const SQLITE_IN_CHUNK = 500;
15
- /**
16
- * Collects, for every page that references at least one CSS stylesheet, the
17
- * set of stylesheet URLs it references.
18
- *
19
- * Reads in three narrow passes instead of one wide JOIN, to avoid
20
- * materializing the full page×stylesheet edge set at once — a real
21
- * 486,000-page archive has ~2.5 million such edges, and a single JOIN
22
- * projecting both URL strings for all of them is a transient allocation
23
- * spike well beyond what any single page's data actually needs:
24
- *
25
- * 1. Resolve which `resource_items` rows are classified `'css'`
26
- * (`content_type_refs.category`) and their stylesheet URL.
27
- * 2. Resolve `resource_ref_edges` for just those CSS resource ids, in
28
- * chunks, projecting only the two integer ids per row.
29
- * 3. Resolve the page ids collected in step 2 to their URLs.
30
- *
31
- * Identical stylesheet-URL arrays are interned across pages: pages sharing a
32
- * template share the same CSS set, so the number of distinct arrays
33
- * converges to a few hundred even across hundreds of thousands of pages.
34
- * @param archive - The archive to read from.
35
- * @returns Map from page URL (parseUrl-normalized `href`, matching
36
- * `Page.url.href`) to the stylesheet URLs it references. Pages that
37
- * reference no CSS resource have no entry — callers should default to an
38
- * empty array on a missed lookup.
39
- * @example
40
- * ```ts
41
- * const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
42
- * const hrefs = stylesheetsByUrl.get(page.url.href) ?? [];
43
- * ```
44
- */
45
- export async function collectPageStylesheetUrls(archive) {
46
- const knex = archive.getKnex();
47
- const cssResources = (await knex('resource_items as ri')
48
- .join('content_type_refs as ctr', 'ctr.id', 'ri.content_type_id')
49
- .where('ctr.category', 'css')
50
- .select('ri.id as id', 'ri.url_id as urlId'));
51
- // A CSS resource identified by a `data:` URI (routed to `blob_refs`
52
- // instead of `url_refs`, `resource_items.url_blob_id`) has no stylesheet
53
- // URL to report — skip it rather than passing a `null` into `whereIn`.
54
- const cssResourcesWithUrl = cssResources.filter((r) => r.urlId != null);
55
- if (cssResourcesWithUrl.length === 0) {
56
- return new Map();
57
- }
58
- const cssUrlById = new Map();
59
- await eachSplitted(cssResourcesWithUrl.map((r) => r.urlId), SQLITE_IN_CHUNK, async (chunk) => {
60
- const rows = (await knex('url_refs').whereIn('id', chunk).select('id', 'url'));
61
- for (const row of rows) {
62
- cssUrlById.set(row.id, row.url);
63
- }
64
- });
65
- const cssUrlByResourceId = new Map();
66
- for (const r of cssResourcesWithUrl) {
67
- const url = cssUrlById.get(r.urlId);
68
- if (url) {
69
- cssUrlByResourceId.set(r.id, url);
70
- }
71
- }
72
- const resourceIdsByPageId = new Map();
73
- await eachSplitted(cssResourcesWithUrl.map((r) => r.id), SQLITE_IN_CHUNK, async (chunk) => {
74
- const rows = (await knex('resource_ref_edges')
75
- .whereIn('resource_id', chunk)
76
- .select('resource_id as resourceId', 'page_id as pageId'));
77
- // Avoid `push(...rows)`: on large real archives this chunk array can
78
- // be large enough to overflow V8's argument-spread limit even though
79
- // the underlying data itself fits in memory.
80
- for (const row of rows) {
81
- const existing = resourceIdsByPageId.get(row.pageId);
82
- if (existing) {
83
- existing.push(row.resourceId);
84
- }
85
- else {
86
- resourceIdsByPageId.set(row.pageId, [row.resourceId]);
87
- }
88
- }
89
- });
90
- if (resourceIdsByPageId.size === 0) {
91
- return new Map();
92
- }
93
- const pageUrlById = new Map();
94
- await eachSplitted([...resourceIdsByPageId.keys()], SQLITE_IN_CHUNK, async (chunk) => {
95
- const rows = (await knex('content_items as ci')
96
- .join('url_refs as ur', 'ur.id', 'ci.url_id')
97
- .whereIn('ci.id', chunk)
98
- .select('ci.id as id', 'ur.url as url'));
99
- for (const row of rows) {
100
- pageUrlById.set(row.id, row.url);
101
- }
102
- });
103
- const internedSets = new Map();
104
- const result = new Map();
105
- for (const [pageId, resourceIds] of resourceIdsByPageId) {
106
- const pageUrlRaw = pageUrlById.get(pageId);
107
- if (!pageUrlRaw) {
108
- continue;
109
- }
110
- const parsed = parseUrl(pageUrlRaw);
111
- if (!parsed) {
112
- continue;
113
- }
114
- const urls = resourceIds
115
- .map((id) => cssUrlByResourceId.get(id))
116
- .filter((url) => url != null)
117
- .toSorted();
118
- if (urls.length === 0) {
119
- continue;
120
- }
121
- const internKey = urls.join(' ');
122
- let interned = internedSets.get(internKey);
123
- if (!interned) {
124
- interned = Object.freeze(urls);
125
- internedSets.set(internKey, interned);
126
- }
127
- result.set(parsed.href, interned);
128
- }
129
- return result;
130
- }
@@ -1,55 +0,0 @@
1
- import type { PageFactory } from '@d-zero/page-cluster/resolve-page-cluster-keys';
2
- import type { Page } from '@nitpicker/crawler';
3
- /**
4
- * Builds the `PageFactory` that `@d-zero/page-cluster`'s
5
- * `resolvePageClusterKeys` reads the corpus through, plus a way to read back
6
- * the URL list it actually yielded (see below for why that can't be known
7
- * synchronously up front).
8
- *
9
- * Only internal HTML pages (`Page.isInternalPage()` — content-type based,
10
- * matching the "a page is judged by content-type, not `isTarget`" archive
11
- * invariant) with retrievable HTML are yielded. A page can be an internal
12
- * HTML page and still have no retrievable HTML (e.g. a skipped or
13
- * self-heal-affected row) — `page.getHtml()` returning falsy is only known
14
- * by actually calling it, not from any synchronous field.
15
- *
16
- * `resolvePageClusterKeys` calls the returned factory one or more times
17
- * (once for corpora at or below its inline threshold, twice above it — see
18
- * its own docs) to re-read the corpus. Whichever pages end up excluded
19
- * (empty HTML) must be excluded **identically** on every call, or the
20
- * pass-to-pass page sequence drifts and the library's same-order contract
21
- * breaks. Excluding by `isInternalPage()` is safe (synchronous,
22
- * deterministic per page). Excluding by empty `getHtml()` is also safe,
23
- * but only because the archive is immutable for the duration of one
24
- * `analyze()` run — the same page's HTML never changes between calls.
25
- *
26
- * `getYieldedUrls()` reflects the URLs yielded by the **most recently
27
- * completed** full iteration of the factory's generator, deliberately not a
28
- * separate up-front probe pass over every candidate's HTML: every documented
29
- * call shape of `resolvePageClusterKeys` fully drains the factory it's given
30
- * (that's what "reading the factory" means in its own docs), so by the time
31
- * `resolvePageClusterKeys` resolves, at least one full drain has already
32
- * happened and `getYieldedUrls()` reflects it — a dedicated probe pass would
33
- * only add a full corpus-wide decompression sweep beyond the library's own
34
- * 1-2, for no benefit. `classifyPageTemplates` still defends against a
35
- * violated assumption here with a hard length check against
36
- * `resolvePageClusterKeys`'s result array.
37
- * @param pages - Candidate pages (as already loaded by `Nitpicker.analyze()`
38
- * — this function does not re-query the archive).
39
- * @param stylesheetsByUrl - Output of `collectPageStylesheetUrls`, keyed by
40
- * `page.url.href`.
41
- * @returns `factory` to pass to `resolvePageClusterKeys`, and
42
- * `getYieldedUrls()` — call this only *after* `resolvePageClusterKeys`
43
- * resolves; its result array has exactly the length and order of
44
- * `getYieldedUrls()` at that point.
45
- * @example
46
- * ```ts
47
- * const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
48
- * const clusterKeys = await resolvePageClusterKeys(factory);
49
- * const yieldedUrls = getYieldedUrls(); // safe to read now
50
- * ```
51
- */
52
- export declare function createPageClusterFactory(pages: readonly Page[], stylesheetsByUrl: ReadonlyMap<string, readonly string[]>): {
53
- factory: PageFactory;
54
- getYieldedUrls: () => readonly string[];
55
- };
@@ -1,88 +0,0 @@
1
- /**
2
- * Builds the `PageFactory` that `@d-zero/page-cluster`'s
3
- * `resolvePageClusterKeys` reads the corpus through, plus a way to read back
4
- * the URL list it actually yielded (see below for why that can't be known
5
- * synchronously up front).
6
- *
7
- * Only internal HTML pages (`Page.isInternalPage()` — content-type based,
8
- * matching the "a page is judged by content-type, not `isTarget`" archive
9
- * invariant) with retrievable HTML are yielded. A page can be an internal
10
- * HTML page and still have no retrievable HTML (e.g. a skipped or
11
- * self-heal-affected row) — `page.getHtml()` returning falsy is only known
12
- * by actually calling it, not from any synchronous field.
13
- *
14
- * `resolvePageClusterKeys` calls the returned factory one or more times
15
- * (once for corpora at or below its inline threshold, twice above it — see
16
- * its own docs) to re-read the corpus. Whichever pages end up excluded
17
- * (empty HTML) must be excluded **identically** on every call, or the
18
- * pass-to-pass page sequence drifts and the library's same-order contract
19
- * breaks. Excluding by `isInternalPage()` is safe (synchronous,
20
- * deterministic per page). Excluding by empty `getHtml()` is also safe,
21
- * but only because the archive is immutable for the duration of one
22
- * `analyze()` run — the same page's HTML never changes between calls.
23
- *
24
- * `getYieldedUrls()` reflects the URLs yielded by the **most recently
25
- * completed** full iteration of the factory's generator, deliberately not a
26
- * separate up-front probe pass over every candidate's HTML: every documented
27
- * call shape of `resolvePageClusterKeys` fully drains the factory it's given
28
- * (that's what "reading the factory" means in its own docs), so by the time
29
- * `resolvePageClusterKeys` resolves, at least one full drain has already
30
- * happened and `getYieldedUrls()` reflects it — a dedicated probe pass would
31
- * only add a full corpus-wide decompression sweep beyond the library's own
32
- * 1-2, for no benefit. `classifyPageTemplates` still defends against a
33
- * violated assumption here with a hard length check against
34
- * `resolvePageClusterKeys`'s result array.
35
- * @param pages - Candidate pages (as already loaded by `Nitpicker.analyze()`
36
- * — this function does not re-query the archive).
37
- * @param stylesheetsByUrl - Output of `collectPageStylesheetUrls`, keyed by
38
- * `page.url.href`.
39
- * @returns `factory` to pass to `resolvePageClusterKeys`, and
40
- * `getYieldedUrls()` — call this only *after* `resolvePageClusterKeys`
41
- * resolves; its result array has exactly the length and order of
42
- * `getYieldedUrls()` at that point.
43
- * @example
44
- * ```ts
45
- * const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
46
- * const clusterKeys = await resolvePageClusterKeys(factory);
47
- * const yieldedUrls = getYieldedUrls(); // safe to read now
48
- * ```
49
- */
50
- export function createPageClusterFactory(pages, stylesheetsByUrl) {
51
- const candidates = pages.filter((page) => page.isInternalPage());
52
- let yieldedUrls = [];
53
- const factory = () => yieldPageClusterSignals(candidates, stylesheetsByUrl, (urls) => {
54
- yieldedUrls = urls;
55
- });
56
- return { factory, getYieldedUrls: () => yieldedUrls };
57
- }
58
- /**
59
- * The actual per-call iterator body for {@link createPageClusterFactory}'s
60
- * `PageFactory`. HTML is fetched lazily, one page at a time, so the archive
61
- * never holds more than one page's decompressed HTML in memory regardless
62
- * of corpus size — matching `@d-zero/page-cluster`'s own memory-bounding
63
- * design for its multi-pass reads.
64
- * @param candidates - Pre-filtered internal HTML pages, in fixed order.
65
- * @param stylesheetsByUrl - See {@link createPageClusterFactory}.
66
- * @param onDrained - Called with every yielded URL, in order, once this
67
- * particular iteration reaches the end of `candidates` — never called if
68
- * the consumer stops iterating early.
69
- * @yields {PageClusterSignals} One per candidate page with retrievable HTML.
70
- */
71
- async function* yieldPageClusterSignals(candidates, stylesheetsByUrl, onDrained) {
72
- const yieldedUrls = [];
73
- for (const page of candidates) {
74
- const html = await page.getHtml();
75
- if (!html) {
76
- continue;
77
- }
78
- const url = page.url;
79
- yieldedUrls.push(url.href);
80
- yield {
81
- paths: url.paths,
82
- stylesheetHrefs: stylesheetsByUrl.get(url.href) ?? [],
83
- html,
84
- host: url.port ? `${url.hostname}:${url.port}` : url.hostname,
85
- };
86
- }
87
- onDrained(yieldedUrls);
88
- }
@@ -1,44 +0,0 @@
1
- import type { ProgressEvent } from '@d-zero/page-cluster/resolve-page-cluster-keys';
2
- import type { Archive, Page, TemplateClusterReason } from '@nitpicker/crawler';
3
- /**
4
- * Options for {@link import('./classify-page-templates.js').classifyPageTemplates}.
5
- */
6
- export interface ClassifyPageTemplatesOptions {
7
- /** The archive to read pages/stylesheets from and to derive the cache key from. */
8
- archive: Archive;
9
- /**
10
- * All pages already loaded for this `analyze()` run (see
11
- * `Nitpicker.analyze`'s accumulation across `getPagesWithRefs` batches —
12
- * classification must run once, globally, not per batch).
13
- */
14
- pages: readonly Page[];
15
- /**
16
- * Progress callback forwarded to `@d-zero/page-cluster`'s
17
- * `resolvePageClusterKeys`, so long-running classification on large
18
- * archives isn't silently unresponsive. Independent of cluster-reason
19
- * capture — `@d-zero/page-cluster` 0.5.3+ composes `onProgress` and
20
- * `onClusterReason` without either one demoting the corpus off its
21
- * progress-emitting path (see `classifyPageTemplates`'s own JSDoc).
22
- */
23
- onProgress?: (event: ProgressEvent) => void;
24
- }
25
- /**
26
- * Result of {@link import('./classify-page-templates.js').classifyPageTemplates}.
27
- */
28
- export interface PageTemplateClassification {
29
- /**
30
- * Page URL (`page.url.href`) → template key. Only internal HTML pages
31
- * with retrievable HTML have an entry.
32
- */
33
- readonly templateKeysByUrl: ReadonlyMap<string, string>;
34
- /**
35
- * Template key → `@d-zero/page-cluster`'s cluster-selection evidence for
36
- * that key. **Not guaranteed to cover every key in `templateKeysByUrl`**:
37
- * `@d-zero/page-cluster` only emits a reason for a final cluster it still
38
- * holds full grouping state for at the moment `onClusterReason` fires,
39
- * which is a best-effort side channel rather than a per-page guarantee
40
- * (unlike `templateKeysByUrl`, which is verified 1:1 against the yielded
41
- * page set — see the hard length check in `classifyPageTemplates`).
42
- */
43
- readonly clusterReasonsByTemplateKey: ReadonlyMap<string, TemplateClusterReason>;
44
- }
@@ -1 +0,0 @@
1
- export {};