Skip to content

feat(page-cluster): landmark-based chrome extraction and clustering pipeline - #907

Merged
YusukeHirao merged 5 commits into
devfrom
feat/page-cluster-landmark-decomposition
Jul 4, 2026
Merged

feat(page-cluster): landmark-based chrome extraction and clustering pipeline#907
YusukeHirao merged 5 commits into
devfrom
feat/page-cluster-landmark-decomposition

Conversation

@YusukeHirao

Copy link
Copy Markdown
Member

Summary

  • Add resolveStructuralClusterKeys, which clusters pages already grouped into one
    blocking key (e.g. from resolveBlockingGroupKeys) by structural similarity, using
    complete-linkage hierarchical clustering computed via the NN-chain algorithm (O(n²)).
  • Single-linkage (connected components of a similarity-threshold graph) was considered
    first but rejected: its "chaining" failure mode lets one unrepresentative page
    transitively merge two otherwise-unrelated templates, defeating template detection.
    Complete-linkage requires every pair across two clusters to clear the threshold,
    ruling that out, at no extra asymptotic cost since the pairwise similarity matrix is
    computed either way.
  • MinHash/LSH approximation is intentionally out of scope: NN-chain already computes
    the exact clustering in O(n²), and real-data validation (see test plan) confirmed
    O(n²) is fast enough at the largest real block size found (~1,400 pages, ~685ms).

Test plan

  • yarn build / yarn lint / yarn test pass
  • /code-review xhigh run; 4 findings (floating-point threshold boundary epsilon,
    unicorn/prefer-math-trunc, Prettier formatting, cspell) all fixed
  • /qa-engineer review added a differential test against a naive brute-force
    complete-linkage reference (caught one real algorithm bug during development —
    an early-exit-on-threshold that's unsound when multiple disjoint NN-chains
    exist), plus a regression test for the epsilon fix and threshold boundary tests
  • /product-manager and /doc reviews passed with one JSDoc gap fixed
  • Real-data validation against two production crawl archives: confirmed
    performance at the largest observed real block size, and found (as a documented,
    non-blocking finding for a future iteration) that per-page body classes on some
    real sites can zero out structural similarity between same-template pages —
    a caller-side normalization concern, not a bug in this function

Add resolvePageClusterKeys to connect resolveBlockingGroupKeys and
resolveStructuralClusterKeys into one entry point, composing cross-block-safe
cluster keys via JSON.stringify. Add subpath exports for every function built
in prior PRs (derivePathGroupKey, deriveStylesheetGroupKey,
resolveBlockingGroupKeys, resolveStructuralClusterKeys,
computeDocumentFrequency, splitTokensByFrequency, arrayEditDistance,
jaccardSimilarity, resolvePageClusterKeys) — package.json's exports field
previously mapped only tokenize(), leaving everything else unreachable from
outside the package.

Fix confirmed bugs found via real-data validation and an xhigh code review:
- tokenize() no longer folds <body>'s own class into every leaf path (CMS
  body_class() conventions inject per-page-category labels there, which
  collapsed near-duplicate pages to zero similarity); the class is returned
  separately as bodyClassList instead of being discarded
- resolveStructuralClusterKeys now narrows each page to its page-specific
  content via splitTokensByFrequency before comparing, so chrome no longer
  inflates similarity between pages with little shared content, with a
  documented floor (n >= 10) and a raw-token fallback for a page that
  narrows to an empty set (two empty sets compare as similarity 1, which
  would otherwise force-merge two structurally unrelated all-chrome pages)
- noise-class-patterns.ts's sc-/css-/generic-hex patterns no longer flag
  real English words that happen to fit a hash's character shape
  (sc-header, css-editor, section-facade, ...)
- a pre-existing literal NUL byte in tokenize.production-scale.spec.ts that
  made git treat the whole file as binary, hiding every diff to it

Fix resolveBlockingGroupKeys' JSDoc, which claimed no later refinement step
exists to justify its exclusive css:/path: key assignment — that premise
predates resolveStructuralClusterKeys and is no longer accurate; the
exclusivity itself is deliberately left unchanged pending a dedicated
follow-up (an OR-merge redesign needs its own validation cycle).

BREAKING CHANGE: tokenize() now returns { tokens, bodyClassList } instead of
a bare string[]. Package is 0.x, so no migration guide is required.
…clustering

The frequency-based chrome/content split alone still left real crawl
data over-fragmented (302 pages into 192 clusters). Explicit landmark
decomposition removes shared site chrome by structural markers
(<header>/<footer>/<nav>/<aside> tags or the matching ARIA landmark
roles) instead of statistical inference, avoiding both the
classify-then-refine circularity and the naive iterative chrome-
broadening approach already shown to backfire (removing a shared
token subset can only lower Jaccard similarity, never raise it).

- add extractLandmarks(): carves out the single shallowest instance
  of each landmark type per page, returning both the landmark HTML
  and the remaining content-only HTML
- add resolveLandmarkVariantKeys(): classifies which landmark variant
  (e.g. "which header design") a page has, as metadata independent
  of the overall per-page template key
- resolvePageClusterKeys() now takes each page's raw html instead of
  a pre-tokenized Set, so it can exclude landmarks before comparing
  (breaking change, package is 0.x); excludeLandmarks defaults to
  true and can be disabled to fall back to the previous behavior
- share opaque-tag (script/style/noscript/svg) detection between
  run-tokenizer.ts and the new extract-landmarks.ts via opaque-tags.ts
  instead of duplicating the tag list and type guard

Validated against two real crawl corpora throughout implementation:
header/footer/nav present on 99%+ of pages, extractLandmarks resolves
malformed/unclosed landmark tags safely (discards the candidate
rather than corrupting the remainder), and resolveLandmarkVariantKeys
classifies an 800-page real sample's headers into 6 clean variants
versus the ~2,000 clusters a full-page comparison produces.
…raction

Code review flagged that upgrading to excludeLandmarks:true (the new
default) without re-tuning similarityThreshold can silently split or
merge pages differently than before, with only prose in the JSDoc
warning about it. Add a regression test reproducing the same shape
as the JSDoc's real-crawl-data example (shared header inflates raw
similarity above 0.8, but is at 0.6 once landmarks are excluded) so
the claim is enforced by the test suite, not just documented.
… regex

htmlparser2 accepts tag names containing regex metacharacters (e.g.
"<div(foo role=\"banner\">" parses with tag name "div(foo"). Building
isGenuineClose's RegExp directly from an unescaped tag name let such
markup either throw ("Unterminated group") or silently change what
the regex matched. Escape the tag name before interpolating it.
…actLandmarks

ExtractLandmarksResult's JSDoc said a field is absent only "if the
page has none", but a field is also left absent when the only
candidate found was malformed markup extractLandmarks declined to
trust. Document that on extractLandmarks itself (where the discard
happens) and cross-reference it from the result type. Add a
regression test pinning that a discarded malformed candidate still
falls back to another well-formed candidate of the same type when
one exists, rather than giving up on that type entirely.
@YusukeHirao
YusukeHirao requested a review from yusasa16 as a code owner July 4, 2026 13:48
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@YusukeHirao
YusukeHirao merged commit 0cc3aad into dev Jul 4, 2026
6 checks passed
@YusukeHirao
YusukeHirao deleted the feat/page-cluster-landmark-decomposition branch July 4, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant