@nitpicker/core 0.22.2 → 0.23.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/nitpicker.d.ts +1 -23
- package/lib/nitpicker.js +2 -156
- package/lib/types.d.ts +0 -11
- package/package.json +4 -5
- package/lib/template-classification/classify-page-templates.d.ts +0 -49
- package/lib/template-classification/classify-page-templates.js +0 -159
- package/lib/template-classification/collect-page-stylesheet-urls.d.ts +0 -32
- package/lib/template-classification/collect-page-stylesheet-urls.js +0 -130
- package/lib/template-classification/create-page-cluster-factory.d.ts +0 -55
- package/lib/template-classification/create-page-cluster-factory.js +0 -88
- package/lib/template-classification/types.d.ts +0 -44
- package/lib/template-classification/types.js +0 -1
package/lib/nitpicker.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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.
|
|
37
|
-
"@nitpicker/types": "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": "
|
|
41
|
+
"gitHead": "c62b266e166b1a1e98f0572c8579012abb25e3a7"
|
|
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 {};
|