@nitpicker/core 0.13.0 → 0.15.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.
- package/lib/discover-analyze-plugins.js +0 -1
- package/lib/get-table-cache-root.d.ts +18 -0
- package/lib/get-table-cache-root.js +22 -0
- package/lib/nitpicker.d.ts +24 -2
- package/lib/nitpicker.js +159 -4
- package/lib/template-classification/classify-page-templates.d.ts +49 -0
- package/lib/template-classification/classify-page-templates.js +159 -0
- package/lib/template-classification/collect-page-stylesheet-urls.d.ts +32 -0
- package/lib/template-classification/collect-page-stylesheet-urls.js +130 -0
- package/lib/template-classification/create-page-cluster-factory.d.ts +55 -0
- package/lib/template-classification/create-page-cluster-factory.js +88 -0
- package/lib/template-classification/types.d.ts +44 -0
- package/lib/template-classification/types.js +1 -0
- package/lib/types.d.ts +11 -5
- package/package.json +5 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the storage location for the analyze command's scratch `table`
|
|
3
|
+
* cache (per-page axe results, cleared at the start of every `analyze` run).
|
|
4
|
+
*
|
|
5
|
+
* Defined as a child of {@link getArchiveCacheRoot} so that
|
|
6
|
+
* `NITPICKER_TAR_CACHE_DIR` — which already redirects the tar-extraction
|
|
7
|
+
* cache — consistently redirects this cache too, instead of the two caches
|
|
8
|
+
* silently splitting across different roots.
|
|
9
|
+
* @returns Absolute path to the `table` cache directory. The `Cache` class
|
|
10
|
+
* creates a further subdirectory under this path named after its `name`
|
|
11
|
+
* argument.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const cache = new Cache('nitpicker-axe', getTableCacheRoot());
|
|
15
|
+
* await cache.clear();
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function getTableCacheRoot(): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getArchiveCacheRoot } from '@nitpicker/crawler';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the storage location for the analyze command's scratch `table`
|
|
5
|
+
* cache (per-page axe results, cleared at the start of every `analyze` run).
|
|
6
|
+
*
|
|
7
|
+
* Defined as a child of {@link getArchiveCacheRoot} so that
|
|
8
|
+
* `NITPICKER_TAR_CACHE_DIR` — which already redirects the tar-extraction
|
|
9
|
+
* cache — consistently redirects this cache too, instead of the two caches
|
|
10
|
+
* silently splitting across different roots.
|
|
11
|
+
* @returns Absolute path to the `table` cache directory. The `Cache` class
|
|
12
|
+
* creates a further subdirectory under this path named after its `name`
|
|
13
|
+
* argument.
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const cache = new Cache('nitpicker-axe', getTableCacheRoot());
|
|
17
|
+
* await cache.clear();
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function getTableCacheRoot() {
|
|
21
|
+
return path.join(getArchiveCacheRoot(), 'table');
|
|
22
|
+
}
|
package/lib/nitpicker.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
|
|
|
66
66
|
* declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
|
|
67
67
|
* enables per-plugin progress tracking via Lanes.
|
|
68
68
|
*
|
|
69
|
-
* The analysis proceeds in
|
|
69
|
+
* The analysis proceeds in up to three phases:
|
|
70
70
|
*
|
|
71
71
|
* 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
|
|
72
72
|
* page analysis tasks to the plugin's worker pool. Progress is
|
|
@@ -76,10 +76,32 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
|
|
|
76
76
|
* sequentially in the main thread. These are lightweight checks
|
|
77
77
|
* that don't need DOM access.
|
|
78
78
|
*
|
|
79
|
-
*
|
|
79
|
+
* 3. **Template classification phase** (opt-in via
|
|
80
|
+
* {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
|
|
81
|
+
* HTML page into a template group by DOM-structure similarity. Unlike
|
|
82
|
+
* phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
|
|
83
|
+
* runs once, globally, after every batch from phases 1-2 has been
|
|
84
|
+
* accumulated (see `accumulatedPages` below for why it cannot run
|
|
85
|
+
* per-batch).
|
|
86
|
+
*
|
|
87
|
+
* On completion, data entries are stored in the archive:
|
|
80
88
|
* - `analysis/report` - Full {@link Report} with headers and data
|
|
81
89
|
* - `analysis/table` - The raw {@link Table} instance (serialized)
|
|
82
90
|
* - SQL-backed analysis violation tables - Flat {@link Violation} records
|
|
91
|
+
* - `page_templates` SQL table - `templateKey` per page, written via
|
|
92
|
+
* `Archive.replacePageTemplates` and never round-tripped through
|
|
93
|
+
* `Table`/`Report`
|
|
94
|
+
*
|
|
95
|
+
* When `filter` resolves to zero plugins (only reachable when
|
|
96
|
+
* `classifyTemplates` is the sole reason for this call — see
|
|
97
|
+
* `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
|
|
98
|
+
* contributes no plugin data of its own. To avoid silently wiping every
|
|
99
|
+
* column and violation from a *previous* run's plugins, this case skips
|
|
100
|
+
* `replaceAnalysisViolations` entirely and seeds the new `Table` from the
|
|
101
|
+
* archive's existing `analysis/report` (if any). This does not apply when
|
|
102
|
+
* one or more plugins ran: running a different plugin subset than a prior
|
|
103
|
+
* call has always fully replaced the report/table/violations with that
|
|
104
|
+
* subset's output, and that established contract is unchanged here.
|
|
83
105
|
* @param filter - Optional list of plugin module names to run.
|
|
84
106
|
* If omitted, all configured plugins are executed.
|
|
85
107
|
* @param options - Optional settings for progress display.
|
package/lib/nitpicker.js
CHANGED
|
@@ -4,9 +4,11 @@ import { Cache } from '@d-zero/shared/cache';
|
|
|
4
4
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
5
5
|
import { Archive } from '@nitpicker/crawler';
|
|
6
6
|
import c from 'ansi-colors';
|
|
7
|
+
import { getTableCacheRoot } from './get-table-cache-root.js';
|
|
7
8
|
import { importModules } from './import-modules.js';
|
|
8
9
|
import { loadPluginSettings } from './load-plugin-settings.js';
|
|
9
10
|
import { Table } from './table.js';
|
|
11
|
+
import { classifyPageTemplates } from './template-classification/classify-page-templates.js';
|
|
10
12
|
import { UrlEventBus } from './url-event-bus.js';
|
|
11
13
|
import { WorkerPool } from './worker/worker-pool.js';
|
|
12
14
|
export { definePlugin } from './hooks/define-plugin.js';
|
|
@@ -29,6 +31,34 @@ const workerPath = path.resolve(__dirname, 'worker/worker.js');
|
|
|
29
31
|
* Tracks the number of CPU cores so that worker pools scale with the host.
|
|
30
32
|
*/
|
|
31
33
|
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
|
+
}
|
|
32
62
|
/**
|
|
33
63
|
* Core orchestrator for running analyze plugins against a `.nitpicker` archive.
|
|
34
64
|
*
|
|
@@ -112,7 +142,7 @@ export class Nitpicker extends EventEmitter {
|
|
|
112
142
|
* declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
|
|
113
143
|
* enables per-plugin progress tracking via Lanes.
|
|
114
144
|
*
|
|
115
|
-
* The analysis proceeds in
|
|
145
|
+
* The analysis proceeds in up to three phases:
|
|
116
146
|
*
|
|
117
147
|
* 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
|
|
118
148
|
* page analysis tasks to the plugin's worker pool. Progress is
|
|
@@ -122,10 +152,32 @@ export class Nitpicker extends EventEmitter {
|
|
|
122
152
|
* sequentially in the main thread. These are lightweight checks
|
|
123
153
|
* that don't need DOM access.
|
|
124
154
|
*
|
|
125
|
-
*
|
|
155
|
+
* 3. **Template classification phase** (opt-in via
|
|
156
|
+
* {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
|
|
157
|
+
* HTML page into a template group by DOM-structure similarity. Unlike
|
|
158
|
+
* phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
|
|
159
|
+
* runs once, globally, after every batch from phases 1-2 has been
|
|
160
|
+
* accumulated (see `accumulatedPages` below for why it cannot run
|
|
161
|
+
* per-batch).
|
|
162
|
+
*
|
|
163
|
+
* On completion, data entries are stored in the archive:
|
|
126
164
|
* - `analysis/report` - Full {@link Report} with headers and data
|
|
127
165
|
* - `analysis/table` - The raw {@link Table} instance (serialized)
|
|
128
166
|
* - SQL-backed analysis violation tables - Flat {@link Violation} records
|
|
167
|
+
* - `page_templates` SQL table - `templateKey` per page, written via
|
|
168
|
+
* `Archive.replacePageTemplates` and never round-tripped through
|
|
169
|
+
* `Table`/`Report`
|
|
170
|
+
*
|
|
171
|
+
* When `filter` resolves to zero plugins (only reachable when
|
|
172
|
+
* `classifyTemplates` is the sole reason for this call — see
|
|
173
|
+
* `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
|
|
174
|
+
* contributes no plugin data of its own. To avoid silently wiping every
|
|
175
|
+
* column and violation from a *previous* run's plugins, this case skips
|
|
176
|
+
* `replaceAnalysisViolations` entirely and seeds the new `Table` from the
|
|
177
|
+
* archive's existing `analysis/report` (if any). This does not apply when
|
|
178
|
+
* one or more plugins ran: running a different plugin subset than a prior
|
|
179
|
+
* call has always fully replaced the report/table/violations with that
|
|
180
|
+
* subset's output, and that established contract is unchanged here.
|
|
129
181
|
* @param filter - Optional list of plugin module names to run.
|
|
130
182
|
* If omitted, all configured plugins are executed.
|
|
131
183
|
* @param options - Optional settings for progress display.
|
|
@@ -149,6 +201,34 @@ export class Nitpicker extends EventEmitter {
|
|
|
149
201
|
const analyzeMods = await importModules(plugins);
|
|
150
202
|
const lanes = options?.lanes;
|
|
151
203
|
const table = new Table();
|
|
204
|
+
if (plugins.length === 0) {
|
|
205
|
+
// Only reachable via `--templates` with zero configured/selected
|
|
206
|
+
// plugins (see this method's JSDoc). Seed from whatever
|
|
207
|
+
// `analysis/report` already exists so this run — which contributes
|
|
208
|
+
// no plugin data of its own — doesn't wipe a previous run's columns.
|
|
209
|
+
try {
|
|
210
|
+
const previousReport = await this.archive.getData('analysis/report');
|
|
211
|
+
if (previousReport.pageData) {
|
|
212
|
+
table.addHeaders(previousReport.pageData.headers);
|
|
213
|
+
table.addData(previousReport.pageData.data);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// ENOENT (no previous `analysis/report` — e.g. first analyze()
|
|
218
|
+
// call ever on this archive) is expected and starting from an
|
|
219
|
+
// empty table is correct. Anything else (corrupted JSON,
|
|
220
|
+
// permission error) is not silently swallowed: emit it so the
|
|
221
|
+
// user knows a previous run's data may not have been preserved,
|
|
222
|
+
// while still proceeding rather than blocking this run entirely.
|
|
223
|
+
if (error?.code !== 'ENOENT') {
|
|
224
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
225
|
+
await this.emit('error', {
|
|
226
|
+
message: `Failed to read previous analysis/report before a zero-plugin run: ${message}`,
|
|
227
|
+
error: error instanceof Error ? error : null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
152
232
|
for (const mod of analyzeMods) {
|
|
153
233
|
if (!mod.headers) {
|
|
154
234
|
continue;
|
|
@@ -159,7 +239,7 @@ export class Nitpicker extends EventEmitter {
|
|
|
159
239
|
table.addHeaders(mod.headers);
|
|
160
240
|
}
|
|
161
241
|
const allViolations = [];
|
|
162
|
-
const cache = new Cache('nitpicker-axe',
|
|
242
|
+
const cache = new Cache('nitpicker-axe', getTableCacheRoot());
|
|
163
243
|
await cache.clear();
|
|
164
244
|
// Build plugin metadata: lane IDs and display labels
|
|
165
245
|
const eachPagePlugins = [];
|
|
@@ -180,7 +260,32 @@ export class Nitpicker extends EventEmitter {
|
|
|
180
260
|
const label = pluginLabels.get(name) ?? name;
|
|
181
261
|
lanes?.update(id, c.dim(`${label}: Waiting...`));
|
|
182
262
|
}
|
|
263
|
+
// Template classification (opt-in, `--templates`) is a corpus-wide batch
|
|
264
|
+
// computation, not a per-page `AnalyzePlugin`, so it has its own lane
|
|
265
|
+
// outside `pluginLaneIds` and does not go through `analyzeMods`/`Worker`.
|
|
266
|
+
const templateClassificationLaneId = options?.classifyTemplates
|
|
267
|
+
? eachPagePlugins.length
|
|
268
|
+
: null;
|
|
269
|
+
if (templateClassificationLaneId != null) {
|
|
270
|
+
lanes?.update(templateClassificationLaneId, c.dim('Template classification: Waiting...'));
|
|
271
|
+
}
|
|
272
|
+
// Accumulated across every `getPagesWithRefs` batch so template
|
|
273
|
+
// classification runs once, globally, after the loop below completes —
|
|
274
|
+
// never per-batch. Per-batch classification would produce template keys
|
|
275
|
+
// that are only comparable within their own batch (see
|
|
276
|
+
// `classifyPageTemplates`'s caller contract). `Page` instances are
|
|
277
|
+
// lightweight handles (HTML is fetched lazily via `getHtml()`), so
|
|
278
|
+
// accumulating every page across a 100,000-page batch size is not an
|
|
279
|
+
// OOM risk even for archives with hundreds of thousands of pages.
|
|
280
|
+
const accumulatedPages = [];
|
|
183
281
|
await this.archive.getPagesWithRefs(100_000, async (pages) => {
|
|
282
|
+
if (options?.classifyTemplates) {
|
|
283
|
+
// Avoid `push(...pages)`: a 100,000-page batch can overflow V8's
|
|
284
|
+
// argument-spread limit even though the data itself fits in memory.
|
|
285
|
+
for (const page of pages) {
|
|
286
|
+
accumulatedPages.push(page);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
184
289
|
const urlEmitter = new UrlEventBus();
|
|
185
290
|
// Phase 1: eachPage plugins (sequentially, pages in parallel)
|
|
186
291
|
for (const [pluginSeqIndex, { plugin, modIndex }] of eachPagePlugins.entries()) {
|
|
@@ -354,13 +459,63 @@ export class Nitpicker extends EventEmitter {
|
|
|
354
459
|
}, {
|
|
355
460
|
withRefs: false,
|
|
356
461
|
});
|
|
462
|
+
// Phase 3: template classification (opt-in). Deliberately outside the
|
|
463
|
+
// `getPagesWithRefs` loop above — it must run once, globally, over every
|
|
464
|
+
// accumulated page, not once per 100,000-page batch (see
|
|
465
|
+
// `accumulatedPages`'s JSDoc comment).
|
|
466
|
+
//
|
|
467
|
+
// Wrapped in try/catch (unlike phases 1-2, whose failures are already
|
|
468
|
+
// caught per-page/per-mod) because this phase has no finer-grained
|
|
469
|
+
// unit to catch around: a failure here must not discard the
|
|
470
|
+
// already-computed `table`/`allViolations` from phases 1-2, which are
|
|
471
|
+
// persisted in the unconditional tail below regardless of this
|
|
472
|
+
// phase's outcome.
|
|
473
|
+
if (templateClassificationLaneId != null) {
|
|
474
|
+
try {
|
|
475
|
+
const classification = await classifyPageTemplates({
|
|
476
|
+
archive: this.archive,
|
|
477
|
+
pages: accumulatedPages,
|
|
478
|
+
// Only worth paying for when there's a lane to update —
|
|
479
|
+
// passing a defined callback at all (even a no-op one)
|
|
480
|
+
// demotes `resolvePageClusterKeys` off its byte-identical,
|
|
481
|
+
// yield-overhead-free sync path for corpora at or below its
|
|
482
|
+
// inline threshold (see @d-zero/page-cluster's own docs).
|
|
483
|
+
// `classifyPageTemplates` always requests cluster reasons too,
|
|
484
|
+
// independent of this — see its own JSDoc for why that no
|
|
485
|
+
// longer costs the sync-path demotion this comment warns about.
|
|
486
|
+
onProgress: lanes
|
|
487
|
+
? (event) => {
|
|
488
|
+
lanes.update(templateClassificationLaneId, `Template classification: ${formatTemplateClassificationProgress(event)}%braille%`);
|
|
489
|
+
}
|
|
490
|
+
: undefined,
|
|
491
|
+
});
|
|
492
|
+
if (classification.templateKeysByUrl.size > 0) {
|
|
493
|
+
await this.archive.replacePageTemplates(classification.templateKeysByUrl, classification.clusterReasonsByTemplateKey);
|
|
494
|
+
}
|
|
495
|
+
lanes?.update(templateClassificationLaneId, c.green(`Template classification: Done (${classification.templateKeysByUrl.size} pages)`));
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
499
|
+
lanes?.update(templateClassificationLaneId, c.red(`Template classification: Failed (${message})`));
|
|
500
|
+
await this.emit('error', {
|
|
501
|
+
message: `[template classification] ${message}`,
|
|
502
|
+
error: error instanceof Error ? error : null,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
357
506
|
const report = {
|
|
358
507
|
name: 'general',
|
|
359
508
|
pageData: table.toJSON(),
|
|
360
509
|
};
|
|
361
510
|
await this.archive.setData('analysis/report', report);
|
|
362
511
|
await this.archive.setData('analysis/table', table);
|
|
363
|
-
|
|
512
|
+
// See this method's JSDoc: a zero-plugin call (only reachable via
|
|
513
|
+
// `--templates` alone) contributes no violations of its own, so
|
|
514
|
+
// replacing the whole table with an empty array would silently erase
|
|
515
|
+
// every violation a previous run's plugins stored.
|
|
516
|
+
if (plugins.length > 0) {
|
|
517
|
+
await this.archive.replaceAnalysisViolations(allViolations);
|
|
518
|
+
}
|
|
364
519
|
}
|
|
365
520
|
/**
|
|
366
521
|
* Loads and caches the plugin configuration from the user's config file.
|
|
@@ -0,0 +1,49 @@
|
|
|
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>;
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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[]>>;
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/types.d.ts
CHANGED
|
@@ -44,6 +44,17 @@ 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;
|
|
47
58
|
}
|
|
48
59
|
/**
|
|
49
60
|
* CLI-specified overrides for individual plugin settings.
|
|
@@ -70,11 +81,6 @@ export interface PluginOverrides {
|
|
|
70
81
|
/** CSS selector to narrow the search scope. */
|
|
71
82
|
scope?: string;
|
|
72
83
|
};
|
|
73
|
-
/** Overrides for `@nitpicker/analyze-main-contents` plugin settings. */
|
|
74
|
-
'@nitpicker/analyze-main-contents'?: {
|
|
75
|
-
/** CSS selector for main content detection. */
|
|
76
|
-
mainContentSelector?: string;
|
|
77
|
-
};
|
|
78
84
|
/** Overrides for `@nitpicker/analyze-axe` plugin settings. */
|
|
79
85
|
'@nitpicker/analyze-axe'?: {
|
|
80
86
|
/** BCP 47 language tag for locale. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Plugin-based page analysis engine for Nitpicker",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@d-zero/dealer": "1.9.4",
|
|
31
|
+
"@d-zero/page-cluster": "0.5.3",
|
|
31
32
|
"@d-zero/shared": "0.22.2",
|
|
32
|
-
"@nitpicker/crawler": "0.
|
|
33
|
-
"@nitpicker/types": "0.
|
|
33
|
+
"@nitpicker/crawler": "0.15.0",
|
|
34
|
+
"@nitpicker/types": "0.15.0",
|
|
34
35
|
"ansi-colors": "4.1.3",
|
|
35
36
|
"cosmiconfig": "9.0.2",
|
|
36
37
|
"jsdom": "29.1.1"
|
|
37
38
|
},
|
|
38
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "d1485df6e43375c6a44edfe07a0293fd39d7810d"
|
|
39
40
|
}
|