@nitpicker/core 0.12.0 → 0.14.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/README.md +11 -13
- package/lib/discover-analyze-plugins.js +0 -1
- package/lib/nitpicker.d.ts +28 -5
- package/lib/nitpicker.js +158 -7
- package/lib/template-classification/classify-page-templates.d.ts +64 -0
- package/lib/template-classification/classify-page-templates.js +119 -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/types.d.ts +11 -5
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
# @nitpicker/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`.nitpicker` アーカイブ内のページに対して analyze プラグインを実行する内部パッケージです。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
プラグインの読み込み、実行、結果の書き戻しを担当します。通常は [@nitpicker/cli](../cli/README.md) の `analyze` コマンドから利用します。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
このパッケージは [Nitpicker](../../README.md) モノレポの内部パッケージです。単体での利用は想定していません。
|
|
10
|
-
|
|
11
|
-
## プラグイン作者向け
|
|
7
|
+
## プラグイン作者向けAPI
|
|
12
8
|
|
|
13
9
|
analyze プラグインは `definePlugin()` で定義します。
|
|
14
10
|
|
|
@@ -23,14 +19,14 @@ export default definePlugin((options: Options) => {
|
|
|
23
19
|
return {
|
|
24
20
|
label: 'キーワード検索',
|
|
25
21
|
// 並列度を宣言できる。省略時は os.cpus().length
|
|
26
|
-
//
|
|
22
|
+
// 重いプラグインは小さく設定する
|
|
27
23
|
concurrency: 4,
|
|
28
24
|
headers: {
|
|
29
25
|
found: 'Keywords Found',
|
|
30
26
|
},
|
|
31
27
|
async eachPage({ window }) {
|
|
32
28
|
const text = window.document.body.textContent ?? '';
|
|
33
|
-
const count = options.keywords.filter((
|
|
29
|
+
const count = options.keywords.filter((keyword) => text.includes(keyword)).length;
|
|
34
30
|
return {
|
|
35
31
|
page: { found: { value: count } },
|
|
36
32
|
};
|
|
@@ -39,12 +35,14 @@ export default definePlugin((options: Options) => {
|
|
|
39
35
|
});
|
|
40
36
|
```
|
|
41
37
|
|
|
42
|
-
|
|
38
|
+
`concurrency` を省略するとCPUコア数が使われます。ChromeやPuppeteerなど重いプロセスを起動するプラグインでは、2〜4程度に絞ることを推奨します。
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
- **小さく設定すべきケース**: Chrome や Puppeteer などの重いプロセスを起動するプラグインは 2〜4 程度に絞る。例えば `@nitpicker/analyze-lighthouse` は Chrome 1 プロセス ≒ 300〜500MB を消費するため `concurrency: 2`
|
|
40
|
+
## 関連リンク
|
|
46
41
|
|
|
47
|
-
|
|
42
|
+
- [Nitpicker README](../../../README.md)
|
|
43
|
+
- [CLI analyze docs](../cli/docs/analyze.md)
|
|
44
|
+
- [CONTRIBUTING.md](../../../CONTRIBUTING.md)
|
|
45
|
+
- [ARCHITECTURE.md](../../../ARCHITECTURE.md)
|
|
48
46
|
|
|
49
47
|
## ライセンス
|
|
50
48
|
|
package/lib/nitpicker.d.ts
CHANGED
|
@@ -8,7 +8,8 @@ export { definePlugin } from './hooks/define-plugin.js';
|
|
|
8
8
|
* Nitpicker opens an existing archive (produced by the crawler), loads the
|
|
9
9
|
* user's plugin configuration via cosmiconfig, then runs each plugin against
|
|
10
10
|
* every page in the archive. Results are stored back into the archive as
|
|
11
|
-
* `analysis/report`, `analysis/table`, and
|
|
11
|
+
* `analysis/report`, `analysis/table`, and SQL-backed analysis violation
|
|
12
|
+
* tables via the archive facade.
|
|
12
13
|
*
|
|
13
14
|
* ## Architecture decisions
|
|
14
15
|
*
|
|
@@ -65,7 +66,7 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
|
|
|
65
66
|
* declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
|
|
66
67
|
* enables per-plugin progress tracking via Lanes.
|
|
67
68
|
*
|
|
68
|
-
* The analysis proceeds in
|
|
69
|
+
* The analysis proceeds in up to three phases:
|
|
69
70
|
*
|
|
70
71
|
* 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
|
|
71
72
|
* page analysis tasks to the plugin's worker pool. Progress is
|
|
@@ -75,10 +76,32 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
|
|
|
75
76
|
* sequentially in the main thread. These are lightweight checks
|
|
76
77
|
* that don't need DOM access.
|
|
77
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:
|
|
88
|
+
* - `analysis/report` - Full {@link Report} with headers and data
|
|
80
89
|
* - `analysis/table` - The raw {@link Table} instance (serialized)
|
|
81
|
-
* -
|
|
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.
|
|
82
105
|
* @param filter - Optional list of plugin module names to run.
|
|
83
106
|
* If omitted, all configured plugins are executed.
|
|
84
107
|
* @param options - Optional settings for progress display.
|
package/lib/nitpicker.js
CHANGED
|
@@ -7,6 +7,7 @@ import c from 'ansi-colors';
|
|
|
7
7
|
import { importModules } from './import-modules.js';
|
|
8
8
|
import { loadPluginSettings } from './load-plugin-settings.js';
|
|
9
9
|
import { Table } from './table.js';
|
|
10
|
+
import { classifyPageTemplates } from './template-classification/classify-page-templates.js';
|
|
10
11
|
import { UrlEventBus } from './url-event-bus.js';
|
|
11
12
|
import { WorkerPool } from './worker/worker-pool.js';
|
|
12
13
|
export { definePlugin } from './hooks/define-plugin.js';
|
|
@@ -29,13 +30,42 @@ const workerPath = path.resolve(__dirname, 'worker/worker.js');
|
|
|
29
30
|
* Tracks the number of CPU cores so that worker pools scale with the host.
|
|
30
31
|
*/
|
|
31
32
|
const DEFAULT_CONCURRENCY = Math.max(1, os.cpus().length);
|
|
33
|
+
/**
|
|
34
|
+
* Renders a `@d-zero/page-cluster` `ProgressEvent` as a short, human-readable
|
|
35
|
+
* fragment for the Lanes template-classification lane, including a
|
|
36
|
+
* done/total percentage wherever the event carries both halves of one
|
|
37
|
+
* (matching the `N/M (X%)` convention every other lane in this file uses).
|
|
38
|
+
* `pass0-signals` and `stage-b-start` carry only a running count with no
|
|
39
|
+
* corpus-wide total, so those two render count-only.
|
|
40
|
+
* @param event - Progress event forwarded from `resolvePageClusterKeys`.
|
|
41
|
+
* @returns A short status fragment, e.g. `"42/100 blocks (42%)"`.
|
|
42
|
+
*/
|
|
43
|
+
function formatTemplateClassificationProgress(event) {
|
|
44
|
+
switch (event.phase) {
|
|
45
|
+
case 'pass0-signals': {
|
|
46
|
+
return `reading pages (${event.pagesSeen})`;
|
|
47
|
+
}
|
|
48
|
+
case 'pass1-block-complete': {
|
|
49
|
+
const percent = Math.round((event.blocksProcessed / event.totalBlocks) * 100);
|
|
50
|
+
return `${event.blocksProcessed}/${event.totalBlocks} blocks (${percent}%)`;
|
|
51
|
+
}
|
|
52
|
+
case 'pass1b-assign': {
|
|
53
|
+
const percent = Math.round((event.pagesAssigned / event.pagesToAssign) * 100);
|
|
54
|
+
return `${event.pagesAssigned}/${event.pagesToAssign} pages (${percent}%)`;
|
|
55
|
+
}
|
|
56
|
+
case 'stage-b-start': {
|
|
57
|
+
return `merging ${event.unitCount} units`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
32
61
|
/**
|
|
33
62
|
* Core orchestrator for running analyze plugins against a `.nitpicker` archive.
|
|
34
63
|
*
|
|
35
64
|
* Nitpicker opens an existing archive (produced by the crawler), loads the
|
|
36
65
|
* user's plugin configuration via cosmiconfig, then runs each plugin against
|
|
37
66
|
* every page in the archive. Results are stored back into the archive as
|
|
38
|
-
* `analysis/report`, `analysis/table`, and
|
|
67
|
+
* `analysis/report`, `analysis/table`, and SQL-backed analysis violation
|
|
68
|
+
* tables via the archive facade.
|
|
39
69
|
*
|
|
40
70
|
* ## Architecture decisions
|
|
41
71
|
*
|
|
@@ -111,7 +141,7 @@ export class Nitpicker extends EventEmitter {
|
|
|
111
141
|
* declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
|
|
112
142
|
* enables per-plugin progress tracking via Lanes.
|
|
113
143
|
*
|
|
114
|
-
* The analysis proceeds in
|
|
144
|
+
* The analysis proceeds in up to three phases:
|
|
115
145
|
*
|
|
116
146
|
* 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
|
|
117
147
|
* page analysis tasks to the plugin's worker pool. Progress is
|
|
@@ -121,10 +151,32 @@ export class Nitpicker extends EventEmitter {
|
|
|
121
151
|
* sequentially in the main thread. These are lightweight checks
|
|
122
152
|
* that don't need DOM access.
|
|
123
153
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
154
|
+
* 3. **Template classification phase** (opt-in via
|
|
155
|
+
* {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
|
|
156
|
+
* HTML page into a template group by DOM-structure similarity. Unlike
|
|
157
|
+
* phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
|
|
158
|
+
* runs once, globally, after every batch from phases 1-2 has been
|
|
159
|
+
* accumulated (see `accumulatedPages` below for why it cannot run
|
|
160
|
+
* per-batch).
|
|
161
|
+
*
|
|
162
|
+
* On completion, data entries are stored in the archive:
|
|
163
|
+
* - `analysis/report` - Full {@link Report} with headers and data
|
|
126
164
|
* - `analysis/table` - The raw {@link Table} instance (serialized)
|
|
127
|
-
* -
|
|
165
|
+
* - SQL-backed analysis violation tables - Flat {@link Violation} records
|
|
166
|
+
* - `page_templates` SQL table - `templateKey` per page, written via
|
|
167
|
+
* `Archive.replacePageTemplates` and never round-tripped through
|
|
168
|
+
* `Table`/`Report`
|
|
169
|
+
*
|
|
170
|
+
* When `filter` resolves to zero plugins (only reachable when
|
|
171
|
+
* `classifyTemplates` is the sole reason for this call — see
|
|
172
|
+
* `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
|
|
173
|
+
* contributes no plugin data of its own. To avoid silently wiping every
|
|
174
|
+
* column and violation from a *previous* run's plugins, this case skips
|
|
175
|
+
* `replaceAnalysisViolations` entirely and seeds the new `Table` from the
|
|
176
|
+
* archive's existing `analysis/report` (if any). This does not apply when
|
|
177
|
+
* one or more plugins ran: running a different plugin subset than a prior
|
|
178
|
+
* call has always fully replaced the report/table/violations with that
|
|
179
|
+
* subset's output, and that established contract is unchanged here.
|
|
128
180
|
* @param filter - Optional list of plugin module names to run.
|
|
129
181
|
* If omitted, all configured plugins are executed.
|
|
130
182
|
* @param options - Optional settings for progress display.
|
|
@@ -148,6 +200,34 @@ export class Nitpicker extends EventEmitter {
|
|
|
148
200
|
const analyzeMods = await importModules(plugins);
|
|
149
201
|
const lanes = options?.lanes;
|
|
150
202
|
const table = new Table();
|
|
203
|
+
if (plugins.length === 0) {
|
|
204
|
+
// Only reachable via `--templates` with zero configured/selected
|
|
205
|
+
// plugins (see this method's JSDoc). Seed from whatever
|
|
206
|
+
// `analysis/report` already exists so this run — which contributes
|
|
207
|
+
// no plugin data of its own — doesn't wipe a previous run's columns.
|
|
208
|
+
try {
|
|
209
|
+
const previousReport = await this.archive.getData('analysis/report');
|
|
210
|
+
if (previousReport.pageData) {
|
|
211
|
+
table.addHeaders(previousReport.pageData.headers);
|
|
212
|
+
table.addData(previousReport.pageData.data);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// ENOENT (no previous `analysis/report` — e.g. first analyze()
|
|
217
|
+
// call ever on this archive) is expected and starting from an
|
|
218
|
+
// empty table is correct. Anything else (corrupted JSON,
|
|
219
|
+
// permission error) is not silently swallowed: emit it so the
|
|
220
|
+
// user knows a previous run's data may not have been preserved,
|
|
221
|
+
// while still proceeding rather than blocking this run entirely.
|
|
222
|
+
if (error?.code !== 'ENOENT') {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
await this.emit('error', {
|
|
225
|
+
message: `Failed to read previous analysis/report before a zero-plugin run: ${message}`,
|
|
226
|
+
error: error instanceof Error ? error : null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
151
231
|
for (const mod of analyzeMods) {
|
|
152
232
|
if (!mod.headers) {
|
|
153
233
|
continue;
|
|
@@ -179,7 +259,32 @@ export class Nitpicker extends EventEmitter {
|
|
|
179
259
|
const label = pluginLabels.get(name) ?? name;
|
|
180
260
|
lanes?.update(id, c.dim(`${label}: Waiting...`));
|
|
181
261
|
}
|
|
262
|
+
// Template classification (opt-in, `--templates`) is a corpus-wide batch
|
|
263
|
+
// computation, not a per-page `AnalyzePlugin`, so it has its own lane
|
|
264
|
+
// outside `pluginLaneIds` and does not go through `analyzeMods`/`Worker`.
|
|
265
|
+
const templateClassificationLaneId = options?.classifyTemplates
|
|
266
|
+
? eachPagePlugins.length
|
|
267
|
+
: null;
|
|
268
|
+
if (templateClassificationLaneId != null) {
|
|
269
|
+
lanes?.update(templateClassificationLaneId, c.dim('Template classification: Waiting...'));
|
|
270
|
+
}
|
|
271
|
+
// Accumulated across every `getPagesWithRefs` batch so template
|
|
272
|
+
// classification runs once, globally, after the loop below completes —
|
|
273
|
+
// never per-batch. Per-batch classification would produce template keys
|
|
274
|
+
// that are only comparable within their own batch (see
|
|
275
|
+
// `classifyPageTemplates`'s caller contract). `Page` instances are
|
|
276
|
+
// lightweight handles (HTML is fetched lazily via `getHtml()`), so
|
|
277
|
+
// accumulating every page across a 100,000-page batch size is not an
|
|
278
|
+
// OOM risk even for archives with hundreds of thousands of pages.
|
|
279
|
+
const accumulatedPages = [];
|
|
182
280
|
await this.archive.getPagesWithRefs(100_000, async (pages) => {
|
|
281
|
+
if (options?.classifyTemplates) {
|
|
282
|
+
// Avoid `push(...pages)`: a 100,000-page batch can overflow V8's
|
|
283
|
+
// argument-spread limit even though the data itself fits in memory.
|
|
284
|
+
for (const page of pages) {
|
|
285
|
+
accumulatedPages.push(page);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
183
288
|
const urlEmitter = new UrlEventBus();
|
|
184
289
|
// Phase 1: eachPage plugins (sequentially, pages in parallel)
|
|
185
290
|
for (const [pluginSeqIndex, { plugin, modIndex }] of eachPagePlugins.entries()) {
|
|
@@ -353,14 +458,60 @@ export class Nitpicker extends EventEmitter {
|
|
|
353
458
|
}, {
|
|
354
459
|
withRefs: false,
|
|
355
460
|
});
|
|
461
|
+
// Phase 3: template classification (opt-in). Deliberately outside the
|
|
462
|
+
// `getPagesWithRefs` loop above — it must run once, globally, over every
|
|
463
|
+
// accumulated page, not once per 100,000-page batch (see
|
|
464
|
+
// `accumulatedPages`'s JSDoc comment).
|
|
465
|
+
//
|
|
466
|
+
// Wrapped in try/catch (unlike phases 1-2, whose failures are already
|
|
467
|
+
// caught per-page/per-mod) because this phase has no finer-grained
|
|
468
|
+
// unit to catch around: a failure here must not discard the
|
|
469
|
+
// already-computed `table`/`allViolations` from phases 1-2, which are
|
|
470
|
+
// persisted in the unconditional tail below regardless of this
|
|
471
|
+
// phase's outcome.
|
|
472
|
+
if (templateClassificationLaneId != null) {
|
|
473
|
+
try {
|
|
474
|
+
const templateKeys = await classifyPageTemplates({
|
|
475
|
+
archive: this.archive,
|
|
476
|
+
pages: accumulatedPages,
|
|
477
|
+
// Only worth paying for when there's a lane to update —
|
|
478
|
+
// passing a defined callback at all (even a no-op one)
|
|
479
|
+
// demotes `resolvePageClusterKeys` off its byte-identical,
|
|
480
|
+
// yield-overhead-free sync path for corpora at or below its
|
|
481
|
+
// inline threshold (see @d-zero/page-cluster's own docs).
|
|
482
|
+
onProgress: lanes
|
|
483
|
+
? (event) => {
|
|
484
|
+
lanes.update(templateClassificationLaneId, `Template classification: ${formatTemplateClassificationProgress(event)}%braille%`);
|
|
485
|
+
}
|
|
486
|
+
: undefined,
|
|
487
|
+
});
|
|
488
|
+
if (templateKeys.size > 0) {
|
|
489
|
+
await this.archive.replacePageTemplates(templateKeys);
|
|
490
|
+
}
|
|
491
|
+
lanes?.update(templateClassificationLaneId, c.green(`Template classification: Done (${templateKeys.size} pages)`));
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
495
|
+
lanes?.update(templateClassificationLaneId, c.red(`Template classification: Failed (${message})`));
|
|
496
|
+
await this.emit('error', {
|
|
497
|
+
message: `[template classification] ${message}`,
|
|
498
|
+
error: error instanceof Error ? error : null,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
356
502
|
const report = {
|
|
357
503
|
name: 'general',
|
|
358
504
|
pageData: table.toJSON(),
|
|
359
|
-
violations: allViolations,
|
|
360
505
|
};
|
|
361
506
|
await this.archive.setData('analysis/report', report);
|
|
362
507
|
await this.archive.setData('analysis/table', table);
|
|
363
|
-
|
|
508
|
+
// See this method's JSDoc: a zero-plugin call (only reachable via
|
|
509
|
+
// `--templates` alone) contributes no violations of its own, so
|
|
510
|
+
// replacing the whole table with an empty array would silently erase
|
|
511
|
+
// every violation a previous run's plugins stored.
|
|
512
|
+
if (plugins.length > 0) {
|
|
513
|
+
await this.archive.replaceAnalysisViolations(allViolations);
|
|
514
|
+
}
|
|
364
515
|
}
|
|
365
516
|
/**
|
|
366
517
|
* Loads and caches the plugin configuration from the user's config file.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ProgressEvent } from '@d-zero/page-cluster/resolve-page-cluster-keys';
|
|
2
|
+
import type { Archive, Page } from '@nitpicker/crawler';
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link 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.
|
|
19
|
+
*/
|
|
20
|
+
onProgress?: (event: ProgressEvent) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classifies every internal HTML page in the archive into a template group
|
|
24
|
+
* by DOM-structure similarity, using `@d-zero/page-cluster`.
|
|
25
|
+
*
|
|
26
|
+
* Result is cached, keyed by the archive file's path + size + mtime + page
|
|
27
|
+
* count (see {@link buildArchiveCacheKey}). **This only helps one narrow
|
|
28
|
+
* case**: a crash or Ctrl-C after this function completes but before
|
|
29
|
+
* `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
|
|
30
|
+
* itself take a while) — re-running on the same, not-yet-rewritten archive
|
|
31
|
+
* file hits the cache instead of re-running the whole multi-pass clustering.
|
|
32
|
+
* It does **not** help a *second, fully successful* `analyze --templates`
|
|
33
|
+
* run on the same crawled data: `nitpicker.write()` always re-packages the
|
|
34
|
+
* archive (new mtime, and usually a different size), which changes the key
|
|
35
|
+
* on every completed run. There is no explicit `--force` escape hatch —
|
|
36
|
+
* any change to the archive changes the key, which is the correctness
|
|
37
|
+
* signal this cache relies on. The cache is **not** cleared at the start of
|
|
38
|
+
* a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
|
|
39
|
+
* purpose is surviving the one crash-before-write window described above.
|
|
40
|
+
*
|
|
41
|
+
* There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
|
|
42
|
+
* holds state across its own internal multi-pass read and has no resume
|
|
43
|
+
* API, so a crash *during* classification means the next run classifies
|
|
44
|
+
* from scratch (this is a limitation of the upstream library, not something
|
|
45
|
+
* this function works around).
|
|
46
|
+
*
|
|
47
|
+
* Internally: the external library's `resolvePageClusterKeys` returns a
|
|
48
|
+
* `clusterKey` per page; this function's public naming is `templateKey`,
|
|
49
|
+
* to avoid colliding with nitpicker's unrelated pre-existing "isolated
|
|
50
|
+
* cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
|
|
51
|
+
* link-reachability graph components — a completely different kind of
|
|
52
|
+
* grouping from DOM-structure similarity).
|
|
53
|
+
* @param options - See {@link ClassifyPageTemplatesOptions}.
|
|
54
|
+
* @returns Map from page URL (`page.url.href`) to its template key. Only
|
|
55
|
+
* internal HTML pages with retrievable HTML have an entry.
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const templateKeys = await classifyPageTemplates({ archive, pages });
|
|
59
|
+
* for (const [url, templateKey] of templateKeys) {
|
|
60
|
+
* table.addData({ [url]: { templateKey: { value: templateKey } } });
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare function classifyPageTemplates(options: ClassifyPageTemplatesOptions): Promise<Map<string, string>>;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
* Classifies every internal HTML page in the archive into a template group
|
|
10
|
+
* by DOM-structure similarity, using `@d-zero/page-cluster`.
|
|
11
|
+
*
|
|
12
|
+
* Result is cached, keyed by the archive file's path + size + mtime + page
|
|
13
|
+
* count (see {@link buildArchiveCacheKey}). **This only helps one narrow
|
|
14
|
+
* case**: a crash or Ctrl-C after this function completes but before
|
|
15
|
+
* `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
|
|
16
|
+
* itself take a while) — re-running on the same, not-yet-rewritten archive
|
|
17
|
+
* file hits the cache instead of re-running the whole multi-pass clustering.
|
|
18
|
+
* It does **not** help a *second, fully successful* `analyze --templates`
|
|
19
|
+
* run on the same crawled data: `nitpicker.write()` always re-packages the
|
|
20
|
+
* archive (new mtime, and usually a different size), which changes the key
|
|
21
|
+
* on every completed run. There is no explicit `--force` escape hatch —
|
|
22
|
+
* any change to the archive changes the key, which is the correctness
|
|
23
|
+
* signal this cache relies on. The cache is **not** cleared at the start of
|
|
24
|
+
* a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
|
|
25
|
+
* purpose is surviving the one crash-before-write window described above.
|
|
26
|
+
*
|
|
27
|
+
* There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
|
|
28
|
+
* holds state across its own internal multi-pass read and has no resume
|
|
29
|
+
* API, so a crash *during* classification means the next run classifies
|
|
30
|
+
* from scratch (this is a limitation of the upstream library, not something
|
|
31
|
+
* this function works around).
|
|
32
|
+
*
|
|
33
|
+
* Internally: the external library's `resolvePageClusterKeys` returns a
|
|
34
|
+
* `clusterKey` per page; this function's public naming is `templateKey`,
|
|
35
|
+
* to avoid colliding with nitpicker's unrelated pre-existing "isolated
|
|
36
|
+
* cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
|
|
37
|
+
* link-reachability graph components — a completely different kind of
|
|
38
|
+
* grouping from DOM-structure similarity).
|
|
39
|
+
* @param options - See {@link ClassifyPageTemplatesOptions}.
|
|
40
|
+
* @returns Map from page URL (`page.url.href`) to its template key. Only
|
|
41
|
+
* internal HTML pages with retrievable HTML have an entry.
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const templateKeys = await classifyPageTemplates({ archive, pages });
|
|
45
|
+
* for (const [url, templateKey] of templateKeys) {
|
|
46
|
+
* table.addData({ [url]: { templateKey: { value: templateKey } } });
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export async function classifyPageTemplates(options) {
|
|
51
|
+
const { archive, pages, onProgress } = options;
|
|
52
|
+
const cache = getTemplateCache();
|
|
53
|
+
const cacheKey = await buildArchiveCacheKey(archive.filePath, pages.length);
|
|
54
|
+
if (cacheKey) {
|
|
55
|
+
const cached = await cache.load(cacheKey);
|
|
56
|
+
if (cached) {
|
|
57
|
+
return new Map(Object.entries(cached));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
|
|
61
|
+
const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
|
|
62
|
+
const clusterKeys = await resolvePageClusterKeys(factory, onProgress ? { onProgress } : undefined);
|
|
63
|
+
// Safe only after `resolvePageClusterKeys` has resolved — see
|
|
64
|
+
// `createPageClusterFactory`'s JSDoc for why.
|
|
65
|
+
const yieldedUrls = getYieldedUrls();
|
|
66
|
+
if (clusterKeys.length !== yieldedUrls.length) {
|
|
67
|
+
// Would silently mis-map template keys to the wrong URLs if allowed
|
|
68
|
+
// through — see createPageClusterFactory's JSDoc for why this should
|
|
69
|
+
// be structurally impossible, but a loud failure here is far
|
|
70
|
+
// preferable to a quiet mis-assignment.
|
|
71
|
+
throw new Error(`classifyPageTemplates: resolvePageClusterKeys returned ${clusterKeys.length} keys for ${yieldedUrls.length} yielded pages.`);
|
|
72
|
+
}
|
|
73
|
+
const result = new Map();
|
|
74
|
+
for (const [index, url] of yieldedUrls.entries()) {
|
|
75
|
+
result.set(url, clusterKeys[index]);
|
|
76
|
+
}
|
|
77
|
+
if (cacheKey) {
|
|
78
|
+
await cache.store(cacheKey, Object.fromEntries(result));
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lazily constructs the persistent template-classification cache. Kept as a
|
|
84
|
+
* function (rather than a module-level constant) so tests can point it at
|
|
85
|
+
* an isolated tmpDir-per-test without module-mocking `os.tmpdir`.
|
|
86
|
+
*/
|
|
87
|
+
function getTemplateCache() {
|
|
88
|
+
return new Cache('nitpicker-templates', path.join(os.tmpdir(), 'nitpicker/cache/templates'));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derives a cache key from the archive file's identity: its absolute path,
|
|
92
|
+
* byte size, mtime, and internal HTML page count. Byte size + mtime alone is
|
|
93
|
+
* a coarse signal — a same-path overwrite that happens to land on the same
|
|
94
|
+
* byte size within the filesystem's mtime tick (coarse mtime resolution,
|
|
95
|
+
* or two writes close enough together) would otherwise be indistinguishable
|
|
96
|
+
* from "unchanged". Page count is folded in as a cheap (already-computed,
|
|
97
|
+
* no extra query), second independent signal that most re-crawls
|
|
98
|
+
* (`--append` / `--inventory`) change; it does **not** catch every
|
|
99
|
+
* possible content change (e.g. `--retry-failed` replacing a failed page's
|
|
100
|
+
* content without changing the total page count), which is an accepted,
|
|
101
|
+
* documented gap rather than an attempt at a full content hash — hashing
|
|
102
|
+
* every page's HTML on every `analyze()` call would cost more than the
|
|
103
|
+
* cache is meant to save.
|
|
104
|
+
* @param archiveFilePath - `Archive.filePath` of the archive being analyzed.
|
|
105
|
+
* @param pageCount - Number of pages passed to {@link classifyPageTemplates}
|
|
106
|
+
* for this run (`pages.length`, before internal/HTML filtering).
|
|
107
|
+
* @returns The cache key, or `null` if the file's stats cannot be read
|
|
108
|
+
* (falls back to always recomputing rather than caching under the wrong
|
|
109
|
+
* key).
|
|
110
|
+
*/
|
|
111
|
+
async function buildArchiveCacheKey(archiveFilePath, pageCount) {
|
|
112
|
+
try {
|
|
113
|
+
const stats = await stat(archiveFilePath);
|
|
114
|
+
return `${archiveFilePath}:${stats.size}:${stats.mtimeMs}:${pageCount}`;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -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
|
+
}
|
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.14.0",
|
|
4
4
|
"description": "Plugin-based page analysis engine for Nitpicker",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -27,13 +27,14 @@
|
|
|
27
27
|
"clean": "tsc --build --clean"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@d-zero/dealer": "1.9.
|
|
31
|
-
"@d-zero/
|
|
32
|
-
"@
|
|
33
|
-
"@nitpicker/
|
|
30
|
+
"@d-zero/dealer": "1.9.4",
|
|
31
|
+
"@d-zero/page-cluster": "0.3.1",
|
|
32
|
+
"@d-zero/shared": "0.22.2",
|
|
33
|
+
"@nitpicker/crawler": "0.14.0",
|
|
34
|
+
"@nitpicker/types": "0.14.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": "d6e1894a29aa29ca94785d9db0acfc3acc74764b"
|
|
39
40
|
}
|