@d-zero/page-cluster 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +95 -41
- package/dist/assign-contained-clusters.d.ts +42 -0
- package/dist/assign-contained-clusters.js +156 -0
- package/dist/auto-cut-threshold.d.ts +17 -0
- package/dist/auto-cut-threshold.js +36 -0
- package/dist/canonicalize-token-set.d.ts +17 -0
- package/dist/canonicalize-token-set.js +19 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +381 -0
- package/dist/collapse-anonymous-divs.d.ts +21 -0
- package/dist/collapse-anonymous-divs.js +42 -0
- package/dist/complete-linkage-dendrogram.d.ts +41 -0
- package/dist/complete-linkage-dendrogram.js +140 -0
- package/dist/derive-comparison-sets.d.ts +22 -0
- package/dist/derive-comparison-sets.js +33 -0
- package/dist/derive-path-cluster-keys.d.ts +53 -0
- package/dist/derive-path-cluster-keys.js +109 -0
- package/dist/extract-landmarks.d.ts +91 -45
- package/dist/extract-landmarks.js +122 -41
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
- package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
- package/dist/find-shallowest-elements.d.ts +48 -11
- package/dist/find-shallowest-elements.js +41 -21
- package/dist/merge-cross-block-clusters.d.ts +61 -0
- package/dist/merge-cross-block-clusters.js +546 -0
- package/dist/pass0-blocking.d.ts +89 -0
- package/dist/pass0-blocking.js +87 -0
- package/dist/per-page-landmark-signatures.d.ts +48 -0
- package/dist/per-page-landmark-signatures.js +62 -0
- package/dist/reservoir-sample.d.ts +43 -0
- package/dist/reservoir-sample.js +98 -0
- package/dist/resolve-blocking-group-keys.d.ts +8 -2
- package/dist/resolve-blocking-group-keys.js +18 -4
- package/dist/resolve-landmark-variant-keys.d.ts +41 -20
- package/dist/resolve-landmark-variant-keys.js +69 -26
- package/dist/resolve-page-cluster-keys.d.ts +292 -191
- package/dist/resolve-page-cluster-keys.js +708 -157
- package/dist/resolve-structural-cluster-keys.d.ts +9 -0
- package/dist/resolve-structural-cluster-keys.js +14 -232
- package/dist/shape-token.d.ts +11 -0
- package/dist/shape-token.js +38 -0
- package/dist/stage-a-per-block.d.ts +133 -0
- package/dist/stage-a-per-block.js +178 -0
- package/dist/tokenize.d.ts +6 -0
- package/dist/tokenize.js +6 -0
- package/package.json +5 -58
- package/dist/html-region-utils.d.ts +0 -74
- package/dist/html-region-utils.js +0 -96
- package/dist/merge-landmark-affined-clusters.d.ts +0 -179
- package/dist/merge-landmark-affined-clusters.js +0 -544
package/dist/cli.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Wire the `page-cluster` executable to the library's factory-based
|
|
3
|
+
// `resolvePageClusterKeys`. Reads JSONL from stdin (one page per line),
|
|
4
|
+
// writes JSONL to stdout (one cluster assignment per line, in input order),
|
|
5
|
+
// and streams progress to stderr via `@d-zero/dealer`'s `Lanes` — in-place
|
|
6
|
+
// animated header on a TTY, appended `[page-cluster] …` lines otherwise.
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { Lanes } from '@d-zero/dealer';
|
|
9
|
+
import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
|
|
10
|
+
const HELP_TEXT = `Usage:
|
|
11
|
+
page-cluster [--content-block-attribute <name>] < pages.jsonl > clusters.jsonl
|
|
12
|
+
|
|
13
|
+
Input (JSONL, one page per line):
|
|
14
|
+
{
|
|
15
|
+
"id": "any stable identifier",
|
|
16
|
+
"html": "<html>...</html>",
|
|
17
|
+
"paths": ["news", "1"], // optional
|
|
18
|
+
"stylesheetHrefs": ["/a.css"], // optional
|
|
19
|
+
"host": "example.com" // optional
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Output (JSONL, one line per input page, in input order):
|
|
23
|
+
{ "id": "...", "clusterKey": "..." }
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--content-block-attribute <name> CMS-provided attribute marking freeform
|
|
27
|
+
content blocks that should be stripped
|
|
28
|
+
before comparison (e.g. \`data-bgb\`).
|
|
29
|
+
--help Print this help and exit.
|
|
30
|
+
--version Print the package version and exit.
|
|
31
|
+
|
|
32
|
+
Progress:
|
|
33
|
+
Emitted to stderr while the run is in progress. On an interactive
|
|
34
|
+
terminal, an in-place animated header shows the current phase and
|
|
35
|
+
elapsed time. When stderr is not a TTY (redirected to a file, piped, or
|
|
36
|
+
under CI), each phase transition is appended as a \`[page-cluster] …\`
|
|
37
|
+
line so \`grep\` / \`awk\` stay trivial. Silence progress with
|
|
38
|
+
\`2>/dev/null\`; the JSONL output on stdout is unaffected either way.
|
|
39
|
+
`;
|
|
40
|
+
/**
|
|
41
|
+
* Log id used for the single-lane `lanes.update()` call under verbose mode.
|
|
42
|
+
* Lanes was originally designed for one line per parallel worker; here we
|
|
43
|
+
* only ever have one narrative line, so a fixed id is enough.
|
|
44
|
+
*/
|
|
45
|
+
const LANE_ID = 0;
|
|
46
|
+
/**
|
|
47
|
+
* Prefix rendered ahead of every verbose progress line. Set on `lanes.header`
|
|
48
|
+
* at CLI init so that `lanes.update(LANE_ID, …)` under Lanes' verbose mode
|
|
49
|
+
* emits `[page-cluster] <line>` rather than the `undefined <line>` string
|
|
50
|
+
* it would produce with an unset header (see `dealer/lanes.ts:104`).
|
|
51
|
+
*/
|
|
52
|
+
const VERBOSE_HEADER = '[page-cluster]';
|
|
53
|
+
/**
|
|
54
|
+
* Parses `process.argv`-style arguments (already sliced past `node script`)
|
|
55
|
+
* into a `CliArgs`. Deliberately tolerant of an unknown flag so the caller
|
|
56
|
+
* can decide the error message shape, and so tests can assert on the
|
|
57
|
+
* unrecognized flag name directly.
|
|
58
|
+
* @param argv
|
|
59
|
+
*/
|
|
60
|
+
export function parseArgs(argv) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const arg = argv[i];
|
|
64
|
+
switch (arg) {
|
|
65
|
+
case '--help':
|
|
66
|
+
case '-h': {
|
|
67
|
+
out.help = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case '--version':
|
|
71
|
+
case '-v': {
|
|
72
|
+
out.version = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case '--content-block-attribute': {
|
|
76
|
+
const next = argv[i + 1];
|
|
77
|
+
if (next === undefined) {
|
|
78
|
+
out.unknownFlag = `${arg} requires a value`;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
out.contentBlockAttribute = next;
|
|
82
|
+
i++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default: {
|
|
86
|
+
out.unknownFlag = arg;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Streams `stdin` and yields per-line JSON-parsed page objects (plus the
|
|
95
|
+
* original line's `id`, preserved for the output row). Chunk-boundary
|
|
96
|
+
* splitting is done by hand rather than via `readline` because certain
|
|
97
|
+
* multi-KB UTF-8 lines have been observed to trip `readline`'s parser on
|
|
98
|
+
* this codebase — see `.page-cluster/scale-spike.mjs`'s note for the
|
|
99
|
+
* concrete case.
|
|
100
|
+
* @param input
|
|
101
|
+
*/
|
|
102
|
+
async function* readJsonlPages(input) {
|
|
103
|
+
input.setEncoding?.('utf8');
|
|
104
|
+
let leftover = '';
|
|
105
|
+
let lineNo = 0;
|
|
106
|
+
for await (const chunk of input) {
|
|
107
|
+
const text = leftover + chunk;
|
|
108
|
+
const parts = text.split('\n');
|
|
109
|
+
leftover = parts.pop() ?? '';
|
|
110
|
+
for (const line of parts) {
|
|
111
|
+
lineNo++;
|
|
112
|
+
if (line.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
let entry;
|
|
115
|
+
try {
|
|
116
|
+
entry = JSON.parse(line);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new Error(`failed to parse JSONL line ${lineNo}: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof entry.html !== 'string') {
|
|
122
|
+
throw new TypeError(`JSONL line ${lineNo} is missing a string \`html\` field`);
|
|
123
|
+
}
|
|
124
|
+
yield {
|
|
125
|
+
id: entry.id,
|
|
126
|
+
page: {
|
|
127
|
+
html: entry.html,
|
|
128
|
+
paths: entry.paths ?? [],
|
|
129
|
+
stylesheetHrefs: entry.stylesheetHrefs ?? [],
|
|
130
|
+
host: entry.host,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (leftover.length > 0) {
|
|
136
|
+
lineNo++;
|
|
137
|
+
let entry;
|
|
138
|
+
try {
|
|
139
|
+
entry = JSON.parse(leftover);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`failed to parse final JSONL line ${lineNo}: ${error.message}`);
|
|
143
|
+
}
|
|
144
|
+
if (typeof entry.html !== 'string') {
|
|
145
|
+
throw new TypeError(`JSONL line ${lineNo} is missing a string \`html\` field`);
|
|
146
|
+
}
|
|
147
|
+
yield {
|
|
148
|
+
id: entry.id,
|
|
149
|
+
page: {
|
|
150
|
+
html: entry.html,
|
|
151
|
+
paths: entry.paths ?? [],
|
|
152
|
+
stylesheetHrefs: entry.stylesheetHrefs ?? [],
|
|
153
|
+
host: entry.host,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Renders the current lane message via TTY header or verbose-appended line
|
|
160
|
+
* depending on the `useTty` mode. Wraps both `lanes.header()` and
|
|
161
|
+
* `lanes.update(LANE_ID, …)` because — as documented in `dealer/lanes.ts` —
|
|
162
|
+
* `header()` under verbose is a state-only setter that produces no output,
|
|
163
|
+
* so verbose runs must go through `update()` to actually emit a line.
|
|
164
|
+
* @param lanes
|
|
165
|
+
* @param useTty
|
|
166
|
+
* @param line
|
|
167
|
+
*/
|
|
168
|
+
function renderProgress(lanes, useTty, line) {
|
|
169
|
+
if (useTty) {
|
|
170
|
+
lanes.header(line.tty);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
lanes.update(LANE_ID, line.verbose);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Human-facing wording for the "reading input pages" moment (before stdin
|
|
178
|
+
* is fully consumed). Verbose lines omit the `[page-cluster]` prefix — it
|
|
179
|
+
* is applied once via `VERBOSE_HEADER` when Lanes constructs each verbose
|
|
180
|
+
* line.
|
|
181
|
+
*/
|
|
182
|
+
const READING_INPUT = {
|
|
183
|
+
tty: '%earth% page-cluster — reading input...',
|
|
184
|
+
verbose: 'reading input pages...',
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Progress line emitted after stdin is fully consumed, before the async
|
|
188
|
+
* factory-based `resolvePageClusterKeys` starts producing events.
|
|
189
|
+
* @param pageCount
|
|
190
|
+
*/
|
|
191
|
+
function readingDoneLine(pageCount) {
|
|
192
|
+
return {
|
|
193
|
+
tty: `%earth% page-cluster — read ${pageCount} pages, clustering...`,
|
|
194
|
+
verbose: `read ${pageCount} pages, clustering...`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Final summary line rendered right before `lanes.close()`.
|
|
199
|
+
* @param pageCount
|
|
200
|
+
* @param clusterCount
|
|
201
|
+
* @param elapsedSec
|
|
202
|
+
*/
|
|
203
|
+
function doneLine(pageCount, clusterCount, elapsedSec) {
|
|
204
|
+
const body = `${pageCount} pages in ${clusterCount} clusters (elapsed ${elapsedSec}s)`;
|
|
205
|
+
return {
|
|
206
|
+
tty: `page-cluster — done: ${body}`,
|
|
207
|
+
verbose: `done — ${body}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Fatal-error line rendered via Lanes rather than a direct
|
|
212
|
+
* `stderr.write` — the interactive Display's `close()` runs a
|
|
213
|
+
* CURSOR_UP + ERASE_DOWN repaint that would wipe any raw stderr write
|
|
214
|
+
* emitted before it, silently swallowing the diagnostic in TTY mode.
|
|
215
|
+
* Sending the error through Lanes puts it into the final repainted
|
|
216
|
+
* frame, so it survives `close()`.
|
|
217
|
+
* @param message
|
|
218
|
+
*/
|
|
219
|
+
function errorLine(message) {
|
|
220
|
+
return {
|
|
221
|
+
tty: `page-cluster: ${message}`,
|
|
222
|
+
verbose: `error: ${message}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The
|
|
227
|
+
* verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` /
|
|
228
|
+
* `stage-b:` phase tokens so callers who grep stderr by phase name (a
|
|
229
|
+
* pattern the earlier `formatProgress` documented) stay compatible; TTY
|
|
230
|
+
* lines use natural-language wording since interactive users read the
|
|
231
|
+
* header, not grep.
|
|
232
|
+
* @param event
|
|
233
|
+
* @param elapsedSec
|
|
234
|
+
*/
|
|
235
|
+
function formatProgressLine(event, elapsedSec) {
|
|
236
|
+
switch (event.phase) {
|
|
237
|
+
case 'pass0-signals': {
|
|
238
|
+
return {
|
|
239
|
+
tty: `%earth% page-cluster — reading signals: ${event.pagesSeen} pages (elapsed ${elapsedSec}s)`,
|
|
240
|
+
verbose: `pass0: ${event.pagesSeen} pages read`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
case 'pass1-block-complete': {
|
|
244
|
+
return {
|
|
245
|
+
tty: `%earth% page-cluster — clustering ${event.blocksProcessed}/${event.totalBlocks} blocks (elapsed ${elapsedSec}s)`,
|
|
246
|
+
verbose: `pass1: clustered block ${event.blocksProcessed}/${event.totalBlocks}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
case 'pass1b-assign': {
|
|
250
|
+
return {
|
|
251
|
+
tty: `%earth% page-cluster — assigning pages: ${event.pagesAssigned}/${event.pagesToAssign} (elapsed ${elapsedSec}s)`,
|
|
252
|
+
verbose: `pass1b: ${event.pagesAssigned}/${event.pagesToAssign} pages assigned`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
case 'stage-b-start': {
|
|
256
|
+
return {
|
|
257
|
+
tty: `%earth% page-cluster — merging ${event.unitCount} units (elapsed ${elapsedSec}s)`,
|
|
258
|
+
verbose: `stage-b: merging ${event.unitCount} units`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Test-friendly entry point: takes the run's stdin/stdout/stderr streams
|
|
265
|
+
* and the parsed CLI flags rather than reading them out of the process
|
|
266
|
+
* globals. `runCli` returns the exit code, allowing the caller (either the
|
|
267
|
+
* top-level `main` here or a spec test) to decide how to signal it.
|
|
268
|
+
* @param options
|
|
269
|
+
* @param options.stdin
|
|
270
|
+
* @param options.stdout
|
|
271
|
+
* @param options.stderr
|
|
272
|
+
* @param options.argv
|
|
273
|
+
* @param options.version
|
|
274
|
+
*/
|
|
275
|
+
export async function runCli(options) {
|
|
276
|
+
const args = parseArgs(options.argv);
|
|
277
|
+
if (args.help) {
|
|
278
|
+
options.stdout.write(HELP_TEXT);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
if (args.version) {
|
|
282
|
+
options.stdout.write(`${options.version}\n`);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
if (args.unknownFlag !== undefined) {
|
|
286
|
+
options.stderr.write(`page-cluster: unrecognized argument ${JSON.stringify(args.unknownFlag)}\n`);
|
|
287
|
+
options.stderr.write(HELP_TEXT);
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
// TTY detection: `NodeJS.WritableStream` doesn't expose `isTTY`, but the
|
|
291
|
+
// concrete `WriteStream` (and the in-memory `Writable` collectors tests
|
|
292
|
+
// pass in) does — reading it defensively lets both real usage and
|
|
293
|
+
// unit-test doubles work without a separate `--no-progress` flag.
|
|
294
|
+
const useTty = options.stderr.isTTY === true;
|
|
295
|
+
const lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
|
|
296
|
+
// Verbose Lanes prepends `#header` to every `update()` line. Without
|
|
297
|
+
// this seed call the header would be undefined and each progress line
|
|
298
|
+
// would begin with the literal string `undefined ` — bug caught by
|
|
299
|
+
// code-review high effort. TTY mode overwrites the header per event
|
|
300
|
+
// so the seed value is only surfaced verbatim under verbose.
|
|
301
|
+
if (!useTty) {
|
|
302
|
+
lanes.header(VERBOSE_HEADER);
|
|
303
|
+
}
|
|
304
|
+
const startTime = Date.now();
|
|
305
|
+
const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000));
|
|
306
|
+
// Every early return past this point must run through the finally block
|
|
307
|
+
// so `lanes.close()` releases the display's setTimeout timer — without
|
|
308
|
+
// it a `return 1` on a stdin parse error would leave the process
|
|
309
|
+
// hanging on the timer's next tick.
|
|
310
|
+
try {
|
|
311
|
+
renderProgress(lanes, useTty, READING_INPUT);
|
|
312
|
+
// Load every JSONL line into memory once so the ids array stays
|
|
313
|
+
// parallel to the pages array — the streaming driver reads its
|
|
314
|
+
// factory twice, and stdin is a one-shot pipe.
|
|
315
|
+
const ids = [];
|
|
316
|
+
const pages = [];
|
|
317
|
+
try {
|
|
318
|
+
for await (const { id, page } of readJsonlPages(options.stdin)) {
|
|
319
|
+
ids.push(id);
|
|
320
|
+
pages.push(page);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
renderProgress(lanes, useTty, errorLine(error.message));
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
renderProgress(lanes, useTty, readingDoneLine(pages.length));
|
|
328
|
+
const resolveOptions = {
|
|
329
|
+
contentBlockAttribute: args.contentBlockAttribute,
|
|
330
|
+
onProgress: (event) => {
|
|
331
|
+
renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
let keys;
|
|
335
|
+
try {
|
|
336
|
+
keys = await resolvePageClusterKeys(() => pages, resolveOptions);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
renderProgress(lanes, useTty, errorLine(error.message));
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
const clusterCount = new Set(keys).size;
|
|
343
|
+
renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
|
|
344
|
+
for (const [index, key] of keys.entries()) {
|
|
345
|
+
options.stdout.write(`${JSON.stringify({ id: ids[index] ?? index, clusterKey: key })}\n`);
|
|
346
|
+
}
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
lanes.close();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Reads the package version out of `package.json` at runtime. Kept as a
|
|
355
|
+
* separate helper so `runCli` can be exercised in tests without needing a
|
|
356
|
+
* real `package.json` on disk.
|
|
357
|
+
*/
|
|
358
|
+
async function readPackageVersion() {
|
|
359
|
+
try {
|
|
360
|
+
const url = new URL('../package.json', import.meta.url);
|
|
361
|
+
const { readFile } = await import('node:fs/promises');
|
|
362
|
+
const raw = await readFile(url, 'utf8');
|
|
363
|
+
const parsed = JSON.parse(raw);
|
|
364
|
+
return parsed.version ?? '0.0.0';
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return '0.0.0';
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Only run when invoked as the actual entry point.
|
|
371
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
372
|
+
const version = await readPackageVersion();
|
|
373
|
+
const exitCode = await runCli({
|
|
374
|
+
stdin: process.stdin,
|
|
375
|
+
stdout: process.stdout,
|
|
376
|
+
stderr: process.stderr,
|
|
377
|
+
argv: process.argv.slice(2),
|
|
378
|
+
version,
|
|
379
|
+
});
|
|
380
|
+
process.exit(exitCode);
|
|
381
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes bare foldable-tag segments (e.g. plain `div`, `span`) from the
|
|
3
|
+
* interior of a path token, leaving the first and last segments untouched.
|
|
4
|
+
* Used before containment-assignment union construction so that two clusters
|
|
5
|
+
* whose paths differ only in intermediate anonymous wrapper `<div>`s are not
|
|
6
|
+
* spuriously separated.
|
|
7
|
+
*
|
|
8
|
+
* Intermediate bare `div`/`span` elements are anonymous wrappers with no
|
|
9
|
+
* class, role, or type — they carry no structural meaning beyond "something
|
|
10
|
+
* was nested here", and their presence varies freely across CMS themes and
|
|
11
|
+
* minor template revisions. Stripping them from the middle of a path keeps
|
|
12
|
+
* the union comparison focused on the meaningful skeleton (semantic tags,
|
|
13
|
+
* class-bearing wrappers, bracketed roles).
|
|
14
|
+
*
|
|
15
|
+
* First and last segments are always preserved: the first segment anchors the
|
|
16
|
+
* path in the document tree (`body`, `main`, …) and the last segment is the
|
|
17
|
+
* leaf element being tokenized — both carry meaning even when they are bare
|
|
18
|
+
* foldable tags.
|
|
19
|
+
* @param token A full path token, e.g. `body>main>div>section.c-x>div>.card`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function collapseAnonymousDivs(token: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FOLDABLE_TAGS } from './foldable-tags.js';
|
|
2
|
+
/**
|
|
3
|
+
* Only `FOLDABLE_TAGS` qualify — those are the tags `build-segment.ts` omits
|
|
4
|
+
* the name from when they have classes (producing `.c-x` instead of `div.c-x`),
|
|
5
|
+
* so a bare `div` or `span` with NO class is a structural-only wrapper.
|
|
6
|
+
* Non-foldable tags (`section`, `article`, …) always appear with their tag name
|
|
7
|
+
* and carry semantic meaning even when classless, so they are kept.
|
|
8
|
+
* @param segment
|
|
9
|
+
*/
|
|
10
|
+
function isAnonymousSegment(segment) {
|
|
11
|
+
return FOLDABLE_TAGS.has(segment);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Removes bare foldable-tag segments (e.g. plain `div`, `span`) from the
|
|
15
|
+
* interior of a path token, leaving the first and last segments untouched.
|
|
16
|
+
* Used before containment-assignment union construction so that two clusters
|
|
17
|
+
* whose paths differ only in intermediate anonymous wrapper `<div>`s are not
|
|
18
|
+
* spuriously separated.
|
|
19
|
+
*
|
|
20
|
+
* Intermediate bare `div`/`span` elements are anonymous wrappers with no
|
|
21
|
+
* class, role, or type — they carry no structural meaning beyond "something
|
|
22
|
+
* was nested here", and their presence varies freely across CMS themes and
|
|
23
|
+
* minor template revisions. Stripping them from the middle of a path keeps
|
|
24
|
+
* the union comparison focused on the meaningful skeleton (semantic tags,
|
|
25
|
+
* class-bearing wrappers, bracketed roles).
|
|
26
|
+
*
|
|
27
|
+
* First and last segments are always preserved: the first segment anchors the
|
|
28
|
+
* path in the document tree (`body`, `main`, …) and the last segment is the
|
|
29
|
+
* leaf element being tokenized — both carry meaning even when they are bare
|
|
30
|
+
* foldable tags.
|
|
31
|
+
* @param token A full path token, e.g. `body>main>div>section.c-x>div>.card`.
|
|
32
|
+
*/
|
|
33
|
+
export function collapseAnonymousDivs(token) {
|
|
34
|
+
const segments = token.split('>');
|
|
35
|
+
if (segments.length <= 2) {
|
|
36
|
+
return token;
|
|
37
|
+
}
|
|
38
|
+
const first = segments[0] ?? '';
|
|
39
|
+
const last = segments.at(-1) ?? '';
|
|
40
|
+
const middle = segments.slice(1, -1).filter((seg) => !isAnonymousSegment(seg));
|
|
41
|
+
return [first, ...middle, last].join('>');
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One merge event in the complete-linkage dendrogram. `survivor` is the
|
|
3
|
+
* lower index (kept active), `dead` the higher (deactivated), `height`
|
|
4
|
+
* is the Jaccard similarity at which the merge occurred. All `n - 1`
|
|
5
|
+
* merges for `n` input sets are recorded, including those below any
|
|
6
|
+
* particular threshold — the caller decides which heights are meaningful
|
|
7
|
+
* via `labelsAtThreshold`.
|
|
8
|
+
*/
|
|
9
|
+
export type DendrogramMerge = {
|
|
10
|
+
readonly survivor: number;
|
|
11
|
+
readonly dead: number;
|
|
12
|
+
readonly height: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Computes the complete-linkage dendrogram for `tokenSets` via the NN-chain
|
|
16
|
+
* algorithm (Murtagh, F., 1983) and returns all `n - 1` merge events in
|
|
17
|
+
* discovery order. This is the same O(n²) NN-chain implementation as
|
|
18
|
+
* `resolve-structural-cluster-keys.ts` — extracted here so that callers can
|
|
19
|
+
* record the full height sequence and apply an arbitrary threshold cut via
|
|
20
|
+
* `labelsAtThreshold`, rather than committing to a fixed threshold upfront.
|
|
21
|
+
*
|
|
22
|
+
* Recording all merges (not just threshold-clearing ones) is essential for
|
|
23
|
+
* `autoCutThreshold`: to find the largest gap in the height sequence, every
|
|
24
|
+
* height must be available regardless of where the eventual cut lands.
|
|
25
|
+
* @param tokenSets
|
|
26
|
+
*/
|
|
27
|
+
export declare function completeLinkageDendrogram(tokenSets: readonly ReadonlySet<string>[]): DendrogramMerge[];
|
|
28
|
+
/**
|
|
29
|
+
* Reconstructs cluster labels for all `size` original items at a given
|
|
30
|
+
* similarity threshold, using the merge list from `completeLinkageDendrogram`.
|
|
31
|
+
*
|
|
32
|
+
* Lance-Williams monotonicity guarantees that only merges at `>= threshold`
|
|
33
|
+
* need to be applied to reconstruct the correct threshold cut, regardless of
|
|
34
|
+
* the order in which NN-chain discovered them. This is the same invariant
|
|
35
|
+
* documented in `resolve-structural-cluster-keys.ts`'s JSDoc for
|
|
36
|
+
* `clusterByCompleteLinkage`.
|
|
37
|
+
* @param size Total number of items (= length of the original `tokenSets`).
|
|
38
|
+
* @param merges All merge events from `completeLinkageDendrogram`.
|
|
39
|
+
* @param threshold Similarity threshold; merges below this are ignored.
|
|
40
|
+
*/
|
|
41
|
+
export declare function labelsAtThreshold(size: number, merges: readonly DendrogramMerge[], threshold: number): number[];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
2
|
+
/**
|
|
3
|
+
* `jaccardSimilarity()` returns a floating-point division that can land a
|
|
4
|
+
* hair below the caller's threshold even when mathematically equal. Same
|
|
5
|
+
* epsilon as `resolve-structural-cluster-keys.ts`.
|
|
6
|
+
*/
|
|
7
|
+
const BOUNDARY_EPSILON = 1e-9;
|
|
8
|
+
/**
|
|
9
|
+
* `noUncheckedIndexedAccess` makes indexed reads return `T | undefined`.
|
|
10
|
+
* Call sites here index positions this function itself generated (NN-chain
|
|
11
|
+
* internal arrays), so the throw branch is unreachable in practice — it exists
|
|
12
|
+
* solely to satisfy the compiler without a non-null assertion everywhere.
|
|
13
|
+
* @param values
|
|
14
|
+
* @param index
|
|
15
|
+
*/
|
|
16
|
+
function requireIndex(values, index) {
|
|
17
|
+
const value = values[index];
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
throw new Error('completeLinkageDendrogram: index out of bounds');
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Path-compressed union-find root lookup. `labelsAtThreshold` rebuilds the
|
|
25
|
+
* cluster label for each item by following its parent chain to the root;
|
|
26
|
+
* path compression keeps repeated lookups O(α) instead of O(n).
|
|
27
|
+
* @param parent
|
|
28
|
+
* @param index
|
|
29
|
+
*/
|
|
30
|
+
function find(parent, index) {
|
|
31
|
+
let root = index;
|
|
32
|
+
while (requireIndex(parent, root) !== root) {
|
|
33
|
+
root = requireIndex(parent, root);
|
|
34
|
+
}
|
|
35
|
+
let current = index;
|
|
36
|
+
while (current !== root) {
|
|
37
|
+
const next = requireIndex(parent, current);
|
|
38
|
+
parent[current] = root;
|
|
39
|
+
current = next;
|
|
40
|
+
}
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Computes the complete-linkage dendrogram for `tokenSets` via the NN-chain
|
|
45
|
+
* algorithm (Murtagh, F., 1983) and returns all `n - 1` merge events in
|
|
46
|
+
* discovery order. This is the same O(n²) NN-chain implementation as
|
|
47
|
+
* `resolve-structural-cluster-keys.ts` — extracted here so that callers can
|
|
48
|
+
* record the full height sequence and apply an arbitrary threshold cut via
|
|
49
|
+
* `labelsAtThreshold`, rather than committing to a fixed threshold upfront.
|
|
50
|
+
*
|
|
51
|
+
* Recording all merges (not just threshold-clearing ones) is essential for
|
|
52
|
+
* `autoCutThreshold`: to find the largest gap in the height sequence, every
|
|
53
|
+
* height must be available regardless of where the eventual cut lands.
|
|
54
|
+
* @param tokenSets
|
|
55
|
+
*/
|
|
56
|
+
export function completeLinkageDendrogram(tokenSets) {
|
|
57
|
+
const size = tokenSets.length;
|
|
58
|
+
if (size <= 1)
|
|
59
|
+
return [];
|
|
60
|
+
const similarity = new Float64Array(size * size);
|
|
61
|
+
for (let i = 0; i < size; i++) {
|
|
62
|
+
for (let j = i + 1; j < size; j++) {
|
|
63
|
+
const score = jaccardSimilarity(requireIndex(tokenSets, i), requireIndex(tokenSets, j));
|
|
64
|
+
similarity[i * size + j] = score;
|
|
65
|
+
similarity[j * size + i] = score;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const active = new Uint8Array(size).fill(1);
|
|
69
|
+
const chain = [];
|
|
70
|
+
const merges = [];
|
|
71
|
+
const findFreshStart = () => {
|
|
72
|
+
for (let index = 0; index < size; index++) {
|
|
73
|
+
if (requireIndex(active, index) === 1)
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
throw new Error('completeLinkageDendrogram: no active cluster left to resume from');
|
|
77
|
+
};
|
|
78
|
+
let activeCount = size;
|
|
79
|
+
while (activeCount > 1) {
|
|
80
|
+
if (chain.length === 0)
|
|
81
|
+
chain.push(findFreshStart());
|
|
82
|
+
const top = requireIndex(chain, chain.length - 1);
|
|
83
|
+
let best = -1;
|
|
84
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
85
|
+
for (let candidate = 0; candidate < size; candidate++) {
|
|
86
|
+
if (candidate !== top && requireIndex(active, candidate) === 1) {
|
|
87
|
+
const score = requireIndex(similarity, top * size + candidate);
|
|
88
|
+
if (score > bestScore) {
|
|
89
|
+
bestScore = score;
|
|
90
|
+
best = candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const secondFromTop = chain.length >= 2 ? chain.at(-2) : undefined;
|
|
95
|
+
if (best === secondFromTop) {
|
|
96
|
+
chain.pop();
|
|
97
|
+
chain.pop();
|
|
98
|
+
const survivor = Math.min(top, best);
|
|
99
|
+
const dead = Math.max(top, best);
|
|
100
|
+
for (let candidate = 0; candidate < size; candidate++) {
|
|
101
|
+
if (candidate !== top &&
|
|
102
|
+
candidate !== best &&
|
|
103
|
+
requireIndex(active, candidate) === 1) {
|
|
104
|
+
const merged = Math.min(requireIndex(similarity, top * size + candidate), requireIndex(similarity, best * size + candidate));
|
|
105
|
+
similarity[survivor * size + candidate] = merged;
|
|
106
|
+
similarity[candidate * size + survivor] = merged;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
active[dead] = 0;
|
|
110
|
+
merges.push({ survivor, dead, height: bestScore });
|
|
111
|
+
activeCount--;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
chain.push(best);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return merges;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reconstructs cluster labels for all `size` original items at a given
|
|
121
|
+
* similarity threshold, using the merge list from `completeLinkageDendrogram`.
|
|
122
|
+
*
|
|
123
|
+
* Lance-Williams monotonicity guarantees that only merges at `>= threshold`
|
|
124
|
+
* need to be applied to reconstruct the correct threshold cut, regardless of
|
|
125
|
+
* the order in which NN-chain discovered them. This is the same invariant
|
|
126
|
+
* documented in `resolve-structural-cluster-keys.ts`'s JSDoc for
|
|
127
|
+
* `clusterByCompleteLinkage`.
|
|
128
|
+
* @param size Total number of items (= length of the original `tokenSets`).
|
|
129
|
+
* @param merges All merge events from `completeLinkageDendrogram`.
|
|
130
|
+
* @param threshold Similarity threshold; merges below this are ignored.
|
|
131
|
+
*/
|
|
132
|
+
export function labelsAtThreshold(size, merges, threshold) {
|
|
133
|
+
const parent = Int32Array.from({ length: size }, (_, i) => i);
|
|
134
|
+
for (const { survivor, dead, height } of merges) {
|
|
135
|
+
if (height >= threshold - BOUNDARY_EPSILON) {
|
|
136
|
+
parent[find(parent, dead)] = find(parent, survivor);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return Array.from({ length: size }, (_, i) => find(parent, i));
|
|
140
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Below this many pages, `computeDocumentFrequency`/`splitTokensByFrequency`
|
|
3
|
+
* (the default 90% cutoff) degenerate rather than usefully separate chrome
|
|
4
|
+
* from content — see `deriveComparisonSets` for the failure mode. Derived
|
|
5
|
+
* from `splitTokensByFrequency`'s own cutoff: a token missing from exactly
|
|
6
|
+
* one page out of `n` still counts as chrome only if
|
|
7
|
+
* `(n - 1) / n >= 0.9`, i.e. `n >= 10`. Below that, the caller should fall
|
|
8
|
+
* back to comparing token sets directly, unfiltered.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT = 10;
|
|
11
|
+
/**
|
|
12
|
+
* Narrows each page's token set to its page-specific content before
|
|
13
|
+
* clustering, so pages that only share site-wide chrome (header/nav/footer)
|
|
14
|
+
* don't read as more similar than they structurally are. Skipped below
|
|
15
|
+
* `MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT` pages — see that constant's JSDoc.
|
|
16
|
+
*
|
|
17
|
+
* A page whose entire token set narrows away falls back to its raw tokens
|
|
18
|
+
* rather than the empty result, for the same reason documented in
|
|
19
|
+
* `resolve-structural-cluster-keys.ts`.
|
|
20
|
+
* @param tokenSets
|
|
21
|
+
*/
|
|
22
|
+
export declare function deriveComparisonSets(tokenSets: readonly ReadonlySet<string>[]): readonly ReadonlySet<string>[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
2
|
+
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
3
|
+
/**
|
|
4
|
+
* Below this many pages, `computeDocumentFrequency`/`splitTokensByFrequency`
|
|
5
|
+
* (the default 90% cutoff) degenerate rather than usefully separate chrome
|
|
6
|
+
* from content — see `deriveComparisonSets` for the failure mode. Derived
|
|
7
|
+
* from `splitTokensByFrequency`'s own cutoff: a token missing from exactly
|
|
8
|
+
* one page out of `n` still counts as chrome only if
|
|
9
|
+
* `(n - 1) / n >= 0.9`, i.e. `n >= 10`. Below that, the caller should fall
|
|
10
|
+
* back to comparing token sets directly, unfiltered.
|
|
11
|
+
*/
|
|
12
|
+
export const MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT = 10;
|
|
13
|
+
/**
|
|
14
|
+
* Narrows each page's token set to its page-specific content before
|
|
15
|
+
* clustering, so pages that only share site-wide chrome (header/nav/footer)
|
|
16
|
+
* don't read as more similar than they structurally are. Skipped below
|
|
17
|
+
* `MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT` pages — see that constant's JSDoc.
|
|
18
|
+
*
|
|
19
|
+
* A page whose entire token set narrows away falls back to its raw tokens
|
|
20
|
+
* rather than the empty result, for the same reason documented in
|
|
21
|
+
* `resolve-structural-cluster-keys.ts`.
|
|
22
|
+
* @param tokenSets
|
|
23
|
+
*/
|
|
24
|
+
export function deriveComparisonSets(tokenSets) {
|
|
25
|
+
if (tokenSets.length < MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT) {
|
|
26
|
+
return tokenSets;
|
|
27
|
+
}
|
|
28
|
+
const corpusFrequency = computeDocumentFrequency(tokenSets);
|
|
29
|
+
return tokenSets.map((tokens) => {
|
|
30
|
+
const { contentTokens } = splitTokensByFrequency(tokens, corpusFrequency);
|
|
31
|
+
return contentTokens.size === 0 && tokens.size > 0 ? tokens : contentTokens;
|
|
32
|
+
});
|
|
33
|
+
}
|