Skip to content

feat: add DOM-structure page-template classification (--templates)#230

Merged
YusukeHirao merged 8 commits into
devfrom
worktree-analyze-page-clusters
Jul 24, 2026
Merged

feat: add DOM-structure page-template classification (--templates)#230
YusukeHirao merged 8 commits into
devfrom
worktree-analyze-page-clusters

Conversation

@YusukeHirao

Copy link
Copy Markdown
Member

Summary

  • Adds DOM-structure page-template classification (analyze --templates), using @d-zero/page-cluster to group internal HTML pages by structural similarity. This runs as a dedicated corpus-wide phase in core's Nitpicker.analyze(), separate from the per-page AnalyzePlugin system, since template clustering needs the whole page set at once.
  • Persists classification results in a new page_templates SQL table (crawler), written via Archive.replacePageTemplates — a dedicated table rather than a Table/analysis/report blob, so the result has one source of truth.
  • Exposes templateKey through the existing Pages list/detail query surface (query) and viewer UI (viewer): a new column on the Pages table, plus a radio filter backed by the archive's distinct template keys. No new routes, screens, MCP tools, or CLI subcommands — scoped intentionally to "add a column to what already exists."
  • templateKey filtering is a first-class fast-path (viewer_pages read-model) filter: it resolves via a WHERE page_id IN (subquery) against the narrow page_templates table, so selecting it does not force the legacy write-model fallback the way urlPattern/directory/header-presence filters do.
  • Every page_templates join/select is gated on hasPageTemplatesTable: archives that predate --templates, or a read-only viewer connection that skipped schema self-heal, degrade to templateKey: null instead of throwing "no such table".

Why

@d-zero/page-cluster was purpose-built for this classification; see https://github.com/d-zero-dev/tools/tree/dev/packages/%40d-zero/page-cluster. Real-archive verification (a ~1,400-page site, 63 distinct template groups) surfaced two gaps this PR also closes:

  1. The first cut wrote templateKey into the Table/analysis/report JSON blob — invisible to the viewer, and a second write path once a dedicated SQL table exists risks the two copies diverging. templateKey is now written to page_templates only.
  2. A viewer opened read-only against an archive whose page_templates table doesn't exist (predates this feature, or a stub connection that skips schema self-heal) would throw no such table: page_templates on any Pages request that joins it. Every query path now checks hasPageTemplatesTable first and falls back to a NULL literal.

Test plan

  • yarn build / yarn lint (0 errors) / yarn test (484 files / 3,415 tests, all green)
  • Unit tests: replace-page-templates.spec.ts (write path — resolve+persist, full replace, empty-map clear, skip-on-unresolvable-URL), read-viewer-page-facets.spec.ts / list-viewer-pages.spec.ts / list-pages.spec.ts / get-page-detail.spec.ts / join-viewer-page-ids-to-list-items.spec.ts (templateKey exposure, missing-table fallback), register-pages-route.spec.ts (templateKey filter stays on the fast path — opaque keyset cursor, not the legacy decimal-offset cursor)
  • Manual verification against a real, previously-crawled archive: ran analyze --templates, confirmed page_templates rows via sqlite3, opened the viewer, confirmed the Template column and filter both render real classification data and respond in single-digit milliseconds via the fast path

Add an opt-in analyze phase that groups internal HTML pages into
templates by DOM-structure similarity, using @d-zero/page-cluster.
It runs as a dedicated core phase rather than an AnalyzePlugin,
since it needs a corpus-wide batch computation that the per-page
eachPage/eachUrl plugin model cannot express.

- collect-page-stylesheet-urls.ts resolves each page's referenced
  CSS via resource_ref_edges, chunked and interned for large archives
- create-page-cluster-factory.ts builds the PageFactory that
  resolvePageClusterKeys reads the corpus through, respecting its
  sequential/multi-pass/same-order contract
- classify-page-templates.ts calls resolvePageClusterKeys and caches
  the result keyed by the archive's path, size, mtime, and page count
- nitpicker.ts runs classification once globally after every
  getPagesWithRefs batch has been accumulated, and merges into the
  existing analysis/report instead of overwriting it when no plugins
  ran, so a standalone run never wipes a previous run's columns

Results surface as a templateKey column through the existing Table/
analysis-report mechanism, so they flow into Google Sheets reports
the same way other analyze plugins' columns do.
Wire AnalyzeOptions.classifyTemplates through to the analyze command
as an opt-in --templates flag, independent of plugin selection.

--templates alone (no --all/--plugin, and no configured plugins) no
longer trips the "no analyze plugins found" / "no valid plugins to
run" guards, since template classification is a valid reason to run
analyze() with zero plugins selected.
…ation

Adds a dedicated page_templates table (page_id PK, WITHOUT ROWID) and a
replacePageTemplates write path, mirroring replaceAnalysisViolations's
whole-table-replace shape. Unlike violations, a page whose URL can't be
resolved back to content_items is silently skipped rather than treated as a
hard failure, since losing one page's classification shouldn't discard a
multi-thousand-page run.
…ble/report

Now that page_templates is a dedicated SQL table (crawler), --templates
classification results are persisted only via Archive.replacePageTemplates
and no longer merged into analysis/report or analysis/table. This removes
the double-write that risked the two copies silently diverging.
… filtering

Joins page_templates (by page_id) into listPages, listPagesByTag,
listPagesByJsonLdType, joinViewerPageIdsToListItems, and getPageDetail, so
--templates classification surfaces as templateKey without a viewer_pages
read-model schema change. Every join is gated on hasPageTemplatesTable: an
archive that predates --templates, or a read-only connection that skipped
schema self-heal, falls back to a NULL literal instead of throwing "no such
table".

templateKey is also a first-class ListPagesOptions/ListViewerPagesOptions
filter. The fast path resolves it via a WHERE page_id IN (subquery) against
page_templates rather than a JOIN, since readKeysetWindow's shared
SELECT page_id, ... would otherwise make that column ambiguous once a
second page_id-bearing table entered the FROM clause.
Adds a Template column to the Pages table alongside a radio filter backed by
the archive's distinct template keys. templateKey is a first-class fast-path
filter (query's page_templates whereIn-subquery join), so selecting it never
forces the legacy write-model fallback the way urlPattern/directory/
header-presence filters do.
@YusukeHirao
YusukeHirao merged commit 681e34a into dev Jul 24, 2026
10 checks passed
@YusukeHirao
YusukeHirao deleted the worktree-analyze-page-clusters branch July 24, 2026 02:57
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