@d-zero/page-cluster 0.3.0 → 0.4.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.
@@ -1,11 +1,13 @@
1
1
  import { excise } from './excise.js';
2
2
  import { findMatchingElements, } from './find-shallowest-elements.js';
3
+ import { buildLineColumnIndex, offsetToLineColumn } from './offset-to-line-column.js';
3
4
  const TAG_TO_TYPE = {
4
5
  header: 'header',
5
6
  footer: 'footer',
6
7
  nav: 'nav',
7
8
  aside: 'aside',
8
9
  search: 'search',
10
+ main: 'main',
9
11
  };
10
12
  const ROLE_TO_TYPE = {
11
13
  banner: 'header',
@@ -14,6 +16,7 @@ const ROLE_TO_TYPE = {
14
16
  complementary: 'aside',
15
17
  form: 'form',
16
18
  search: 'search',
19
+ main: 'main',
17
20
  };
18
21
  /**
19
22
  * Determines which landmark type(s) an element matches by tag name or
@@ -40,6 +43,16 @@ function matchLandmarkTypes(tagName, role) {
40
43
  }
41
44
  return types;
42
45
  }
46
+ /**
47
+ * Type guard splitting `findMatchingElements`' combined match list into the
48
+ * excisable six landmark types vs `main`. `main` is content, not chrome, and
49
+ * must never be mixed into the same `keepOutermost` sweep as the other six
50
+ * — see `extractLandmarks`'s "main handling" note for why.
51
+ * @param match
52
+ */
53
+ function isMainMatch(match) {
54
+ return match.type === 'main';
55
+ }
43
56
  /**
44
57
  * Filters out any match whose whole-element span is strictly contained by
45
58
  * another match's span, keeping only outermost instances. Runs across all
@@ -151,21 +164,50 @@ function keepOutermost(matches) {
151
164
  * segment then disappears from the surviving paths, shortening them by one
152
165
  * level. This is inherent to "delete the matched span, use whatever's
153
166
  * left" and is not treated as a bug.
167
+ *
168
+ * ## Main handling
169
+ *
170
+ * `main` (the `<main>` tag or `role="main"`) is collected the same way as
171
+ * the other six types — one entry per genuinely-closed instance, in
172
+ * document order — but is kept out of every mechanism the other six feed:
173
+ *
174
+ * - It is **never excised**: its span is never added to `remainderHtml`'s
175
+ * excise list, because `main` is the page's actual content, not chrome.
176
+ * Removing it would gut `remainderHtml` down to whatever sits outside
177
+ * `<main>` (nothing, on most real pages).
178
+ * - Its `keepOutermost` nesting sweep runs **separately** from the other six
179
+ * types'. If it shared the sweep, a `<main>` that wraps most of the page
180
+ * (as it typically does) would make every `header`/`nav`/`aside` nested
181
+ * inside it look "contained by main" and get dropped — destroying the
182
+ * section-local chrome detection this module exists to enable (see "Why
183
+ * collect every instance" above). Only nested `<main>`s (an edge case —
184
+ * HTML discourages more than one) are deduplicated against each other.
185
+ * - It never contributes to chrome/shell-frequency analysis (`main` is
186
+ * absent from `computePerPageLandmarkInstances`'s `ALL_LANDMARK_TYPES`):
187
+ * its instances are reported for position purposes only, never treated as
188
+ * candidate chrome.
154
189
  * @param html
155
190
  * @example
156
191
  * ```ts
157
192
  * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
158
193
  * // {
159
- * // header: ['<header>H</header>'],
160
- * // footer: ['<footer>F</footer>'],
194
+ * // header: [{ html: '<header>H</header>', startOffset: 6, endOffset: 24,
195
+ * // startLine: 1, startColumn: 7, endLine: 1, endColumn: 25 }],
196
+ * // footer: [{ html: '<footer>F</footer>', startOffset: 38, endOffset: 56,
197
+ * // startLine: 1, startColumn: 39, endLine: 1, endColumn: 57 }],
161
198
  * // nav: [], aside: [], form: [], search: [],
199
+ * // main: [{ html: '<main>M</main>', startOffset: 24, endOffset: 38,
200
+ * // startLine: 1, startColumn: 25, endLine: 1, endColumn: 39 }],
162
201
  * // remainderHtml: '<body><main>M</main></body>',
163
202
  * // }
164
203
  * ```
165
204
  */
166
205
  export function extractLandmarks(html) {
167
206
  const allMatches = findMatchingElements(html, matchLandmarkTypes);
168
- const outermost = keepOutermost(allMatches);
207
+ const mainMatches = allMatches.filter(isMainMatch);
208
+ const excisableMatches = allMatches.filter((match) => !isMainMatch(match));
209
+ const outermost = keepOutermost(excisableMatches);
210
+ const outermostMain = keepOutermost(mainMatches);
169
211
  const result = {
170
212
  header: [],
171
213
  footer: [],
@@ -173,13 +215,31 @@ export function extractLandmarks(html) {
173
215
  aside: [],
174
216
  form: [],
175
217
  search: [],
218
+ main: [],
176
219
  remainderHtml: html,
177
220
  };
178
221
  const spans = [];
222
+ const lineColumnIndex = buildLineColumnIndex(html);
223
+ const toInstance = (match) => {
224
+ const start = offsetToLineColumn(lineColumnIndex, match.startOffset);
225
+ const end = offsetToLineColumn(lineColumnIndex, match.endOffset);
226
+ return {
227
+ html: html.slice(match.startOffset, match.endOffset),
228
+ startOffset: match.startOffset,
229
+ endOffset: match.endOffset,
230
+ startLine: start.line,
231
+ startColumn: start.column,
232
+ endLine: end.line,
233
+ endColumn: end.column,
234
+ };
235
+ };
179
236
  for (const match of outermost) {
180
- result[match.type].push(html.slice(match.startOffset, match.endOffset));
237
+ result[match.type].push(toInstance(match));
181
238
  spans.push({ start: match.startOffset, end: match.endOffset });
182
239
  }
240
+ for (const match of outermostMain) {
241
+ result.main.push(toInstance(match));
242
+ }
183
243
  result.remainderHtml = excise(html, spans);
184
244
  return result;
185
245
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Default containment threshold for {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}.
3
+ * Chosen to match the quorum/shell fractions used elsewhere in this pipeline
4
+ * ({@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
5
+ * `QUORUM_FRACTION`, {@link ./shell-quorum.js | SHELL_QUORUM_FALLBACK_FRACTION}) —
6
+ * unlike those, this exact value has not been validated against real crawl
7
+ * data; it is a starting point carried over by convention, adjustable via
8
+ * this function's `threshold` parameter.
9
+ */
10
+ export declare const DEFAULT_CHROME_OVERLAP_THRESHOLD = 0.8;
11
+ /**
12
+ * Classifies a single landmark instance as chrome (shared site/section
13
+ * furniture) or content, given the instance's own tokens and the shell
14
+ * token set {@link ./shell-quorum.js | shellQuorum} discovered for its unit.
15
+ *
16
+ * ## Why containment (`|instance ∩ shell| / |instance|`) and not Jaccard
17
+ *
18
+ * `shellTokens` is the union of chrome tokens across an entire unit's
19
+ * landmark instances (header + nav + footer + …, corpus-wide), so it is
20
+ * usually far larger than any single instance's own token set. Jaccard's
21
+ * denominator is the *union* of both sets, which stays shell-sized even when
22
+ * the instance is 100% shell tokens — driving the score down regardless of
23
+ * how purely "shell" the instance is. Containment instead asks "of this
24
+ * instance's own tokens, how many are shell tokens", which is the question
25
+ * that actually matters for classifying one instance.
26
+ *
27
+ * An instance with zero tokens is never chrome (there is nothing to
28
+ * corroborate) — matches {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
29
+ * own choice to drop zero-token instances before they ever reach a
30
+ * `PerPageLandmarkInstance`, kept here as a defensive default rather than an
31
+ * assumption about every caller.
32
+ * @param instanceTokens
33
+ * @param shellTokens
34
+ * @param threshold
35
+ * @example
36
+ * ```ts
37
+ * const shellTokens = shellQuorum(unitPerPageInstances);
38
+ * const isChrome = isChromeLandmarkInstance(instance.tokens, shellTokens);
39
+ * ```
40
+ */
41
+ export declare function isChromeLandmarkInstance(instanceTokens: ReadonlySet<string>, shellTokens: ReadonlySet<string>, threshold?: number): boolean;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Default containment threshold for {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}.
3
+ * Chosen to match the quorum/shell fractions used elsewhere in this pipeline
4
+ * ({@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
5
+ * `QUORUM_FRACTION`, {@link ./shell-quorum.js | SHELL_QUORUM_FALLBACK_FRACTION}) —
6
+ * unlike those, this exact value has not been validated against real crawl
7
+ * data; it is a starting point carried over by convention, adjustable via
8
+ * this function's `threshold` parameter.
9
+ */
10
+ export const DEFAULT_CHROME_OVERLAP_THRESHOLD = 0.8;
11
+ /**
12
+ * Classifies a single landmark instance as chrome (shared site/section
13
+ * furniture) or content, given the instance's own tokens and the shell
14
+ * token set {@link ./shell-quorum.js | shellQuorum} discovered for its unit.
15
+ *
16
+ * ## Why containment (`|instance ∩ shell| / |instance|`) and not Jaccard
17
+ *
18
+ * `shellTokens` is the union of chrome tokens across an entire unit's
19
+ * landmark instances (header + nav + footer + …, corpus-wide), so it is
20
+ * usually far larger than any single instance's own token set. Jaccard's
21
+ * denominator is the *union* of both sets, which stays shell-sized even when
22
+ * the instance is 100% shell tokens — driving the score down regardless of
23
+ * how purely "shell" the instance is. Containment instead asks "of this
24
+ * instance's own tokens, how many are shell tokens", which is the question
25
+ * that actually matters for classifying one instance.
26
+ *
27
+ * An instance with zero tokens is never chrome (there is nothing to
28
+ * corroborate) — matches {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
29
+ * own choice to drop zero-token instances before they ever reach a
30
+ * `PerPageLandmarkInstance`, kept here as a defensive default rather than an
31
+ * assumption about every caller.
32
+ * @param instanceTokens
33
+ * @param shellTokens
34
+ * @param threshold
35
+ * @example
36
+ * ```ts
37
+ * const shellTokens = shellQuorum(unitPerPageInstances);
38
+ * const isChrome = isChromeLandmarkInstance(instance.tokens, shellTokens);
39
+ * ```
40
+ */
41
+ export function isChromeLandmarkInstance(instanceTokens, shellTokens, threshold = DEFAULT_CHROME_OVERLAP_THRESHOLD) {
42
+ if (instanceTokens.size === 0)
43
+ return false;
44
+ let hit = 0;
45
+ for (const token of instanceTokens) {
46
+ if (shellTokens.has(token))
47
+ hit++;
48
+ }
49
+ return hit / instanceTokens.size >= threshold;
50
+ }
@@ -1,11 +1,11 @@
1
1
  import { assignContainedClusters } from './assign-contained-clusters.js';
2
- import { autoCutThreshold } from './auto-cut-threshold.js';
3
2
  import { collapseAnonymousDivs } from './collapse-anonymous-divs.js';
4
3
  import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
5
4
  import { computeDocumentFrequency } from './compute-document-frequency.js';
6
5
  import { jaccardSimilarity } from './jaccard-similarity.js';
7
6
  import { reservoirSample } from './reservoir-sample.js';
8
7
  import { shapeToken } from './shape-token.js';
8
+ import { shellQuorum } from './shell-quorum.js';
9
9
  import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
10
10
  /**
11
11
  * Fixed complete-linkage threshold for the cross-block fine stage.
@@ -26,11 +26,10 @@ const CROSS_BLOCK_THRESHOLD = 0.8;
26
26
  * on real crawl data. Full union is shell-dominated: 298 pages collapsed into
27
27
  * 4 clusters — also confirmed. 80% quorum avoids both failure modes.
28
28
  *
29
- * Also reused as the fallback clamp for {@link ./auto-cut-threshold.js |
30
- * autoCutThreshold} when running on the per-landmark-instance frequency
31
- * distribution in {@link ./merge-cross-block-clusters.js | shellQuorum}. The
32
- * clamp only ever *loosens* the cut relative to this floor (never tightens),
33
- * and only fires in the degenerate cases the JSDoc there describes.
29
+ * Not shared with {@link ./shell-quorum.js | shellQuorum}'s own fallback
30
+ * clamp (`SHELL_QUORUM_FALLBACK_FRACTION`) the two happen to be the same
31
+ * value today because both were validated against the same real crawl
32
+ * corpora, but they are independently tunable.
34
33
  */
35
34
  const QUORUM_FRACTION = 0.8;
36
35
  /**
@@ -159,97 +158,6 @@ function l2Contained(xSig, ySig) {
159
158
  }
160
159
  return true;
161
160
  }
162
- /**
163
- * Discovers a unit's shell tokens by auto-cutting the per-*token* page-
164
- * frequency histogram of every landmark instance's tokens. This is the same
165
- * max-gap primitive used for Stage A merge-height cutoffs, applied
166
- * recursively at the landmark-token layer.
167
- *
168
- * ## Why per-token and not per-signature
169
- *
170
- * An earlier iteration ran the histogram at the level of full landmark-
171
- * instance signatures (canonicalized token sets). That failed on a real,
172
- * common pattern: a shared site chrome whose markup carries a per-page
173
- * distinguishing element (a breadcrumb, a page-title element with a page-
174
- * specific class, a "current" state). All pages have most of the same
175
- * tokens, but every page's full signature is distinct because tokens embed
176
- * class names. Per-signature counting saw 5 signatures at freq 0.2 each,
177
- * autoCutThreshold on the flat distribution returned the clamp, and the
178
- * shell collapsed to empty even though every page shared the core header
179
- * skeleton. Per-token counting handles the same case correctly — the shared
180
- * skeleton tokens each hit freq 1.0.
181
- *
182
- * ## The histogram
183
- *
184
- * For every member page, all its landmark instances are tokenized and
185
- * unioned into a single per-page token set (order-agnostic, deduped: a
186
- * token appearing in two of the page's landmarks still counts once for
187
- * that page). The corpus histogram is then "how many pages contain each
188
- * token". Tokens that appear on nearly every page are the unit's chrome;
189
- * tokens that appear on only a handful are page-specific content that
190
- * happens to be tagged as a landmark.
191
- *
192
- * ## Why auto-cut instead of a hard-coded quorum
193
- *
194
- * A fixed 80% quorum (this file's earlier implementation) baked one
195
- * threshold in for every unit. Real corpora don't obey a universal cutoff:
196
- * a section-local landmark token that appears on 60% of a unit's pages is
197
- * the section's chrome under any reasonable reading, but 80% quorum
198
- * discards it. Auto-cut looks at the *shape* of the frequency distribution
199
- * and picks the widest gap between adjacent frequencies — if the
200
- * distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
201
- * and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
202
- * 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
203
- * "chrome for this unit". If instead the distribution is flat, the clamp
204
- * to {@link QUORUM_FRACTION} keeps the threshold from being tighter than
205
- * the fallback default.
206
- *
207
- * ## Fallbacks
208
- *
209
- * A single distinct token (`heights.length < 2`) or a perfectly flat
210
- * distribution (`maxGap === 0`) returns the {@link QUORUM_FRACTION} clamp
211
- * verbatim — exactly the same 80%-quorum behavior as before. So degenerate
212
- * cases degrade to the old contract; only richer distributions get the
213
- * auto-cut benefit.
214
- *
215
- * A page with no landmarks contributes an empty set, deliberately, so the
216
- * shell-corroboration jaccard between two landmark-less pages is 0 rather
217
- * than 1 (which it would be if we handed back a `<body></body>`-derived
218
- * `{body}` fallback set to both sides).
219
- * @param perPageInstances
220
- */
221
- function shellQuorum(perPageInstances) {
222
- const pageCount = perPageInstances.length;
223
- if (pageCount === 0)
224
- return new Set();
225
- // Union all instance token sets per page (dedupe within page: a token
226
- // present on both header and footer of the same page still counts once
227
- // for that page's contribution).
228
- const tokenPageCount = new Map();
229
- for (const instances of perPageInstances) {
230
- const perPageUnion = new Set();
231
- for (const inst of instances) {
232
- for (const token of inst.tokens)
233
- perPageUnion.add(token);
234
- }
235
- for (const token of perPageUnion) {
236
- tokenPageCount.set(token, (tokenPageCount.get(token) ?? 0) + 1);
237
- }
238
- }
239
- if (tokenPageCount.size === 0)
240
- return new Set();
241
- const frequencies = [];
242
- for (const count of tokenPageCount.values()) {
243
- frequencies.push(count / pageCount);
244
- }
245
- const cut = autoCutThreshold(frequencies, QUORUM_FRACTION);
246
- const shell = new Set();
247
- for (const [token, count] of tokenPageCount) {
248
- if (count / pageCount >= cut)
249
- shell.add(token);
250
- }
251
- return shell;
252
- }
253
161
  // ---------------------------------------------------------------------------
254
162
  // Main function
255
163
  // ---------------------------------------------------------------------------
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A 1-based line/column position within an HTML string.
3
+ */
4
+ export type LineColumn = {
5
+ readonly line: number;
6
+ readonly column: number;
7
+ };
8
+ /**
9
+ * Precomputed lookup structure for {@link ./offset-to-line-column.js | offsetToLineColumn}:
10
+ * every newline's string-index offset, ascending.
11
+ */
12
+ export type LineColumnIndex = {
13
+ readonly newlineOffsets: readonly number[];
14
+ };
15
+ /**
16
+ * Scans `html` once and records every `\n` offset, so repeated
17
+ * {@link ./offset-to-line-column.js | offsetToLineColumn} calls against the
18
+ * same HTML string can binary-search instead of re-scanning from the start
19
+ * each time. Built once per page and reused across every landmark instance's
20
+ * start/end offset — a page with dozens of landmark instances would
21
+ * otherwise pay for a fresh O(n) scan per offset instead of one O(n) scan
22
+ * total.
23
+ * @param html
24
+ */
25
+ export declare function buildLineColumnIndex(html: string): LineColumnIndex;
26
+ /**
27
+ * Converts a string-index `offset` (the same unit as `htmlparser2`'s
28
+ * `startIndex`/`endIndex`, i.e. UTF-16 code units) into a 1-based
29
+ * `{line, column}` position, using an index built by
30
+ * {@link ./offset-to-line-column.js | buildLineColumnIndex}.
31
+ *
32
+ * `\r\n` line endings are handled without special-casing: the `\r` is
33
+ * counted as the last column of its own line, matching how most editors
34
+ * report position for CRLF files.
35
+ * @param index
36
+ * @param offset
37
+ */
38
+ export declare function offsetToLineColumn(index: LineColumnIndex, offset: number): LineColumn;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Scans `html` once and records every `\n` offset, so repeated
3
+ * {@link ./offset-to-line-column.js | offsetToLineColumn} calls against the
4
+ * same HTML string can binary-search instead of re-scanning from the start
5
+ * each time. Built once per page and reused across every landmark instance's
6
+ * start/end offset — a page with dozens of landmark instances would
7
+ * otherwise pay for a fresh O(n) scan per offset instead of one O(n) scan
8
+ * total.
9
+ * @param html
10
+ */
11
+ export function buildLineColumnIndex(html) {
12
+ const newlineOffsets = [];
13
+ for (let i = 0; i < html.length; i++) {
14
+ if (html.codePointAt(i) === 10)
15
+ newlineOffsets.push(i);
16
+ }
17
+ return { newlineOffsets };
18
+ }
19
+ /**
20
+ * Converts a string-index `offset` (the same unit as `htmlparser2`'s
21
+ * `startIndex`/`endIndex`, i.e. UTF-16 code units) into a 1-based
22
+ * `{line, column}` position, using an index built by
23
+ * {@link ./offset-to-line-column.js | buildLineColumnIndex}.
24
+ *
25
+ * `\r\n` line endings are handled without special-casing: the `\r` is
26
+ * counted as the last column of its own line, matching how most editors
27
+ * report position for CRLF files.
28
+ * @param index
29
+ * @param offset
30
+ */
31
+ export function offsetToLineColumn(index, offset) {
32
+ const { newlineOffsets } = index;
33
+ let low = 0;
34
+ let high = newlineOffsets.length;
35
+ while (low < high) {
36
+ const mid = (low + high) >>> 1;
37
+ if (newlineOffsets[mid] < offset) {
38
+ low = mid + 1;
39
+ }
40
+ else {
41
+ high = mid;
42
+ }
43
+ }
44
+ // `low` is the count of newlines strictly before `offset`, i.e. the
45
+ // number of completed lines — so `low` newlines completed means we're on
46
+ // line `low + 1`.
47
+ const lineStart = low === 0 ? 0 : newlineOffsets[low - 1] + 1;
48
+ return { line: low + 1, column: offset - lineStart + 1 };
49
+ }
@@ -1,4 +1,4 @@
1
- import type { ExtractLandmarksResult, LandmarkType } from './extract-landmarks.js';
1
+ import type { ExtractLandmarksResult, LandmarkPosition, LandmarkType } from './extract-landmarks.js';
2
2
  import type { TokenizeOptions } from './types.js';
3
3
  /**
4
4
  * Every landmark type extractLandmarks may populate, iterated in a fixed
@@ -13,11 +13,20 @@ export declare const ALL_LANDMARK_TYPES: readonly LandmarkType[];
13
13
  * {@link ./canonicalize-token-set.js | canonicalizeTokenSet}). Signatures
14
14
  * are reused across consumers so two callers see the same "same instance"
15
15
  * verdict without independently re-canonicalizing.
16
+ *
17
+ * `position` is the instance's location in the page it came from, computed
18
+ * once by {@link ./extract-landmarks.js | extractLandmarks} and carried here
19
+ * unchanged — a back-reference for callers (e.g.
20
+ * {@link ./build-page-landmark-report.js | buildPageLandmarkReport}) that
21
+ * need to report where a chrome-classified instance actually sits, not just
22
+ * that it exists. It plays no part in `tokens`/`signature` computation or in
23
+ * the corpus-frequency logic that consumes this type.
16
24
  */
17
25
  export type PerPageLandmarkInstance = {
18
26
  readonly type: LandmarkType;
19
27
  readonly tokens: ReadonlySet<string>;
20
28
  readonly signature: string;
29
+ readonly position: LandmarkPosition;
21
30
  };
22
31
  /**
23
32
  * Tokenizes every landmark instance across every page once, keyed by page
@@ -44,17 +44,29 @@ export function computePerPageLandmarkInstances(landmarks, tokenizeOptions) {
44
44
  const seenSignatures = new Set();
45
45
  const out = [];
46
46
  for (const type of ALL_LANDMARK_TYPES) {
47
- for (const instanceHtml of entry[type]) {
48
- if (!instanceHtml)
47
+ for (const instance of entry[type]) {
48
+ if (!instance.html)
49
49
  continue;
50
- const tokens = new Set(tokenize(`<body>${instanceHtml}</body>`, tokenizeOptions).tokens);
50
+ const tokens = new Set(tokenize(`<body>${instance.html}</body>`, tokenizeOptions).tokens);
51
51
  if (tokens.size === 0)
52
52
  continue;
53
53
  const signature = canonicalizeTokenSet(tokens);
54
54
  if (seenSignatures.has(signature))
55
55
  continue;
56
56
  seenSignatures.add(signature);
57
- out.push({ type, tokens, signature });
57
+ out.push({
58
+ type,
59
+ tokens,
60
+ signature,
61
+ position: {
62
+ startOffset: instance.startOffset,
63
+ endOffset: instance.endOffset,
64
+ startLine: instance.startLine,
65
+ startColumn: instance.startColumn,
66
+ endLine: instance.endLine,
67
+ endColumn: instance.endColumn,
68
+ },
69
+ });
58
70
  }
59
71
  }
60
72
  return out;
@@ -1,7 +1,9 @@
1
1
  import type { ExtractLandmarksResult } from './extract-landmarks.js';
2
+ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
2
3
  import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
3
4
  import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
4
5
  import type { TokenizeOptions } from './types.js';
6
+ import { type PageLandmarkReport } from './build-page-landmark-report.js';
5
7
  /**
6
8
  * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
7
9
  * into its block token set for Stage A clustering, restoring exactly the
@@ -65,6 +67,7 @@ import type { TokenizeOptions } from './types.js';
65
67
  export declare function computeLocalChromeArtifacts(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions: TokenizeOptions | undefined): {
66
68
  readonly localSignatures: ReadonlySet<string>;
67
69
  readonly localTokensByPage: readonly ReadonlySet<string>[];
70
+ readonly perPageInstances: readonly (readonly PerPageLandmarkInstance[])[];
68
71
  };
69
72
  /**
70
73
  * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
@@ -162,9 +165,47 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
162
165
  * sync helper to running a per-block async loop that emits
163
166
  * `pass1-block-complete` and `stage-b-start`. Omitting `onProgress`
164
167
  * keeps the small-corpus branch on the pre-refactor sync path with
165
- * zero yield overhead.
168
+ * zero yield overhead. Ignored when `includeLandmarkPositions` is
169
+ * `true` — that option always routes the small-corpus branch through
170
+ * the sync helper (see `includeLandmarkPositions`'s own JSDoc), so no
171
+ * progress events fire in that combination.
166
172
  */
167
173
  onProgress?: (event: ProgressEvent) => void;
174
+ /**
175
+ * When `true`, every result entry additionally carries a
176
+ * {@link PageLandmarkReport} — each landmark instance's position, plus
177
+ * a chrome/content verdict for the six excisable types (see
178
+ * {@link ./build-page-landmark-report.js | buildPageLandmarkReport}).
179
+ * Changes the return shape from `string[]` to
180
+ * `{@link PageClusterKeyResult}[]` (see the overloads on
181
+ * `resolvePageClusterKeysInMemory`/`resolvePageClusterKeys`/
182
+ * `resolvePageClusterKeysFromArray`). Defaults to `false`, in which
183
+ * case every existing caller's behavior and return type are
184
+ * unchanged.
185
+ *
186
+ * Not supported on the streaming path
187
+ * (`pageCount > {@link CORPUS_INLINE_THRESHOLD}`): reservoir sampling
188
+ * and Jaccard-based non-sample assignment there have no notion of
189
+ * "this page's shell tokens" to classify chrome against, and
190
+ * retrofitting one is out of scope. `resolvePageClusterKeys` throws a
191
+ * `RangeError` up front if both apply to the same call, rather than
192
+ * silently degrading semantics.
193
+ *
194
+ * On the async factory-based `resolvePageClusterKeys`, this option
195
+ * forces the small-corpus branch through the sync
196
+ * `resolvePageClusterKeysInMemory` helper regardless of `onProgress`
197
+ * — see `onProgress`'s own JSDoc.
198
+ */
199
+ includeLandmarkPositions?: boolean;
200
+ };
201
+ /**
202
+ * One page's clustering result when `includeLandmarkPositions` is `true`:
203
+ * the same `clusterKey` every caller already gets, plus that page's
204
+ * {@link PageLandmarkReport}.
205
+ */
206
+ export type PageClusterKeyResult = {
207
+ readonly clusterKey: string;
208
+ readonly landmarks: PageLandmarkReport;
168
209
  };
169
210
  /**
170
211
  * Corpus size at or below which the async factory-based
@@ -188,6 +229,17 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
188
229
  * anything above 20,000 is routed to streaming.
189
230
  */
190
231
  export declare const CORPUS_INLINE_THRESHOLD = 20000;
232
+ /**
233
+ * Throws when `includeLandmarkPositions` is combined with a corpus over
234
+ * `threshold` pages (the streaming path — see `includeLandmarkPositions`'s
235
+ * own JSDoc for why it has no sample-based equivalent there). Split out from
236
+ * its call site so tests can exercise the boundary with a small injected
237
+ * `threshold` instead of constructing a 20,001-page fixture to cross the
238
+ * real {@link CORPUS_INLINE_THRESHOLD}.
239
+ * @param pageCount
240
+ * @param threshold
241
+ */
242
+ export declare function assertLandmarkPositionsSupportedForPageCount(pageCount: number, threshold: number): void;
191
243
  /**
192
244
  * Reservoir-sample size per block on the streaming path. Blocks larger than
193
245
  * this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
@@ -240,6 +292,9 @@ export declare const BLOCK_SAMPLE_SIZE = 100;
240
292
  * @param pages
241
293
  * @param options
242
294
  */
295
+ export declare function resolvePageClusterKeysInMemory(pages: readonly PageClusterSignals[], options: ResolvePageClusterKeysOptions & {
296
+ includeLandmarkPositions: true;
297
+ }): PageClusterKeyResult[];
243
298
  export declare function resolvePageClusterKeysInMemory(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): string[];
244
299
  /**
245
300
  * Factory function returning an iterator over pages. Called once per streaming
@@ -303,6 +358,9 @@ export type PageFactory = () => Iterable<PageClusterSignals> | AsyncIterable<Pag
303
358
  * });
304
359
  * ```
305
360
  */
361
+ export declare function resolvePageClusterKeys(pages: PageFactory, options: ResolvePageClusterKeysOptions & {
362
+ includeLandmarkPositions: true;
363
+ }): Promise<PageClusterKeyResult[]>;
306
364
  export declare function resolvePageClusterKeys(pages: PageFactory, options?: ResolvePageClusterKeysOptions): Promise<string[]>;
307
365
  /**
308
366
  * Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
@@ -320,4 +378,7 @@ export declare function resolvePageClusterKeys(pages: PageFactory, options?: Res
320
378
  * ]);
321
379
  * ```
322
380
  */
381
+ export declare function resolvePageClusterKeysFromArray(pages: readonly PageClusterSignals[], options: ResolvePageClusterKeysOptions & {
382
+ includeLandmarkPositions: true;
383
+ }): Promise<PageClusterKeyResult[]>;
323
384
  export declare function resolvePageClusterKeysFromArray(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): Promise<string[]>;