From 7131d47b6c4c3a12a206d4c2ea1c5fe9276bf0aa Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:38:34 +0900 Subject: [PATCH 01/10] chore(repo): register dedupe-cap e2e shard and cspell dictionary terms Add the new dedupe-cap.e2e.ts file to the e2e workflow shard manifest (CI would otherwise never run it) and add Misra-Gries/unfinalized to the cspell dictionary for issue #208 crawler-side terminology. --- .github/workflows/e2e.yml | 1 + .github/workflows/viewer-e2e.yml | 1 + cspell.json | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bf53ad29..c850738e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -103,6 +103,7 @@ jobs: config-persistence.e2e.ts, scope-auth-leak.e2e.ts, main-contents.e2e.ts, + dedupe-cap.e2e.ts, ] - id: retry-exclude files: diff --git a/.github/workflows/viewer-e2e.yml b/.github/workflows/viewer-e2e.yml index 9301a3ad..43b3b423 100644 --- a/.github/workflows/viewer-e2e.yml +++ b/.github/workflows/viewer-e2e.yml @@ -45,3 +45,4 @@ jobs: - run: yarn workspace @nitpicker/viewer test:e2e:directory-tree - run: yarn workspace @nitpicker/viewer test:e2e:template-clusters - run: yarn workspace @nitpicker/viewer test:e2e:inbound-links + - run: yarn workspace @nitpicker/viewer test:e2e:duplicate-clusters diff --git a/cspell.json b/cspell.json index 2dfaedd3..3afaac51 100644 --- a/cspell.json +++ b/cspell.json @@ -10,6 +10,10 @@ "bench-output/**" ], "words": [ + // Misra-Gries algorithm (DedupeCapTracker, issue #208) + "Misra", + "Gries", + // nitpicker "readtext", "unanalyzed", @@ -32,6 +36,7 @@ "greppable", "desync", "lossily", + "unfinalized", // HTML "noarchive", From f3d9fb8cb0edece62177f168f797382ab9e3d540 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:38:54 +0900 Subject: [PATCH 02/10] feat(crawler): fix self-generating pagination URLs and add dedupe-cap soft cap Fixes issue #208. The reported infinite URL space (scientific-notation page numbers) turned out to be self-generated by the crawler's own pagination predictor rather than a site-side trap: - generate-predicted-urls.ts: bound predicted tokens to safe integers, reject scientific-notation/overflowed digit counts, and preserve zero-padding, so a runaway token can no longer be generated at all. - crawler.ts: scope `paginationState` per page (inside `#handleResult`) instead of sharing one instance across the whole crawl, so anchors from unrelated pages are never compared as a pagination pair. - crawler.ts: always-on discard of a predicted page whose body is a byte-for-byte duplicate of the previous predicted page in the same URL shape, stopping further predictions for that shape. Adds an opt-in `--dedupe-cap` backstop (crawler/dedupe/): a Misra-Gries majority-vote tracker confirms a same-metadata-cluster trap per URL shape, with body_hash and og:url-mismatch signals halving the effective threshold. Confirmed shapes are recorded in the new `dedupe_cap_events` journal table and gate further enqueues for that shape; rejection counts are accumulated in memory and finalized once per shape at crawl end (or, for a shape capped in an earlier session and preloaded as sticky, accumulated onto that earlier row instead of being silently dropped). --- .../@nitpicker/crawler/src/archive/archive.ts | 60 +++ .../src/archive/create-adjunct-tables.spec.ts | 35 ++ .../src/archive/create-adjunct-tables.ts | 43 +++ .../crawler/src/archive/database.ts | 69 ++++ ...cumulate-dedupe-cap-rejected-count.spec.ts | 96 +++++ .../accumulate-dedupe-cap-rejected-count.ts | 29 ++ .../finalize-dedupe-cap-event.spec.ts | 92 +++++ .../dedupe-cap/finalize-dedupe-cap-event.ts | 21 ++ .../insert-dedupe-cap-event.spec.ts | 90 +++++ .../dedupe-cap/insert-dedupe-cap-event.ts | 36 ++ .../list-dedupe-cap-shape-keys.spec.ts | 64 ++++ .../dedupe-cap/list-dedupe-cap-shape-keys.ts | 31 ++ .../@nitpicker/crawler/src/archive/types.ts | 14 + .../crawler/src/crawler-orchestrator.spec.ts | 183 +++++++++ .../crawler/src/crawler-orchestrator.ts | 125 ++++++- .../crawler/src/crawler/crawler.spec.ts | 350 ++++++++++++++++++ .../@nitpicker/crawler/src/crawler/crawler.ts | 271 ++++++++++++-- .../dedupe/compute-meta-signature.spec.ts | 64 ++++ .../crawler/dedupe/compute-meta-signature.ts | Bin 0 -> 2052 bytes .../crawler/dedupe/compute-shape-key.spec.ts | 55 +++ .../src/crawler/dedupe/compute-shape-key.ts | 62 ++++ .../crawler/dedupe/dedupe-cap-tracker.spec.ts | 187 ++++++++++ .../src/crawler/dedupe/dedupe-cap-tracker.ts | 196 ++++++++++ .../is-predicted-content-duplicate.spec.ts | 19 + .../dedupe/is-predicted-content-duplicate.ts | 29 ++ .../crawler/dedupe/is-shape-capped.spec.ts | 15 + .../src/crawler/dedupe/is-shape-capped.ts | 12 + .../dedupe/resolve-og-url-mismatch.spec.ts | 46 +++ .../crawler/dedupe/resolve-og-url-mismatch.ts | 40 ++ .../crawler/src/crawler/dedupe/types.ts | 45 +++ .../crawler/generate-predicted-urls.spec.ts | 41 ++ .../src/crawler/generate-predicted-urls.ts | 38 +- .../@nitpicker/crawler/src/crawler/types.ts | 42 +++ 33 files changed, 2466 insertions(+), 34 deletions(-) create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.spec.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.spec.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.spec.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.spec.ts create mode 100644 packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.spec.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.ts create mode 100644 packages/@nitpicker/crawler/src/crawler/dedupe/types.ts diff --git a/packages/@nitpicker/crawler/src/archive/archive.ts b/packages/@nitpicker/crawler/src/archive/archive.ts index 71209492..02022ccc 100644 --- a/packages/@nitpicker/crawler/src/archive/archive.ts +++ b/packages/@nitpicker/crawler/src/archive/archive.ts @@ -1,6 +1,7 @@ import type { TemplateClusterReason } from './db-ops/analysis/types.js'; import type { Config, + InsertDedupeCapEventParams, InsertNetworkOutageParams, InventoryRunMeta, PageSource, @@ -122,6 +123,27 @@ export default class Archive extends ArchiveAccessor { */ abort() {} + /** + * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row, + * looked up by `shape_key` rather than `id` — used for a shape that + * capped in an earlier session (preloaded into `DedupeCapTracker`'s + * sticky set) and so has no event id from the current session. + * + * Thin facade over {@link Database.accumulateDedupeCapRejectedCount}. + * @param shapeKey - The capped shape whose rejection count to accumulate. + * @param rejectedCount - Additional anchors rejected for this shape in the current session. + */ + async accumulateDedupeCapRejectedCount( + shapeKey: string, + rejectedCount: number, + ): Promise { + dbLog( + 'Accumulate dedupe cap rejected count shapeKey=%s rejectedCount=%d', + shapeKey, + rejectedCount, + ); + return await this.#db.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount); + } /** * Records a crawler-level error to both the human-readable `error.log` (full * stack, for debugging) and the structured `crawl_errors` table (queryable, @@ -167,6 +189,19 @@ export default class Archive extends ArchiveAccessor { dbLog('Close network outage id=%d endedAt=%d', id, endedAt); return await this.#db.closeNetworkOutage(id, endedAt); } + /** + * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a + * no-op if already finalized. + * + * Thin facade over {@link Database.finalizeDedupeCapEvent}. + * @param id - The `dedupe_cap_events.id` to finalize. + * @param rejectedCount - Number of anchors rejected for this shape after it capped. + */ + async finalizeDedupeCapEvent(id: number, rejectedCount: number): Promise { + dbLog('Finalize dedupe cap event id=%d rejectedCount=%d', id, rejectedCount); + return await this.#db.finalizeDedupeCapEvent(id, rejectedCount); + } + /** * Retrieves the current crawling state, including lists of scraped and pending URLs. * @returns An object with `scraped` and `pending` URL arrays. @@ -234,6 +269,18 @@ export default class Archive extends ArchiveAccessor { async getUrl() { return this.#db.getBaseUrl(); } + /** + * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events` + * journal. + * + * Thin facade over {@link Database.insertDedupeCapEvent}. + * @param params - The newly-capped shape's fields to record. + * @returns The autoincremented `id` of the inserted row. + */ + async insertDedupeCapEvent(params: InsertDedupeCapEventParams): Promise { + dbLog('Insert dedupe cap event: shapeKey=%s', params.shapeKey); + return await this.#db.insertDedupeCapEvent(params); + } /** * Pre-insert inventory non-HTML URLs as `source='inventory-seed'` * placeholders in the `resources` table — the non-HTML counterpart of @@ -291,6 +338,18 @@ export default class Archive extends ArchiveAccessor { ); return await this.#db.insertNetworkOutage(params); } + + /** + * Every distinct `dedupe_cap_events.shape_key` recorded in this archive. + * Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s + * sticky set on `--resume` / `--append` / `--retry-failed` / + * `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s + * writer-only exposure. + * @returns Distinct shape keys already confirmed capped. + */ + async listDedupeCapShapeKeys(): Promise { + return this.#db.listDedupeCapShapeKeys(); + } /** * Hostnames whose `crawl_errors` history is consistently DNS failures and * for which no recent 2xx/3xx page or resource is recorded. Consumed by @@ -307,6 +366,7 @@ export default class Archive extends ArchiveAccessor { async listDnsBurnedHostCandidates(): Promise { return this.#db.listDnsBurnedHostCandidates(); } + /** * Lists every recorded outage as a resolved {@link OutageWindow}. * diff --git a/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.spec.ts b/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.spec.ts index 686a553b..de4b9986 100644 --- a/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.spec.ts +++ b/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.spec.ts @@ -24,6 +24,7 @@ const ADJUNCT_TABLES = [ 'page_main_content_canvases', 'inventory_runs', 'network_outages', + 'dedupe_cap_events', 'analysis_text_refs', 'analysis_violations', 'page_templates', @@ -146,6 +147,40 @@ describe('createAdjunctTables', () => { expect(rows[0]?.started_at).toBe(100); }); + it('declares dedupe_cap_events with no FK and no secondary index', async () => { + // Deliberately no index, same reasoning as network_outages: a crawl + // session produces at most a handful of rows. + await createAdjunctTables(db); + expect(await db.schema.hasColumn('dedupe_cap_events', 'shape_key')).toBe(true); + expect(await db.schema.hasColumn('dedupe_cap_events', 'sample_url')).toBe(true); + expect(await db.schema.hasColumn('dedupe_cap_events', 'body_hash')).toBe(true); + expect(await db.schema.hasColumn('dedupe_cap_events', 'effective_threshold')).toBe( + true, + ); + expect(await db.schema.hasColumn('dedupe_cap_events', 'observed_count')).toBe(true); + expect(await db.schema.hasColumn('dedupe_cap_events', 'detected_at')).toBe(true); + expect(await db.schema.hasColumn('dedupe_cap_events', 'rejected_count')).toBe(true); + const parents = await fkParentTables(db, 'dedupe_cap_events'); + expect(parents.size).toBe(0); + }); + + it('preserves existing dedupe_cap_events rows across a second createAdjunctTables run', async () => { + await createAdjunctTables(db); + await db('dedupe_cap_events').insert({ + shape_key: 'example.com/news/date/{n}/', + sample_url: 'https://example.com/news/date/2024/', + body_hash: Buffer.from('hash'), + effective_threshold: 50, + observed_count: 100, + detected_at: 1000, + rejected_count: null, + }); + await createAdjunctTables(db); + const rows = await db('dedupe_cap_events').select('*'); + expect(rows).toHaveLength(1); + expect(rows[0]?.shape_key).toBe('example.com/news/date/{n}/'); + }); + it('declares page_template_clusters with no FK and the BLOB+codec+size shape', async () => { await createAdjunctTables(db); const parents = await fkParentTables(db, 'page_template_clusters'); diff --git a/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.ts b/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.ts index 0a9d042b..543bd3a0 100644 --- a/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.ts +++ b/packages/@nitpicker/crawler/src/archive/create-adjunct-tables.ts @@ -17,6 +17,9 @@ import type { Knex } from 'knex'; * - `inventory_runs` — `--inventory` audit log (no FK; append-only) * - `network_outages` — operator-network-outage journal (no FK; append-only * except `ended_at`, which is written once on recovery) + * - `dedupe_cap_events` — `--dedupe-cap` same-cluster soft-cap audit log (no + * FK; append-only except `rejected_count`, which is written once at + * `crawlEnd`) * - `analysis_text_refs` + `analysis_violations` — analyze-phase findings, * FK → `content_items(id)` * - `page_templates` — DOM-structure template classification (`--templates`, @@ -359,6 +362,46 @@ export async function createAdjunctTables(instance: Knex): Promise { }); } + if (!(await instance.schema.hasTable('dedupe_cap_events'))) { + await instance.schema.createTable('dedupe_cap_events', (t) => { + // One row per URL shape the `--dedupe-cap` same-cluster soft cap + // (`DedupeCapTracker`) confirmed as a trap during this crawl. No + // index: a crawl produces at most a handful of these rows (same + // reasoning as `network_outages`, above). + t.increments('id'); + // The URL shape key (`computeShapeKey`) that capped — a template + // with placeholders (e.g. `example.com/news/date/{n}/`), not a + // literal URL. + t.string('shape_key').notNullable(); + // One concrete URL matching this shape, captured at cap time so a + // human reading the audit log can identify what was being + // crawled — `shape_key` alone is a template, not a navigable URL. + t.string('sample_url').notNullable(); + // `computeBodyHash` result recorded at cap time. Nullable only in + // the sense that BLOB columns are nullable by default; every row + // this feature writes populates it (a page with no rendered + // `` never reaches the tracker — see `Crawler#handleResult`). + t.binary('body_hash').nullable(); + // The Misra-Gries threshold that actually triggered the cap, + // after halving for the `body_hash`-match / `og:url`-mismatch + // confidence signals — NOT necessarily equal to `--dedupe-cap`'s + // raw value. + t.integer('effective_threshold').notNullable(); + // The tracker's Misra-Gries counter value at cap time: a LOWER + // BOUND on the number of matching-signature pages seen for this + // shape, not an exact observation count (see `DedupeCapTracker`). + t.integer('observed_count').notNullable(); + t.integer('detected_at').notNullable(); + // NULL until `crawlEnd` finalizes it (see + // `Crawler#getDedupeCapRejections`). Unlike `network_outages.ended_at`, + // a NULL here has no ambiguous "still ongoing" reading — a + // crawl that never reached `crawlEnd` simply left the count + // undetermined, so no boot-time reconciliation pass is needed + // (readers display "unknown", not "0" or "unbounded"). + t.integer('rejected_count').nullable(); + }); + } + if (!(await instance.schema.hasTable('analysis_text_refs'))) { await instance.raw(` CREATE TABLE analysis_text_refs ( diff --git a/packages/@nitpicker/crawler/src/archive/database.ts b/packages/@nitpicker/crawler/src/archive/database.ts index 3b27f5a2..56358e92 100644 --- a/packages/@nitpicker/crawler/src/archive/database.ts +++ b/packages/@nitpicker/crawler/src/archive/database.ts @@ -17,6 +17,7 @@ import type { DB_Redirect, DB_Resource, DatabaseEvent, + InsertDedupeCapEventParams, InsertNetworkOutageParams, InventoryRunMeta, PageFilter, @@ -49,6 +50,10 @@ import { getName as getNameOp } from './db-ops/config/get-name.js'; import { setConfig as setConfigOp } from './db-ops/config/set-config.js'; import { updateConfig as updateConfigOp } from './db-ops/config/update-config.js'; import { replaceConsoleLogs as replaceConsoleLogsOp } from './db-ops/console-logs/replace-console-logs.js'; +import { accumulateDedupeCapRejectedCount as accumulateDedupeCapRejectedCountOp } from './db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js'; +import { finalizeDedupeCapEvent as finalizeDedupeCapEventOp } from './db-ops/dedupe-cap/finalize-dedupe-cap-event.js'; +import { insertDedupeCapEvent as insertDedupeCapEventOp } from './db-ops/dedupe-cap/insert-dedupe-cap-event.js'; +import { listDedupeCapShapeKeys as listDedupeCapShapeKeysOp } from './db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js'; import { insertCrawlError as insertCrawlErrorOp } from './db-ops/errors/insert-crawl-error.js'; import { insertPageError as insertPageErrorOp } from './db-ops/errors/insert-page-error.js'; import { listDnsBurnedHostCandidates as listDnsBurnedHostCandidatesOp } from './db-ops/errors/list-dns-burned-host-candidates.js'; @@ -153,6 +158,25 @@ export class Database extends EventEmitter { }); } + /** + * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row, + * looked up by `shape_key` rather than `id`. Delegates to + * {@link accumulateDedupeCapRejectedCountOp}. + * @param shapeKey - The capped shape whose rejection count to accumulate. + * @param rejectedCount - Additional anchors rejected for this shape in the current session. + */ + async accumulateDedupeCapRejectedCount( + shapeKey: string, + rejectedCount: number, + ): Promise { + return emitErrorAndRetry( + this, + 'Database.accumulateDedupeCapRejectedCount', + async () => + await accumulateDedupeCapRejectedCountOp(this.#instance, shapeKey, rejectedCount), + retrySetting, + ); + } /** * Forces a WAL checkpoint, writing all pending WAL data back to the main * database file. Delegates to {@link checkpointOp}. @@ -181,6 +205,22 @@ export class Database extends EventEmitter { async destroy() { await destroyOp(this.#instance); } + /** + * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a + * no-op if the row is already finalized. Delegates to + * {@link finalizeDedupeCapEventOp}. + * @param id - The `dedupe_cap_events.id` to finalize. + * @param rejectedCount - Number of anchors rejected for this shape after it capped. + */ + async finalizeDedupeCapEvent(id: number, rejectedCount: number): Promise { + return emitErrorAndRetry( + this, + 'Database.finalizeDedupeCapEvent', + async () => await finalizeDedupeCapEventOp(this.#instance, id, rejectedCount), + retrySetting, + ); + } + /** * Retrieves all anchors (outgoing links) on a specific page. * Delegates to {@link getAnchorsOnPageOp}. @@ -609,6 +649,20 @@ export class Database extends EventEmitter { retrySetting, ); } + /** + * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events` + * journal. Delegates to {@link insertDedupeCapEventOp}. + * @param params - The newly-capped shape's fields to record. + * @returns The autoincremented `id` of the newly-inserted row. + */ + async insertDedupeCapEvent(params: InsertDedupeCapEventParams): Promise { + return emitErrorAndRetry( + this, + 'Database.insertDedupeCapEvent', + async () => await insertDedupeCapEventOp(this.#instance, params), + retrySetting, + ); + } /** * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows. * Delegates to {@link insertInventoryResourcesOp}. @@ -717,6 +771,20 @@ export class Database extends EventEmitter { ); } + /** + * Every distinct `dedupe_cap_events.shape_key` recorded in this archive. + * Delegates to {@link listDedupeCapShapeKeysOp}. + * @returns Distinct shape keys already confirmed capped, or `[]` on an + * archive that predates `dedupe_cap_events` or has recorded none. + */ + async listDedupeCapShapeKeys(): Promise { + return emitErrorAndRetry( + this, + 'Database.listDedupeCapShapeKeys', + async () => await listDedupeCapShapeKeysOp(this.#instance), + retrySetting, + ); + } /** * Hostnames whose `crawl_errors` history is consistently DNS failures and * for which no recent 2xx-3xx page or resource is recorded. @@ -731,6 +799,7 @@ export class Database extends EventEmitter { retrySetting, ); } + /** * Lists every recorded outage as a resolved {@link OutageWindow}. * Delegates to {@link listNetworkOutagesOp}. diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.spec.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.spec.ts new file mode 100644 index 00000000..2f7a0e7a --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.spec.ts @@ -0,0 +1,96 @@ +import type { Knex } from 'knex'; + +import knex from 'knex'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAdjunctTables } from '../../create-adjunct-tables.js'; +import { createEntityTables } from '../../create-entity-tables.js'; +import { createRefTables } from '../../create-ref-tables.js'; +import { LibsqlDialect } from '../../libsql-dialect.js'; + +import { accumulateDedupeCapRejectedCount } from './accumulate-dedupe-cap-rejected-count.js'; +import { insertDedupeCapEvent } from './insert-dedupe-cap-event.js'; + +describe('accumulateDedupeCapRejectedCount', () => { + let db: Knex; + + beforeEach(async () => { + db = knex({ + client: LibsqlDialect, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createRefTables(db); + await createEntityTables(db); + await createAdjunctTables(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it('treats a still-NULL rejected_count as 0 and sets it to the given amount', async () => { + const id = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + + await accumulateDedupeCapRejectedCount(db, 'example.com/a/{n}/', 7); + + const row = await db('dedupe_cap_events').where({ id }).first(); + expect(row.rejected_count).toBe(7); + }); + + it('adds onto an already-finalized rejected_count instead of overwriting it', async () => { + const id = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + await db('dedupe_cap_events').where({ id }).update({ rejected_count: 40 }); + + await accumulateDedupeCapRejectedCount(db, 'example.com/a/{n}/', 5); + + const row = await db('dedupe_cap_events').where({ id }).first(); + expect(row.rejected_count).toBe(45); + }); + + it('only updates the row matching the given shape_key, leaving others untouched', async () => { + const firstId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + const secondId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/b/{n}/', + sampleUrl: 'https://example.com/b/1/', + bodyHash: Buffer.from('b'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 2000, + }); + + await accumulateDedupeCapRejectedCount(db, 'example.com/a/{n}/', 3); + + const untouchedRow = await db('dedupe_cap_events').where({ id: secondId }).first(); + expect(untouchedRow.rejected_count).toBeNull(); + const updatedRow = await db('dedupe_cap_events').where({ id: firstId }).first(); + expect(updatedRow.rejected_count).toBe(3); + }); + + it('is a no-op when no row matches the shape_key', async () => { + await expect( + accumulateDedupeCapRejectedCount(db, 'example.com/nonexistent/{n}/', 3), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.ts new file mode 100644 index 00000000..f7e491a3 --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.ts @@ -0,0 +1,29 @@ +import type { Knex } from 'knex'; + +/** + * Adds `rejectedCount` onto the `rejected_count` of the `dedupe_cap_events` + * row for `shapeKey`, treating a still-`NULL` count as `0`. Unlike + * `finalize-dedupe-cap-event.ts` (which stamps a session's own newly-capped + * shape exactly once, guarded by `whereNull`), this targets a shape that + * capped in an EARLIER session and was preloaded into `DedupeCapTracker`'s + * sticky set (see `DedupeCapTracker`'s constructor JSDoc) — gate rejections + * for such a shape still occur in the current session, but no `dedupeCap` + * event (and thus no new row) is ever emitted for it, since the tracker + * short-circuits on an already-sticky shape before `observe` runs. Matches + * by `shape_key` rather than `id` because the caller (`CrawlerOrchestrator`) + * only has the shape key for a preloaded-sticky shape, never its row id. + * @param knex - Knex query builder connected to the archive DB. + * @param shapeKey - The capped shape whose rejection count to accumulate. + * @param rejectedCount - Additional anchors rejected for this shape in the current session. + */ +export async function accumulateDedupeCapRejectedCount( + knex: Knex, + shapeKey: string, + rejectedCount: number, +): Promise { + await knex('dedupe_cap_events') + .where({ shape_key: shapeKey }) + .update({ + rejected_count: knex.raw('COALESCE(rejected_count, 0) + ?', [rejectedCount]), + }); +} diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.spec.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.spec.ts new file mode 100644 index 00000000..2ca2ac73 --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.spec.ts @@ -0,0 +1,92 @@ +import type { Knex } from 'knex'; + +import knex from 'knex'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAdjunctTables } from '../../create-adjunct-tables.js'; +import { createEntityTables } from '../../create-entity-tables.js'; +import { createRefTables } from '../../create-ref-tables.js'; +import { LibsqlDialect } from '../../libsql-dialect.js'; + +import { finalizeDedupeCapEvent } from './finalize-dedupe-cap-event.js'; +import { insertDedupeCapEvent } from './insert-dedupe-cap-event.js'; + +describe('finalizeDedupeCapEvent', () => { + let db: Knex; + + beforeEach(async () => { + db = knex({ + client: LibsqlDialect, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createRefTables(db); + await createEntityTables(db); + await createAdjunctTables(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it('sets rejected_count on the target row, leaving every other column untouched', async () => { + const id = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + const before = await db('dedupe_cap_events').where({ id }).first(); + + await finalizeDedupeCapEvent(db, id, 12_345); + + const after = await db('dedupe_cap_events').where({ id }).first(); + expect(after.rejected_count).toBe(12_345); + expect(after.shape_key).toBe(before.shape_key); + expect(after.sample_url).toBe(before.sample_url); + expect(after.effective_threshold).toBe(before.effective_threshold); + expect(after.observed_count).toBe(before.observed_count); + }); + + it('is idempotent — finalizing an already-finalized row a second time does not change rejected_count', async () => { + const id = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + await finalizeDedupeCapEvent(db, id, 100); + await finalizeDedupeCapEvent(db, id, 999); + + const row = await db('dedupe_cap_events').where({ id }).first(); + expect(row.rejected_count).toBe(100); + }); + + it('does not affect a different, still-unfinalized row', async () => { + const firstId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + const secondId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/b/{n}/', + sampleUrl: 'https://example.com/b/1/', + bodyHash: Buffer.from('b'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 2000, + }); + + await finalizeDedupeCapEvent(db, firstId, 100); + + const secondRow = await db('dedupe_cap_events').where({ id: secondId }).first(); + expect(secondRow.rejected_count).toBeNull(); + }); +}); diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.ts new file mode 100644 index 00000000..7254a29e --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.ts @@ -0,0 +1,21 @@ +import type { Knex } from 'knex'; + +/** + * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — but + * ONLY if it is still unset. The `whereNull('rejected_count')` guard makes + * this idempotent, mirroring `close-network-outage.ts`'s `ended_at` guard: a + * second call matches zero rows and is a silent no-op rather than + * overwriting an already-finalized count. + * @param knex - Knex query builder connected to the archive DB. + * @param id - The `dedupe_cap_events.id` to finalize. + * @param rejectedCount - Number of anchors rejected for this shape after it capped. + */ +export async function finalizeDedupeCapEvent( + knex: Knex, + id: number, + rejectedCount: number, +): Promise { + await knex('dedupe_cap_events').where({ id }).whereNull('rejected_count').update({ + rejected_count: rejectedCount, + }); +} diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.spec.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.spec.ts new file mode 100644 index 00000000..50e131b1 --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.spec.ts @@ -0,0 +1,90 @@ +import type { Knex } from 'knex'; + +import knex from 'knex'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAdjunctTables } from '../../create-adjunct-tables.js'; +import { createEntityTables } from '../../create-entity-tables.js'; +import { createRefTables } from '../../create-ref-tables.js'; +import { LibsqlDialect } from '../../libsql-dialect.js'; + +import { insertDedupeCapEvent } from './insert-dedupe-cap-event.js'; + +describe('insertDedupeCapEvent', () => { + let db: Knex; + + beforeEach(async () => { + db = knex({ + client: LibsqlDialect, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createRefTables(db); + await createEntityTables(db); + await createAdjunctTables(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it('inserts exactly one row with rejected_count NULL', async () => { + await insertDedupeCapEvent(db, { + shapeKey: 'example.com/news/date/{n}/', + sampleUrl: 'https://example.com/news/date/2024/', + bodyHash: Buffer.from('hash'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + const rows = await db('dedupe_cap_events').select('*'); + expect(rows).toHaveLength(1); + expect(rows[0]?.shape_key).toBe('example.com/news/date/{n}/'); + expect(rows[0]?.sample_url).toBe('https://example.com/news/date/2024/'); + expect(rows[0]?.effective_threshold).toBe(50); + expect(rows[0]?.observed_count).toBe(100); + expect(rows[0]?.detected_at).toBe(1000); + expect(rows[0]?.rejected_count).toBeNull(); + }); + + it('returns the autoincremented id of the new row', async () => { + const firstId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 1000, + }); + const secondId = await insertDedupeCapEvent(db, { + shapeKey: 'example.com/b/{n}/', + sampleUrl: 'https://example.com/b/1/', + bodyHash: Buffer.from('b'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 2000, + }); + expect(secondId).toBeGreaterThan(firstId); + }); + + it('allows multiple distinct shapes to be inserted in the same crawl', async () => { + await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 1000, + }); + await insertDedupeCapEvent(db, { + shapeKey: 'example.com/b/{n}/', + sampleUrl: 'https://example.com/b/1/', + bodyHash: Buffer.from('b'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 2000, + }); + const rows = await db('dedupe_cap_events').select('*'); + expect(rows).toHaveLength(2); + }); +}); diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.ts new file mode 100644 index 00000000..0f985cef --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.ts @@ -0,0 +1,36 @@ +import type { InsertDedupeCapEventParams } from '../../types.js'; +import type { Knex } from 'knex'; + +/** + * Appends one row to the `dedupe_cap_events` journal, with `rejected_count` + * left `NULL` — the row starts life without a finalized rejection count. + * + * Called the instant `DedupeCapTracker#observe` confirms a URL shape as a + * same-cluster trap (the `dedupeCap` event). See + * `finalize-dedupe-cap-event.ts` for how `rejected_count` is later set. + * @param knex - Knex query builder connected to the archive DB. + * @param params - The newly-capped shape's fields to record. + * @returns The autoincremented `id` of the newly-inserted row. + */ +export async function insertDedupeCapEvent( + knex: Knex, + params: InsertDedupeCapEventParams, +): Promise { + const inserted = await knex + .from('dedupe_cap_events') + .insert({ + shape_key: params.shapeKey, + sample_url: params.sampleUrl, + body_hash: params.bodyHash, + effective_threshold: params.effectiveThreshold, + observed_count: params.observedCount, + detected_at: params.detectedAt, + rejected_count: null, + }) + .returning('id'); + const id = inserted[0]?.id; + if (typeof id !== 'number') { + throw new TypeError('insertDedupeCapEvent: INSERT returned no row id'); + } + return id; +} diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.spec.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.spec.ts new file mode 100644 index 00000000..86b25b06 --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.spec.ts @@ -0,0 +1,64 @@ +import type { Knex } from 'knex'; + +import knex from 'knex'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAdjunctTables } from '../../create-adjunct-tables.js'; +import { createEntityTables } from '../../create-entity-tables.js'; +import { createRefTables } from '../../create-ref-tables.js'; +import { LibsqlDialect } from '../../libsql-dialect.js'; + +import { insertDedupeCapEvent } from './insert-dedupe-cap-event.js'; +import { listDedupeCapShapeKeys } from './list-dedupe-cap-shape-keys.js'; + +describe('listDedupeCapShapeKeys', () => { + let db: Knex; + + beforeEach(async () => { + db = knex({ + client: LibsqlDialect, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createRefTables(db); + await createEntityTables(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it('legacy archive(dedupe_cap_eventsテーブルなし)は空配列を返す', async () => { + expect(await listDedupeCapShapeKeys(db)).toEqual([]); + }); + + it('記録が無ければ空配列を返す', async () => { + await createAdjunctTables(db); + expect(await listDedupeCapShapeKeys(db)).toEqual([]); + }); + + it('記録済みのshape_keyを重複なく返す', async () => { + await createAdjunctTables(db); + await insertDedupeCapEvent(db, { + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 1000, + }); + await insertDedupeCapEvent(db, { + shapeKey: 'example.com/b/{n}/', + sampleUrl: 'https://example.com/b/1/', + bodyHash: Buffer.from('b'), + effectiveThreshold: 10, + observedCount: 10, + detectedAt: 2000, + }); + + const shapeKeys = await listDedupeCapShapeKeys(db); + expect(new Set(shapeKeys)).toEqual( + new Set(['example.com/a/{n}/', 'example.com/b/{n}/']), + ); + }); +}); diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.ts b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.ts new file mode 100644 index 00000000..32b33993 --- /dev/null +++ b/packages/@nitpicker/crawler/src/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.ts @@ -0,0 +1,31 @@ +import type { Knex } from 'knex'; + +/** + * Every distinct `dedupe_cap_events.shape_key` recorded in this archive — + * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set + * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap + * this crawl already paid the cost of discovering once is not re-admitted + * in a later session. Fresh (non-resuming) crawls do not call this — there + * is no archive history to seed from. + * + * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is + * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays + * confirmed — there is no equivalent of "the host might have recovered + * since". + * + * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events` + * table (self-healed on next writer open, so this is never a permanent + * state) or that have recorded no capped shapes. + * @param knex - Knex query builder connected to the archive DB. + * @returns Distinct shape keys already confirmed capped. + */ +export async function listDedupeCapShapeKeys(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('dedupe_cap_events'); + if (!hasTable) { + return []; + } + const rows = (await knex('dedupe_cap_events').distinct('shape_key')) as { + shape_key: string; + }[]; + return rows.map((row) => row.shape_key); +} diff --git a/packages/@nitpicker/crawler/src/archive/types.ts b/packages/@nitpicker/crawler/src/archive/types.ts index 94fa7f05..62d496f7 100644 --- a/packages/@nitpicker/crawler/src/archive/types.ts +++ b/packages/@nitpicker/crawler/src/archive/types.ts @@ -197,6 +197,20 @@ export interface InsertNetworkOutageParams { triggerHostCount: number; } +/** + * Fields required to record a newly-capped URL shape via + * `Database.insertDedupeCapEvent`. camelCase, mapped to snake_case columns + * on write — same convention as {@link InsertNetworkOutageParams}. + */ +export interface InsertDedupeCapEventParams { + shapeKey: string; + sampleUrl: string; + bodyHash: Buffer; + effectiveThreshold: number; + observedCount: number; + detectedAt: number; +} + /** * Filter type for querying pages from the database. * diff --git a/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts b/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts index b11f696a..a91c8a7e 100644 --- a/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts @@ -32,6 +32,14 @@ vi.mock('./crawler/crawler.js', () => { /** No-op abort to satisfy the orchestrator's interface. */ abort() {} + /** + * Returns an empty rejection map, matching a crawl where + * `--dedupe-cap` never capped any shape. + * @returns An empty `Map`. + */ + getDedupeCapRejections() { + return new Map(); + } /** * Returns an empty undead-PID list. * @returns An empty array. @@ -989,6 +997,7 @@ describe('CrawlerOrchestrator.inventory: cumulative pagesScraped offset', () => getResourceUrlList: vi.fn(() => Promise.resolve([])), getScrapedHtmlPageCount: vi.fn(() => Promise.resolve(140_000)), listDnsBurnedHostCandidates: vi.fn(() => Promise.resolve([])), + listDedupeCapShapeKeys: vi.fn(() => Promise.resolve([])), setUrlOrder: vi.fn(() => Promise.resolve()), close: vi.fn(() => Promise.resolve()), setResources: vi.fn(() => Promise.resolve()), @@ -1028,6 +1037,180 @@ describe('CrawlerOrchestrator.inventory: cumulative pagesScraped offset', () => }); }); +describe('CrawlerOrchestrator.crawling: dedupeCap event handling (issue #208)', () => { + it('dedupeCap → archive.insertDedupeCapEvent が呼ばれ、その id が crawlEnd での finalizeDedupeCapEvent に使われる', async () => { + const insertDedupeCapEvent = vi.fn(() => Promise.resolve(99)); + const finalizeDedupeCapEvent = vi.fn(() => Promise.resolve()); + const accumulateDedupeCapRejectedCount = vi.fn(() => Promise.resolve()); + const fakeArchive = { + on: vi.fn(), + setConfig: vi.fn(() => Promise.resolve()), + getConfig: vi.fn(() => Promise.resolve({ analyze: [] })), + setUrlOrder: vi.fn(() => Promise.resolve()), + getResourceByUrl: vi.fn(() => Promise.resolve(null)), + insertDedupeCapEvent, + finalizeDedupeCapEvent, + accumulateDedupeCapRejectedCount, + filePath: '/tmp/orchestrator-dedupe-cap-test.nitpicker', + } as unknown as Archive; + + const archiveModule = await import('./archive/archive.js'); + vi.spyOn(archiveModule.default, 'create').mockResolvedValueOnce(fakeArchive); + + fakeCrawlerDriver = (crawler) => { + crawler.handlers.get('dedupeCap')?.({ + shapeKey: 'example.com/news/{n}/', + sampleUrl: 'https://example.com/news/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 1, + observedCount: 2, + } as never); + ( + crawler as unknown as { getDedupeCapRejections: () => Map } + ).getDedupeCapRejections = () => new Map([['example.com/news/{n}/', 5]]); + crawler.handlers.get('crawlEnd')?.(undefined as never); + }; + + await CrawlerOrchestrator.crawling(['https://example.com/'], { + cwd: '/tmp', + filePath: '/tmp/orchestrator-dedupe-cap-test.nitpicker', + }); + + expect(insertDedupeCapEvent).toHaveBeenCalledWith( + expect.objectContaining({ + shapeKey: 'example.com/news/{n}/', + sampleUrl: 'https://example.com/news/1/', + effectiveThreshold: 1, + observedCount: 2, + }), + ); + expect(finalizeDedupeCapEvent).toHaveBeenCalledWith(99, 5); + expect(accumulateDedupeCapRejectedCount).not.toHaveBeenCalled(); + }); + + it('このセッションでdedupeCapイベントが発火していないshape(プリロード済みsticky)の拒否数はaccumulateDedupeCapRejectedCountでshape_key照合により加算される', async () => { + // A shape preloaded into DedupeCapTracker's sticky set from an earlier + // session's `dedupe_cap_events` row never re-fires `dedupeCap` this + // session (the tracker short-circuits before `observe` runs), so + // `#dedupeCapEventIds` has no entry for it — yet gate rejections still + // accumulate. Regression guard: this must not be silently dropped. + const insertDedupeCapEvent = vi.fn(() => Promise.resolve(99)); + const finalizeDedupeCapEvent = vi.fn(() => Promise.resolve()); + const accumulateDedupeCapRejectedCount = vi.fn(() => Promise.resolve()); + const fakeArchive = { + on: vi.fn(), + setConfig: vi.fn(() => Promise.resolve()), + getConfig: vi.fn(() => Promise.resolve({ analyze: [] })), + setUrlOrder: vi.fn(() => Promise.resolve()), + getResourceByUrl: vi.fn(() => Promise.resolve(null)), + insertDedupeCapEvent, + finalizeDedupeCapEvent, + accumulateDedupeCapRejectedCount, + filePath: '/tmp/orchestrator-dedupe-cap-preloaded-test.nitpicker', + } as unknown as Archive; + + const archiveModule = await import('./archive/archive.js'); + vi.spyOn(archiveModule.default, 'create').mockResolvedValueOnce(fakeArchive); + + fakeCrawlerDriver = (crawler) => { + // No `dedupeCap` event fired this session — simulates a shape that + // was already sticky from a prior session's preload. + ( + crawler as unknown as { getDedupeCapRejections: () => Map } + ).getDedupeCapRejections = () => new Map([['example.com/archived-trap/{n}/', 3]]); + crawler.handlers.get('crawlEnd')?.(undefined as never); + }; + + await CrawlerOrchestrator.crawling(['https://example.com/'], { + cwd: '/tmp', + filePath: '/tmp/orchestrator-dedupe-cap-preloaded-test.nitpicker', + }); + + expect(insertDedupeCapEvent).not.toHaveBeenCalled(); + expect(finalizeDedupeCapEvent).not.toHaveBeenCalled(); + expect(accumulateDedupeCapRejectedCount).toHaveBeenCalledWith( + 'example.com/archived-trap/{n}/', + 3, + ); + }); +}); + +describe('CrawlerOrchestrator.inventory: dedupeCap sticky preload wiring (issue #208)', () => { + it('archive.listDedupeCapShapeKeys() の結果がCrawlerのpreloadedStickyShapeKeysオプションへ渡される', async () => { + const fakeArchive = { + on: vi.fn(), + getConfig: vi.fn(() => + Promise.resolve({ + name: 'fixture', + baseUrl: 'https://example.com', + roots: ['https://example.com/'], + recursive: true, + interval: 0, + image: false, + fetchExternal: false, + parallels: 1, + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 10, + retry: 0, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: true, + }), + ), + getCrawlingState: vi.fn(() => Promise.resolve({ scraped: [], pending: [] })), + getExistingPageUrls: vi.fn(() => Promise.resolve([])), + getExistingResourceUrls: vi.fn(() => Promise.resolve([])), + getResourceUrlList: vi.fn(() => Promise.resolve([])), + getScrapedHtmlPageCount: vi.fn(() => Promise.resolve(0)), + listDnsBurnedHostCandidates: vi.fn(() => Promise.resolve([])), + listDedupeCapShapeKeys: vi.fn(() => + Promise.resolve(['example.com/old-trap/{n}/', 'example.com/other-trap/{v}']), + ), + setUrlOrder: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()), + setResources: vi.fn(() => Promise.resolve()), + insertInventorySeeds: vi.fn(() => Promise.resolve()), + insertInventoryResources: vi.fn(() => Promise.resolve()), + addError: vi.fn(() => Promise.resolve()), + recordInventoryRun: vi.fn(() => Promise.resolve(1)), + } as unknown as Archive; + + const archiveModule = await import('./archive/archive.js'); + vi.spyOn(archiveModule.default, 'open').mockResolvedValueOnce(fakeArchive); + + fakeCrawlerDriver = (crawler) => { + crawler.handlers.get('crawlEnd')?.(undefined as never); + }; + + const testCwd = path.resolve('/tmp/inventory-dedupe-cap-preload-test'); + await fs.mkdir(testCwd, { recursive: true }); + const fixturePath = path.join(testCwd, 'fixture.nitpicker'); + await fs.writeFile(fixturePath, ''); + + try { + await CrawlerOrchestrator.inventory( + 'fixture.nitpicker', + ['https://example.com/new-page.html'], + { cwd: testCwd }, + ); + } finally { + await fs.rm(testCwd, { recursive: true, force: true }); + } + + expect(fakeArchive.listDedupeCapShapeKeys).toHaveBeenCalledTimes(1); + expect(fakeCrawlerConstructorCalls).toHaveLength(1); + expect(fakeCrawlerConstructorCalls[0]).toMatchObject({ + preloadedStickyShapeKeys: [ + 'example.com/old-trap/{n}/', + 'example.com/other-trap/{v}', + ], + }); + }); +}); + describe('CrawlerOrchestrator: openPluginData regression guard (issue #99)', () => { // `Archive.open`'s default extracts only `db.sqlite`; `write()` re-tars // the whole tmpDir, so any writer path that skips `openPluginData: true` diff --git a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts index e717470e..03b8765e 100644 --- a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts +++ b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts @@ -126,6 +126,21 @@ interface CrawlConfig extends Config { * `options` so an E2E test can inject it via the public API. */ networkProbe: NetworkProbe | null; + + /** See {@link CrawlerOptions.dedupeCap}. `null`/omitted disables the feature. */ + dedupeCap: number | null; + + /** See {@link CrawlerOptions.dedupeMapCap}. Omitted falls through to `Crawler`'s own default. */ + dedupeMapCap: number; + + /** + * See {@link CrawlerOptions.preloadedStickyShapeKeys}. Set internally by + * the four resuming-session static methods + * (`append`/`inventory`/`retryFailed`/`resume`) via + * `#preloadDedupeCapStickyShapeKeys`; not part of the public options a + * caller of those methods passes directly. + */ + preloadedStickyShapeKeys: readonly string[]; } /** @@ -174,6 +189,15 @@ export class CrawlerOrchestrator extends EventEmitter { readonly #archive: Archive; /** The crawler engine that discovers and scrapes pages. */ readonly #crawler: Crawler; + /** + * `dedupe_cap_events.id` for each shape confirmed capped this session, so + * `crawlEnd` can look up the right row to finalize with + * `Crawler#getDedupeCapRejections`'s counts. A `Map` (not a single + * scalar like {@link #openNetworkOutageId}) because, unlike a network + * outage, more than one shape can be capped simultaneously within one + * crawl. + */ + readonly #dedupeCapEventIds = new Map(); /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */ readonly #fromList: boolean; /** @@ -274,6 +298,13 @@ export class CrawlerOrchestrator extends EventEmitter { networkOutageHostThreshold: options?.networkOutageHostThreshold, networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs, networkProbe: options?.networkProbe ?? null, + dedupeCap: options?.dedupeCap ?? null, + dedupeMapCap: options?.dedupeMapCap, + // Only the four resuming-session static methods + // (`append`/`inventory`/`retryFailed`/`resume`) pass this — a + // fresh `crawling()` has no archive history to seed from (see + // `#preloadDedupeCapStickyShapeKeys`'s JSDoc). + preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [], }); } @@ -436,6 +467,34 @@ export class CrawlerOrchestrator extends EventEmitter { .catch((error) => reject(error)); }); + this.#crawler.on( + 'dedupeCap', + ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => { + crawlerLog( + 'Dedupe cap reached: shapeKey=%s effectiveThreshold=%d observedCount=%d', + shapeKey, + effectiveThreshold, + observedCount, + ); + console.error( + `[dedupe-cap] same-cluster trap confirmed: ${shapeKey} (sample: ${sampleUrl})`, + ); + writeQueue + .enqueue(async () => { + const id = await this.#archive.insertDedupeCapEvent({ + shapeKey, + sampleUrl, + bodyHash, + effectiveThreshold, + observedCount, + detectedAt: Date.now(), + }); + this.#dedupeCapEventIds.set(shapeKey, id); + }) + .catch((error) => reject(error)); + }, + ); + this.#crawler.on('response', ({ resource, source }) => { writeQueue .enqueue(() => this.#archive.setResources(resource, source)) @@ -455,6 +514,43 @@ export class CrawlerOrchestrator extends EventEmitter { }); this.#crawler.on('crawlEnd', () => { + // Deferred to INSIDE a queued closure, not read synchronously + // here, for the same reason `networkOutageRecovered`'s handler + // defers reading `#openNetworkOutageId`: a `dedupeCap` event's + // INSERT closure may still be queued (not yet executed) at the + // instant `crawlEnd` fires. `WriteQueue` runs enqueued + // operations in submission order, so by the time THIS closure + // executes, every earlier-queued `dedupeCap` INSERT has + // already completed and `#dedupeCapEventIds` is reliably + // populated. + writeQueue + .enqueue(async () => { + const rejections = this.#crawler.getDedupeCapRejections(); + await Promise.all( + [...rejections].map(([shapeKey, rejectedCount]) => { + const id = this.#dedupeCapEventIds.get(shapeKey); + // A shape capped THIS session has an id here (the + // `dedupeCap` event always enqueues an INSERT before any + // rejection for that shape can be counted) and is + // finalized once via its row id. A shape with no id was + // never observed this session at all — it was preloaded + // into `DedupeCapTracker`'s sticky set from an EARLIER + // session's `dedupe_cap_events` row (see + // `#preloadDedupeCapStickyShapeKeys`'s JSDoc), so gate + // rejections still accumulate for it but no new row (and + // thus no id) is ever created. That earlier row's count is + // accumulated onto by shape_key instead of overwritten. + return id === undefined + ? this.#archive.accumulateDedupeCapRejectedCount( + shapeKey, + rejectedCount, + ) + : this.#archive.finalizeDedupeCapEvent(id, rejectedCount); + }), + ); + }) + .catch((error) => reject(error)); + writeQueue .drain() .then(() => resolve()) @@ -694,9 +790,14 @@ export class CrawlerOrchestrator extends EventEmitter { } await archive.repromoteExternalPages(scopeMap, archived); + // Seed the sticky set from prior sessions' confirmed traps so + // `--append` does not pay the cost of re-discovering them (see + // `DedupeCapTracker`'s constructor JSDoc). + const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys(); const orchestrator = new CrawlerOrchestrator(archive, { ...mergedConfig, roots: mergedRoots, + preloadedStickyShapeKeys, }); const { scraped, pending } = await archive.getCrawlingState(); const resources = await archive.getResourceUrlList(); @@ -1059,6 +1160,16 @@ export class CrawlerOrchestrator extends EventEmitter { inventoryMode: { seedUrls: seedSet }, }; if (htmlSeeds.length > 0) { + // Seed the sticky set from prior sessions' confirmed traps + // so `--inventory` does not pay the cost of + // re-discovering them (see `DedupeCapTracker`'s + // constructor JSDoc). Scoped to this branch only, + // matching `#preloadDnsBurnedHostCache`'s scoping below — + // the fallback (non-HTML-only) branch never calls + // `orchestrator.crawling(...)`, so the tracker is never + // consulted there. + orchestratorOptions.preloadedStickyShapeKeys = + await archive.listDedupeCapShapeKeys(); const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions); // Re-read pending *after* the pre-insert so the strict- // pending set includes the freshly inserted @@ -1235,7 +1346,14 @@ export class CrawlerOrchestrator extends EventEmitter { log('Archive %s', absFilePath); log('Reset %d failed page(s)', resetUrls.length); - const orchestrator = new CrawlerOrchestrator(archive, config); + // Seed the sticky set from prior sessions' confirmed traps so + // `--retry-failed` does not pay the cost of re-discovering + // them (see `DedupeCapTracker`'s constructor JSDoc). + const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys(); + const orchestrator = new CrawlerOrchestrator(archive, { + ...config, + preloadedStickyShapeKeys, + }); const { scraped, pending } = await archive.getCrawlingState(); const resources = await archive.getResourceUrlList(); const pagesScrapedOffset = await archive.getScrapedHtmlPageCount(); @@ -1289,9 +1407,14 @@ export class CrawlerOrchestrator extends EventEmitter { ) { const archive = await Archive.resume(stubPath); const archivedConfig = await archive.getConfig(); + // Seed the sticky set from prior sessions' confirmed traps so + // `--resume` does not pay the cost of re-discovering them (see + // `DedupeCapTracker`'s constructor JSDoc). + const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys(); const config = { ...archivedConfig, ...cleanObject(options), + preloadedStickyShapeKeys, }; const orchestrator = new CrawlerOrchestrator(archive, config); const _url = await archive.getUrl(); diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts b/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts index e140386b..4e3047c5 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts @@ -104,6 +104,55 @@ async function driveDeal() { return { push, unshift }; } +/** + * Variant of {@link driveDeal} whose `push`/`unshift` spies feed newly + * discovered URLs (e.g. predicted pagination batches) back into the same + * queue instead of merely recording the call, draining until empty. Needed + * for scenarios where a later assertion depends on a URL discovered + * mid-crawl actually being scraped (`fetchDestination` called again for + * it) — `driveDeal`'s single pass over the initial `items` never reaches + * such URLs. + * + * Deduplicates by `withoutHashAndAuth` before dealing, mirroring the real + * `@d-zero/dealer`'s `seen`-set contract (each URL is processed at most + * once per crawl) — without this, a page whose anchors are re-fetched with + * the SAME anchor list every time (as a test fixture's `mockImplementation` + * naturally does, since it keys off the requested URL rather than crawl + * progress) would have those anchors re-enqueued and re-dealt forever. + * @returns The shared `push` and `unshift` spies passed to the worker factory. + */ +async function driveDealRecursive() { + const push = vi.fn((...urls: unknown[]) => { + pending.push(...urls); + return Promise.resolve(); + }); + const unshift = vi.fn((...urls: unknown[]) => { + pending.unshift(...urls); + return Promise.resolve(); + }); + let pending: unknown[] = []; + const seen = new Set(); + const { deal } = await import('@d-zero/dealer'); + vi.mocked(deal).mockImplementation(async (items, factory) => { + pending = [...(items as unknown[])]; + while (pending.length > 0) { + const item = pending.shift(); + const key = (item as ExURL).withoutHashAndAuth; + if (seen.has(key)) continue; + seen.add(key); + const noop = () => {}; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- deal factory signature is complex; cast is intentional in test + const workFn = (factory as Function)(item, noop, 0, noop, push, unshift) as + | (() => Promise) + | undefined; + if (workFn) { + await workFn(); + } + } + }); + return { push, unshift }; +} + /** * Default crawler options for testing. */ @@ -857,6 +906,307 @@ describe('Crawler', () => { 'https://example.com/page/6', ]); }); + + it('--dedupe-cap: 既にcapped済みのshapeを持つ新規anchorはenqueueされない', async () => { + const { push, unshift } = await driveDeal(); + const { default: Crawler } = await import('./crawler.js'); + const { computeShapeKey } = await import('./dedupe/compute-shape-key.js'); + + const htmlAnchor = parseUrl('https://example.com/about')!; + const assetAnchor = parseUrl('https://example.com/doc.pdf')!; + const cappedShapeKey = computeShapeKey(htmlAnchor.withoutHashAndAuth)!; + + const fetchDestMod = await import('./fetch-destination.js'); + vi.spyOn(fetchDestMod, 'fetchDestination').mockResolvedValue( + nonHtmlResultWithAnchors([ + { href: htmlAnchor, textContent: 'About' }, + { href: assetAnchor, textContent: 'PDF' }, + ]) as Awaited>, + ); + + const crawler = new Crawler({ + ...defaultOptions, + dedupeCap: 100, + preloadedStickyShapeKeys: [cappedShapeKey], + }); + let crawlEndEmitted = false; + crawler.on('crawlEnd', () => { + crawlEndEmitted = true; + }); + + crawler.start([parseUrl('https://example.com/feed.xml')!]); + + await vi.waitFor(() => { + expect(crawlEndEmitted).toBe(true); + }); + + // htmlAnchor's shape was preloaded as capped (gate 1) → never enqueued. + expect(unshift).not.toHaveBeenCalled(); + // assetAnchor has a different shape → unaffected, still routed to push. + expect(push).toHaveBeenCalledWith(assetAnchor); + }); + }); + + describe('predicted-page content-duplicate discard (always-on, issue #208)', () => { + /** + * Minimal non-HTML PageData builder — a non-HTML `contentType` makes + * `#scrapePage` return the HEAD pre-flight result verbatim, skipping + * the browser entirely (same trick `nonHtmlResultWithAnchors` above + * uses), while still letting `html` carry real content so the A-3 + * body-hash comparison has something to compare. + * @param url - The page's own URL. + * @param anchors - Anchors to expose on the scraped page. + * @param html - The page's body content. + * @returns A PageData-shaped object for fetchDestination to resolve with. + */ + function nonHtmlPageData( + url: ExURL, + anchors: { href: ExURL; textContent: string }[], + html: string, + ) { + return { + url, + redirectPaths: [], + isTarget: false, + isExternal: false, + status: 200, + statusText: 'OK', + contentType: 'application/xml', + contentLength: 0, + responseHeaders: {}, + meta: { title: '' }, + anchorList: anchors, + imageList: [], + html, + isSkipped: false, + }; + } + + it('連続する予測ページの本文が同一なら2件目以降を破棄し、pageイベントを発火しない', async () => { + await driveDealRecursive(); + const { default: Crawler } = await import('./crawler.js'); + + const origin = parseUrl('https://example.com/feed.xml')!; + const page2 = parseUrl('https://example.com/page/2')!; + const page3 = parseUrl('https://example.com/page/3')!; + const page4 = parseUrl('https://example.com/page/4')!; + const page5 = parseUrl('https://example.com/page/5')!; + + const fetchDestMod = await import('./fetch-destination.js'); + vi.spyOn(fetchDestMod, 'fetchDestination').mockImplementation((args) => { + const href = (args as { url: ExURL }).url.withoutHashAndAuth; + if (href === page4.withoutHashAndAuth || href === page5.withoutHashAndAuth) { + // Both predicted pages render byte-for-byte identical bodies — + // the site ignores the extrapolated page number entirely. + return Promise.resolve( + nonHtmlPageData(parseUrl(href)!, [], '

duplicate

') as Awaited< + ReturnType + >, + ); + } + return Promise.resolve( + nonHtmlPageData( + origin, + [ + { href: page2, textContent: 'Page 2' }, + { href: page3, textContent: 'Page 3' }, + ], + '', + ) as Awaited>, + ); + }); + + // parallels: 2 → two predicted URLs of the same shape (page/4, page/5), + // dealt in order by driveDealRecursive so page/4 is scraped (and its + // body hash recorded) before page/5 is compared against it. + const crawler = new Crawler({ ...defaultOptions, parallels: 2 }); + const pages: CrawlerEventTypes['page'][] = []; + crawler.on('page', (p) => { + pages.push(p); + }); + let crawlEndEmitted = false; + crawler.on('crawlEnd', () => { + crawlEndEmitted = true; + }); + + crawler.start([origin]); + + await vi.waitFor(() => { + expect(crawlEndEmitted).toBe(true); + }); + + const pageUrls = pages.map((p) => p.result.url.withoutHashAndAuth); + expect(pageUrls).toContain(page4.withoutHashAndAuth); + expect(pageUrls).not.toContain(page5.withoutHashAndAuth); + }); + }); + + describe('same-cluster cap gate 2: JS-redirect direct enqueue (issue #208)', () => { + it('JS-redirectの着地先URLのshapeが既にcapped済みなら、addUrlクロージャ(gate 1)を経由せずにenqueueをブロックし拒否数を記録する', async () => { + await driveDeal(); + const { default: Crawler } = await import('./crawler.js'); + const { computeShapeKey } = await import('./dedupe/compute-shape-key.js'); + + const sourceUrl = parseUrl('https://example.com/redirector')!; + const destinationUrl = parseUrl('https://example.com/trap/99')!; + const cappedShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth)!; + + const fetchDestMod = await import('./fetch-destination.js'); + vi.spyOn(fetchDestMod, 'fetchDestination').mockResolvedValue({ + url: sourceUrl, + redirectPaths: [], + isTarget: true, + isExternal: false, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 0, + responseHeaders: {}, + meta: { title: '' }, + anchorList: [], + imageList: [], + html: '', + isSkipped: false, + } as Awaited>); + + // `_launchBrowserAndScrape` is the sanctioned test-only hook for + // driving the js-redirect cascade without a real browser (see its + // own JSDoc `@internal` note). + vi.spyOn( + Crawler.prototype as unknown as { + _launchBrowserAndScrape: (...args: unknown[]) => Promise; + }, + '_launchBrowserAndScrape', + ).mockResolvedValue({ + type: 'redirect-edge', + source: 'js-redirect', + pageData: { + url: sourceUrl, + redirectPaths: [destinationUrl.href], + isTarget: true, + isExternal: false, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 0, + responseHeaders: {}, + meta: { title: '' }, + anchorList: [], + imageList: [], + html: '', + isSkipped: false, + }, + }); + + const crawler = new Crawler({ + ...defaultOptions, + dedupeCap: 100, + preloadedStickyShapeKeys: [cappedShapeKey], + }); + let crawlEndEmitted = false; + crawler.on('crawlEnd', () => { + crawlEndEmitted = true; + }); + + crawler.start([sourceUrl]); + + await vi.waitFor(() => { + expect(crawlEndEmitted).toBe(true); + }); + + // Gate 2 rejects the JS-redirect destination before it is ever + // enqueued — this path does not go through the addUrl closure + // (gate 1) at all, so this is the only place that can catch a + // regression here. + expect(crawler.getDedupeCapRejections().get(cappedShapeKey)).toBe(1); + }); + }); + + describe('paginationState is scoped per page (issue #208 regression guard)', () => { + it('別ページ由来の連番URL同士を比較して予測URLを生成しない', async () => { + const { unshift } = await driveDeal(); + const { default: Crawler } = await import('./crawler.js'); + + const pageA = parseUrl('https://example.com/page-a.xml')!; + const pageB = parseUrl('https://example.com/page-b.xml')!; + const productA1 = parseUrl('https://example.com/product/1')!; + const productB2 = parseUrl('https://example.com/product/2')!; + + /** + * + * @param url + * @param anchors + */ + function nonHtmlResult( + url: ExURL, + anchors: { href: ExURL; textContent: string }[], + ) { + return { + url, + redirectPaths: [], + isTarget: false, + isExternal: false, + status: 200, + statusText: 'OK', + contentType: 'application/xml', + contentLength: 0, + responseHeaders: {}, + meta: { title: '' }, + anchorList: anchors, + imageList: [], + html: '', + isSkipped: false, + }; + } + + const fetchDestMod = await import('./fetch-destination.js'); + vi.spyOn(fetchDestMod, 'fetchDestination').mockImplementation((args) => { + const href = (args as { url: ExURL }).url.withoutHashAndAuth; + if (href === pageA.withoutHashAndAuth) { + return Promise.resolve( + nonHtmlResult(pageA, [ + { href: productA1, textContent: 'Product 1' }, + ]) as Awaited>, + ); + } + return Promise.resolve( + nonHtmlResult(pageB, [ + { href: productB2, textContent: 'Product 2' }, + ]) as Awaited>, + ); + }); + + // parallels: 2 → if a (bogus) pattern were detected, two predicted + // URLs would be generated and unshifted as ONE batched call. + const crawler = new Crawler({ ...defaultOptions, parallels: 2 }); + let crawlEndEmitted = false; + crawler.on('crawlEnd', () => { + crawlEndEmitted = true; + }); + + // Two SEPARATE root pages, each contributing exactly ONE anchor. + // Under the pre-fix bug (`paginationState` declared once per crawl + // in `#runDeal`, shared across every page's `#handleResult`), + // product/1 (page A's only push) would still be + // `paginationState.lastPushedUrl` when product/2 (page B's only + // push) is processed — despite the two anchors never appearing + // together on the same document, they would look like a valid + // numeric pagination pair and trigger prediction. The fix scopes + // `paginationState` fresh to each `#handleResult` invocation, so + // page B's processing starts with `lastPushedUrl: null` and no + // cross-page comparison ever happens. + crawler.start([pageA, pageB]); + + await vi.waitFor(() => { + expect(crawlEndEmitted).toBe(true); + }); + + // Real single-anchor routing always calls unshift with exactly one + // URL; only a (bogus) predicted-URL batch would call it with more + // than one, so this is the discriminating assertion. + const batchCalls = unshift.mock.calls.filter((args) => args.length > 1); + expect(batchCalls).toHaveLength(0); + }); }); describe('start() with the unified signature', () => { diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.ts b/packages/@nitpicker/crawler/src/crawler/crawler.ts index e2170f21..b4f5a341 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.ts @@ -29,6 +29,7 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa import c from 'ansi-colors'; import pkg from '../../package.json' with { type: 'json' }; +import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js'; import { classifyErrorKind } from '../classify-error-kind.js'; import { crawlerLog } from '../debug.js'; @@ -37,6 +38,11 @@ import { buildRedirectEvent } from './build-redirect-event.js'; import { captureImageDomPaths } from './capture-image-dom-paths.js'; import { chooseProbeHost } from './choose-probe-host.js'; import { createChangePhaseHandler } from './create-change-phase-handler.js'; +import { computeMetaSignature } from './dedupe/compute-meta-signature.js'; +import { computeShapeKey } from './dedupe/compute-shape-key.js'; +import DedupeCapTracker from './dedupe/dedupe-cap-tracker.js'; +import { isPredictedContentDuplicate } from './dedupe/is-predicted-content-duplicate.js'; +import { resolveOgUrlMismatch } from './dedupe/resolve-og-url-mismatch.js'; import { derivePageSource } from './derive-page-source.js'; import { destinationCache } from './destination-cache.js'; import { detectPaginationPattern } from './detect-pagination-pattern.js'; @@ -97,6 +103,8 @@ const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5; const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2; /** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000; +/** Default {@link CrawlerOptions.dedupeMapCap}. */ +const DEFAULT_DEDUPE_MAP_CAP = 100_000; /** * The core crawler engine that discovers and scrapes web pages. @@ -112,6 +120,25 @@ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000; export default class Crawler extends EventEmitter { /** Controller used to cancel the deal-based crawl via its AbortSignal. */ readonly #abortController = new AbortController(); + /** + * Per-shape count of anchors rejected by the dedupe-cap enqueue gates + * after that shape capped. Read by {@link getDedupeCapRejections} at + * `crawlEnd` so the orchestrator can finalize each + * `dedupe_cap_events.rejected_count` exactly once (see + * `Crawler#getDedupeCapRejections`'s JSDoc for why this is not written + * to the archive incrementally). + */ + readonly #dedupeCapRejectionCounts = new Map(); + /** + * Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed + * (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is + * `null`), gated on by `#options.dedupeCap !== null` at each call site + * rather than being conditionally `undefined`, so the two enqueue gates + * and the observation call in {@link #handleResult} do not need to + * null-check a class field. + */ + readonly #dedupeCapTracker: DedupeCapTracker; + /** Tracks discovered URLs, their scrape status, and deduplication. */ readonly #linkList = new LinkList(); /** @@ -158,6 +185,23 @@ export default class Crawler extends EventEmitter { string /* url.href */, { phase: string; message: string }[] >(); + /** + * Predicted-pagination body-hash tracking (always-on — independent of + * the opt-in `--dedupe-cap` tracker). Maps a URL shape key + * ({@link computeShapeKey}) to the {@link computeBodyHash} of the most + * recently scraped *predicted* page of that shape. Never reset mid-crawl + * (persists for the whole session, like {@link #scrapedDestinations}). + */ + readonly #predictedShapeBodyHashes = new Map(); + /** + * Shapes for which {@link #predictedShapeBodyHashes} detected a + * content-duplicate predicted page (see {@link isPredictedContentDuplicate}). + * Once a shape lands here, no further predicted URLs are generated for it + * (checked in {@link #handleResult}'s pagination-pattern branch) — the + * cheapest possible way to stop a self-generating trap without needing + * the opt-in dedupe-cap machinery. + */ + readonly #predictedShapeStopped = new Set(); /** Set of resource URLs (without hash) already captured, for deduplication. */ readonly #resources = new Set(); /** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */ @@ -241,6 +285,9 @@ export default class Crawler extends EventEmitter { networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS, networkProbe: options?.networkProbe ?? null, + dedupeCap: options?.dedupeCap ?? null, + dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP, + preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [], }; this.#networkOutageDetector = new NetworkOutageDetector({ @@ -249,6 +296,11 @@ export default class Crawler extends EventEmitter { hostThreshold: this.#options.networkOutageHostThreshold, }); + this.#dedupeCapTracker = new DedupeCapTracker( + { cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, + this.#options.preloadedStickyShapeKeys, + ); + this.#robotsChecker = new RobotsChecker( this.#options.userAgent, !this.#options.ignoreRobots, @@ -275,6 +327,20 @@ export default class Crawler extends EventEmitter { this.#abortController.abort(); } + /** + * Per-shape count of anchors the dedupe-cap enqueue gates rejected after + * that shape capped (opt-in `--dedupe-cap`). Read by + * `CrawlerOrchestrator` at `crawlEnd` to finalize each + * `dedupe_cap_events.rejected_count` exactly once — rejections are + * accumulated in memory rather than written to the archive per-rejection + * to avoid write amplification (a capped trap can generate an unbounded + * number of rejected anchors). + * @returns A snapshot of the per-shape rejection counts. Empty when + * `--dedupe-cap` was not enabled or no shape has capped yet. + */ + getDedupeCapRejections(): ReadonlyMap { + return this.#dedupeCapRejectionCounts; + } /** * Retrieve the list of Chromium process IDs that are still running. * @@ -560,32 +626,102 @@ export default class Crawler extends EventEmitter { * @param enqueue - Callback to enqueue newly discovered URLs into the dealer * queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}). * Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order. - * @param paginationState - Mutable state for predicted pagination cascade prevention - * @param paginationState.lastPushedUrl - * @param paginationState.lastPushedWasPredicted * @param concurrency - Current concurrency level, used to determine predicted URL count */ #handleResult( result: ScrapeResult, url: ExURL, enqueue: (...urls: ExURL[]) => Promise, - paginationState?: { lastPushedUrl: string | null; lastPushedWasPredicted: boolean }, concurrency?: number, ) { switch (result.type) { case 'success': { if (!result.pageData) break; + // Scoped to this one page's anchor list (fresh per `#handleResult` + // call, not shared across pages): pagination-pattern detection + // compares consecutive anchors as they are discovered by + // `processAnchors`'s single synchronous loop below, so "consecutive" + // must mean "adjacent in this document", not "adjacent in whatever + // order the crawl's workers happened to finish". Sharing this state + // across pages/workers let `step` be computed from two unrelated + // URLs, compounding across rounds until a `/news/date/{year}/` + // pager's predicted token overflowed into scientific notation + // (`1.7715854126052197e+120`, observed in production). + const paginationState: { + lastPushedUrl: string | null; + lastPushedWasPredicted: boolean; + } = { + lastPushedUrl: null, + lastPushedWasPredicted: false, + }; + + // Feed this page's own signature into the same-cluster tracker + // (opt-in via `--dedupe-cap`). This is deliberately separate + // from the enqueue gates below: gating decides whether to + // admit a not-yet-scraped anchor based on shape alone; this + // observes the page that was JUST scraped, using its actual + // meta/body content. External and metadata-only pages carry no + // useful signal for this feature and are skipped, matching the + // signature-scope exclusions in `computeMetaSignature`'s design. + if ( + this.#options.dedupeCap !== null && + !result.pageData.isExternal && + !this.#linkList.isMetadataOnly(result.pageData.url.withoutHash) && + result.pageData.html.length > 0 + ) { + const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth); + const metaSig = computeMetaSignature(result.pageData.meta); + if (shapeKey && metaSig) { + const bodyHash = computeBodyHash(result.pageData.html); + const ogUrlMismatch = resolveOgUrlMismatch( + result.pageData.meta, + result.pageData.url.href, + ); + const event = this.#dedupeCapTracker.observe({ + shapeKey, + metaSig, + bodyHash, + ogUrlMismatch, + url: result.pageData.url.href, + }); + if (event) { + void this.emit('dedupeCap', event); + } + } + } + handleScrapeEnd( result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => { + // Gate 1: blocks real anchors discovered on this page whose + // shape is already confirmed as a trap. This does NOT cover + // predicted URLs — `generatePredictedUrls`'s output is + // pushed directly below (`this.#linkList.add(specUrl, ...)`), + // bypassing this closure entirely — so the predicted-URL + // generation site below has its own equivalent check + // (`shapeIsStopped`, combined with `#predictedShapeStopped`). + // External / metadata-only anchors are out of scope for the + // cap (issue #208: "cap 適用は internal only"). + if ( + this.#options.dedupeCap !== null && + !opts?.metadataOnly && + findScopeEntry(newUrl, this.#scope, this.#options) !== null + ) { + const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth); + if (gateShapeKey && this.#dedupeCapTracker.isCapped(gateShapeKey)) { + this.#recordDedupeCapRejection(gateShapeKey); + return; + } + } + this.#linkList.add(newUrl, opts); void enqueue(newUrl); // Predicted pagination detection - if (!paginationState || !concurrency) return; + if (!concurrency) return; // metadataOnly / external: update tracking but skip pattern detection if ( @@ -607,25 +743,41 @@ export default class Crawler extends EventEmitter { newUrl.withoutHashAndAuth, ); if (pattern) { - const urls = generatePredictedUrls( - pattern, - newUrl.withoutHashAndAuth, - concurrency, - ); - const specUrls: ExURL[] = []; - for (const specUrlStr of urls) { - const specUrl = parseUrl(specUrlStr, this.#options); - if (specUrl) { - this.#linkList.add(specUrl, { predicted: true }); - specUrls.push(specUrl); + // Stop generating further predicted URLs for this shape + // once EITHER confirmation mechanism has fired — the + // always-on content-duplication check + // (`#predictedShapeStopped`), or the opt-in + // `--dedupe-cap` tracker (`#dedupeCapTracker.isCapped`, + // only consulted when the flag is set). Falls through to + // the plain (non-predicted) bookkeeping below instead of + // returning, since the anchor itself is still real. + const shapeKey = computeShapeKey(newUrl.withoutHashAndAuth); + const shapeIsStopped = + shapeKey !== null && + (this.#predictedShapeStopped.has(shapeKey) || + (this.#options.dedupeCap !== null && + this.#dedupeCapTracker.isCapped(shapeKey))); + if (!shapeIsStopped) { + const urls = generatePredictedUrls( + pattern, + newUrl.withoutHashAndAuth, + concurrency, + ); + const specUrls: ExURL[] = []; + for (const specUrlStr of urls) { + const specUrl = parseUrl(specUrlStr, this.#options); + if (specUrl) { + this.#linkList.add(specUrl, { predicted: true }); + specUrls.push(specUrl); + } } + // Enqueue as one batch so ascending page order is kept + // at the front of the queue (see enqueue in #runDeal). + if (specUrls.length > 0) void enqueue(...specUrls); + paginationState.lastPushedUrl = newUrl.withoutHashAndAuth; + paginationState.lastPushedWasPredicted = true; + return; } - // Enqueue as one batch so ascending page order is kept - // at the front of the queue (see enqueue in #runDeal). - if (specUrls.length > 0) void enqueue(...specUrls); - paginationState.lastPushedUrl = newUrl.withoutHashAndAuth; - paginationState.lastPushedWasPredicted = true; - return; } } @@ -734,6 +886,22 @@ export default class Crawler extends EventEmitter { window: { startedAt, endedAt }, }); } + /** + * Increments {@link #dedupeCapRejectionCounts} for one shape. Scoped to + * the two concrete enqueue-time rejections (a real anchor or a + * JS-redirect destination that was discovered but blocked) — it does + * NOT count predicted URLs that were never generated at all because + * their shape was already stopped (see the `shapeIsStopped` check in + * {@link #handleResult}), since nothing concrete existed there to + * reject. + * @param shapeKey - The capped shape a rejection is being recorded for. + */ + #recordDedupeCapRejection(shapeKey: string): void { + this.#dedupeCapRejectionCounts.set( + shapeKey, + (this.#dedupeCapRejectionCounts.get(shapeKey) ?? 0) + 1, + ); + } /** * Feed one observed network-layer error into * {@link #networkOutageDetector} and hand off to @@ -758,6 +926,7 @@ export default class Crawler extends EventEmitter { void this.#handleOutageSuspect(suspect); } } + /** * Resolve the source label of the page being scraped so sub-resources * captured during its render can inherit the correct lineage label @@ -861,12 +1030,6 @@ export default class Crawler extends EventEmitter { ? Math.max(this.#options.parallels, 1) : Crawler.MAX_PROCESS_LENGTH; - // Predicted pagination state - const paginationState = { - lastPushedUrl: null as string | null, - lastPushedWasPredicted: false, - }; - await deal( initialUrls, (url, update, _index, setLineHeader, push, unshift) => { @@ -1021,8 +1184,26 @@ export default class Crawler extends EventEmitter { if (destination) { const destinationUrl = parseUrl(destination, this.#options); if (destinationUrl) { - this.#linkList.add(destinationUrl); - void enqueue(destinationUrl); + // Gate 2: this direct enqueue does not go through + // `#handleResult`'s addUrl closure (gate 1), so it needs + // its own same-cluster-cap check — a JS-redirect trap + // that advances a parameter via `location.replace()` + // would otherwise keep re-entering the queue here. + const gateShapeKey = computeShapeKey( + destinationUrl.withoutHashAndAuth, + ); + const isCapped = + this.#options.dedupeCap !== null && + gateShapeKey !== null && + findScopeEntry(destinationUrl, this.#scope, this.#options) !== + null && + this.#dedupeCapTracker.isCapped(gateShapeKey); + if (isCapped) { + if (gateShapeKey) this.#recordDedupeCapRejection(gateShapeKey); + } else { + this.#linkList.add(destinationUrl); + void enqueue(destinationUrl); + } } else { // `deriveJsRedirectTarget` already canonicalises // via WHATWG URL parsing, so reaching the @@ -1088,6 +1269,34 @@ export default class Crawler extends EventEmitter { return; } + // Discard a predicted URL whose rendered body is a + // byte-for-byte duplicate of the previous predicted page of the + // same shape, and stop generating further predictions for that + // shape (checked above, in the pagination-pattern branch). This + // is the always-on backstop against a site that returns 2xx for + // any extrapolated token but ignores it entirely (e.g. always + // serving the same "no results" template) — `shouldDiscardPredicted` + // alone cannot see this, since it only inspects HTTP status. + if ( + isPredicted && + result.type === 'success' && + result.pageData && + result.pageData.html.length > 0 + ) { + const shapeKey = computeShapeKey(url.withoutHashAndAuth); + if (shapeKey) { + const bodyHash = computeBodyHash(result.pageData.html); + const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null; + if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) { + this.#predictedShapeStopped.add(shapeKey); + handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options); + log(c.dim('Predicted (content duplicate, discarded)')); + return; + } + this.#predictedShapeBodyHashes.set(shapeKey, bodyHash); + } + } + // Count only after discard check: rendered HTML pages that // will be persisted to the archive. Launch failures bypass // this point via the catch block; discarded predicted URLs @@ -1097,7 +1306,7 @@ export default class Crawler extends EventEmitter { } log('Saving results%dots%'); - this.#handleResult(result, url, enqueue, paginationState, concurrency); + this.#handleResult(result, url, enqueue, concurrency); const parentSource = await this.#resolveParentSource(url); this.#handleResources(result.resources, parentSource); this.#handleConsoleLogs( diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.spec.ts new file mode 100644 index 00000000..a5f91f98 --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.spec.ts @@ -0,0 +1,64 @@ +import type { Meta } from '@d-zero/beholder'; + +import { describe, expect, it } from 'vitest'; + +import { computeMetaSignature } from './compute-meta-signature.js'; + +/** + * + * @param overrides + */ +function buildMeta(overrides: Partial = {}): Meta { + return { + title: '', + ...overrides, + } as Meta; +} + +describe('computeMetaSignature', () => { + it('titleとog:*が全て空ならnullを返す', () => { + const meta = buildMeta({ title: '', og: {} } as Partial); + expect(computeMetaSignature(meta)).toBeNull(); + }); + + it('titleがあればsignatureを返す', () => { + const meta = buildMeta({ title: 'お知らせ' }); + expect(computeMetaSignature(meta)).not.toBeNull(); + }); + + it('titleが空でもog:titleがあればsignatureを返す', () => { + const meta = buildMeta({ title: '', og: { title: 'OG Title' } } as Partial); + expect(computeMetaSignature(meta)).not.toBeNull(); + }); + + it('titleが空でもog:urlがあればsignatureを返す', () => { + const meta = buildMeta({ title: '', og: { url: '/news' } } as Partial); + expect(computeMetaSignature(meta)).not.toBeNull(); + }); + + it('同一の4フィールドは同一signatureになる', () => { + const metaA = buildMeta({ + title: 'お知らせ', + description: '一覧です', + og: { title: 'お知らせ', url: '/news' }, + } as Partial); + const metaB = buildMeta({ + title: 'お知らせ', + description: '一覧です', + og: { title: 'お知らせ', url: '/news' }, + } as Partial); + expect(computeMetaSignature(metaA)).toBe(computeMetaSignature(metaB)); + }); + + it('descriptionだけが異なれば別signatureになる', () => { + const metaA = buildMeta({ title: 'お知らせ', description: 'A' }); + const metaB = buildMeta({ title: 'お知らせ', description: 'B' }); + expect(computeMetaSignature(metaA)).not.toBe(computeMetaSignature(metaB)); + }); + + it('前後の空白は無視する', () => { + const metaA = buildMeta({ title: 'お知らせ' }); + const metaB = buildMeta({ title: ' お知らせ ' }); + expect(computeMetaSignature(metaA)).toBe(computeMetaSignature(metaB)); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-meta-signature.ts new file mode 100644 index 0000000000000000000000000000000000000000..86472512ad1a615feff8904d927794448d353509 GIT binary patch literal 2052 zcma)7v2xo+5KU`-#f}Su)DYyjrA#fuYR{XQLn|>Qi1+ z3tViKXTjntNM{l5xzwJENFjFH**p;}T!$_|XdP+qI#ZJ2h{wGsxrPf4Tm(!NMQU84 zWSqN9O~r#o{o=faCXI>(O0AT(XnAKn8~CYA+`_2+MS!*kqYNHVA>RW6mCzKrU%dK3 z6mzDFI=~Ii!dCtu>1;(HA)KV@K)806!8S6Xa#U+!Z6w~paS%MfAQaIzp);r84u%j( z8O>uaXwWifxXnh0p2+Jr@U?$gWUd9(rG&NFF7) z$@=3c9NHxO1Q!Lky-m_j6_t9ogM=_v@_?BSbbB^|fe0;;OGOM!n}-4xkZN_qGif!Q zNl4Rgf}Fk`_Dzn)K-4mwmrAw-DjPZU5B)Hm=T!4m&bGh~D(Ush^Ze<(Bj?hWVSaVL zdC~|#cA4BXh(d#s*Ku+*aZy#tj&#OX3a&`Ave|568o<6UENk=ixZI{UJ>rV8yLW&7 z``6!hzyERf?n4F^)e1WngO01E<$cJoJ8uar1Yq_|I4qa6eu4IdsJ4csD(DO2M!Eb# zx-rEA&;e1lmq36z{wybamG=IwvqhSj|Am&Vx$4QSM#KF^0!25IM=}EVa>$aUb!t+- z(-v@MGetQ=#L>1LHq7;c6sz}^=KTR`uONiV~J_2cmwFv+K literal 0 HcmV?d00001 diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.spec.ts new file mode 100644 index 00000000..c408e9de --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { computeShapeKey } from './compute-shape-key.js'; + +describe('computeShapeKey', () => { + it('数字のみのpathセグメントを同一shapeに畳む', () => { + const a = computeShapeKey('//example.com/news/date/2024/'); + const b = computeShapeKey('//example.com/news/date/2025/'); + expect(a).toBe(b); + }); + + it('科学表記に化けたトークンも同じ元セグメントの数値トークンと同一shapeになる', () => { + const a = computeShapeKey('//example.com/news/date/2024/'); + const b = computeShapeKey('//example.com/news/date/1.5e+32/'); + expect(a).toBe(b); + }); + + it('2次元trap(path + サブページ)を1つのshapeに畳む', () => { + const a = computeShapeKey('//example.com/news/date/2024/page/3/'); + const b = computeShapeKey('//example.com/news/date/2025/page/9/'); + expect(a).toBe(b); + }); + + it('query値のtrapを同一shapeに畳む(数値でもランダム文字列でも)', () => { + const a = computeShapeKey('//example.com/list?page=1'); + const b = computeShapeKey('//example.com/list?page=2'); + const c = computeShapeKey('//example.com/list?session=ab12cd'); + const d = computeShapeKey('//example.com/list?session=gh34ij'); + expect(a).toBe(b); + expect(c).toBe(d); + expect(a).not.toBe(c); + }); + + it('数字を含まない正当な階層違いは別shapeになる', () => { + const a = computeShapeKey('//example.com/recruit/'); + const b = computeShapeKey('//example.com/business/'); + expect(a).not.toBe(b); + }); + + it('数字を含むセグメントは混在文字列でも丸ごと畳む(item42 と item99 は同一shape)', () => { + const a = computeShapeKey('//example.com/item42'); + const b = computeShapeKey('//example.com/item99'); + expect(a).toBe(b); + }); + + it('ホストが異なれば別shapeになる', () => { + const a = computeShapeKey('//example.com/news/date/2024/'); + const b = computeShapeKey('//other.example.com/news/date/2024/'); + expect(a).not.toBe(b); + }); + + it('decomposeできないURLはnullを返す', () => { + expect(computeShapeKey('not-a-url')).toBeNull(); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.ts new file mode 100644 index 00000000..aea530eb --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/compute-shape-key.ts @@ -0,0 +1,62 @@ +import { decomposeUrl } from '../decompose-url.js'; + +const DIGIT_CONTAINING_SEGMENT_PATTERN = /\d/; +const SEGMENT_PLACEHOLDER = '{n}'; +const VALUE_PLACEHOLDER = '{v}'; + +/** + * Computes a URL "shape" key: the host plus path/query with every path + * segment that contains a digit collapsed to a fixed placeholder, and every + * query value (regardless of content) collapsed to a fixed placeholder. + * + * This absorbs both the "numeric pager" trap shape (`/news/date/2024/` and + * `/news/date/1.5e+32/` collapse to the same key) and the "query trap" shape + * (`?page=1` / `?page=2` / `?session=ab12cd` all collapse to the same key), + * without needing two separate `parentPath` definitions the way issue + * #208's original proposal did. + * + * Uses `../decompose-url.ts` (the pagination-detection one) — NOT + * `../../archive/populate-ref-tables/decompose-url.ts`, an unrelated same-named + * module with a different `DecomposedUrl` shape used for ref-table population. + * + * The masking rule here is the deliberate inverse of + * `../../archive/body-hash/mask-dynamic-ids.ts`: that module leaves + * pure-digit tokens untouched (they are more likely stable content than a + * dynamic id) and only masks mixed alphanumeric runs. A shape key needs the + * opposite: ANY digit inside a path segment marks it as "probably a + * pagination/date/id token", so the whole segment is collapsed. Do not share + * masking logic between the two — they classify the same kind of text for + * opposite purposes. + * @param url - A URL string (protocol-agnostic `//host/...` or full + * `https://host/...`), typically `ExURL.withoutHashAndAuth`. + * @returns The shape key, or `null` if `url` cannot be decomposed. + * @example + * ```ts + * computeShapeKey('//example.com/news/date/2024/'); + * // => 'example.com/news/date/{n}/' + * computeShapeKey('//example.com/news/date/1.5e+32/'); + * // => 'example.com/news/date/{n}/' — same shape + * computeShapeKey('//example.com/list?page=1'); + * // => 'example.com/list?page={v}' + * ``` + */ +export function computeShapeKey(url: string): string | null { + const decomposed = decomposeUrl(url); + if (!decomposed) return null; + + const { host, pathSegments, queryKeys } = decomposed; + + const shapedSegments = pathSegments.map((segment) => + DIGIT_CONTAINING_SEGMENT_PATTERN.test(segment) ? SEGMENT_PLACEHOLDER : segment, + ); + + let key = host; + if (shapedSegments.length > 0) { + key += `/${shapedSegments.join('/')}`; + } + if (queryKeys.length > 0) { + const pairs = queryKeys.map((k) => `${k}=${VALUE_PLACEHOLDER}`); + key += `?${pairs.join('&')}`; + } + return key; +} diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts new file mode 100644 index 00000000..91604b6c --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import DedupeCapTracker from './dedupe-cap-tracker.js'; + +/** + * + * @param overrides + * @param overrides.shapeKey + * @param overrides.metaSig + * @param overrides.bodyHash + * @param overrides.ogUrlMismatch + * @param overrides.url + */ +function observation(overrides: { + shapeKey?: string; + metaSig?: string; + bodyHash?: Buffer; + ogUrlMismatch?: boolean; + url?: string; +}) { + return { + shapeKey: 'shapeA', + metaSig: 'sigA', + bodyHash: Buffer.from('body-1'), + ogUrlMismatch: false, + url: 'https://example.com/news/date/2024/', + ...overrides, + }; +} + +describe('DedupeCapTracker', () => { + it('metaSig一致でcountが増え、閾値到達でcapイベントを返す', () => { + const tracker = new DedupeCapTracker({ cap: 3, mapCap: 100 }); + + expect(tracker.observe(observation({ bodyHash: Buffer.from('b1') }))).toBeNull(); + expect(tracker.observe(observation({ bodyHash: Buffer.from('b2') }))).toBeNull(); + const event = tracker.observe( + observation({ + bodyHash: Buffer.from('b3'), + url: 'https://example.com/news/date/2026/', + }), + ); + + expect(event).toEqual({ + shapeKey: 'shapeA', + sampleUrl: 'https://example.com/news/date/2026/', + bodyHash: Buffer.from('b3'), + effectiveThreshold: 3, + observedCount: 3, + }); + }); + + it('cap到達でstickyへ移送しstateから削除される', () => { + const tracker = new DedupeCapTracker({ cap: 2, mapCap: 100 }); + tracker.observe(observation({})); + expect(tracker.isCapped('shapeA')).toBe(false); + expect(tracker.size).toBe(1); + + tracker.observe(observation({})); + expect(tracker.isCapped('shapeA')).toBe(true); + expect(tracker.size).toBe(0); + expect(tracker.stickyCount).toBe(1); + }); + + it('capped shapeへのobserveはno-op', () => { + const tracker = new DedupeCapTracker({ cap: 1, mapCap: 100 }); + const firstEvent = tracker.observe(observation({})); + expect(firstEvent).not.toBeNull(); + expect(tracker.stickyCount).toBe(1); + + const secondCall = tracker.observe( + observation({ url: 'https://example.com/other/' }), + ); + expect(secondCall).toBeNull(); + expect(tracker.stickyCount).toBe(1); + expect(tracker.size).toBe(0); + }); + + it('metaSig不一致でcountが減り、0でスロットが新しいsigにリセットされる', () => { + const tracker = new DedupeCapTracker({ cap: 3, mapCap: 100 }); + + // A: count=1 + tracker.observe(observation({ metaSig: 'A', bodyHash: Buffer.from('a1') })); + // B (mismatch): count-- => 0 -> reset to {metaSig: B, count: 1} + const afterReset = tracker.observe( + observation({ metaSig: 'B', bodyHash: Buffer.from('b1') }), + ); + expect(afterReset).toBeNull(); + // B match: count=2 + expect( + tracker.observe(observation({ metaSig: 'B', bodyHash: Buffer.from('b2') })), + ).toBeNull(); + // B match: count=3 -> cap (reset を経てからちょうど3回目でcapする = リセットが正しく効いている証拠) + const capped = tracker.observe( + observation({ metaSig: 'B', bodyHash: Buffer.from('b3') }), + ); + expect(capped?.observedCount).toBe(3); + }); + + it('body_hashがスロットの記録値と一致すると実効閾値が半分(切り上げ)になる', () => { + const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }); + const bodyHash = Buffer.from('same-body'); + + // スロット作成時のbodyHashを記録 + tracker.observe(observation({ bodyHash })); + // 2回目: bodyHash一致 → 実効閾値 ceil(5/2)=3、count=2 (<3) → まだcapしない + expect(tracker.observe(observation({ bodyHash }))).toBeNull(); + // 3回目: count=3 >= 3 → cap(bodyHash一致がなければcap=5でまだ足りないはず) + const capped = tracker.observe(observation({ bodyHash })); + expect(capped).toEqual({ + shapeKey: 'shapeA', + sampleUrl: 'https://example.com/news/date/2024/', + bodyHash, + effectiveThreshold: 3, + observedCount: 3, + }); + }); + + it('og:url不一致でも実効閾値が半分(切り上げ)になる', () => { + const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }); + + tracker.observe(observation({ bodyHash: Buffer.from('b1'), ogUrlMismatch: true })); + expect( + tracker.observe(observation({ bodyHash: Buffer.from('b2'), ogUrlMismatch: true })), + ).toBeNull(); + const capped = tracker.observe( + observation({ bodyHash: Buffer.from('b3'), ogUrlMismatch: true }), + ); + expect(capped?.effectiveThreshold).toBe(3); + expect(capped?.observedCount).toBe(3); + }); + + it('body_hash一致とog:url不一致が重なるとさらに閾値が下がる', () => { + const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }); + const bodyHash = Buffer.from('same-body'); + + // 1回目: スロット作成(count=1) + tracker.observe(observation({ bodyHash, ogUrlMismatch: true })); + // 2回目: bodyHash一致 + og:url不一致 → 閾値 ceil(ceil(5/2)/2) = ceil(3/2) = 2、count=2 >= 2 → cap + const capped = tracker.observe(observation({ bodyHash, ogUrlMismatch: true })); + expect(capped?.effectiveThreshold).toBe(2); + expect(capped?.observedCount).toBe(2); + }); + + it('mapCapを超えて追加してもstateサイズはmapCapを超えない(決定的アサート)', () => { + const tracker = new DedupeCapTracker({ cap: 100_000, mapCap: 100 }); + for (let i = 0; i < 150; i++) { + tracker.observe( + observation({ + shapeKey: `shape-${i}`, + metaSig: `sig-${i}`, + bodyHash: Buffer.from(`b${i}`), + }), + ); + } + expect(tracker.size).toBeLessThanOrEqual(100); + expect(tracker.size).toBe(100); + }); + + it('mapCap超過時は最も古いshapeがLRU的に追い出される', () => { + const tracker = new DedupeCapTracker({ cap: 2, mapCap: 2 }); + + tracker.observe(observation({ shapeKey: 'shapeA', metaSig: 'A' })); + tracker.observe(observation({ shapeKey: 'shapeB', metaSig: 'B' })); + // shapeC の挿入で mapCap(2) を超えるため、最も古い shapeA が追い出される + tracker.observe(observation({ shapeKey: 'shapeC', metaSig: 'C' })); + expect(tracker.size).toBe(2); + + // shapeA は追い出されているので、再度観測すると新規スロット(count=1)から始まる + const firstAfterEviction = tracker.observe( + observation({ shapeKey: 'shapeA', metaSig: 'A' }), + ); + expect(firstAfterEviction).toBeNull(); + const secondAfterEviction = tracker.observe( + observation({ shapeKey: 'shapeA', metaSig: 'A' }), + ); + expect(secondAfterEviction?.observedCount).toBe(2); + }); + + it('preloadedStickyでコンストラクタ時にcapped済みとして扱える', () => { + const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }, ['preloaded-shape']); + expect(tracker.isCapped('preloaded-shape')).toBe(true); + expect(tracker.stickyCount).toBe(1); + expect(tracker.observe(observation({ shapeKey: 'preloaded-shape' }))).toBeNull(); + expect(tracker.stickyCount).toBe(1); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts new file mode 100644 index 00000000..6c2823bc --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts @@ -0,0 +1,196 @@ +import type { + DedupeCapEvent, + DedupeCapObservation, + DedupeCapOptions, + DedupeSlot, +} from './types.js'; + +import { isShapeCapped } from './is-shape-capped.js'; + +/** + * Tracks, per URL shape, whether the crawl has run into a same-metadata + * cluster trap (a pager/query-parameter trap the site keeps serving 2xx + * for), and confirms it via a Misra-Gries majority-vote counter rather than + * a plain observation count. + * + * **Why Misra-Gries (one slot per shape) instead of a multi-layer memory + * design (issue #208's original proposal)**: a single `{ metaSig, count }` + * slot per shape can never overcount — `count` is a lower bound on the true + * number of matching observations, so false-positive cap firing is + * structurally impossible regardless of how many unrelated legit pages + * share a shape (e.g. `/product/{id}` with thousands of genuinely distinct + * pages). This makes most of that original proposal's eviction machinery + * unnecessary: age-based eviction and "parent-path bucket completion" + * eviction both exist there only to bound memory for a naive + * `Map`, which this design never needs since it holds at most + * one slot per *shape* (not per signature-per-shape). Only "cap-reached + * sticky migration" (`#sticky`) and "hard map cap with LRU eviction" + * (`mapCap`) remain relevant here. + * + * **Known limitation (accepted, not fixed)**: Misra-Gries (k=1) can only + * detect a *strict majority* signature. If a trap alternates between two + * near-equally-frequent `metaSig` values for the same shape (e.g. an + * even/odd-year template split), `count` oscillates near zero and the cap + * never fires. A total-observation-count backstop was considered and + * rejected: it would misfire on legitimate large sections (e.g. a + * `/product/{id}` catalogue with thousands of distinct, correctly-unique + * pages sharing one shape). Because arrival order under concurrent + * crawling is non-deterministic, whether this alternating-signature case + * fires is itself non-deterministic — test fixtures for this tracker use a + * single dominant `metaSig` per shape to keep results deterministic. + * + * **Rejected alternative — top-K "space-saving" per shape**: keeping the + * top K=4 `{metaSig, count}` candidates per shape (instead of one) was + * considered so a shape could distinguish more than one competing + * signature. Rejected because eviction semantics have no safe default: if + * an evicted candidate's count is inherited by its replacement (the + * textbook space-saving guarantee), churn through a large legitimate + * section (e.g. thousands of distinct `/product/{id}` pages sharing one + * shape) inflates an unrelated candidate's inherited count and can + * false-positive cap it; if not inherited, a genuine trap can be evicted + * before it accumulates enough count, producing a false negative. A single + * majority-vote slot per shape has neither failure mode. + * + * **Rejected alternative — streak counting** (increment on a match with the + * immediately preceding observation, reset to zero otherwise): fails the + * same way under concurrent crawling as the alternating-signature case + * above — a trap and an unrelated same-shape legit page interleaving resets + * the streak before it can reach the cap. + * @see {@link https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_majority_vote_algorithm} for the underlying algorithm (Misra-Gries generalises it to top-K; this uses K=1). + */ +export default class DedupeCapTracker { + readonly #options: DedupeCapOptions; + readonly #state = new Map(); + readonly #sticky: Set; + + /** Number of distinct shapes currently held in the (non-sticky) state map. Exposed for the `mapCap` bound assertion in tests. */ + get size(): number { + return this.#state.size; + } + /** Number of shapes confirmed capped (sticky) so far. */ + get stickyCount(): number { + return this.#sticky.size; + } + /** + * @param options - `--dedupe-cap` / `--dedupe-map-cap` thresholds. + * @param preloadedSticky - Shape keys already confirmed capped in a prior + * session (from `dedupe_cap_events.shape_key`), seeded so `--resume` / + * `--append` / `--retry-failed` / `--inventory` do not re-admit a trap + * this crawl already paid the cost of discovering once. + */ + constructor(options: DedupeCapOptions, preloadedSticky: Iterable = []) { + this.#options = options; + this.#sticky = new Set(preloadedSticky); + } + + /** + * Whether a shape has already been confirmed capped. Callers gate + * enqueue decisions on this before ever calling {@link observe}. + * @param shapeKey + */ + isCapped(shapeKey: string): boolean { + return isShapeCapped(this.#sticky, shapeKey); + } + + /** + * Registers one page's observation and applies the Misra-Gries + * majority-vote update for its shape. + * @param observation - See {@link DedupeCapObservation}. Callers must not + * call this for a shape that is already capped (check {@link isCapped} + * first) — doing so is a no-op returning `null`, since the shape's slot + * was already dropped from `#state` when it capped. + * @returns A {@link DedupeCapEvent} the instant this observation causes + * the shape to newly cross its effective threshold, otherwise `null`. + */ + observe(observation: DedupeCapObservation): DedupeCapEvent | null { + const { shapeKey, metaSig, bodyHash, ogUrlMismatch, url } = observation; + if (this.#sticky.has(shapeKey)) return null; + + const existing = this.#state.get(shapeKey); + let slot: DedupeSlot; + // The body-hash confidence signal only means something when compared + // against a hash a PRIOR observation already recorded for this shape — + // comparing a freshly-created (or just-reset) slot's `bodyHash` + // against itself would trivially "match" every single time (it is the + // same value), collapsing the threshold on the very first + // observation of any shape. So this stays `false` whenever the slot + // has no observation history to compare against yet. + let bodyHashMatches: boolean; + if (!existing) { + slot = { metaSig, count: 1, bodyHash }; + bodyHashMatches = false; + } else if (existing.metaSig === metaSig) { + existing.count++; + slot = existing; + bodyHashMatches = existing.bodyHash.equals(bodyHash); + } else { + existing.count--; + if (existing.count <= 0) { + slot = { metaSig, count: 1, bodyHash }; + } else { + slot = existing; + } + bodyHashMatches = false; + } + // Re-insert to move this shape to the "most recently touched" end of + // the Map's iteration order, which `#enforceHardCap` relies on to + // evict the least-recently-touched shape first. + this.#state.delete(shapeKey); + this.#state.set(shapeKey, slot); + + const effectiveThreshold = computeEffectiveThreshold( + this.#options.cap, + bodyHashMatches, + ogUrlMismatch, + ); + + if (slot.count >= effectiveThreshold) { + this.#state.delete(shapeKey); + this.#sticky.add(shapeKey); + return { + shapeKey, + sampleUrl: url, + bodyHash, + effectiveThreshold, + observedCount: slot.count, + }; + } + + this.#enforceHardCap(); + return null; + } + + /** + * Evicts the least-recently-touched shape(s) until `#state` is back + * within `mapCap`. This is the pathological-case backstop — under normal + * operation the Misra-Gries design keeps `#state` bounded by the number + * of distinct shapes actually seen, which rarely approaches `mapCap`. + */ + #enforceHardCap(): void { + while (this.#state.size > this.#options.mapCap) { + const oldestKey = this.#state.keys().next().value; + if (oldestKey === undefined) break; + this.#state.delete(oldestKey); + } + } +} + +/** + * Computes the effective same-cluster cap threshold: the base `--dedupe-cap` + * value, halved independently for each confidence signal present (a + * matching `body_hash` and an `og:url` that does not point at the page + * itself), rounded up so the threshold never reaches zero. + * @param baseCap + * @param bodyHashMatches + * @param ogUrlMismatch + */ +function computeEffectiveThreshold( + baseCap: number, + bodyHashMatches: boolean, + ogUrlMismatch: boolean, +): number { + let threshold = baseCap; + if (bodyHashMatches) threshold = Math.ceil(threshold / 2); + if (ogUrlMismatch) threshold = Math.ceil(threshold / 2); + return Math.max(threshold, 1); +} diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.spec.ts new file mode 100644 index 00000000..795c19af --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { isPredictedContentDuplicate } from './is-predicted-content-duplicate.js'; + +describe('isPredictedContentDuplicate', () => { + it('直前のhashがnullなら重複ではない', () => { + expect(isPredictedContentDuplicate(Buffer.from('a'), null)).toBe(false); + }); + + it('同一バイト列のhashなら重複と判定する', () => { + expect(isPredictedContentDuplicate(Buffer.from('same'), Buffer.from('same'))).toBe( + true, + ); + }); + + it('異なるバイト列のhashなら重複ではない', () => { + expect(isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('b'))).toBe(false); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.ts new file mode 100644 index 00000000..7706d692 --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/is-predicted-content-duplicate.ts @@ -0,0 +1,29 @@ +/** + * Determines whether a predicted URL's rendered body is a byte-for-byte + * duplicate of the previous predicted page generated for the same URL shape. + * + * The comparison is deliberately against the previous *predicted* page, not + * the origin (real) page the pattern was detected from: a real listing + * page's body legitimately differs from an empty/placeholder predicted + * page's body regardless of whether the site is a trap, so comparing + * against the origin would never fire. Two consecutive predicted pages of + * the same shape rendering identical bodies is direct evidence the site + * ignores the extrapolated token entirely (e.g. an out-of-range + * `/news/date/{n}/` always serves the same "no results" template). + * @param bodyHash - The `computeBodyHash` result for the predicted page just scraped. + * @param lastBodyHash - The previous predicted page's body hash for the same + * shape, or `null` if this is the first predicted page seen for the shape. + * @returns `true` when both hashes exist and are byte-identical. + * @example + * ```ts + * isPredictedContentDuplicate(Buffer.from('a'), null); // false — no prior hash yet + * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('a')); // true + * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('b')); // false + * ``` + */ +export function isPredictedContentDuplicate( + bodyHash: Buffer, + lastBodyHash: Buffer | null, +): boolean { + return lastBodyHash !== null && bodyHash.equals(lastBodyHash); +} diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.spec.ts new file mode 100644 index 00000000..434a4d2c --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { isShapeCapped } from './is-shape-capped.js'; + +describe('isShapeCapped', () => { + it('sticky に含まれていればtrue', () => { + const sticky = new Set(['example.com/news/date/{n}/']); + expect(isShapeCapped(sticky, 'example.com/news/date/{n}/')).toBe(true); + }); + + it('sticky に含まれていなければfalse', () => { + const sticky = new Set(['example.com/news/date/{n}/']); + expect(isShapeCapped(sticky, 'example.com/other/')).toBe(false); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.ts new file mode 100644 index 00000000..64acf9f3 --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/is-shape-capped.ts @@ -0,0 +1,12 @@ +/** + * Checks whether a shape has already been confirmed as a same-cluster trap + * and immigrated to the sticky set (see `DedupeCapTracker`). A capped shape + * needs only this O(1) Set lookup — its Misra-Gries slot has already been + * dropped from the tracker's main state map. + * @param sticky - The tracker's sticky-shape set. + * @param shapeKey - The shape key to check. + * @returns `true` if the shape is capped. + */ +export function isShapeCapped(sticky: ReadonlySet, shapeKey: string): boolean { + return sticky.has(shapeKey); +} diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.spec.ts new file mode 100644 index 00000000..b1ebbc1f --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.spec.ts @@ -0,0 +1,46 @@ +import type { Meta } from '@d-zero/beholder'; + +import { describe, expect, it } from 'vitest'; + +import { resolveOgUrlMismatch } from './resolve-og-url-mismatch.js'; + +/** + * + * @param ogUrl + */ +function buildMeta(ogUrl: string | undefined): Meta { + return { title: '', og: { url: ogUrl } } as Meta; +} + +describe('resolveOgUrlMismatch', () => { + it('og:urlが無ければfalse(シグナルなし)', () => { + expect(resolveOgUrlMismatch(buildMeta(), 'https://example.com/news/date/2024/')).toBe( + false, + ); + }); + + it('og:urlが親一覧を指していればtrue', () => { + expect( + resolveOgUrlMismatch(buildMeta('/news'), 'https://example.com/news/date/2024/'), + ).toBe(true); + }); + + it('og:urlが絶対URLで自分自身と一致すればfalse', () => { + expect( + resolveOgUrlMismatch( + buildMeta('https://example.com/news/date/2024/'), + 'https://example.com/news/date/2024/', + ), + ).toBe(false); + }); + + it('相対URLの自己参照は絶対化してから比較し、一致すればfalse', () => { + expect(resolveOgUrlMismatch(buildMeta('./'), 'https://example.com/')).toBe(false); + }); + + it('不正なog:urlはfalse(シグナルなし)', () => { + expect( + resolveOgUrlMismatch(buildMeta('http://[::not-valid'), 'https://example.com/'), + ).toBe(false); + }); +}); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.ts new file mode 100644 index 00000000..aa9d79fb --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/resolve-og-url-mismatch.ts @@ -0,0 +1,40 @@ +import type { Meta } from '@d-zero/beholder'; + +/** + * Determines whether a page's (absolutised) `og:url` points somewhere other + * than the page itself — one of the two confidence signals that lower the + * effective same-cluster cap threshold (see `DedupeCapTracker`). A pager + * trap's `og:url` typically still points at the parent listing page rather + * than the (fake) paginated URL, which this signal is built to catch. + * + * Duplicates the tiny URL-absolutisation logic from + * `../../archive/meta/derive-flat-from-meta.ts` rather than importing it: + * that file exports only `deriveFlatFromMeta` (one export per file is a + * project convention), so its internal `absolutizeUrl` helper is not + * reachable from here. `og:url` arrives un-absolutised (beholder extracts it + * via `getAttribute`, preserving relative URLs as-written) — comparing it to + * the page's own absolute URL without resolving it first would treat every + * relative self-reference (e.g. `content="./"`) as a mismatch, inflating + * this signal on ordinary pages. + * @param meta - Beholder-derived metadata for the page. + * @param pageUrl - The page's own absolute URL. + * @returns `true` if `og:url` is present and resolves to a URL different + * from `pageUrl`; `false` if absent (no signal) or if it resolves to the + * same URL. + * @example + * ```ts + * resolveOgUrlMismatch({ title: '', og: { url: '/news' } } as Meta, 'https://example.com/news/date/2024/'); + * // => true — og:url points at the parent listing, not this page + * resolveOgUrlMismatch({ title: '', og: { url: './' } } as Meta, 'https://example.com/'); + * // => false — relative self-reference resolves to the same URL + * ``` + */ +export function resolveOgUrlMismatch(meta: Meta, pageUrl: string): boolean { + const raw = meta.og?.url; + if (!raw) return false; + try { + return new URL(raw, pageUrl).href !== pageUrl; + } catch { + return false; + } +} diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/types.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/types.ts new file mode 100644 index 00000000..618bd378 --- /dev/null +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/types.ts @@ -0,0 +1,45 @@ +/** Options controlling the opt-in same-cluster soft cap (`--dedupe-cap` / `--dedupe-map-cap`). */ +export interface DedupeCapOptions { + /** Base Misra-Gries majority-vote threshold, before confidence-signal halving. */ + cap: number; + /** Hard cap on the number of distinct shapes tracked at once; the least-recently-touched shape is evicted beyond this. */ + mapCap: number; +} + +/** Per-shape Misra-Gries majority-vote slot. */ +export interface DedupeSlot { + /** The dominant meta signature currently "winning" for this shape. */ + metaSig: string; + /** Majority-vote counter: incremented on a matching `metaSig`, decremented otherwise. Never exceeds the true count of matching observations. */ + count: number; + /** The `computeBodyHash` result recorded when this slot was (re)created. */ + bodyHash: Buffer; +} + +/** + * One page's observation fed into `DedupeCapTracker#observe`. Callers are + * responsible for excluding pages with no signal (empty `computeMetaSignature` + * result, external, or metadata-only) before constructing this — the tracker + * itself does not special-case them. + */ +export interface DedupeCapObservation { + /** The page's URL shape key (see `computeShapeKey`). */ + shapeKey: string; + /** The page's meta signature (see `computeMetaSignature`). */ + metaSig: string; + /** The page's `computeBodyHash` result. */ + bodyHash: Buffer; + /** Whether the page's (absolutised) `og:url` differs from its own URL (see `resolveOgUrlMismatch`). */ + ogUrlMismatch: boolean; + /** The page's own URL — recorded as the resulting event's `sampleUrl` if this observation caps the shape. */ + url: string; +} + +/** Emitted the moment a shape's effective threshold is first reached. */ +export interface DedupeCapEvent { + shapeKey: string; + sampleUrl: string; + bodyHash: Buffer; + effectiveThreshold: number; + observedCount: number; +} diff --git a/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.spec.ts b/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.spec.ts index 34d5aea0..33070444 100644 --- a/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.spec.ts @@ -39,4 +39,45 @@ describe('generatePredictedUrls', () => { const urls = generatePredictedUrls(pattern, '//example.com/a/b/3/c', 2); expect(urls).toEqual(['//example.com/a/b/4/c', '//example.com/a/b/5/c']); }); + + it('ゼロ埋め幅を保つ', () => { + const pattern = { tokenIndex: 1, step: 1, currentNumber: 1 }; + const urls = generatePredictedUrls(pattern, '//example.com/page/01', 2); + expect(urls).toEqual(['//example.com/page/02', '//example.com/page/03']); + }); + + it('元トークンの桁数+1を超えるトークンは生成せず打ち切る', () => { + const pattern = { tokenIndex: 1, step: 90, currentNumber: 9 }; + const urls = generatePredictedUrls(pattern, '//example.com/page/9', 3); + // i=1: 99 (2桁, 元の1桁+1まで許容) → 生成 + // i=2: 189 (3桁) → 桁数超過で打ち切り、以降は生成しない + expect(urls).toEqual(['//example.com/page/99']); + }); + + it('safe integer を超えるトークンは生成せず打ち切る', () => { + const pattern = { + tokenIndex: 1, + step: Number.MAX_SAFE_INTEGER, + currentNumber: Number.MAX_SAFE_INTEGER, + }; + const urls = generatePredictedUrls(pattern, '//example.com/page/9007199254740991', 5); + expect(urls).toEqual([]); + }); + + it('報告された 1.7715854126052197e+120 形状は生成不可能である(回帰テスト)', () => { + // 実際の trap は「無関係な2ページ由来のURL比較で step が毎ラウンド倍化する」 + // ことで生じたが、この関数はその暴走の最終出力段(数値→トークン文字列化)を + // 担うため、ここで科学表記の出力自体を構造的に禁止することが最後の砦になる。 + const pattern = { + tokenIndex: 2, + step: 1e100, + currentNumber: 1.771_585_412_605_219_7e100, + }; + const urls = generatePredictedUrls(pattern, '//example.com/news/date/2024/', 10); + for (const url of urls) { + expect(url).not.toMatch(/e[+-]\d+/i); + } + // safe integer を最初の反復で超えるため実際には全て空になる + expect(urls).toEqual([]); + }); }); diff --git a/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.ts b/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.ts index 0baebb1c..2a0ca22f 100644 --- a/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.ts +++ b/packages/@nitpicker/crawler/src/crawler/generate-predicted-urls.ts @@ -3,12 +3,26 @@ import type { PaginationPattern } from './types.js'; import { decomposeUrl } from './decompose-url.js'; import { reconstructUrl } from './reconstruct-url.js'; +const DIGITS_ONLY_PATTERN = /^\d+$/; + /** * Generates predicted URLs by extrapolating the detected pagination pattern. * * Starting from `currentUrl`, applies the pattern's step `count` times to produce * future page URLs (e.g. if step=1 and currentNumber=2, generates page 3, 4, ...). * These URLs are pushed into the crawl queue and discarded later if they 404. + * + * Generation stops at the first token that would lose its original digit-string + * shape, rather than skipping it and continuing: beyond `Number.MAX_SAFE_INTEGER`, + * `String()` renders scientific notation (e.g. `"1e+21"`), which is not a valid + * path/query token and — left unguarded — becomes a self-generated URL that no + * page on the target site ever linked to (observed in production: a + * `/news/date/{year}/` pager whose per-anchor `step` was miscalculated from + * unrelated pages, compounding across rounds until it emitted + * `1.7715854126052197e+120`). A token growing far beyond its original digit + * count is equally implausible as a next page number. Both trends are + * monotonic as `i` increases, so once one prediction is rejected, every later + * one in the same batch would be too — there is nothing to skip past. * @param pattern - The detected pagination pattern from `detectPaginationPattern()` * @param currentUrl - The URL to extrapolate from (protocol-agnostic, without hash/auth) * @param count - Number of predicted URLs to generate (typically equals concurrency) @@ -24,11 +38,31 @@ export function generatePredictedUrls( const decomposed = decomposeUrl(currentUrl); if (!decomposed) return []; + const { pathSegments, queryValues } = decomposed; + const originalToken = + pattern.tokenIndex < pathSegments.length + ? pathSegments[pattern.tokenIndex] + : queryValues[pattern.tokenIndex - pathSegments.length]; + if (originalToken === undefined) return []; + + // A pager jumping from 4 digits to 5 (e.g. year 9999 → 10000) is plausible; + // jumping straight to 6+ digits within the same predicted batch is not. + const maxDigits = originalToken.length + 1; + const results: string[] = []; for (let i = 1; i <= count; i++) { const nextNum = pattern.currentNumber + pattern.step * i; - const url = reconstructUrl(decomposed, pattern.tokenIndex, String(nextNum)); - results.push(url); + if (!Number.isSafeInteger(nextNum)) break; + + const rendered = String(nextNum); + if (!DIGITS_ONLY_PATTERN.test(rendered)) break; + + // Preserve zero-padding width (e.g. "01" → "02"), without truncating a + // value that has legitimately grown past the original width. + const padded = rendered.padStart(originalToken.length, '0'); + if (padded.length > maxDigits) break; + + results.push(reconstructUrl(decomposed, pattern.tokenIndex, padded)); } return results; } diff --git a/packages/@nitpicker/crawler/src/crawler/types.ts b/packages/@nitpicker/crawler/src/crawler/types.ts index e13ff594..64a014b1 100644 --- a/packages/@nitpicker/crawler/src/crawler/types.ts +++ b/packages/@nitpicker/crawler/src/crawler/types.ts @@ -220,6 +220,32 @@ export interface CrawlerOptions extends Required< * without touching the real network. */ networkProbe: NetworkProbe | null; + + /** + * Same-cluster soft-cap threshold (`--dedupe-cap`), or `null` to disable + * the feature entirely (the default). When set, {@link Crawler} stops + * enqueueing newly-discovered URLs whose shape (see `computeShapeKey`) + * has accumulated this many matching-signature observations (see + * `DedupeCapTracker`). + */ + dedupeCap: number | null; + + /** + * Hard cap on the number of distinct URL shapes the same-cluster soft + * cap tracks at once (`--dedupe-map-cap`); the least-recently-touched + * shape is evicted beyond this. Only relevant when {@link dedupeCap} is + * non-null. + */ + dedupeMapCap: number; + + /** + * Shape keys already confirmed capped in a prior session + * (persisted as `dedupe_cap_events.shape_key`), seeded into the + * tracker's sticky set so `--resume` / `--append` / `--retry-failed` / + * `--inventory` do not re-admit a trap this crawl already paid the cost + * of discovering once. Ignored when {@link dedupeCap} is `null`. + */ + preloadedStickyShapeKeys: readonly string[]; } /** @@ -492,6 +518,22 @@ export interface CrawlerEventTypes { /** Epoch ms the recovery probe first succeeded. */ endedAt: number; }; + + /** + * Emitted the instant the opt-in same-cluster soft cap + * ({@link CrawlerOptions.dedupeCap}) confirms a URL shape as a trap (see + * `DedupeCapTracker`). The orchestrator persists this via + * `Archive.insertDedupeCapEvent` and must remember the returned row id so + * `rejected_count` can be finalized once at `crawlEnd` (`Crawler` itself + * never touches the archive). + */ + dedupeCap: { + shapeKey: string; + sampleUrl: string; + bodyHash: Buffer; + effectiveThreshold: number; + observedCount: number; + }; } /** From 7c5fd9a3585a5a27d457871ea482aec8b32a00f9 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:39:31 +0900 Subject: [PATCH 03/10] feat(query): add duplicate-body-cluster and dedupe-cap-event queries Adds listDuplicateBodyClusters (issue #208): aggregates pages sharing a body_hash into clusters, filtered to a minimum size with a uniform title and ranked by og:url-mismatch ratio (a same-cluster-trap indicator) then cluster size, with a per-cluster directory distribution and bounded sample URLs. Kept separate from the existing findDuplicateBodies so its output contract stays stable for current CLI/MCP callers. Adds listDedupeCapEvents, mirroring listNetworkOutages, to read back the crawler's `--dedupe-cap` audit log. --- .../query/src/list-dedupe-cap-events.spec.ts | 167 +++++++++++ .../query/src/list-dedupe-cap-events.ts | 83 ++++++ .../src/list-duplicate-body-clusters.spec.ts | 282 ++++++++++++++++++ .../query/src/list-duplicate-body-clusters.ts | 154 ++++++++++ packages/@nitpicker/query/src/query.ts | 2 + packages/@nitpicker/query/src/types.ts | 94 ++++++ 6 files changed, 782 insertions(+) create mode 100644 packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts create mode 100644 packages/@nitpicker/query/src/list-dedupe-cap-events.ts create mode 100644 packages/@nitpicker/query/src/list-duplicate-body-clusters.spec.ts create mode 100644 packages/@nitpicker/query/src/list-duplicate-body-clusters.ts diff --git a/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts b/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts new file mode 100644 index 00000000..653c377c --- /dev/null +++ b/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts @@ -0,0 +1,167 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { Archive } from '@nitpicker/crawler'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import { listDedupeCapEvents } from './list-dedupe-cap-events.js'; + +const __filename = new URL(import.meta.url).pathname; +const __dirname = path.dirname(__filename); +const workingDir = path.resolve(__dirname, '__test_fixtures_list_dedupe_cap_events__'); + +/** + * Minimal archive config — listDedupeCapEvents reads from + * `dedupe_cap_events` only, so anything beyond what `setConfig` requires is + * irrelevant. + * @param fileName + */ +async function buildArchive(fileName: string) { + await fs.mkdir(workingDir, { recursive: true }); + const archiveFilePath = path.resolve(workingDir, fileName); + await fs.rm(archiveFilePath, { force: true }); + const archive = await Archive.create({ + filePath: archiveFilePath, + cwd: workingDir, + }); + await archive.setConfig({ + baseUrl: 'https://example.com', + name: 'test', + version: '0.13.0', + recursive: true, + interval: 0, + image: false, + fetchExternal: false, + parallels: 1, + roots: ['https://example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: false, + }); + return archive; +} + +afterAll(async () => { + await fs.rm(workingDir, { recursive: true, force: true }); +}); + +describe('listDedupeCapEvents', () => { + let archive: InstanceType; + + beforeEach(async () => { + archive = await buildArchive(`dedupe-cap-events-${Date.now()}.nitpicker`); + }); + + it('returns rows ordered by detected_at DESC (newest first)', async () => { + await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/mid/{n}/', + sampleUrl: 'https://example.com/mid/1/', + bodyHash: Buffer.from('mid'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 2000, + }); + await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/oldest/{n}/', + sampleUrl: 'https://example.com/oldest/1/', + bodyHash: Buffer.from('oldest'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/newest/{n}/', + sampleUrl: 'https://example.com/newest/1/', + bodyHash: Buffer.from('newest'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 3000, + }); + + const result = await listDedupeCapEvents(archive); + expect(result.items.map((r) => r.shape_key)).toEqual([ + 'example.com/newest/{n}/', + 'example.com/mid/{n}/', + 'example.com/oldest/{n}/', + ]); + expect(result.total).toBe(3); + }); + + it('honours `limit` and `offset` for pagination', async () => { + for (const detectedAt of [3000, 2000, 1000]) { + await archive.insertDedupeCapEvent({ + shapeKey: `example.com/a/${detectedAt}/{n}/`, + sampleUrl: `https://example.com/a/${detectedAt}/1/`, + bodyHash: Buffer.from(String(detectedAt)), + effectiveThreshold: 50, + observedCount: 100, + detectedAt, + }); + } + + const firstPage = await listDedupeCapEvents(archive, { limit: 2 }); + expect(firstPage.items).toHaveLength(2); + expect(firstPage.total).toBe(3); + + const secondPage = await listDedupeCapEvents(archive, { limit: 2, offset: 2 }); + expect(secondPage.items).toHaveLength(1); + expect(secondPage.total).toBe(3); + }); + + it('returns every column, with body_hash as a hex string', async () => { + const id = await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/full/{n}/', + sampleUrl: 'https://example.com/full/1/', + bodyHash: Buffer.from('full-fields'), + effectiveThreshold: 25, + observedCount: 50, + detectedAt: 1000, + }); + await archive.finalizeDedupeCapEvent(id, 999); + + const result = await listDedupeCapEvents(archive); + expect(result.items[0]).toMatchObject({ + shape_key: 'example.com/full/{n}/', + sample_url: 'https://example.com/full/1/', + effective_threshold: 25, + observed_count: 50, + detected_at: 1000, + rejected_count: 999, + }); + expect(result.items[0]?.body_hash).toBe(Buffer.from('full-fields').toString('hex')); + expect(typeof result.items[0]?.id).toBe('number'); + }); + + it('rejected_count が未確定(crawlEnd未到達)のときnullをそのまま返す', async () => { + await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/unfinalized/{n}/', + sampleUrl: 'https://example.com/unfinalized/1/', + bodyHash: Buffer.from('unfinalized'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + + const result = await listDedupeCapEvents(archive); + expect(result.items[0]?.rejected_count).toBeNull(); + }); + + it('dedupe_cap_events が存在しない場合は空を返す(read-only / legacy archive fallback)', async () => { + await archive.getKnex().schema.dropTableIfExists('dedupe_cap_events'); + const result = await listDedupeCapEvents(archive); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it('テーブルはあるが記録が無い場合は空を返す', async () => { + const result = await listDedupeCapEvents(archive); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); +}); diff --git a/packages/@nitpicker/query/src/list-dedupe-cap-events.ts b/packages/@nitpicker/query/src/list-dedupe-cap-events.ts new file mode 100644 index 00000000..5c871121 --- /dev/null +++ b/packages/@nitpicker/query/src/list-dedupe-cap-events.ts @@ -0,0 +1,83 @@ +import type { DedupeCapEventEntry, ListDedupeCapEventsOptions } from './types.js'; +import type { ArchiveAccessor } from '@nitpicker/crawler'; + +/** + * List recorded same-cluster-cap audit rows from the archive, newest first. + * + * Surfaces the `dedupe_cap_events` table (opt-in `--dedupe-cap`, issue + * #208) so the CLI / MCP / viewer can answer "which URL shapes did this + * crawl confirm as self-generating traps, and how many anchors did the cap + * reject" — see `packages/@nitpicker/crawler/src/archive/create-adjunct-tables.ts`'s + * DDL JSDoc for the write-side contract. + * + * Tolerates a missing `dedupe_cap_events` table: archives that predate the + * table and read-only `stub` connections both arrive here with no table. + * Returns `{ items: [], total: 0 }` rather than throwing — mirrors + * `listNetworkOutages`'s handling of the same situation. + * + * Unlike `listNetworkOutages`, a `null` `rejected_count` (crawl never + * reached `crawlEnd`) is returned as-is, not resolved to a synthetic value + * — see `DedupeCapEventEntry.rejected_count`'s JSDoc for why no such + * resolution is needed here. + * + * Read-only — safe against viewer / stub-mode archives. + * @param accessor - The archive accessor to query. + * @param options - Pagination options. + * @returns Paginated list of dedupe-cap events ordered by `detected_at DESC`. + * @example + * ```ts + * const { items, total } = await listDedupeCapEvents(accessor, { limit: 10 }); + * for (const event of items) { + * console.log(`${event.shape_key}: ${event.rejected_count ?? 'unknown'} rejected`); + * } + * ``` + */ +export async function listDedupeCapEvents( + accessor: ArchiveAccessor, + options: ListDedupeCapEventsOptions = {}, +): Promise<{ items: DedupeCapEventEntry[]; total: number }> { + const knex = accessor.getKnex(); + const limit = options.limit ?? 100; + const offset = options.offset ?? 0; + + const hasTable = await knex.schema.hasTable('dedupe_cap_events'); + if (!hasTable) { + return { items: [], total: 0 }; + } + + const countResult = (await knex('dedupe_cap_events').count('id as total')) as { + total: number; + }[]; + const total = Number(countResult[0]?.total ?? 0); + + const rows = (await knex('dedupe_cap_events') + .select( + 'id', + 'shape_key', + 'sample_url', + 'body_hash', + 'effective_threshold', + 'observed_count', + 'detected_at', + 'rejected_count', + ) + .orderBy('detected_at', 'desc') + .limit(limit) + .offset(offset)) as { + id: number; + shape_key: string; + sample_url: string; + body_hash: Uint8Array | null; + effective_threshold: number; + observed_count: number; + detected_at: number; + rejected_count: number | null; + }[]; + + const items: DedupeCapEventEntry[] = rows.map((row) => ({ + ...row, + body_hash: row.body_hash ? Buffer.from(row.body_hash).toString('hex') : null, + })); + + return { items, total }; +} diff --git a/packages/@nitpicker/query/src/list-duplicate-body-clusters.spec.ts b/packages/@nitpicker/query/src/list-duplicate-body-clusters.spec.ts new file mode 100644 index 00000000..2ef9770a --- /dev/null +++ b/packages/@nitpicker/query/src/list-duplicate-body-clusters.spec.ts @@ -0,0 +1,282 @@ +import type { Meta } from '@d-zero/beholder'; + +import path from 'node:path'; + +import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url'; +import { Archive } from '@nitpicker/crawler'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { listDuplicateBodyClusters } from './list-duplicate-body-clusters.js'; + +const __filename = new URL(import.meta.url).pathname; +const __dirname = path.dirname(__filename); +const workingDir = path.resolve(__dirname, '__test_fixtures_duplicate_body_clusters__'); + +/** + * Builds a minimal, intentionally-partial `Meta` object for test fixtures — + * only the fields `deriveFlatFromMeta` actually reads (`title`, `og.url`) + * are populated. Matches the existing pragmatic convention in + * `find-duplicate-bodies.spec.ts` (vitest's esbuild/oxc transform does not + * type-check spec files, so this shape is validated at runtime behaviour, + * not full `Meta` compliance). + * @param title + * @param ogUrl + */ +function buildMeta(title: string, ogUrl?: string): Meta { + return { + title, + og: ogUrl ? { url: ogUrl } : undefined, + } as unknown as Meta; +} + +describe('listDuplicateBodyClusters', () => { + let archive: InstanceType; + const archiveFilePath = path.resolve(workingDir, 'dup-clusters-test.nitpicker'); + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(workingDir, { recursive: true }); + + archive = await Archive.create({ filePath: archiveFilePath, cwd: workingDir }); + await archive.setConfig({ + baseUrl: 'https://a.example.com', + name: 'test', + version: '0.13.0', + recursive: true, + interval: 0, + image: true, + fetchExternal: false, + parallels: 1, + roots: ['https://a.example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: false, + }); + + const pages: { url: string; html: string; title: string; ogUrl?: string }[] = [ + // "trap-mismatch": 5 pages, identical body/title, og:url all point + // at a parent listing (never the page itself) — ogUrlMismatchRatio + // should be 1.0. + ...Array.from({ length: 5 }, (_, i) => ({ + url: `https://a.example.com/news/date/${2020 + i}/`, + html: 'trap-mismatch body', + title: 'お知らせ', + ogUrl: 'https://a.example.com/news', + })), + // "trap-match": 4 pages, identical body/title, og:url always equal + // to the page's own absolute URL — ogUrlMismatchRatio should be 0. + ...Array.from({ length: 4 }, (_, i) => { + const url = `https://a.example.com/blog/post-${i}/`; + return { + url, + html: 'trap-match body', + title: 'ブログ', + ogUrl: url, + }; + }), + // "mixed-ratio": 4 pages, 2 mismatch + 2 match — ogUrlMismatchRatio 0.5. + ...Array.from({ length: 4 }, (_, i) => { + const url = `https://a.example.com/mixed/page-${i}/`; + return { + url, + html: 'mixed-ratio body', + title: '一覧', + ogUrl: i < 2 ? 'https://a.example.com/mixed' : url, + }; + }), + // "below-threshold": 2 pages, no og:url at all (ratio 0) — used to + // test minCount filtering. + ...Array.from({ length: 2 }, (_, i) => ({ + url: `https://a.example.com/small/page-${i}/`, + html: 'below-threshold body', + title: '小規模', + })), + // "non-uniform-title": 3 pages, same body but 2 distinct titles — + // must be excluded by the title-uniformity filter regardless of count. + { + url: 'https://a.example.com/nonuniform/a/', + html: 'non-uniform-title body', + title: 'タイトルA', + }, + { + url: 'https://a.example.com/nonuniform/b/', + html: 'non-uniform-title body', + title: 'タイトルA', + }, + { + url: 'https://a.example.com/nonuniform/c/', + html: 'non-uniform-title body', + title: 'タイトルB', + }, + // "null-title-uniform": 3 pages, same body, all titles empty + // (→ null after nullableString trims them) — must still be + // included (COALESCE fix), ratio 0 (no og:url). + ...Array.from({ length: 3 }, (_, i) => ({ + url: `https://a.example.com/no-title/page-${i}/`, + html: 'null-title-uniform body', + title: '', + })), + // "alias-pair": 2 pages sharing body/title — one is flipped to an + // alias of the other after insertion, must be excluded entirely. + { + url: 'https://a.example.com/alias/a/', + html: 'alias-pair body', + title: 'エイリアス', + }, + { + url: 'https://a.example.com/alias/b/', + html: 'alias-pair body', + title: 'エイリアス', + }, + ]; + + for (const p of pages) { + await archive.setPage({ + url: parseUrl(p.url)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: p.html, + meta: buildMeta(p.title, p.ogUrl), + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + + const knex = archive.getKnex(); + const idByUrl = async (url: string) => { + const row = await knex('content_items') + .join('url_refs', 'url_refs.id', 'content_items.url_id') + .where('url_refs.url', url) + .select('content_items.id as id') + .first(); + return row.id as number; + }; + const aliasTargetId = await idByUrl('https://a.example.com/alias/a/'); + const aliasSourceId = await idByUrl('https://a.example.com/alias/b/'); + await knex('content_items') + .where('id', aliasSourceId) + .update({ alias_of_id: aliasTargetId }); + }); + + afterAll(async () => { + if (archive) { + await archive.close(); + } + const { rmSync } = await import('node:fs'); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('minCount未満のクラスタを除外する', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 3 }); + const hit = result.find((c) => c.count === 2 && c.ogUrlMismatchRatio === 0); + // below-threshold (count=2) must not appear when minCount=3 + expect(hit).toBeUndefined(); + }); + + it('タイトルが一致しないクラスタを件数に関わらず除外する', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 2 }); + const samplePagesFlat = result.flatMap((c) => c.samplePages); + expect(samplePagesFlat).not.toContain('https://a.example.com/nonuniform/a/'); + expect(samplePagesFlat).not.toContain('https://a.example.com/nonuniform/b/'); + expect(samplePagesFlat).not.toContain('https://a.example.com/nonuniform/c/'); + }); + + it('全ページtitleがNULLで一致しているクラスタは除外しない(COALESCE)', async () => { + const result = await listDuplicateBodyClusters(archive, { + minCount: 3, + samplePagesLimit: 10, + }); + const cluster = result.find((c) => c.count === 3 && c.ogUrlMismatchRatio === 0); + expect(cluster).toBeDefined(); + expect(cluster?.samplePages.some((u) => u.includes('/no-title/'))).toBe(true); + }); + + it('alias_of_idが設定されたページを含むクラスタを除外する', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 2 }); + const samplePagesFlat = result.flatMap((c) => c.samplePages); + expect(samplePagesFlat).not.toContain('https://a.example.com/alias/a/'); + expect(samplePagesFlat).not.toContain('https://a.example.com/alias/b/'); + }); + + it('og:urlが常に親一覧を指すクラスタはogUrlMismatchRatio=1になる', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 3 }); + const cluster = result.find((c) => c.count === 5); + expect(cluster).toBeDefined(); + expect(cluster?.ogUrlMismatchRatio).toBe(1); + expect(cluster?.signature).toMatch(/^[0-9a-f]{64}$/); + }); + + it('og:urlが常に自URLと一致するクラスタはogUrlMismatchRatio=0になる', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 3 }); + const cluster = result.find( + (c) => c.count === 4 && c.samplePages.some((u) => u.includes('/blog/')), + ); + expect(cluster).toBeDefined(); + expect(cluster?.ogUrlMismatchRatio).toBe(0); + }); + + it('og:urlが半々で一致/不一致のクラスタはogUrlMismatchRatio=0.5になる', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 3 }); + const cluster = result.find( + (c) => c.count === 4 && c.samplePages.some((u) => u.includes('/mixed/')), + ); + expect(cluster).toBeDefined(); + expect(cluster?.ogUrlMismatchRatio).toBe(0.5); + }); + + it('ogUrlMismatchRatio降順・count降順でソートされる', async () => { + const result = await listDuplicateBodyClusters(archive, { minCount: 2 }); + const ratios = result.map((c) => c.ogUrlMismatchRatio); + for (let i = 1; i < ratios.length; i++) { + expect(ratios[i]! <= ratios[i - 1]!).toBe(true); + } + }); + + it('samplePagesLimitでsamplePagesを切り詰めるが count は全件を反映する', async () => { + const result = await listDuplicateBodyClusters(archive, { + minCount: 3, + samplePagesLimit: 2, + }); + const cluster = result.find((c) => c.count === 5); + expect(cluster).toBeDefined(); + expect(cluster?.samplePages).toHaveLength(2); + expect(cluster?.count).toBe(5); + }); + + it('commonDirectoriesを全メンバーURLから計算する(samplePagesの切り詰めに影響されない)', async () => { + const result = await listDuplicateBodyClusters(archive, { + minCount: 3, + samplePagesLimit: 1, + }); + const cluster = result.find((c) => c.count === 5); + expect(cluster).toBeDefined(); + const totalFromDirectories = cluster!.commonDirectories.reduce( + (sum, d) => sum + d.pageCount, + 0, + ); + expect(totalFromDirectories).toBe(5); + }); + + it('limit/offsetでページングできる', async () => { + const all = await listDuplicateBodyClusters(archive, { minCount: 2, limit: 50 }); + const paged = await listDuplicateBodyClusters(archive, { + minCount: 2, + limit: 50, + offset: 1, + }); + expect(paged).toEqual(all.slice(1)); + }); +}); diff --git a/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts b/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts new file mode 100644 index 00000000..45cbe714 --- /dev/null +++ b/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts @@ -0,0 +1,154 @@ +import type { + DuplicateBodyClusterEntry, + ListDuplicateBodyClustersOptions, +} from './types.js'; +import type { ArchiveAccessor } from '@nitpicker/crawler'; + +import { computeDirectoryDistribution } from './compute-directory-distribution.js'; +import { requireAliasOfIdColumn } from './require-alias-of-id-column.js'; + +const DEFAULT_MIN_COUNT = 10; +const DEFAULT_LIMIT = 50; +const DEFAULT_SAMPLE_PAGES_LIMIT = 20; + +/** + * Lists same-`body_hash` clusters filtered and ranked for the "is this a + * same-cluster trap" question (issue #208), on top of + * {@link import('./find-duplicate-bodies.js').findDuplicateBodies}'s raw + * grouping. + * + * A new function rather than extending `findDuplicateBodies`: that + * function's `{bodyHash, urls, count}` contract already has CLI/MCP + * consumers expecting every member URL back, so adding a `minCount` filter, + * a title-uniformity requirement, and a trap-oriented sort there would be a + * breaking change for them. This function reuses `body_hash` as-is for its + * `signature` (issue #208 originally proposed defining a new hash for this — + * unnecessary, since `body_hash` already is one) and needs no additional + * JOIN beyond what `findDuplicateBodies` already uses: `title_text_id` and + * `og_url_id` are both already columns on `page_meta`. + * + * **Two-stage query, not one**: a single `GROUP_CONCAT` (as + * `findDuplicateBodies` uses) cannot express "first N URLs per group" — + * SQLite's `GROUP_CONCAT` has no `ORDER BY ... LIMIT` inside the aggregate. + * Stage 1 computes the filtered/ranked cluster list from aggregate columns + * only (no per-row URL data). Stage 2 then fetches, per surviving cluster, + * every member URL — needed in full (not just `samplePagesLimit`) because + * `commonDirectories` must reflect the true distribution across the whole + * cluster (see `computeDirectoryDistribution`'s own JSDoc on why a partial + * sample would misrepresent a multi-section trap). `samplePages` is then a + * plain in-memory slice of that same fetch. Splitting into two stages keeps + * stage 1's `GROUP BY` from ever materialising a member URL list only to + * discard everything past `limit` clusters. + * + * **Title-uniformity filter uses `COALESCE(title_text_id, -1)`, not the raw + * column**: `COUNT(DISTINCT title_text_id)` ignores `NULL`s, so a cluster + * where every member page lacks a `` (all `NULL`) would otherwise + * evaluate to `0`, failing a plain `= 1` check even though every member + * agrees (on having no title). Coalescing to a sentinel keeps that case + * indistinguishable from "everyone has the same real title". + * + * **`og:url` mismatch is a ratio, not a boolean**: "does this cluster's + * `og:url` point elsewhere" is a per-page fact, and a cluster's members do + * not always agree — expressing it as + * `SUM(mismatch ? 1 : 0) / COUNT(*)` (rather than an ANY/ALL boolean) + * avoids having to define which of those two the boolean would mean. + * + * Applies the same `alias_of_id` filter `findDuplicates` uses (unlike + * `findDuplicateBodies`, which omits it — see that function's own JSDoc): + * a Tier B alias pair is defined by matching `title_text_id` AND + * `body_hash`, so an alias pair would otherwise always surface here as a + * (spurious) 2-page cluster. + * @param accessor - The archive accessor to query. + * @param options - See {@link ListDuplicateBodyClustersOptions}. + * @returns Clusters ranked by `ogUrlMismatchRatio` descending, then `count` + * descending, then `signature` ascending (deterministic tie-break). + * @throws {Error} If `page_meta.body_hash` does not exist on this connection + * (see `findDuplicateBodies`'s matching throw for the same reasoning), or + * if `content_items.alias_of_id` does not exist (see + * `requireAliasOfIdColumn`). + * @example + * ```ts + * const clusters = await listDuplicateBodyClusters(accessor, { minCount: 100 }); + * for (const cluster of clusters) { + * console.log(`${cluster.count} pages, og:url mismatch ${cluster.ogUrlMismatchRatio}`); + * } + * ``` + */ +export async function listDuplicateBodyClusters( + accessor: ArchiveAccessor, + options: ListDuplicateBodyClustersOptions = {}, +): Promise<DuplicateBodyClusterEntry[]> { + const knex = accessor.getKnex(); + + if (!(await knex.schema.hasColumn('page_meta', 'body_hash'))) { + throw new Error( + 'listDuplicateBodyClusters: this archive predates the page_meta.body_hash column. ' + + 'Run `viewer-build` (or a writable crawl: `crawl --append` / `--retry-failed`) ' + + 'against it once to add and backfill body_hash before querying clusters.', + ); + } + await requireAliasOfIdColumn(knex); + + const minCount = options.minCount ?? DEFAULT_MIN_COUNT; + const limit = options.limit ?? DEFAULT_LIMIT; + const offset = options.offset ?? 0; + const samplePagesLimit = options.samplePagesLimit ?? DEFAULT_SAMPLE_PAGES_LIMIT; + + // Stage 1: aggregate-only. No URL data yet — see this function's JSDoc + // for why fetching URLs here would waste work on clusters `HAVING`/`LIMIT` + // later discard. + const clusterRows = (await knex('page_meta as pm') + .join('content_items as ci', 'ci.id', 'pm.page_id') + .select( + knex.raw('"pm"."body_hash" as bodyHash'), + knex.raw('count(*) as cnt'), + knex.raw( + 'cast(sum(case when "pm"."og_url_id" is not null and "pm"."og_url_id" != "ci"."url_id" then 1 else 0 end) as real) / count(*) as ogUrlMismatchRatio', + ), + ) + .where({ 'ci.scraped': 1, 'ci.is_external': 0 }) + .whereNull('ci.redirect_dest_id') + .whereNull('ci.alias_of_id') + .whereNotNull('pm.body_hash') + .groupBy('pm.body_hash') + .having(knex.raw('count(*) >= ?', [minCount])) + .having(knex.raw('count(distinct coalesce("pm"."title_text_id", -1)) = 1')) + .orderBy([ + { column: 'ogUrlMismatchRatio', order: 'desc' }, + { column: 'cnt', order: 'desc' }, + { column: 'pm.body_hash', order: 'asc' }, + ]) + .limit(limit) + .offset(offset)) as { + bodyHash: Uint8Array; + cnt: number; + ogUrlMismatchRatio: number; + }[]; + + // Stage 2: per surviving cluster, fetch every member URL. + return Promise.all( + clusterRows.map(async (row) => { + // The driver only binds Buffer (not a plain Uint8Array) for a BLOB + // parameter — `row.bodyHash` as returned by stage 1 is a bare + // Uint8Array, so it must be re-wrapped before use in a `.where()`. + const bodyHash = Buffer.from(row.bodyHash); + const urlRows = (await knex('page_meta as pm') + .join('content_items as ci', 'ci.id', 'pm.page_id') + .join('url_refs as ur', 'ur.id', 'ci.url_id') + .select('ur.url as url') + .where({ 'ci.scraped': 1, 'ci.is_external': 0, 'pm.body_hash': bodyHash }) + .whereNull('ci.redirect_dest_id') + .whereNull('ci.alias_of_id') + .orderBy('ur.url', 'asc')) as { url: string }[]; + const urls = urlRows.map((r) => r.url); + + return { + signature: bodyHash.toString('hex'), + count: Number(row.cnt), + ogUrlMismatchRatio: Number(row.ogUrlMismatchRatio), + samplePages: urls.slice(0, samplePagesLimit), + commonDirectories: computeDirectoryDistribution(urls), + }; + }), + ); +} diff --git a/packages/@nitpicker/query/src/query.ts b/packages/@nitpicker/query/src/query.ts index 9cb0a5e7..032bfc18 100644 --- a/packages/@nitpicker/query/src/query.ts +++ b/packages/@nitpicker/query/src/query.ts @@ -59,8 +59,10 @@ export { getViolations } from './get-violations.js'; export { hasViewerReadModel } from './viewer-read-model/has-viewer-read-model.js'; export { isViewerReadModelCurrent } from './viewer-read-model/is-viewer-read-model-current.js'; export { listConsoleLogs } from './list-console-logs.js'; +export { listDedupeCapEvents } from './list-dedupe-cap-events.js'; export { listDirectoryChildren } from './list-directory-children.js'; export { listDirectoryPages } from './list-directory-pages.js'; +export { listDuplicateBodyClusters } from './list-duplicate-body-clusters.js'; export { listExternalLinks } from './list-external-links.js'; export { listImages } from './list-images.js'; export { listInboundLinks } from './list-inbound-links.js'; diff --git a/packages/@nitpicker/query/src/types.ts b/packages/@nitpicker/query/src/types.ts index 7057cc06..5aaf84c2 100644 --- a/packages/@nitpicker/query/src/types.ts +++ b/packages/@nitpicker/query/src/types.ts @@ -382,6 +382,50 @@ export interface ListNetworkOutagesOptions { offset?: number; } +/** + * One row of {@link import('./list-dedupe-cap-events.js').listDedupeCapEvents} + * output — one URL shape confirmed as a same-cluster trap during a crawl + * (opt-in `--dedupe-cap`, issue #208). + * + * Schema-mirror of the `dedupe_cap_events` table. + */ +export interface DedupeCapEventEntry { + /** Autoincrement primary key (monotonically increasing per archive). */ + id: number; + /** The URL shape key that capped (e.g. `example.com/news/date/{n}/`) — a template, not a navigable URL. */ + shape_key: string; + /** One concrete member URL, captured at cap time for human identification. */ + sample_url: string; + /** `computeBodyHash` result recorded at cap time, as a 64-char hex string, or `null` if the page had no rendered body. */ + body_hash: string | null; + /** The Misra-Gries threshold that actually triggered the cap, after halving for confidence signals — not necessarily `--dedupe-cap`'s raw value. */ + effective_threshold: number; + /** The tracker's Misra-Gries counter value at cap time — a lower bound on matching-signature pages seen, not an exact observation count. */ + observed_count: number; + /** Epoch ms the cap was confirmed. */ + detected_at: number; + /** + * Number of anchors rejected for this shape after it capped, finalized + * once at `crawlEnd`. `null` means "unknown" (the crawl never reached + * `crawlEnd`) — unlike `NetworkOutageEntry.ended_at`, this is never + * resolved on the fly to a synthetic value, since there is no + * "unbounded window" correctness hazard to guard against here (see + * `dedupe_cap_events`'s DDL JSDoc). + */ + rejected_count: number | null; +} + +/** + * Pagination options for + * {@link import('./list-dedupe-cap-events.js').listDedupeCapEvents}. + */ +export interface ListDedupeCapEventsOptions { + /** Maximum rows to return. Defaults to 100. */ + limit?: number; + /** Rows to skip from the start. Defaults to 0. */ + offset?: number; +} + /** * Options for opening a .nitpicker archive file. */ @@ -2183,6 +2227,56 @@ export interface DuplicateBodyEntry { count: number; } +/** + * One same-`body_hash` cluster, filtered and enriched for the "is this a + * same-cluster trap" question (issue #208) — the curated counterpart of + * {@link import('./find-duplicate-bodies.js').findDuplicateBodies}'s raw, + * unfiltered group list. See + * {@link import('./list-duplicate-body-clusters.js').listDuplicateBodyClusters}. + */ +export interface DuplicateBodyClusterEntry { + /** The shared `body_hash`, as a 64-char hex string — reused as-is rather than defining a new hash (issue #208's proposal to define a separate "signature" was dropped; `body_hash` already is one). */ + signature: string; + /** Total number of pages in this cluster. */ + count: number; + /** + * Fraction (0–1, over the full `count`) of member pages whose non-null + * `og:url` points somewhere other than the page itself — a + * pager/query-parameter trap's `og:url` typically still points at the + * parent listing rather than the (fake) paginated URL, so a high ratio + * here is a trap indicator. A page with no `og:url` at all contributes to + * the denominator (`count`) but not the numerator, since the ratio + * expresses "share of the whole cluster", not "share of pages that have + * an og:url". + */ + ogUrlMismatchRatio: number; + /** Up to the caller's `samplePagesLimit` member URLs (not the full member list — see `count` for the true size). */ + samplePages: string[]; + /** + * Top-N first-path-segment directories across the cluster's FULL member + * set (not just `samplePages`) — see `computeDirectoryDistribution` for + * why this is a frequency distribution rather than a single deepest + * common prefix, and why a plain single `parentPath` (as issue #208 + * originally proposed) would collapse a multi-section trap to the site + * root. + */ + commonDirectories: DirectoryDistributionEntry[]; +} + +/** + * Options for {@link import('./list-duplicate-body-clusters.js').listDuplicateBodyClusters}. + */ +export interface ListDuplicateBodyClustersOptions { + /** Minimum cluster size to include. Defaults to 10 (issue #208's proposed N). */ + minCount?: number; + /** Maximum number of clusters to return. Defaults to 50. */ + limit?: number; + /** Number of clusters (in ranked order) to skip before `limit` is applied. Defaults to 0. */ + offset?: number; + /** Maximum number of sample URLs per cluster. Defaults to 20. */ + samplePagesLimit?: number; +} + /** * A metadata mismatch found on a page. */ From e41a4230e6b8b9ba1315ef9dd8b922c642e5cbeb Mon Sep 17 00:00:00 2001 From: Yusuke Hirao <hirao@d-zero.co.jp> Date: Fri, 31 Jul 2026 02:39:59 +0900 Subject: [PATCH 04/10] feat(mcp-server): add find_duplicate_clusters and list_dedupe_cap_events tools Exposes the new query/src/list-duplicate-body-clusters.ts and list-dedupe-cap-events.ts functions (issue #208) as MCP tools, matching the find_duplicates/find_duplicate_bodies and list_network_outages naming conventions. --- .../mcp-server/src/mcp-server.spec.ts | 140 +++++++++++++++++- .../@nitpicker/mcp-server/src/mcp-server.ts | 22 +++ .../mcp-server/src/tool-definitions.ts | 42 ++++++ 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts b/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts index 149ad92e..33e7c912 100644 --- a/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts +++ b/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts @@ -267,9 +267,9 @@ describe('createServer', () => { rmSync(workingDir, { recursive: true, force: true }); }); - it('ListTools で32個のツールが返される', async () => { + it('ListTools で34個のツールが返される', async () => { const result = await listTools(server); - expect(result.tools).toHaveLength(32); + expect(result.tools).toHaveLength(34); const names = result.tools.map((t) => t.name); expect(names).toContain('open_archive'); expect(names).toContain('list_inbound_links'); @@ -282,6 +282,8 @@ describe('createServer', () => { expect(names).toContain('get_tag_inventory'); expect(names).toContain('get_page_jsonld'); expect(names).toContain('get_page_tags'); + expect(names).toContain('find_duplicate_clusters'); + expect(names).toContain('list_dedupe_cap_events'); expect(names).toContain('get_page_main_contents'); expect(names).toContain('count_pages_by_tag'); expect(names).toContain('count_pages_by_jsonld_type'); @@ -330,6 +332,20 @@ describe('createServer', () => { expect(data).toEqual({ items: [], total: 0 }); }); + it('list_dedupe_cap_events でdedupe-cap発火の一覧を取得する(未記録なら空配列)', async () => { + const result = await callTool(server, 'list_dedupe_cap_events', { archiveId }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data).toEqual({ items: [], total: 0 }); + }); + + it('find_duplicate_clusters でminCount以上のクラスタを検出する(fixtureは重複件数が少ないため空配列)', async () => { + const result = await callTool(server, 'find_duplicate_clusters', { archiveId }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data).toEqual([]); + }); + it('list_console_logs で捕捉した console ログを集約して取得する', async () => { const result = await callTool(server, 'list_console_logs', { archiveId }); expect(result.isError).toBeUndefined(); @@ -730,3 +746,123 @@ describe('createServer stub-mode support', () => { await callTool(server, 'close_archive', { archiveId: data.archiveId }); }); }); + +describe('createServer: find_duplicate_clusters / list_dedupe_cap_events with real data (issue #208)', () => { + // A dedicated small fixture, separate from the shared one above, so + // asserting on non-empty results here can't be broken by unrelated + // changes to the shared fixture's page count/content. + const dedupeCapWorkingDir = path.resolve(workingDir, '__mcp_dedupe_cap_fixture__'); + const dedupeCapFilePath = path.resolve(dedupeCapWorkingDir, 'mcp-dedupe-cap.nitpicker'); + let server: ReturnType<typeof createServer>; + let archiveId: string; + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(dedupeCapWorkingDir, { recursive: true }); + const archive = await Archive.create({ + filePath: dedupeCapFilePath, + cwd: dedupeCapWorkingDir, + }); + + await archive.setConfig({ + baseUrl: 'https://trap.example.com', + roots: ['https://trap.example.com'], + name: 'mcp-dedupe-cap', + version: '0.13.0', + recursive: true, + interval: 0, + image: false, + fetchExternal: false, + parallels: 1, + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'mcp-dedupe-cap', + ignoreRobots: false, + }); + + // Three pages sharing one body_hash and title — a same-cluster trap. + for (let i = 0; i < 3; i++) { + await archive.setPage({ + url: parseUrl(`https://trap.example.com/news/date/${i}/`)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: '<html><body>trap body</body></html>', + meta: { title: 'お知らせ' } as never, + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + + await archive.insertDedupeCapEvent({ + shapeKey: 'trap.example.com/news/date/{n}/', + sampleUrl: 'https://trap.example.com/news/date/0/', + bodyHash: Buffer.from('trap-body-hash'), + effectiveThreshold: 1, + observedCount: 2, + detectedAt: 1000, + }); + + await buildViewerReadModel(archive); + await archive.write(); + await archive.close(); + + server = createServer(); + const result = await callTool(server, 'open_archive', { + filePath: dedupeCapFilePath, + }); + archiveId = JSON.parse(result.content[0]!.text).archiveId; + }); + + afterAll(async () => { + const { rmSync } = await import('node:fs'); + rmSync(dedupeCapWorkingDir, { recursive: true, force: true }); + }); + + it('find_duplicate_clusters がminCount/samplePagesLimit引数を実際に反映した結果を返す', async () => { + const result = await callTool(server, 'find_duplicate_clusters', { + archiveId, + minCount: 3, + samplePagesLimit: 2, + }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data).toHaveLength(1); + expect(data[0].count).toBe(3); + expect(data[0].samplePages).toHaveLength(2); + }); + + it('find_duplicate_clusters はminCountを満たさない場合は空配列を返す', async () => { + const result = await callTool(server, 'find_duplicate_clusters', { + archiveId, + minCount: 10, + }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data).toEqual([]); + }); + + it('list_dedupe_cap_events が記録済みのcapイベントを返す', async () => { + const result = await callTool(server, 'list_dedupe_cap_events', { archiveId }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.total).toBe(1); + expect(data.items[0]).toMatchObject({ + shape_key: 'trap.example.com/news/date/{n}/', + sample_url: 'https://trap.example.com/news/date/0/', + effective_threshold: 1, + observed_count: 2, + }); + }); +}); diff --git a/packages/@nitpicker/mcp-server/src/mcp-server.ts b/packages/@nitpicker/mcp-server/src/mcp-server.ts index e23e488e..631c5dde 100644 --- a/packages/@nitpicker/mcp-server/src/mcp-server.ts +++ b/packages/@nitpicker/mcp-server/src/mcp-server.ts @@ -26,6 +26,8 @@ import { getTagInventory, getViolations, listConsoleLogs, + listDedupeCapEvents, + listDuplicateBodyClusters, listInboundLinks, listIsolatedClustersFastPath, listIsolatedPagesFastPath, @@ -383,6 +385,17 @@ export function createServer() { ), ); } + case 'find_duplicate_clusters': { + const accessor = manager.get(requireString(args, 'archiveId')); + return jsonResult( + await listDuplicateBodyClusters(accessor, { + minCount: optionalNumber(args, 'minCount'), + limit: optionalNumber(args, 'limit'), + offset: optionalNumber(args, 'offset'), + samplePagesLimit: optionalNumber(args, 'samplePagesLimit'), + }), + ); + } case 'find_mismatches': { const accessor = manager.get(requireString(args, 'archiveId')); const type = validateEnum( @@ -474,6 +487,15 @@ export function createServer() { }), ); } + case 'list_dedupe_cap_events': { + const accessor = manager.get(requireString(args, 'archiveId')); + return jsonResult( + await listDedupeCapEvents(accessor, { + limit: optionalNumber(args, 'limit'), + offset: optionalNumber(args, 'offset'), + }), + ); + } case 'list_pages_by_tag': { const accessor = manager.get(requireString(args, 'archiveId')); return jsonResult( diff --git a/packages/@nitpicker/mcp-server/src/tool-definitions.ts b/packages/@nitpicker/mcp-server/src/tool-definitions.ts index 8071b889..9b8bff4d 100644 --- a/packages/@nitpicker/mcp-server/src/tool-definitions.ts +++ b/packages/@nitpicker/mcp-server/src/tool-definitions.ts @@ -764,4 +764,46 @@ export const toolDefinitions: Tool[] = [ required: ['archiveId'], }, }, + { + name: 'find_duplicate_clusters', + description: + 'Find same-`body_hash` clusters filtered and ranked for "is this a self-generating crawl trap" (a pager/query-parameter loop the crawler kept following, e.g. `/news/date/{n}/`) — the curated counterpart of find_duplicate_bodies. Filters to clusters at or above minCount with a uniform title across every member page, and ranks by ogUrlMismatchRatio (share of members whose og:url points elsewhere, typically the parent listing) then cluster size. Each result includes a bounded samplePages list and a commonDirectories frequency distribution computed from the full member set.', + inputSchema: { + type: 'object' as const, + properties: { + archiveId: { + type: 'string', + description: 'The archive ID returned by open_archive', + }, + minCount: { + type: 'number', + description: 'Minimum cluster size to include (default: 10)', + }, + limit: { type: 'number', description: 'Max clusters (default: 50)' }, + offset: { type: 'number', description: 'Clusters to skip (default: 0)' }, + samplePagesLimit: { + type: 'number', + description: 'Max inline sample URLs per cluster (default: 20)', + }, + }, + required: ['archiveId'], + }, + }, + { + name: 'list_dedupe_cap_events', + description: + 'List recorded same-cluster-cap audit rows: URL shapes (e.g. `example.com/news/date/{n}/`) that the opt-in `--dedupe-cap` crawl flag confirmed as self-generating traps and stopped enqueueing further anchors for. Each row has the shape key, a sample URL, the effective threshold that triggered the cap (after halving for confidence signals), the observed count, when it was detected, and rejected_count (anchors rejected afterward — null if the crawl never reached crawlEnd).', + inputSchema: { + type: 'object' as const, + properties: { + archiveId: { + type: 'string', + description: 'The archive ID returned by open_archive', + }, + limit: { type: 'number', description: 'Max results (default: 100)' }, + offset: { type: 'number', description: 'Results to skip (default: 0)' }, + }, + required: ['archiveId'], + }, + }, ]; From 7cb87f1027d382dda36dd10c2ebee9d583d0f8d1 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao <hirao@d-zero.co.jp> Date: Fri, 31 Jul 2026 02:40:28 +0900 Subject: [PATCH 05/10] feat(viewer): add Duplicate Clusters view for issue #208 New /duplicate-clusters route showing same-body_hash clusters (size, og:url-mismatch ratio, common directories, sample pages), following the template-clusters route/cache pattern (memory LRU, no read model). Surfaces a "crawl confirmed N same-cluster traps" notice sourced from --dedupe-cap's dedupe_cap_events, mirroring how network_outages backs a notice rather than a standalone page. Adds a dedicated Playwright fixture/config (duplicate-clusters), since the shared e2e fixture has no body_hash cluster meeting the minCount threshold. --- .../viewer/e2e/duplicate-clusters.spec.ts | 59 +++++++ .../generate-duplicate-clusters-fixture.mjs | 90 ++++++++++ packages/@nitpicker/viewer/package.json | 3 +- .../@nitpicker/viewer/playwright.config.ts | 24 +-- .../playwright.duplicate-clusters.config.ts | 51 ++++++ packages/@nitpicker/viewer/src/create-app.ts | 4 + .../src/duplicate-clusters-cache.spec.ts | 156 ++++++++++++++++++ .../viewer/src/duplicate-clusters-cache.ts | 53 ++++++ .../register-dedupe-cap-events-route.spec.ts | 154 +++++++++++++++++ .../register-dedupe-cap-events-route.ts | 38 +++++ .../register-duplicate-clusters-route.spec.ts | 130 +++++++++++++++ .../register-duplicate-clusters-route.ts | 32 ++++ .../viewer/web/api/use-dedupe-cap-events.ts | 19 +++ .../viewer/web/api/use-duplicate-clusters.ts | 28 ++++ packages/@nitpicker/viewer/web/app.tsx | 2 + .../viewer/web/components/nav-sidebar.tsx | 1 + .../viewer/web/i18n/translations.ts | 34 ++++ .../web/routes/duplicate-clusters-view.tsx | 112 +++++++++++++ 18 files changed, 978 insertions(+), 12 deletions(-) create mode 100644 packages/@nitpicker/viewer/e2e/duplicate-clusters.spec.ts create mode 100644 packages/@nitpicker/viewer/e2e/generate-duplicate-clusters-fixture.mjs create mode 100644 packages/@nitpicker/viewer/playwright.duplicate-clusters.config.ts create mode 100644 packages/@nitpicker/viewer/src/duplicate-clusters-cache.spec.ts create mode 100644 packages/@nitpicker/viewer/src/duplicate-clusters-cache.ts create mode 100644 packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.spec.ts create mode 100644 packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.ts create mode 100644 packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.spec.ts create mode 100644 packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.ts create mode 100644 packages/@nitpicker/viewer/web/api/use-dedupe-cap-events.ts create mode 100644 packages/@nitpicker/viewer/web/api/use-duplicate-clusters.ts create mode 100644 packages/@nitpicker/viewer/web/routes/duplicate-clusters-view.tsx diff --git a/packages/@nitpicker/viewer/e2e/duplicate-clusters.spec.ts b/packages/@nitpicker/viewer/e2e/duplicate-clusters.spec.ts new file mode 100644 index 00000000..9afe072b --- /dev/null +++ b/packages/@nitpicker/viewer/e2e/duplicate-clusters.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +/** + * Covers the Duplicate Clusters view (issue #208) against a dedicated + * fixture (`generate-duplicate-clusters-fixture.mjs`) — see + * `playwright.duplicate-clusters.config.ts` for why a dedicated fixture is + * needed here. + */ +test.describe('Nitpicker Viewer duplicate clusters', () => { + test('デフォルトのminCount(10)で12件のクラスタが表示される', async ({ page }) => { + await page.goto('/duplicate-clusters'); + await expect( + page.getByRole('heading', { name: 'Duplicate Clusters', level: 1 }), + ).toBeVisible(); + + const cluster = page.locator('details').first(); + await expect(cluster.locator('summary')).toContainText('12 pages'); + }); + + test('crawlが確認したdedupe-cap traps件数の通知を表示する', async ({ page }) => { + await page.goto('/duplicate-clusters'); + await expect(page.getByText(/same-cluster trap/)).toBeVisible(); + }); + + test('クラスタを展開するとog:url不一致率・シグネチャ・サンプルページを表示する', async ({ + page, + }) => { + await page.goto('/duplicate-clusters'); + + const cluster = page.locator('details').first(); + await cluster.locator('summary').click(); + await expect(cluster).toContainText('Signature (body hash)'); + await expect(cluster).toContainText('og:url mismatch ratio'); + await expect(cluster).toContainText('Sample pages'); + }); + + test('minCountを引き上げるとクラスタが消える', async ({ page }) => { + await page.goto('/duplicate-clusters'); + await expect(page.locator('details')).toHaveCount(1); + + await page.getByLabel('Minimum cluster size').fill('100'); + await expect( + page.getByText('No clusters found at or above this size.'), + ).toBeVisible(); + }); + + test('共通ディレクトリのリンクをクリックするとdirectoryフィルタ付きでPagesビューに遷移する', async ({ + page, + }) => { + await page.goto('/duplicate-clusters'); + + const cluster = page.locator('details').first(); + await cluster.locator('summary').click(); + await cluster.getByRole('link', { name: /example\.com\/news\// }).click(); + + await expect(page).toHaveURL(/\/pages\?directory=/); + await expect(page.getByRole('heading', { name: 'Pages', level: 1 })).toBeVisible(); + }); +}); diff --git a/packages/@nitpicker/viewer/e2e/generate-duplicate-clusters-fixture.mjs b/packages/@nitpicker/viewer/e2e/generate-duplicate-clusters-fixture.mjs new file mode 100644 index 00000000..b14ff267 --- /dev/null +++ b/packages/@nitpicker/viewer/e2e/generate-duplicate-clusters-fixture.mjs @@ -0,0 +1,90 @@ +import { mkdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; + +import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url'; +import { Archive } from '@nitpicker/crawler'; + +const dirname = import.meta.dirname; +const FIXTURE_PATH = path.resolve(dirname, '.fixture-duplicate-clusters.nitpicker'); +const FIXTURE_CWD = path.resolve(dirname, '.fixture-duplicate-clusters-tmp'); + +rmSync(FIXTURE_PATH, { force: true }); +rmSync(FIXTURE_CWD, { recursive: true, force: true }); +mkdirSync(FIXTURE_CWD, { recursive: true }); + +const archive = await Archive.create({ filePath: FIXTURE_PATH, cwd: FIXTURE_CWD }); +await archive.setConfig({ + baseUrl: 'https://example.com', + name: 'e2e-duplicate-clusters-fixture', + version: '0.13.0', + recursive: true, + interval: 0, + image: false, + fetchExternal: false, + parallels: 1, + roots: ['https://example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'e2e', + ignoreRobots: false, +}); + +/** + * Registers one internal, successfully-crawled page — a same-cluster-trap + * page (identical `<body>`, identical title, `og:url` pointing at a parent + * listing rather than itself). + * @param url - The page's absolute URL. + * @param ogUrl - Absolute `og:url` value to embed (self or parent listing). + */ +async function setTrapPage(url, ogUrl) { + await archive.setPage({ + url: parseUrl(url), + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 200, + responseHeaders: {}, + html: '<html><head><title>お知らせtrap body (identical across every member)', + meta: { title: 'お知らせ', og: { url: ogUrl } }, + anchorList: [], + imageList: [], + isSkipped: false, + }); +} + +// A 12-member same-cluster trap spanning two directories (exercises +// `commonDirectories`' top-N distribution) — 8 members with `og:url` +// pointing at the parent listing (mismatch) and 4 pointing at themselves +// (match), so `ogUrlMismatchRatio` renders as a non-trivial value rather +// than a trivial 0 or 1. +for (let i = 0; i < 8; i++) { + const url = `https://example.com/news/date/${2015 + i}/`; + await setTrapPage(url, 'https://example.com/news'); +} +for (let i = 0; i < 4; i++) { + const url = `https://example.com/press/date/${2015 + i}/`; + await setTrapPage(url, url); +} + +const eventId = await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/news/date/{n}/', + sampleUrl: 'https://example.com/news/date/2022/', + bodyHash: Buffer.from('trap-body-hash-placeholder'), + effectiveThreshold: 5, + observedCount: 8, + detectedAt: Date.now(), +}); +await archive.finalizeDedupeCapEvent(eventId, 42); + +await archive.write(); +await archive.close(); +// eslint-disable-next-line no-console +console.log(`E2E duplicate-clusters fixture created: ${FIXTURE_PATH}`); diff --git a/packages/@nitpicker/viewer/package.json b/packages/@nitpicker/viewer/package.json index 8ff72685..1495af2e 100644 --- a/packages/@nitpicker/viewer/package.json +++ b/packages/@nitpicker/viewer/package.json @@ -33,7 +33,8 @@ "test:e2e:stub": "node ./e2e/generate-stub-fixture.mjs && playwright test --config playwright.stub.config.ts", "test:e2e:directory-tree": "node ./e2e/generate-directory-tree-fixture.mjs && playwright test --config playwright.directory-tree.config.ts", "test:e2e:template-clusters": "node ./e2e/generate-template-clusters-fixture.mjs && playwright test --config playwright.template-clusters.config.ts", - "test:e2e:inbound-links": "node ./e2e/generate-inbound-links-fixture.mjs && playwright test --config playwright.inbound-links.config.ts" + "test:e2e:inbound-links": "node ./e2e/generate-inbound-links-fixture.mjs && playwright test --config playwright.inbound-links.config.ts", + "test:e2e:duplicate-clusters": "node ./e2e/generate-duplicate-clusters-fixture.mjs && playwright test --config playwright.duplicate-clusters.config.ts" }, "dependencies": { "@d-zero/dealer": "1.9.4", diff --git a/packages/@nitpicker/viewer/playwright.config.ts b/packages/@nitpicker/viewer/playwright.config.ts index 2cfe2a99..380eeaa0 100644 --- a/packages/@nitpicker/viewer/playwright.config.ts +++ b/packages/@nitpicker/viewer/playwright.config.ts @@ -19,20 +19,22 @@ const PORT = 4325; */ export default defineConfig({ testDir: './e2e', - // The stub-mode, directory-tree, template-clusters-classified, and - // inbound-links suites each have their own webServer (a different fixture - // and port) and are wired up via `playwright.stub.config.ts` / - // `test:e2e:stub`, `playwright.directory-tree.config.ts` / - // `test:e2e:directory-tree`, `playwright.template-clusters.config.ts` / - // `test:e2e:template-clusters`, and `playwright.inbound-links.config.ts` / - // `test:e2e:inbound-links`. Keep them out of this run so every - // dedicated-fixture suite stays independently scheduled in CI — without - // this, e.g. `directory-tree.spec.ts` would also run here against the - // shared fixture, whose `/api/directory-tree` always returns an empty + // The stub-mode, directory-tree, template-clusters-classified, + // inbound-links, and duplicate-clusters suites each have their own + // webServer (a different fixture and port) and are wired up via + // `playwright.stub.config.ts` / `test:e2e:stub`, + // `playwright.directory-tree.config.ts` / `test:e2e:directory-tree`, + // `playwright.template-clusters.config.ts` / `test:e2e:template-clusters`, + // `playwright.inbound-links.config.ts` / `test:e2e:inbound-links`, and + // `playwright.duplicate-clusters.config.ts` / `test:e2e:duplicate-clusters`. + // Keep them out of this run so every dedicated-fixture suite stays + // independently scheduled in CI — without this, e.g. + // `directory-tree.spec.ts` would also run here against the shared + // fixture, whose `/api/directory-tree` always returns an empty // `{ roots: [] }` (the shared fixture never builds the viewer read // model), and every assertion would fail. testIgnore: - /(viewer-stub|directory-tree|template-clusters-classified|inbound-links)\.spec\.ts$/, + /(viewer-stub|directory-tree|template-clusters-classified|inbound-links|duplicate-clusters)\.spec\.ts$/, fullyParallel: false, workers: 1, retries: 0, diff --git a/packages/@nitpicker/viewer/playwright.duplicate-clusters.config.ts b/packages/@nitpicker/viewer/playwright.duplicate-clusters.config.ts new file mode 100644 index 00000000..f4658ffd --- /dev/null +++ b/packages/@nitpicker/viewer/playwright.duplicate-clusters.config.ts @@ -0,0 +1,51 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { defineConfig, devices } from '@playwright/test'; + +const dirname = import.meta.dirname; +const fixturePath = path.resolve(dirname, 'e2e/.fixture-duplicate-clusters.nitpicker'); +const cliBin = path.resolve(dirname, '../cli/bin/nitpicker.js'); + +/** Port the duplicate-clusters-mode viewer server listens on during E2E. */ +const PORT = 4329; + +/** + * Playwright configuration for the **Duplicate Clusters** Viewer E2E suite + * (issue #208). + * + * Sibling of `playwright.config.ts` / `playwright.template-clusters.config.ts`: + * same SPA, same CLI bin, but the `webServer` points at a fixture built with + * `e2e/generate-duplicate-clusters-fixture.mjs`, which writes a 12-member + * same-`body_hash` cluster plus a `dedupe_cap_events` row. Kept out of the + * shared fixture (rather than adding a 10+ member duplicate-body cluster to + * it) for the same reason `template-clusters-classified.spec.ts` has its own + * fixture — the shared fixture's other view assertions (Duplicates, + * Resources) would otherwise have to account for the extra pages. + */ +if (!existsSync(fixturePath)) { + throw new Error( + `Duplicate-clusters fixture not found: ${fixturePath}. Run e2e/generate-duplicate-clusters-fixture.mjs first.`, + ); +} + +export default defineConfig({ + testDir: './e2e', + testMatch: /duplicate-clusters\.spec\.ts$/, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: 'list', + webServer: { + command: `node ${cliBin} viewer ${fixturePath} --no-open --port ${PORT}`, + url: `http://localhost:${PORT}`, + reuseExistingServer: false, + timeout: 60_000, + }, + use: { + baseURL: `http://localhost:${PORT}`, + locale: 'en-US', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/packages/@nitpicker/viewer/src/create-app.ts b/packages/@nitpicker/viewer/src/create-app.ts index 6448fbc0..db2e2fa4 100644 --- a/packages/@nitpicker/viewer/src/create-app.ts +++ b/packages/@nitpicker/viewer/src/create-app.ts @@ -5,9 +5,11 @@ import { Hono } from 'hono'; import { registerArchiveInfoRoute } from './routes/register-archive-info-route.js'; import { registerConsoleLogsRoute } from './routes/register-console-logs-route.js'; +import { registerDedupeCapEventsRoute } from './routes/register-dedupe-cap-events-route.js'; import { registerDirectoryTreeChildrenRoute } from './routes/register-directory-tree-children-route.js'; import { registerDirectoryTreePagesRoute } from './routes/register-directory-tree-pages-route.js'; import { registerDirectoryTreeRoute } from './routes/register-directory-tree-route.js'; +import { registerDuplicateClustersRoute } from './routes/register-duplicate-clusters-route.js'; import { registerDuplicatesRoute } from './routes/register-duplicates-route.js'; import { registerErrorKindsRoute } from './routes/register-error-kinds-route.js'; import { registerGraphRoute } from './routes/register-graph-route.js'; @@ -66,6 +68,8 @@ export function createApp(options: CreateAppOptions): Hono { registerHeaderChecksRoute(app, context); registerViolationsRoute(app, context); registerDuplicatesRoute(app, context); + registerDuplicateClustersRoute(app, context); + registerDedupeCapEventsRoute(app, context); registerMismatchesRoute(app, context); registerGraphRoute(app, context); registerArchiveInfoRoute(app, context); diff --git a/packages/@nitpicker/viewer/src/duplicate-clusters-cache.spec.ts b/packages/@nitpicker/viewer/src/duplicate-clusters-cache.spec.ts new file mode 100644 index 00000000..31fb4fb2 --- /dev/null +++ b/packages/@nitpicker/viewer/src/duplicate-clusters-cache.spec.ts @@ -0,0 +1,156 @@ +import type { ArchiveContext } from './types.js'; +import type * as NitpickerQuery from '@nitpicker/query'; +import type { ArchiveAccessor, ArchiveManager } from '@nitpicker/query'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getCachedDuplicateBodyClusters } from './duplicate-clusters-cache.js'; + +vi.mock('@nitpicker/query', async () => { + const actual = await vi.importActual('@nitpicker/query'); + return { + ...actual, + listDuplicateBodyClusters: vi.fn(), + }; +}); + +const { listDuplicateBodyClusters } = await import('@nitpicker/query'); + +afterEach(() => { + vi.clearAllMocks(); +}); + +/** + * Build a viewer `ArchiveContext` populated with a stub `ArchiveManager` + * that returns the supplied accessor sentinel — same convention as + * `template-clusters-cache.spec.ts`. + * @param archiveId - Identifier the cache will use as part of its map key. + */ +function makeContext(archiveId: string): ArchiveContext { + const accessor = { id: archiveId } as unknown as ArchiveAccessor; + const manager = { + get: vi.fn().mockReturnValue(accessor), + } as unknown as ArchiveManager; + return { + manager, + archiveId, + filePath: `/fake/${archiveId}.nitpicker`, + mode: 'archive', + crawlerLockHolder: null, + }; +} + +describe('getCachedDuplicateBodyClusters', () => { + // Each test uses a distinct archiveId (matching `template-clusters-cache.spec.ts`'s + // convention) — the module-level `lru` persists across tests within this + // file (only mock call history is reset by `afterEach`'s `clearAllMocks`), + // so reusing an archiveId + options combo already exercised by an earlier + // test would silently hit that test's leftover cache entry instead of + // exercising the behaviour under test here. + + it('computes once per (archiveId, options) and returns the cached result on subsequent calls', async () => { + vi.mocked(listDuplicateBodyClusters).mockResolvedValueOnce([ + { + signature: 'a', + count: 5, + ogUrlMismatchRatio: 1, + samplePages: [], + commonDirectories: [], + }, + ]); + const context = makeContext('archive_1'); + const options = { minCount: 10 }; + + const first = await getCachedDuplicateBodyClusters(context, options); + const second = await getCachedDuplicateBodyClusters(context, options); + + expect(listDuplicateBodyClusters).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + }); + + it('treats different options as distinct cache slots for the same archive', async () => { + vi.mocked(listDuplicateBodyClusters) + .mockResolvedValueOnce([ + { + signature: 'a', + count: 5, + ogUrlMismatchRatio: 1, + samplePages: [], + commonDirectories: [], + }, + ]) + .mockResolvedValueOnce([ + { + signature: 'b', + count: 3, + ogUrlMismatchRatio: 0, + samplePages: [], + commonDirectories: [], + }, + ]); + const context = makeContext('archive_2'); + + const a = await getCachedDuplicateBodyClusters(context, { minCount: 10 }); + const b = await getCachedDuplicateBodyClusters(context, { minCount: 3 }); + + expect(a[0]?.signature).toBe('a'); + expect(b[0]?.signature).toBe('b'); + expect(listDuplicateBodyClusters).toHaveBeenCalledTimes(2); + }); + + it('bypasses the cache in stub mode so live-crawl updates are visible on every request', async () => { + vi.mocked(listDuplicateBodyClusters) + .mockResolvedValueOnce([ + { + signature: 'first', + count: 5, + ogUrlMismatchRatio: 1, + samplePages: [], + commonDirectories: [], + }, + ]) + .mockResolvedValueOnce([ + { + signature: 'second', + count: 5, + ogUrlMismatchRatio: 1, + samplePages: [], + commonDirectories: [], + }, + ]); + const stubContext: ArchiveContext = { ...makeContext('archive_3'), mode: 'stub' }; + const options = { minCount: 10 }; + + const first = await getCachedDuplicateBodyClusters(stubContext, options); + const second = await getCachedDuplicateBodyClusters(stubContext, options); + + expect(first[0]?.signature).toBe('first'); + expect(second[0]?.signature).toBe('second'); + expect(listDuplicateBodyClusters).toHaveBeenCalledTimes(2); + }); + + it('drops a rejected entry so the next request retries instead of replaying the cached error', async () => { + const failure = new Error('transient SQL failure'); + vi.mocked(listDuplicateBodyClusters) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce([ + { + signature: 'recovered', + count: 5, + ogUrlMismatchRatio: 1, + samplePages: [], + commonDirectories: [], + }, + ]); + const context = makeContext('archive_4'); + const options = { minCount: 10 }; + + await expect(getCachedDuplicateBodyClusters(context, options)).rejects.toThrow( + 'transient SQL failure', + ); + + const recovered = await getCachedDuplicateBodyClusters(context, options); + expect(recovered[0]?.signature).toBe('recovered'); + expect(listDuplicateBodyClusters).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/@nitpicker/viewer/src/duplicate-clusters-cache.ts b/packages/@nitpicker/viewer/src/duplicate-clusters-cache.ts new file mode 100644 index 00000000..2d1c3381 --- /dev/null +++ b/packages/@nitpicker/viewer/src/duplicate-clusters-cache.ts @@ -0,0 +1,53 @@ +import type { ArchiveContext } from './types.js'; +import type { + DuplicateBodyClusterEntry, + ListDuplicateBodyClustersOptions, +} from '@nitpicker/query'; + +import { listDuplicateBodyClusters } from '@nitpicker/query'; + +import { createPromiseLru } from './promise-lru.js'; + +/** + * Maximum number of (archiveId + params) cache keys to keep. Matches + * `template-clusters-cache.ts`'s budget — the viewer normally holds one + * archive open at a time, and a handful of distinct parameter combinations + * (default view, plus maybe one or two `minCount` adjustments) fits well + * within this. + */ +const MAX_ENTRIES = 4; + +/** + * Shared LRU of `listDuplicateBodyClusters` promises, keyed by `archiveId` + * plus the resolved options (unlike `template-clusters-cache.ts`, this + * function takes caller-adjustable parameters, so the archiveId alone is + * not a valid cache key — two different `minCount` values for the same + * archive must not share a cache slot). + */ +const lru = createPromiseLru({ + maxEntries: MAX_ENTRIES, +}); + +/** + * Return the (cached) `listDuplicateBodyClusters` result for an archive + + * options, computing it on first request and reusing it on subsequent ones. + * + * **Stub-mode bypass.** Same rationale as `template-clusters-cache.ts` / + * `isolated-clusters-cache.ts` / `summary-cache.ts`: an in-progress crawl's + * `content_items`/`page_meta` rows keep shifting, so a cached result would + * go stale mid-crawl. Recompute on every request instead. + * @param context - The viewer's per-request archive context. + * @param options - See {@link ListDuplicateBodyClustersOptions}. + * @returns A promise that resolves to the (cached, except in stub mode) result. + */ +export async function getCachedDuplicateBodyClusters( + context: ArchiveContext, + options: ListDuplicateBodyClustersOptions, +): Promise { + const accessor = context.manager.get(context.archiveId); + if (context.mode === 'stub') { + return listDuplicateBodyClusters(accessor, options); + } + const cacheKey = `${context.archiveId}:${JSON.stringify(options)}`; + return lru.getOrLoad(cacheKey, () => listDuplicateBodyClusters(accessor, options)); +} diff --git a/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.spec.ts b/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.spec.ts new file mode 100644 index 00000000..20e16dc8 --- /dev/null +++ b/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.spec.ts @@ -0,0 +1,154 @@ +import path from 'node:path'; + +import { Archive } from '@nitpicker/crawler'; +import { ArchiveManager } from '@nitpicker/query'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp } from '../create-app.js'; + +const __filename = new URL(import.meta.url).pathname; +const __dirname = path.dirname(__filename); +const workingDir = path.resolve( + __dirname, + '__test_fixtures_register_dedupe_cap_events_route__', +); + +const BASE_CONFIG = { + baseUrl: 'https://example.com', + name: 'test', + version: '0.13.0', + recursive: true, + interval: 0, + image: false, + fetchExternal: true, + parallels: 1, + roots: ['https://example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: false, +}; + +/** Response shape of `GET /api/dedupe-cap-events`. */ +interface DedupeCapEventsResponseBody { + items: { + id: number; + shape_key: string; + sample_url: string; + body_hash: string | null; + effective_threshold: number; + observed_count: number; + detected_at: number; + rejected_count: number | null; + }[]; + total: number; +} + +describe('registerDedupeCapEventsRoute — /api/dedupe-cap-events (integration)', () => { + let archive: InstanceType; + let manager: ArchiveManager; + let app: ReturnType; + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(workingDir, { recursive: true }); + archive = await Archive.create({ + filePath: path.resolve(workingDir, 'fixture.nitpicker'), + cwd: workingDir, + }); + await archive.setConfig(BASE_CONFIG); + + const eventId = await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/news/date/{n}/', + sampleUrl: 'https://example.com/news/date/2024/', + bodyHash: Buffer.from('trap-body'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + await archive.finalizeDedupeCapEvent(eventId, 500); + + manager = new ArchiveManager(); + const { archiveId, mode } = await manager.open(archive.tmpDir); + app = createApp({ + context: { + manager, + archiveId, + filePath: archive.tmpDir, + mode, + crawlerLockHolder: null, + }, + publicDir: '/tmp/no-such-dir-register-dedupe-cap-events-route-spec', + }); + }); + + afterAll(async () => { + await manager.closeAll(); + const { rmSync } = await import('node:fs'); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('returns the recorded dedupe-cap event', async () => { + const res = await app.request('/api/dedupe-cap-events'); + const body = (await res.json()) as DedupeCapEventsResponseBody; + expect(body.total).toBe(1); + expect(body.items[0]).toMatchObject({ + shape_key: 'example.com/news/date/{n}/', + sample_url: 'https://example.com/news/date/2024/', + effective_threshold: 50, + observed_count: 100, + detected_at: 1000, + rejected_count: 500, + }); + }); +}); + +describe('registerDedupeCapEventsRoute — no events recorded', () => { + const emptyWorkingDir = path.resolve( + __dirname, + '__test_fixtures_register_dedupe_cap_events_route_empty__', + ); + let archive: InstanceType; + let manager: ArchiveManager; + let app: ReturnType; + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(emptyWorkingDir, { recursive: true }); + archive = await Archive.create({ + filePath: path.resolve(emptyWorkingDir, 'fixture.nitpicker'), + cwd: emptyWorkingDir, + }); + await archive.setConfig(BASE_CONFIG); + + manager = new ArchiveManager(); + const { archiveId, mode } = await manager.open(archive.tmpDir); + app = createApp({ + context: { + manager, + archiveId, + filePath: archive.tmpDir, + mode, + crawlerLockHolder: null, + }, + publicDir: '/tmp/no-such-dir-register-dedupe-cap-events-route-spec-empty', + }); + }); + + afterAll(async () => { + await manager.closeAll(); + const { rmSync } = await import('node:fs'); + rmSync(emptyWorkingDir, { recursive: true, force: true }); + }); + + it('returns an empty list', async () => { + const res = await app.request('/api/dedupe-cap-events'); + const body = (await res.json()) as DedupeCapEventsResponseBody; + expect(body).toEqual({ items: [], total: 0 }); + }); +}); diff --git a/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.ts b/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.ts new file mode 100644 index 00000000..970c3443 --- /dev/null +++ b/packages/@nitpicker/viewer/src/routes/register-dedupe-cap-events-route.ts @@ -0,0 +1,38 @@ +import type { ArchiveContext } from '../types.js'; +import type { Hono } from 'hono'; + +import { listDedupeCapEvents } from '@nitpicker/query'; + +/** + * Effectively-unbounded page size — the Duplicate Clusters view needs every + * recorded cap event for its "crawl confirmed N same-cluster traps" notice, + * not a page of them. Mirrors `register-network-outages-route.ts`'s + * identical rationale (`ALL_OUTAGES_LIMIT`). + */ +const ALL_DEDUPE_CAP_EVENTS_LIMIT = 10_000; + +/** + * Registers `GET /api/dedupe-cap-events` — URL shapes the opt-in + * `--dedupe-cap` crawl flag confirmed as self-generating traps (issue + * #208), via `@nitpicker/query`'s `listDedupeCapEvents`. Backs the + * Duplicate Clusters view's "crawl confirmed N same-cluster traps" notice — + * deliberately not a standalone nav item, matching how + * `register-network-outages-route.ts` backs a notice on the Summary view + * rather than getting its own page. + * + * No cache layer, same reasoning as `register-network-outages-route.ts`: + * `listDedupeCapEvents` is already a small, direct `dedupe_cap_events` read + * with no separate fast-path/legacy split. Live in stub mode too — a cap + * confirmed moments ago by an in-progress crawl should show up immediately. + * @param app - The Hono application. + * @param context - The opened archive context. + */ +export function registerDedupeCapEventsRoute(app: Hono, context: ArchiveContext): void { + app.get('/api/dedupe-cap-events', async (c) => { + const accessor = context.manager.get(context.archiveId); + const result = await listDedupeCapEvents(accessor, { + limit: ALL_DEDUPE_CAP_EVENTS_LIMIT, + }); + return c.json(result); + }); +} diff --git a/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.spec.ts b/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.spec.ts new file mode 100644 index 00000000..ac85e08e --- /dev/null +++ b/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.spec.ts @@ -0,0 +1,130 @@ +import type { Meta } from '@d-zero/beholder'; + +import path from 'node:path'; + +import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url'; +import { Archive } from '@nitpicker/crawler'; +import { ArchiveManager } from '@nitpicker/query'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp } from '../create-app.js'; + +const __filename = new URL(import.meta.url).pathname; +const __dirname = path.dirname(__filename); +const workingDir = path.resolve( + __dirname, + '__test_fixtures_register_duplicate_clusters_route__', +); + +/** + * Same pragmatic partial-Meta convention as `list-duplicate-body-clusters.spec.ts`. + * @param title + */ +function buildMeta(title: string): Meta { + return { title } as unknown as Meta; +} + +/** Response entry shape of `GET /api/duplicate-clusters`. */ +interface DuplicateClusterResponseEntry { + signature: string; + count: number; + ogUrlMismatchRatio: number; + samplePages: string[]; + commonDirectories: { directory: string; pageCount: number }[]; +} + +describe('registerDuplicateClustersRoute — /api/duplicate-clusters (integration)', () => { + let archive: InstanceType; + let manager: ArchiveManager; + let app: ReturnType; + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(workingDir, { recursive: true }); + archive = await Archive.create({ + filePath: path.resolve(workingDir, 'fixture.nitpicker'), + cwd: workingDir, + }); + await archive.setConfig({ + baseUrl: 'https://example.com', + name: 'test', + version: '0.13.0', + recursive: true, + interval: 0, + image: false, + fetchExternal: false, + parallels: 1, + roots: ['https://example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: false, + }); + + for (let i = 0; i < 3; i++) { + await archive.setPage({ + url: parseUrl(`https://example.com/trap/${i}/`)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: 'trap body', + meta: buildMeta('お知らせ'), + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + + manager = new ArchiveManager(); + const { archiveId, mode } = await manager.open(archive.tmpDir); + app = createApp({ + context: { + manager, + archiveId, + filePath: archive.tmpDir, + mode, + crawlerLockHolder: null, + }, + publicDir: '/tmp/no-such-dir-register-duplicate-clusters-route-spec', + }); + }); + + afterAll(async () => { + await manager.closeAll(); + const { rmSync } = await import('node:fs'); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('minCount=3 で3ページのクラスタを返す', async () => { + const res = await app.request('/api/duplicate-clusters?minCount=3'); + const body = (await res.json()) as DuplicateClusterResponseEntry[]; + expect(body).toHaveLength(1); + expect(body[0]?.count).toBe(3); + expect(body[0]?.signature).toMatch(/^[0-9a-f]{64}$/); + }); + + it('デフォルトのminCount(10)未満のためクラスタなしを返す', async () => { + const res = await app.request('/api/duplicate-clusters'); + const body = (await res.json()) as DuplicateClusterResponseEntry[]; + expect(body).toEqual([]); + }); + + it('samplePagesLimitでsamplePagesを切り詰める', async () => { + const res = await app.request( + '/api/duplicate-clusters?minCount=3&samplePagesLimit=1', + ); + const body = (await res.json()) as DuplicateClusterResponseEntry[]; + expect(body[0]?.samplePages).toHaveLength(1); + expect(body[0]?.count).toBe(3); + }); +}); diff --git a/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.ts b/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.ts new file mode 100644 index 00000000..087b8951 --- /dev/null +++ b/packages/@nitpicker/viewer/src/routes/register-duplicate-clusters-route.ts @@ -0,0 +1,32 @@ +import type { ArchiveContext } from '../types.js'; +import type { ListDuplicateBodyClustersOptions } from '@nitpicker/query'; +import type { Hono } from 'hono'; + +import { getCachedDuplicateBodyClusters } from '../duplicate-clusters-cache.js'; +import { toNumber } from '../query-params/to-number.js'; + +/** + * Registers `GET /api/duplicate-clusters?minCount=&limit=&offset=&samplePagesLimit=` + * — same-`body_hash` clusters filtered and ranked for "is this a + * self-generating crawl trap" (issue #208), via + * `@nitpicker/query`'s `listDuplicateBodyClusters`. See that function's own + * JSDoc for the filtering/ranking rules (minimum size, uniform title, + * `ogUrlMismatchRatio` descending). + * + * Query params are all optional; `listDuplicateBodyClusters` applies its own + * defaults (`minCount: 10`, `limit: 50`, `samplePagesLimit: 20`) when + * omitted. + * @param app - The Hono application. + * @param context - The opened archive context. + */ +export function registerDuplicateClustersRoute(app: Hono, context: ArchiveContext): void { + app.get('/api/duplicate-clusters', async (c) => { + const options: ListDuplicateBodyClustersOptions = { + minCount: toNumber(c.req.query('minCount')), + limit: toNumber(c.req.query('limit')), + offset: toNumber(c.req.query('offset')), + samplePagesLimit: toNumber(c.req.query('samplePagesLimit')), + }; + return c.json(await getCachedDuplicateBodyClusters(context, options)); + }); +} diff --git a/packages/@nitpicker/viewer/web/api/use-dedupe-cap-events.ts b/packages/@nitpicker/viewer/web/api/use-dedupe-cap-events.ts new file mode 100644 index 00000000..6e0f65da --- /dev/null +++ b/packages/@nitpicker/viewer/web/api/use-dedupe-cap-events.ts @@ -0,0 +1,19 @@ +import type { DedupeCapEventEntry } from '@nitpicker/query'; + +import { useQuery } from '@tanstack/react-query'; + +import { apiGet } from './api-client.js'; + +/** + * Fetches every recorded same-cluster-cap audit row (opt-in + * `--dedupe-cap`, issue #208) — backs the Duplicate Clusters view's "crawl + * confirmed N same-cluster traps" notice. + * @returns The TanStack Query result for the dedupe-cap event list. + */ +export function useDedupeCapEvents() { + return useQuery({ + queryKey: ['dedupe-cap-events'], + queryFn: () => + apiGet<{ items: DedupeCapEventEntry[]; total: number }>('/api/dedupe-cap-events'), + }); +} diff --git a/packages/@nitpicker/viewer/web/api/use-duplicate-clusters.ts b/packages/@nitpicker/viewer/web/api/use-duplicate-clusters.ts new file mode 100644 index 00000000..9c5cb23b --- /dev/null +++ b/packages/@nitpicker/viewer/web/api/use-duplicate-clusters.ts @@ -0,0 +1,28 @@ +import type { DuplicateBodyClusterEntry } from '@nitpicker/query'; + +import { useQuery } from '@tanstack/react-query'; + +import { apiGet } from './api-client.js'; + +/** Parameters accepted by {@link useDuplicateClusters}. */ +export interface UseDuplicateClustersParams { + /** Minimum cluster size to include. Server defaults to 10 when omitted. */ + minCount?: number; +} + +/** + * Fetches same-`body_hash` clusters filtered and ranked for "is this a + * self-generating crawl trap" (issue #208) — see `@nitpicker/query`'s + * `listDuplicateBodyClusters` for the filtering/ranking rules. + * @param params - See {@link UseDuplicateClustersParams}. + * @returns The TanStack Query result for the duplicate cluster list. + */ +export function useDuplicateClusters(params: UseDuplicateClustersParams = {}) { + return useQuery({ + queryKey: ['duplicate-clusters', params.minCount], + queryFn: () => + apiGet('/api/duplicate-clusters', { + minCount: params.minCount, + }), + }); +} diff --git a/packages/@nitpicker/viewer/web/app.tsx b/packages/@nitpicker/viewer/web/app.tsx index cdebdd24..b14c68d3 100644 --- a/packages/@nitpicker/viewer/web/app.tsx +++ b/packages/@nitpicker/viewer/web/app.tsx @@ -9,6 +9,7 @@ import { I18nProvider } from './i18n/i18n-provider.js'; import { BrokenLinksView } from './routes/broken-links-view.js'; import { ConsoleLogsView } from './routes/console-logs-view.js'; import { DirectoryTreeView } from './routes/directory-tree-view.js'; +import { DuplicateClustersView } from './routes/duplicate-clusters-view.js'; import { DuplicatesView } from './routes/duplicates-view.js'; import { ErrorsView } from './routes/errors-view.js'; import { ExternalLinksView } from './routes/external-links-view.js'; @@ -70,6 +71,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/@nitpicker/viewer/web/components/nav-sidebar.tsx b/packages/@nitpicker/viewer/web/components/nav-sidebar.tsx index 6830ffac..782da1c7 100644 --- a/packages/@nitpicker/viewer/web/components/nav-sidebar.tsx +++ b/packages/@nitpicker/viewer/web/components/nav-sidebar.tsx @@ -17,6 +17,7 @@ const NAV_ITEMS: NavItem[] = [ { path: '/graph', labelKey: 'nav.graph' }, { path: '/violations', labelKey: 'nav.violations' }, { path: '/duplicates', labelKey: 'nav.duplicates' }, + { path: '/duplicate-clusters', labelKey: 'nav.duplicateClusters' }, { path: '/mismatches', labelKey: 'nav.mismatches' }, { path: '/errors', labelKey: 'nav.errors' }, { path: '/isolated-pages', labelKey: 'nav.isolatedPages' }, diff --git a/packages/@nitpicker/viewer/web/i18n/translations.ts b/packages/@nitpicker/viewer/web/i18n/translations.ts index 137c9123..6febeb28 100644 --- a/packages/@nitpicker/viewer/web/i18n/translations.ts +++ b/packages/@nitpicker/viewer/web/i18n/translations.ts @@ -30,6 +30,7 @@ export const translations: Record> = { externalLinks: 'External Links', violations: 'Violations', duplicates: 'Duplicates', + duplicateClusters: 'Duplicate Clusters', mismatches: 'Mismatches', graph: 'Graph', errors: 'Connection Failures', @@ -426,6 +427,22 @@ export const translations: Record> = { colTotalCount: 'Total', filterType: 'Type', }, + duplicateClusters: { + title: 'Duplicate Clusters', + description: + 'Same-content page clusters (by masked hash), filtered to a minimum size with a uniform title and ranked by og:url mismatch ratio — a self-generating crawl trap (e.g. a `/news/date/{n}/` pager) typically has every page pointing its og:url at the parent listing rather than itself.', + minCountLabel: 'Minimum cluster size', + noClusters: 'No clusters found at or above this size.', + clusterHeading: '{count} pages, {ratio} og:url mismatch', + signature: 'Signature (body hash)', + ogUrlMismatchRatio: 'og:url mismatch ratio', + commonDirectories: 'Top directories', + pageCount: '{count} pages', + samplePages: 'Sample pages', + otherPages: '{count} other pages', + capNotice: + 'This crawl confirmed {count} same-cluster trap(s) via --dedupe-cap and stopped enqueueing further anchors for them.', + }, }, }, ja: { @@ -451,6 +468,7 @@ export const translations: Record> = { externalLinks: '外部リンク', violations: '違反', duplicates: '重複', + duplicateClusters: '重複クラスタ', mismatches: '不一致', graph: 'グラフ', errors: '接続障害', @@ -846,6 +864,22 @@ export const translations: Record> = { colTotalCount: '総出現数', filterType: 'Type', }, + duplicateClusters: { + title: '重複クラスタ', + description: + '本文(マスク済みハッシュ)が同一のページ群を、最小クラスタサイズ以上・タイトル完全一致でフィルタし、og:url不一致率でランク付けした一覧 — 自己生成型クロールトラップ(例: `/news/date/{n}/` のようなページャ)は多くの場合、全ページのog:urlが自分自身ではなく親一覧を指します。', + minCountLabel: '最小クラスタサイズ', + noClusters: 'このサイズ以上のクラスタは見つかりません。', + clusterHeading: '{count} ページ、og:url不一致率 {ratio}', + signature: 'シグネチャ(body hash)', + ogUrlMismatchRatio: 'og:url不一致率', + commonDirectories: '主要ディレクトリ', + pageCount: '{count} ページ', + samplePages: 'サンプルページ', + otherPages: '他 {count} ページ', + capNotice: + 'このクロールは --dedupe-cap により {count} 件の同一クラスタトラップを検出し、以降のanchor投入を停止しました。', + }, }, }, }; diff --git a/packages/@nitpicker/viewer/web/routes/duplicate-clusters-view.tsx b/packages/@nitpicker/viewer/web/routes/duplicate-clusters-view.tsx new file mode 100644 index 00000000..02b262d7 --- /dev/null +++ b/packages/@nitpicker/viewer/web/routes/duplicate-clusters-view.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; + +import { useDedupeCapEvents } from '../api/use-dedupe-cap-events.js'; +import { useDuplicateClusters } from '../api/use-duplicate-clusters.js'; +import { ViewHeader } from '../components/view-header.js'; +import { useI18n } from '../i18n/use-i18n.js'; +import { formatPercent } from '../utils/format-percent.js'; + +const DEFAULT_MIN_COUNT = 10; + +/** + * Same-`body_hash` cluster analysis (issue #208): one collapsible section + * per cluster, showing size, `og:url` mismatch ratio (a same-cluster-trap + * indicator — a pager/query-parameter trap's `og:url` typically still + * points at the parent listing rather than the fake paginated URL), top + * directories by page count, and a bounded sample of member URLs. + * + * Also surfaces a "crawl confirmed N same-cluster traps" notice from + * `dedupe_cap_events` (the opt-in `--dedupe-cap` crawl flag's audit log) — + * deliberately folded into this view rather than given its own nav item, + * matching how `network_outages` backs a notice on the Summary view instead + * of a standalone page. + * @returns The duplicate clusters view element. + */ +export function DuplicateClustersView() { + const { t } = useI18n(); + const [minCount, setMinCount] = useState(DEFAULT_MIN_COUNT); + const { data: clusters, isLoading, error } = useDuplicateClusters({ minCount }); + const { data: capEvents } = useDedupeCapEvents(); + + return ( +
+ + {capEvents && capEvents.total > 0 && ( +
+ {t('views.duplicateClusters.capNotice', { count: capEvents.total })} +
+ )} + + {isLoading &&
{t('common.loading')}
} + {error &&
{error.message}
} + {clusters && clusters.length === 0 && ( +
{t('views.duplicateClusters.noClusters')}
+ )} + {clusters?.map((cluster) => ( +
+ + {t('views.duplicateClusters.clusterHeading', { + count: cluster.count, + ratio: formatPercent(cluster.ogUrlMismatchRatio), + })} + +
+
{t('views.duplicateClusters.signature')}
+
+ {cluster.signature} +
+
{t('views.duplicateClusters.ogUrlMismatchRatio')}
+
{formatPercent(cluster.ogUrlMismatchRatio)}
+
{t('views.duplicateClusters.commonDirectories')}
+
+ {cluster.commonDirectories.length > 0 ? ( +
    + {cluster.commonDirectories.map((entry) => ( +
  • + + {entry.directory} + {' '} + ( + {t('views.duplicateClusters.pageCount', { count: entry.pageCount })} + ) +
  • + ))} +
+ ) : ( + '—' + )} +
+
{t('views.duplicateClusters.samplePages')}
+
+
    + {cluster.samplePages.map((url) => ( +
  • {url}
  • + ))} +
+ {cluster.count > cluster.samplePages.length && ( +

+ {t('views.duplicateClusters.otherPages', { + count: cluster.count - cluster.samplePages.length, + })} +

+ )} +
+
+
+ ))} +
+ ); +} From ef4628228a8c0747655311cd3ee1eb65d31ab53b Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:41:13 +0900 Subject: [PATCH 06/10] feat(cli): add --dedupe-cap flags and duplicate-clusters/dedupe-cap-events subcommands Adds --dedupe-cap/--dedupe-map-cap to crawl and pipeline (issue #208), and new query subcommands duplicate-clusters / dedupe-cap-events dispatching to the corresponding @nitpicker/query functions. Documents both in docs/crawl.md and docs/query.md. --- packages/@nitpicker/cli/docs/crawl.md | 60 ++++++++++--------- packages/@nitpicker/cli/docs/query.md | 2 + packages/@nitpicker/cli/src/commands/crawl.ts | 8 +++ .../cli/src/commands/pipeline.spec.ts | 19 ++++++ .../@nitpicker/cli/src/commands/pipeline.ts | 10 ++++ packages/@nitpicker/cli/src/commands/query.ts | 6 +- .../crawl/map-flags-to-crawl-config.spec.ts | 15 +++++ .../src/crawl/map-flags-to-crawl-config.ts | 2 + packages/@nitpicker/cli/src/crawl/types.ts | 4 ++ .../cli/src/query/dispatch-query.spec.ts | 34 +++++++++++ .../cli/src/query/dispatch-query.ts | 17 ++++++ .../query/map-flags-to-query-options.spec.ts | 25 ++++++++ .../src/query/map-flags-to-query-options.ts | 9 +++ packages/@nitpicker/cli/src/query/types.ts | 6 +- 14 files changed, 186 insertions(+), 31 deletions(-) diff --git a/packages/@nitpicker/cli/docs/crawl.md b/packages/@nitpicker/cli/docs/crawl.md index 3f04ce23..3b4a8e4d 100644 --- a/packages/@nitpicker/cli/docs/crawl.md +++ b/packages/@nitpicker/cli/docs/crawl.md @@ -166,35 +166,37 @@ npx @nitpicker/cli crawl --diff ./before.nitpicker ./after.nitpicker ## オプション一覧 -| オプション | 型 | 説明 | -| ------------------------------------------ | ------------------ | ----------------------------------------------------- | -| `--resume`, `-R` | string | stubディレクトリからクロールを再開 | -| `--append`, `-A` | string, repeatable | 既存アーカイブへ新しい再帰クロール起点を追加 | -| `--retry-failed` | boolean | 既存アーカイブ内の失敗ページを再取得 | -| `--inventory` | string | サーバー側URLリストを既存アーカイブへ取り込み | -| `--interval`, `-I` | number | リクエスト間隔をミリ秒で指定 | -| `--image` / `--no-image` | boolean | 画像を取得するか。既定は有効 | -| `--fetch-external` / `--no-fetch-external` | boolean | 外部リンクを取得するか。既定は有効 | -| `--parallels`, `-P` | number | 並列スクレイピング数 | -| `--recursive` / `--no-recursive` | boolean | 再帰クロールするか。既定は有効 | -| `--exclude` | string, repeatable | 除外するページURLパスのglob | -| `--exclude-keyword` | string, repeatable | ページ本文に含まれる除外キーワード | -| `--exclude-url` | string, repeatable | 除外する外部URL prefix | -| `--disable-queries`, `-Q` | boolean | URLのクエリ文字列を無効化 | -| `--image-file-size-threshold` | number | 画像ファイルサイズのしきい値 | -| `--single` | boolean | 単一ページモード | -| `--max-excluded-depth` | number | 指定深さを超えるクロールを避ける | -| `--retry` | number | URLごとのスクレイプ失敗リトライ回数。既定は `3` | -| `--list` | string, repeatable | 指定URLリストだけをクロール | -| `--list-file` | string | URLリストファイルだけをクロール | -| `--user-agent` | string | HTTPリクエストのUser-Agent | -| `--ignore-robots` | boolean | robots.txt制限を無視 | -| `--main-content-selector` | string | メインコンテンツ領域の自動検出を上書きするCSSセレクタ | -| `--output`, `-o` | string | 出力 `.nitpicker` ファイルパス | -| `--strict` | boolean | 外部リンクエラーを致命的エラーとして扱う | -| `--verbose` | boolean | 詳細ログを出力 | -| `--silent` | boolean | 標準出力ログを抑制 | -| `--diff` | boolean | 2つのアーカイブの差分を出力 | +| オプション | 型 | 説明 | +| ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--resume`, `-R` | string | stubディレクトリからクロールを再開 | +| `--append`, `-A` | string, repeatable | 既存アーカイブへ新しい再帰クロール起点を追加 | +| `--retry-failed` | boolean | 既存アーカイブ内の失敗ページを再取得 | +| `--inventory` | string | サーバー側URLリストを既存アーカイブへ取り込み | +| `--interval`, `-I` | number | リクエスト間隔をミリ秒で指定 | +| `--image` / `--no-image` | boolean | 画像を取得するか。既定は有効 | +| `--fetch-external` / `--no-fetch-external` | boolean | 外部リンクを取得するか。既定は有効 | +| `--parallels`, `-P` | number | 並列スクレイピング数 | +| `--recursive` / `--no-recursive` | boolean | 再帰クロールするか。既定は有効 | +| `--exclude` | string, repeatable | 除外するページURLパスのglob | +| `--exclude-keyword` | string, repeatable | ページ本文に含まれる除外キーワード | +| `--exclude-url` | string, repeatable | 除外する外部URL prefix | +| `--disable-queries`, `-Q` | boolean | URLのクエリ文字列を無効化 | +| `--image-file-size-threshold` | number | 画像ファイルサイズのしきい値 | +| `--single` | boolean | 単一ページモード | +| `--max-excluded-depth` | number | 指定深さを超えるクロールを避ける | +| `--retry` | number | URLごとのスクレイプ失敗リトライ回数。既定は `3` | +| `--list` | string, repeatable | 指定URLリストだけをクロール | +| `--list-file` | string | URLリストファイルだけをクロール | +| `--user-agent` | string | HTTPリクエストのUser-Agent | +| `--ignore-robots` | boolean | robots.txt制限を無視 | +| `--main-content-selector` | string | メインコンテンツ領域の自動検出を上書きするCSSセレクタ | +| `--output`, `-o` | string | 出力 `.nitpicker` ファイルパス | +| `--strict` | boolean | 外部リンクエラーを致命的エラーとして扱う | +| `--verbose` | boolean | 詳細ログを出力 | +| `--silent` | boolean | 標準出力ログを抑制 | +| `--diff` | boolean | 2つのアーカイブの差分を出力 | +| `--dedupe-cap` | number | 同一クラスタ soft cap。URL形状(例: `/news/date/{n}/`)ごとにtitle/description/og:tagが一致する観測がこの件数に達したら以降の新規URLをenqueueしない。opt-in(省略で無効)。自己生成型のpager/queryパラメータtrapへの保険。発火内容は `query dedupe-cap-events` で確認可能 | +| `--dedupe-map-cap` | number | `--dedupe-cap` が同時追跡するURL形状の数の上限。超過分は最も長く未更新の形状から破棄。`--dedupe-cap` 指定時のみ有効 | ## 終了コード diff --git a/packages/@nitpicker/cli/docs/query.md b/packages/@nitpicker/cli/docs/query.md index 8de167bf..fe4d735e 100644 --- a/packages/@nitpicker/cli/docs/query.md +++ b/packages/@nitpicker/cli/docs/query.md @@ -40,6 +40,8 @@ npx @nitpicker/cli query ./site.nitpicker page-detail --url https://example.com/ | `images` | 画像一覧と画像品質フィルタ | | `violations` | 分析プラグインの違反結果 | | `duplicates` | title/descriptionの重複 | +| `duplicate-clusters` | 同一body_hashクラスタの集約(trap兆候でソート) | +| `dedupe-cap-events` | `--dedupe-cap` の同一クラスタ soft cap 発火履歴 | | `mismatches` | canonical/OGPメタデータの不一致 | | `headers` | セキュリティヘッダー確認 | | `resource-referrers` | 指定リソースの参照元ページ | diff --git a/packages/@nitpicker/cli/src/commands/crawl.ts b/packages/@nitpicker/cli/src/commands/crawl.ts index bfc5d751..224124b5 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.ts @@ -155,6 +155,14 @@ export const commandDef = { type: 'boolean', desc: 'Diff mode', }, + dedupeCap: { + type: 'number', + desc: 'Same-cluster soft cap: stop enqueueing newly-discovered internal URLs whose shape (e.g. `/news/date/{n}/`) has accumulated this many matching-title/description/og-tag observations. Opt-in — omit to disable. Backstop against a site that keeps serving 2xx for a self-generating pager/query-parameter trap; see `query dedupe-cap-events` for what fired.', + }, + dedupeMapCap: { + type: 'number', + desc: 'Hard cap on the number of distinct URL shapes --dedupe-cap tracks at once; the least-recently-touched shape is evicted beyond this. Only relevant when --dedupe-cap is set.', + }, }, } as const satisfies CommandDef; diff --git a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts index 44366633..b1f13490 100644 --- a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts +++ b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts @@ -134,6 +134,25 @@ describe('pipeline command', () => { ); }); + it("forwards --dedupe-cap/--dedupe-map-cap to startCrawl (pipeline.ts hand-writes its own flags object rather than reusing crawl.ts's mapper, see the TODO on commandDef.flags)", async () => { + vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker'); + vi.mocked(analyzeFn).mockResolvedValue(); + + await pipeline(['https://example.com'], { + ...defaultFlags, + dedupeCap: 5, + dedupeMapCap: 2000, + }); + + expect(startCrawlFn).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ + dedupeCap: 5, + dedupeMapCap: 2000, + }), + ); + }); + it('runs crawl, analyze, and report when --sheet is provided', async () => { const sheetUrl = 'https://docs.google.com/spreadsheets/d/xxx'; vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker'); diff --git a/packages/@nitpicker/cli/src/commands/pipeline.ts b/packages/@nitpicker/cli/src/commands/pipeline.ts index e15e2a44..efb813fb 100644 --- a/packages/@nitpicker/cli/src/commands/pipeline.ts +++ b/packages/@nitpicker/cli/src/commands/pipeline.ts @@ -115,6 +115,14 @@ export const commandDef = { type: 'boolean', desc: 'Treat external link errors as fatal (exit code 1 instead of 2)', }, + dedupeCap: { + type: 'number', + desc: 'Same-cluster soft cap: stop enqueueing newly-discovered internal URLs whose shape (e.g. `/news/date/{n}/`) has accumulated this many matching-title/description/og-tag observations. Opt-in — omit to disable. Backstop against a site that keeps serving 2xx for a self-generating pager/query-parameter trap; see `query dedupe-cap-events` for what fired.', + }, + dedupeMapCap: { + type: 'number', + desc: 'Hard cap on the number of distinct URL shapes --dedupe-cap tracks at once; the least-recently-touched shape is evicted beyond this. Only relevant when --dedupe-cap is set.', + }, // analyze flags all: { type: 'boolean', @@ -248,6 +256,8 @@ export async function pipeline(args: string[], flags: PipelineFlags) { retryFailed: false, inventory: undefined, diff: undefined, + dedupeCap: flags.dedupeCap, + dedupeMapCap: flags.dedupeMapCap, }); } catch (error) { if ( diff --git a/packages/@nitpicker/cli/src/commands/query.ts b/packages/@nitpicker/cli/src/commands/query.ts index 531ba78a..4b3884b2 100644 --- a/packages/@nitpicker/cli/src/commands/query.ts +++ b/packages/@nitpicker/cli/src/commands/query.ts @@ -36,7 +36,11 @@ export const commandDef = { }, pagesLimit: { type: 'number', - desc: 'Inline member-page URL sample size per duplicate group (duplicates). Defaults to 20.', + desc: 'Inline member-page URL sample size per duplicate group (duplicates), or per body-hash cluster (duplicate-clusters). Defaults to 20.', + }, + minCount: { + type: 'number', + desc: 'Minimum cluster size to include (duplicate-clusters). Defaults to 10.', }, url: { type: 'string', diff --git a/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.spec.ts b/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.spec.ts index c43dfa23..1b87f482 100644 --- a/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.spec.ts +++ b/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.spec.ts @@ -83,4 +83,19 @@ describe('mapFlagsToCrawlConfig', () => { expect(result.excludeUrls).toBeUndefined(); expect(result.interval).toBeUndefined(); }); + + it('dedupeCap を指定した値のままマッピングする', () => { + const result = mapFlagsToCrawlConfig({ dedupeCap: 100 }); + expect(result.dedupeCap).toBe(100); + }); + + it('dedupeCap 未指定は null(無効化)にマッピングする', () => { + const result = mapFlagsToCrawlConfig({}); + expect(result.dedupeCap).toBeNull(); + }); + + it('dedupeMapCap を指定した値のままマッピングする', () => { + const result = mapFlagsToCrawlConfig({ dedupeMapCap: 50_000 }); + expect(result.dedupeMapCap).toBe(50_000); + }); }); diff --git a/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.ts b/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.ts index 55270519..7c50eb09 100644 --- a/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.ts +++ b/packages/@nitpicker/cli/src/crawl/map-flags-to-crawl-config.ts @@ -29,5 +29,7 @@ export function mapFlagsToCrawlConfig(flags: CrawlFlagInput) { excludes: flags.exclude, excludeKeywords: flags.excludeKeyword, excludeUrls: flags.excludeUrl, + dedupeCap: flags.dedupeCap ?? null, + dedupeMapCap: flags.dedupeMapCap, }; } diff --git a/packages/@nitpicker/cli/src/crawl/types.ts b/packages/@nitpicker/cli/src/crawl/types.ts index 6e38f88e..1b23d6e4 100644 --- a/packages/@nitpicker/cli/src/crawl/types.ts +++ b/packages/@nitpicker/cli/src/crawl/types.ts @@ -38,4 +38,8 @@ export interface CrawlFlagInput { readonly verbose?: boolean; /** CSS selector overriding beholder's automatic main-content detection. */ readonly mainContentSelector?: string; + /** Same-cluster soft-cap threshold. `undefined` disables the feature (mapped to `null`). */ + readonly dedupeCap?: number; + /** Hard cap on the number of distinct URL shapes `dedupeCap` tracks at once. */ + readonly dedupeMapCap?: number; } diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts index b45c224b..0de7a88c 100644 --- a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts +++ b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts @@ -38,6 +38,8 @@ vi.mock('@nitpicker/query', () => ({ prevCursor: null, }), findDuplicateBodies: vi.fn().mockResolvedValue([]), + listDuplicateBodyClusters: vi.fn().mockResolvedValue([]), + listDedupeCapEvents: vi.fn().mockResolvedValue({ items: [], total: 0 }), getMismatchesFastPath: vi.fn().mockResolvedValue({ items: [], total: 0, @@ -401,6 +403,38 @@ describe('dispatchQuery', () => { expect(findDuplicateBodies).toHaveBeenCalledWith(mockAccessor, 50, 25); }); + it('dispatches duplicate-clusters sub-command with minCount/limit/offset/samplePagesLimit', async () => { + const { listDuplicateBodyClusters } = await import('@nitpicker/query'); + // `pagesLimit` is the CLI flag name; `dispatchQuery` runs it through the + // real `mapFlagsToQueryOptions`, which renames it to `samplePagesLimit`. + const result = await dispatchQuery(mockAccessor, 'duplicate-clusters', { + minCount: 5, + limit: 50, + offset: 25, + pagesLimit: 3, + } as never); + expect(result).toEqual([]); + expect(listDuplicateBodyClusters).toHaveBeenCalledWith(mockAccessor, { + minCount: 5, + limit: 50, + offset: 25, + samplePagesLimit: 3, + }); + }); + + it('dispatches dedupe-cap-events sub-command with limit and offset', async () => { + const { listDedupeCapEvents } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'dedupe-cap-events', { + limit: 25, + offset: 5, + } as never); + expect(result).toEqual({ items: [], total: 0 }); + expect(listDedupeCapEvents).toHaveBeenCalledWith(mockAccessor, { + limit: 25, + offset: 5, + }); + }); + it('dispatches unused-resources sub-command with limit and offset', async () => { const { listUnusedResources } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'unused-resources', { diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.ts b/packages/@nitpicker/cli/src/query/dispatch-query.ts index a00af689..0eaed136 100644 --- a/packages/@nitpicker/cli/src/query/dispatch-query.ts +++ b/packages/@nitpicker/cli/src/query/dispatch-query.ts @@ -34,6 +34,8 @@ import { getTagInventory, getViolations, listConsoleLogs, + listDedupeCapEvents, + listDuplicateBodyClusters, listInboundLinks, listInventoryRuns, listIsolatedClustersFastPath, @@ -133,6 +135,21 @@ export async function dispatchQuery( const { limit, offset } = options as { limit?: number; offset?: number }; return findDuplicateBodies(accessor, limit, offset); } + case 'duplicate-clusters': { + return listDuplicateBodyClusters( + accessor, + options as { + minCount?: number; + limit?: number; + offset?: number; + samplePagesLimit?: number; + }, + ); + } + case 'dedupe-cap-events': { + const { limit, offset } = options as { limit?: number; offset?: number }; + return listDedupeCapEvents(accessor, { limit, offset }); + } case 'mismatches': { const { type, ...rest } = options as { type: 'canonical' | 'og:title' | 'og:description'; diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts index 983cfd7e..919524e4 100644 --- a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts @@ -359,6 +359,31 @@ describe('mapFlagsToQueryOptions', () => { }); }); + it('returns limit/offset only for dedupe-cap-events (no required filters)', () => { + expect(mapFlagsToQueryOptions('dedupe-cap-events', { limit: 20, offset: 0 })).toEqual( + { + limit: 20, + offset: 0, + }, + ); + }); + + it('maps duplicate-clusters flags, renaming --pagesLimit to samplePagesLimit', () => { + expect( + mapFlagsToQueryOptions('duplicate-clusters', { + minCount: 5, + limit: 50, + offset: 25, + pagesLimit: 3, + } as never), + ).toEqual({ + minCount: 5, + limit: 50, + offset: 25, + samplePagesLimit: 3, + }); + }); + it('maps console-logs flags correctly', () => { expect( mapFlagsToQueryOptions('console-logs', { diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts index 5777262f..d988a589 100644 --- a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts @@ -286,6 +286,7 @@ export function mapFlagsToQueryOptions( case 'unused-resources': case 'inventory-runs': case 'duplicate-bodies': + case 'dedupe-cap-events': case 'outages': { // Pagination-only — no required filters. return { @@ -293,6 +294,14 @@ export function mapFlagsToQueryOptions( offset: flags.offset, }; } + case 'duplicate-clusters': { + return { + minCount: flags.minCount, + limit: flags.limit, + offset: flags.offset, + samplePagesLimit: flags.pagesLimit, + }; + } case 'get-isolated-cluster': { if (!flags.representativeUrl) { throw new Error( diff --git a/packages/@nitpicker/cli/src/query/types.ts b/packages/@nitpicker/cli/src/query/types.ts index 84f03ab6..4d4ce558 100644 --- a/packages/@nitpicker/cli/src/query/types.ts +++ b/packages/@nitpicker/cli/src/query/types.ts @@ -32,7 +32,9 @@ export type QuerySubCommand = | 'inventory-runs' | 'outages' | 'console-logs' - | 'page-console-logs'; + | 'page-console-logs' + | 'duplicate-clusters' + | 'dedupe-cap-events'; /** * List of all valid query sub-command names. @@ -69,4 +71,6 @@ export const VALID_SUB_COMMANDS = [ 'outages', 'console-logs', 'page-console-logs', + 'duplicate-clusters', + 'dedupe-cap-events', ] as const satisfies readonly QuerySubCommand[]; From 5331fcabf00c7e6d73a3343948c1939f1ef3f710 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:41:43 +0900 Subject: [PATCH 07/10] test(test-server): add dedupe-cap e2e trap fixture Reproduces the self-generating pagination trap (issue #208): fixed anchors only, arbitrary values accepted with 2xx, meta/og:url/body patterns that trigger the crawler's pagination-predictor fix and the opt-in --dedupe-cap tracker end-to-end. --- .../src/__tests__/e2e/dedupe-cap.e2e.ts | 159 ++++++++++++++++++ .../test-server/src/routes/dedupe-cap-trap.ts | 132 +++++++++++++++ packages/test-server/src/server.ts | 2 + 3 files changed, 293 insertions(+) create mode 100644 packages/test-server/src/__tests__/e2e/dedupe-cap.e2e.ts create mode 100644 packages/test-server/src/routes/dedupe-cap-trap.ts diff --git a/packages/test-server/src/__tests__/e2e/dedupe-cap.e2e.ts b/packages/test-server/src/__tests__/e2e/dedupe-cap.e2e.ts new file mode 100644 index 00000000..276d8d0c --- /dev/null +++ b/packages/test-server/src/__tests__/e2e/dedupe-cap.e2e.ts @@ -0,0 +1,159 @@ +import { listDedupeCapEvents } from '@nitpicker/query'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + +/** + * E2E coverage for issue #208: the self-generating same-cluster trap + * (`/trap/date/{n}/` etc, served by `dedupe-cap-trap.ts`) and the two + * mitigations built against it. + * + * - Always-on: the pagination predictor never emits a malformed URL + * (scientific notation / runaway digit growth) — see + * `generate-predicted-urls.spec.ts` for the exact numeric regression this + * backstops; this suite proves the same holds true end-to-end through a + * real crawl of a fixture that reproduces the trap shape. + * - Opt-in (`--dedupe-cap`): `DedupeCapTracker` confirms a same-cluster + * trap and stops enqueueing further anchors for its shape, recording the + * confirmation in `dedupe_cap_events`. + * + * `parallels: 1` throughout — the cap-firing tests need strictly sequential + * processing so exactly N pages are admitted before the shape caps; with + * concurrent fetches, several already-in-flight anchors could slip past the + * gate non-deterministically (the gate blocks new enqueues, not in-flight + * work). Each fixture route (`dedupe-cap-trap.ts`) links to only TWO fixed + * anchors — real Puppeteer launches per page make this suite's runtime + * dominated by page count, and (per the threshold arithmetic below) two + * member pages already exercise the cap. + * + * Threshold arithmetic (base `--dedupe-cap 3`, applies to every trap shape + * below): the 1st observation of a shape always has `bodyHashMatches: false` + * (nothing recorded yet to compare against — see `DedupeCapTracker`'s own + * JSDoc), so only the `og:url`-mismatch halving applies: threshold = + * ceil(3/2) = 2, count 1 < 2 → not capped. The 2nd observation matches the + * recorded `metaSig`, so count = 2; for `/trap/date/` and `/trap/query/` + * (identical body across every member) `bodyHashMatches` is now also true, + * halving again: threshold = ceil(2/2) = 1, count 2 >= 1 → CAPPED, with + * `observed_count: 2` and `effective_threshold: 1`. For `/trap/echo/` + * (body echoes the value, so `bodyHashMatches` is never true) only the + * `og:url` halving ever applies: threshold stays 2, count 2 >= 2 → CAPPED, + * with `observed_count: 2` and `effective_threshold: 2`. + * + * Page-count nuance for `/trap/date/` and `/trap/echo/` (NOT `/trap/query/` + * — see below): the fixture's two fixed anchors (e.g. years 2020, 2021) are + * sequential integers, so `detectPaginationPattern` treats them as a valid + * pagination pattern and — before either page has even been fetched, while + * still processing the INDEX page's own anchors — enqueues ONE predicted + * page (year 2022) via the pagination-prediction branch. This happens + * before the tracker has any observations for the shape, so the cap cannot + * have fired yet; by the time the cap DOES fire (after the 2nd real page is + * observed), that one predicted page is already queued and gate 1 cannot + * retroactively cancel it (it only blocks NEW enqueues). So the resulting + * page count is 3 (2 real + 1 pre-capped predicted), not 2 — this predicted + * page does NOT itself count as a THIRD tracker observation, because + * `DedupeCapTracker#observe` no-ops once `#sticky` already has the shape + * (confirmed by `observed_count` staying 2 below). `/trap/query/`'s anchors + * differ only by query string (not a numeric path segment), so + * `detectPaginationPattern` never fires for them and this nuance does not + * apply there — its page count is not asserted for this reason. + */ +describe('dedupe-cap trap fixture (issue #208)', () => { + let result: CrawlResult; + + afterAll(async () => { + if (result) await cleanup(result); + }); + + it('--dedupe-cap 無しでも科学表記・異常な桁数のURLを一切生成しない', async () => { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/trap/date/`], { + parallels: 1, + }); + const pages = await result.accessor.getPages('internal-page'); + const pathnames = pages.map((p) => p.url.pathname); + for (const pathname of pathnames) { + expect(pathname).not.toMatch(/e[+-]\d+/i); + // The trap's own anchors are a fixed set of 4-digit years; any + // path segment growing past a handful of digits would indicate + // runaway extrapolation. + expect(pathname).not.toMatch(/\d{6,}/); + } + }, 120_000); +}); + +describe('dedupe-cap trap fixture — --dedupe-cap opt-in (issue #208)', () => { + let result: CrawlResult; + + afterAll(async () => { + if (result) await cleanup(result); + }); + + it('同一メタデータ+同一bodyのtrapはbody_hash一致とog:url不一致の両シグナルで早期にcapする', async () => { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/trap/date/`], { + parallels: 1, + dedupeCap: 3, + }); + + const pages = await result.accessor.getPages('internal-page'); + const trapPages = pages.filter((p) => /^\/trap\/date\/\d+\/$/.test(p.url.pathname)); + // 2 real anchors + 1 pre-capped predicted page — see this file's + // top-level JSDoc "Page-count nuance" for why. + expect(trapPages).toHaveLength(3); + + const { items, total } = await listDedupeCapEvents(result.accessor); + expect(total).toBe(1); + expect(items[0]?.shape_key).toContain('/trap/date/{n}/'); + expect(items[0]?.observed_count).toBe(2); + expect(items[0]?.effective_threshold).toBe(1); + }, 120_000); + + it('bodyがパラメータをエコーしてもmetaSigのみでcapする(body_hash加点が効かない変種)', async () => { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/trap/echo/`], { + parallels: 1, + dedupeCap: 3, + }); + + const pages = await result.accessor.getPages('internal-page'); + const trapPages = pages.filter((p) => /^\/trap\/echo\/\d+\/$/.test(p.url.pathname)); + // 2 real anchors + 1 pre-capped predicted page — see this file's + // top-level JSDoc "Page-count nuance" for why. + expect(trapPages).toHaveLength(3); + + const { items, total } = await listDedupeCapEvents(result.accessor); + expect(total).toBe(1); + expect(items[0]?.shape_key).toContain('/trap/echo/{n}/'); + expect(items[0]?.observed_count).toBe(2); + expect(items[0]?.effective_threshold).toBe(2); + }, 120_000); + + it('クエリパラメータtrapもshapeKeyで畳み込まれてcapする', async () => { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/trap/query/`], { + parallels: 1, + dedupeCap: 3, + }); + + const { items, total } = await listDedupeCapEvents(result.accessor); + expect(total).toBe(1); + expect(items[0]?.shape_key).toContain('{v}'); + expect(items[0]?.observed_count).toBe(2); + expect(items[0]?.effective_threshold).toBe(1); + }, 120_000); + + it('正当なページャ(各ページでtitleが異なる)はcapしない(false-positiveなし)', async () => { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/pagination/`], { + parallels: 1, + dedupeCap: 3, + }); + + const pages = await result.accessor.getPages('internal-page'); + const paginationPages = pages.filter((p) => + p.url.pathname.startsWith('/pagination/page/'), + ); + // 正当なページャなので dedupe-cap が無くても page/1〜page/10 全件が + // 到達可能であることを確認(cap による誤検知で欠落していないこと)。 + expect(paginationPages).toHaveLength(10); + + const { total } = await listDedupeCapEvents(result.accessor); + expect(total).toBe(0); + }, 180_000); +}); diff --git a/packages/test-server/src/routes/dedupe-cap-trap.ts b/packages/test-server/src/routes/dedupe-cap-trap.ts new file mode 100644 index 00000000..688a646b --- /dev/null +++ b/packages/test-server/src/routes/dedupe-cap-trap.ts @@ -0,0 +1,132 @@ +import type { PortRef } from '../server.js'; +import type { Hono } from 'hono'; + +/** + * Registers routes reproducing the two dedupe-cap trap shapes exercised by + * `crawler`'s e2e suite (issue #208). + * + * Every route links to exactly TWO fixed real anchors (not a large range) — + * deliberately minimal, since: + * + * 1. The `--dedupe-cap` confidence signals (`og:url` mismatch + `body_hash` + * match) drop the effective threshold to 1 by the SECOND observation for + * every trap shape here, so two real member pages are already enough to + * prove the cap fires (see `dedupe-cap.e2e.ts`'s threshold arithmetic in + * its own comments). + * 2. Each page fetch launches a real Puppeteer browser (no cross-page + * reuse), so keeping the fixture's page count small matters for e2e + * runtime — this is an integration smoke test, not a load test. + * + * - `/trap/date/:value/` — a self-generating pager trap. Returns 200 for + * ANY `:value` (not just the two fixed anchors), with identical + * title/description and an `og:url` that always points at the parent + * listing rather than itself — exactly the pattern that caused + * nitpicker's own pagination predictor to keep extrapolating past + * `Number.MAX_SAFE_INTEGER` into scientific-notation URLs in production. + * - `/trap/echo/:value/` — same shape, but the `` ECHOES `:value` + * into its text. Used to prove the `body_hash` confidence signal in + * `DedupeCapTracker` does NOT fire for this variant (every page's body + * differs), so the same-cluster cap must rely on the `metaSig` + * (title/description/og:*) majority vote alone to still catch it. + * - `/trap/query/list/?page=:value` — a query-parameter trap, same + * title/description/og:url behaviour as `/trap/date/`. `/trap/query/` + * itself is only the index page (linking to the query-bearing anchors); + * see the query-links comment below for why the trap page itself must + * live one path segment deeper. + * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the absolute `og:url` pointing at the parent listing. + */ +export function dedupeCapTrapRoutes(app: Hono, portRef: PortRef) { + const FIXED_ANCHOR_VALUES = [2020, 2021]; + + const dateLinks = FIXED_ANCHOR_VALUES.map( + (value) => `${value}`, + ).join(''); + + app.get('/trap/date/', (c) => + c.html( + 'News Index' + + dateLinks + + '', + ), + ); + + app.get('/trap/date/:value/', (c) => { + const ogUrl = `http://localhost:${portRef.port}/trap/date/`; + return c.html( + '' + + 'お知らせ' + + '' + + '' + + `` + + '' + + '

trap body (identical across every value)

' + + dateLinks + + '', + ); + }); + + const echoLinks = FIXED_ANCHOR_VALUES.map( + (value) => `${value}`, + ).join(''); + + app.get('/trap/echo/', (c) => + c.html( + 'Echo Index' + + echoLinks + + '', + ), + ); + + app.get('/trap/echo/:value/', (c) => { + const value = c.req.param('value'); + const ogUrl = `http://localhost:${portRef.port}/trap/echo/`; + return c.html( + '' + + 'お知らせ' + + '' + + '' + + `` + + '' + + `

Year: ${value}

` + + echoLinks + + '', + ); + }); + + // The query-bearing pages live one path segment DEEPER than this index + // (`/trap/query/list/?page=N`, not `/trap/query/?page=N`) so that + // `isLowerLayer` (the crawler's scope check) admits them via path depth + // alone — two URLs that differ ONLY in their query string, with an + // otherwise-identical path, are NOT treated as "lower layer" of each + // other by `@d-zero/shared/is-lower-layer` (confirmed empirically), + // which would otherwise make every `?page=N` anchor look external + // relative to a `/trap/query/` root. + const queryLinks = FIXED_ANCHOR_VALUES.map( + (value) => `Page ${value}`, + ).join(''); + + app.get('/trap/query/', (c) => + c.html( + 'Query Trap Index' + + queryLinks + + '', + ), + ); + + app.get('/trap/query/list/', (c) => { + const ogUrl = `http://localhost:${portRef.port}/trap/query/`; + return c.html( + '' + + '一覧' + + '' + + '' + + `` + + '' + + '

query trap body (identical across every value)

' + + queryLinks + + '', + ); + }); +} diff --git a/packages/test-server/src/server.ts b/packages/test-server/src/server.ts index d6c46795..9fb643b4 100644 --- a/packages/test-server/src/server.ts +++ b/packages/test-server/src/server.ts @@ -5,6 +5,7 @@ import { Hono } from 'hono'; import { basicRoutes } from './routes/basic.js'; import { consoleLogsRoutes } from './routes/console-logs.js'; +import { dedupeCapTrapRoutes } from './routes/dedupe-cap-trap.js'; import { errorStatusRoutes } from './routes/error-status.js'; import { excludeRoutes } from './routes/exclude.js'; import { flakyRoutes } from './routes/flaky.js'; @@ -60,6 +61,7 @@ export function createApp(portRef: PortRef) { jsRedirectRoutes(app); mainContentRoutes(app); consoleLogsRoutes(app); + dedupeCapTrapRoutes(app, portRef); return app; } From 95072dedb19ad6ba06b99ce96681e0d015a108da Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 02:42:04 +0900 Subject: [PATCH 08/10] docs(repo): document issue #208 changes in ARCHITECTURE.md Adds Reading paths for the same-cluster soft cap change, the dedupe_cap_events schema entry and its Append-Only Journal Table classification, and two invariant/negative-knowledge entries: the self-generating pagination-URL-space bug and its two-part fix, and the Misra-Gries tracker design (rejected alternatives, accepted limitation). --- ARCHITECTURE.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 337cd624..b7fafb97 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,6 +64,7 @@ - **page_errors / crawl_errors**: 失敗の 2 系統(スクレイプ経路 / crawler レベル)。kind は保存せず読み取り時に `classifyErrorKind` で導出 - **inventory_runs**: `--inventory` の監査ログ。append-only・UNIQUE 制約なし(同一 sha256 の再適用は 2 行になる。dedupe 判定用に `source_file_sha256` 列だけ確保してある)。`source_file_path` は privacy のため永続化しないが、ソースリストの生バイト列自体は `saveInventorySourceList` が tar 内 `inventory/.txt` として保存する(ファイル名がそのまま content hash — 再投入は同名上書きで実質 no-op、元ファイル名に案件識別子が含まれていても残さない)。`total_lines` は CLI が不正 URL 行を warn-and-skip した**後**の有効 URL 数であり、元ファイルの生行数ではない。何行スキップしたかは `invalid_skipped` 列に残る(`scope_skipped` と対になる列。行番号自体は列を持たず、`inventory/.txt` を読み直せば事後導出できる) - **network_outages**: オペレータ側ネットワーク断(と疑われた区間)の append-only 履歴。`started_at`(検出ウィンドウ内の最古エラーまで遡及)/ `detected_at` / `ended_at`(復旧まで NULL)/ `probe_host` / `trigger_error_count` / `trigger_host_count`。索引なし(1 クロールあたり数件、消費側は全行をメモリに読んで `isWithinOutageWindow` で判定する)。`ended_at` は復旧確定 or 次回 writer open 時の打ち切りで一度だけ UPDATE される(`crawler/src/is-within-outage-window.ts`、`archive/db-ops/outages/`) +- **dedupe_cap_events**(issue #208、opt-in `--dedupe-cap`): `DedupeCapTracker`(Misra-Gries 多数決カウンタ、`crawler/src/crawler/dedupe/`)が URL 形状(`computeShapeKey`)を同一クラスタトラップと確定した際の append-only 監査ログ。`shape_key` / `sample_url` / `body_hash` / `effective_threshold`(信頼シグナルで底上げ後の実効閾値)/ `observed_count`(多数決カウンタの値、実観測回数ではなく下界)/ `detected_at` / `rejected_count`(cap 後に拒否した anchor 数、`crawlEnd` で一度だけ確定)。索引なし(`network_outages` と同じ理由)。`rejected_count` の NULL は「crawl が `crawlEnd` に未到達で未確定」を意味し、`network_outages.ended_at` と異なり無限区間と誤読される危険がないため boot-time reconciliation は持たない - **console_log_items / page_console_logs**(issue #228、beholder `ScrapeResult.consoleLogs`): 2 テーブル構成。`console_log_items` は `text_refs` / `json_refs` / `url_refs` と同じ内容ハッシュ dedup の辞書(`hash` が `type` + `text` + `args` JSON + `location` + `stack` の canonical タプルの SHA-256)で、全ページ横断で 1 内容 1 行 — 同一フレームワーク警告が全ページで出るケースでもストレージは線形に増えない。`page_console_logs` は `(page_id, console_log_id, ts)` の 1 出現 1 行のエッジテーブルで、同じページが同じメッセージを 3 回出せば 3 行になる。書き込みは `replaceConsoleLogs`(`crawler/src/archive/db-ops/console-logs/`)が 1 トランザクションで `DELETE FROM page_console_logs WHERE page_id = ?` → 辞書 upsert → INSERT を行う Scoped-Replace(`anchor_edges` と同じ分類、下記 4 分類参照)。空 `entries` では呼び出し自体をスキップする非空ガードは crawler 側(`Crawler#handleConsoleLogs`)が担う。`page_meta.console_error_count`(`pageerror`+`error` の occurrence 数)は `tag_count` と同じ denormalised aggregate だが、書き込みのアトミック性は異なる — `tag_count` は `insertPage` が `page_meta` 行自体を作る INSERT に同梱するのに対し、`console_error_count` は `replaceConsoleLogs` が entries から直接計算して**別トランザクション**で UPDATE する(crawler の `'page'` イベントと `'consoleLogs'` イベントは別々の WriteQueue タスクなため)。両者の間でプロセスが落ちると、その 1 ページの `console_error_count` は列デフォルト値のまま永続的に取り残される(自己修復しない。page_meta 行が無い `'skipped'`/`'error'` スクレイプでは UPDATE が 0 行に落ちるだけで無害)。読み取りは read model 非経由のライブ集計(`query/src/list-console-logs.ts` / `get-page-console-logs.ts` / `count-console-logs-by-type.ts`)— 新機能で前身データが無いため `viewer_header_checks` 級の read-model + fast-path 二層は採用せず `getViolations` 相当のシンプル構成。`viewer_pages.console_error_count` / `viewer_summary.console_json` のみ read model にコピーする(ソート・ダッシュボード用) - **リダイレクト**: 独立テーブルなし。`content_items.redirect_dest_id` を書き込み時(`linkRedirectSources`)に常に最終宛先まで pre-flatten し、読み取りは `COALESCE(target.redirect_dest_id, target.id)` の 1 ホップ(read 時に chain-walk しない) - **content_items.alias_of_id**: URL 正規化で同一ページとみなせる行同士を統合する自己参照 FK(`redirect_dest_id` と同形、`DEFERRABLE INITIALLY DEFERRED`)。実際の 3xx を要件としない点で redirect と異なる。判定は 2 階層とも `page_meta.title_text_id` 一致が AND 条件: Tier A(`computeTierAAliasKey` — scheme 等価・host 大文字小文字・`/index.{ext}` 表記ゆれ、URL 文字列のみで決定的)と Tier B(`computeTierBAliasKey` — さらに末尾スラッシュ差異のみ、`page_meta.body_hash` 一致も必須)。両ティアは決定的キー関数の完全一致でグルーピングするため各ティア単体は推移的だが、2 種の関係の合成は自動では推移的にならず、Union-Find(`viewer-read-model/backfill-alias-of-id.ts`)で連結成分の閉包を取る(`compareUrlSortKeys` のペア比較が推移律を保証しないのとは別の理由・別の設計)。代表選定は「グループ内の他メンバーを指す canonical 数最多」→「最短 URL」→「文字列昇順」。列自体は `body_hash` と同じ自己修復マイグレーション(`migrate-content-items-alias-of-id.ts`、索引は DDL でなくマイグレーション側で作成 — 理由は body_hash の索引バグと同じ)で追加されるが、値の計算は backfill ではなく `viewer-build` 実行毎のフルリコンピュート(`backfillAliasOfId`、`body_hash` backfill の直後 — Tier B が計算済み body_hash に依存するため)。read-only 接続では列追加が走らないため、read-only 経路からも呼ばれる全クエリ(`get-summary.ts` / `list-pages.ts` / `get-link-graph.ts` / `get-page-detail.ts` / `list-inbound-links.ts` / `find-duplicates.ts` / `find-mismatches.ts` / `check-headers.ts` / `list-links.ts` / `compute-isolated-clusters.ts`)が冒頭で `requireAliasOfIdColumn` を呼び、列が無ければ `viewer-build` 実行を促すエラーを投げる。`build-viewer-read-model.ts` / `compute-anchor-fact-rows.ts` はこのガードを呼ばない — 書き込み可能接続(`ensureViewerReadModel` 経由)でのみ実行され、その時点で `migrateContentItemsAliasOfId` 適用済みが保証されるため不要。適用パターンは箇所ごとに `redirect_dest_id` の既存方針を踏襲: 除外専用の `get-link-graph.ts` / `compute-isolated-clusters.ts` の候補集合は drop、`list-links.ts` / `compute-anchor-fact-rows.ts` の宛先解決は `COALESCE` で resolve。`list-pages.ts` の `urlPattern` フィルターと `get-page-detail.ts` の URL 検索は、redirect 元 URL・alias メンバー URL のどちらで検索してもヒットするよう、`redirect_dest_id` と `alias_of_id` を対等に解決する(`list-pages.ts` は各カラムを個別 `IN` サブクエリにしてから `UNION ALL` で束ねる — 1 つの `OR` に畳むとフルスキャンに落ちる負の知識は前述のとおり)。両者は別の関係(redirect は実観測の 3xx、alias は本文/URL 形状からの推論)だが「このURLの実体は別の行」という意味では同じ扱いにする。 @@ -104,13 +105,15 @@ - **Raw Data Table = クロールが観測した値そのものを持つ実体行(1 エンティティ = 1 行)。** 特性: 書き込みのたびに、対象の 1 行だけを直接 UPDATE して最新化する(テーブル全体には触れない)。差分マージは不要で、常にその 1 行を今回の観測値で上書きする。理由: この行自体が一次情報源であり、他のテーブルから計算して復元できる値ではないため、UPDATE 以外に鮮度を保つ手段がない。例: `content_items` / `page_meta`(`db-ops/pages/write/update-page.ts`)。**`resource_items` は本来この分類に属するが、現状 `insert-resource.ts` が `ON CONFLICT IGNORE` の first-write-wins で書かれており、既知リソースを再取得してもその行の status / content-type / headers が更新されない(既知の逸脱)** - **Scoped-Replace Table = 派生行を「書き込み単位(ページ)ごとに」丸ごと置き換えるテーブル(テーブル全体を作り直すわけではない)。** 特性: 今回書き込まれたページに紐づく既存行だけを全 DELETE してから今回分を INSERT し直す(個々の行を UPDATE しない)。書き込まれていない他のページの行には一切触れない — `--inventory` / `--append` / `--retry-failed` で今回スコープ外のページの行はそのまま残り、新規ページは単純 INSERT のみで既存行の削除は発生しない。**この置換がページ単位に閉じているからこそ、通常運用でテーブルが壊れることはない。** 理由: 元になる生入力(1 ページ分のアンカー一覧・画像一覧)はどこにも永続化されない一過性データで、しかも dedup / 集約(同一宛先への複数アンカーを 1 行にまとめる等)を伴うため、部分 UPDATE では再現できず「そのページだけ毎回作り直す」以外に鮮度を保つ手段がない。ページ単位の置換さえ正しく機能していれば変更のあったページだけが最新化されれば十分なので、テーブル全体を生データから一括再構築する必要自体が生じない(raw が残らないのはこの前提の裏返しであって欠落ではない)。万一置換ロジック自体にバグがあった場合の是正は「該当ページを再クロールし直す」ことで行う。これは限界ではなく、この設計における妥当な回復経路である。例: `anchor_edges` / `image_items`(`replaceAnchorEdges` / `replaceImageItems`、`update-page.ts`)。`page_console_logs`(issue #228)も同じ分類 — ただし非空ガードの実装位置が異なり、`replaceConsoleLogs` 自体は無条件 DELETE+INSERT で、呼び出し側(`Crawler#handleConsoleLogs`)が空 `entries` のとき呼び出し自体をスキップすることで「劣化再スクレイプが前回の良いデータを消さない」を担保する。`resource_ref_edges` も同じ分類に属する — `update-page.ts` がページ単位で既存 edge を全 DELETE し、直後に `Crawler#handleResources`(`insertResourceReferrers`、同じ WriteQueue で直列化)が今回分を INSERT し直す。ただし DELETE と INSERT は別トランザクションのため、その一瞬の窓で同一アーカイブを読んでいる読み取り専用接続(viewer / MCP)がそのページのリソースを一時的に「未使用」と観測しうる(自己修復する表示上のズレであり、恒久的なデータ欠損ではない) - **Computed Readonly Table = 一次情報を持たず、他の永続化テーブルの集計・整形結果だけを持つキャッシュ層。** 特性: 他のテーブル(Raw Data Table / Scoped-Replace Table)から、テーブル全体をいつでも計算しなおせる。書き込み API を持たず、鮮度はテーブルを丸ごと「破棄 → 一括再構築」することで保つ(Scoped-Replace Table と異なり、行単位の部分更新は行わない)。理由: reader 側の性能のために事前計算しておく必要があるが、一次情報を持たないぶんテーブル全体をいつ再構築しても正しさが保証されるので、鮮度管理は「テーブル全体を再構築するか否かの判定」だけで済む。例: viewer read model(`viewer_*` テーブル群、`buildViewerReadModel`)。**append / retry-failed / inventory 後もこのテーブル群を再構築するかどうかは `is-viewer-read-model-current.ts` の `schema_version` 一致判定のみで決まり、データが変わっただけではテーブル群は再構築されない**(80 行目の build 経路トリガー自体とは別軸の問題。既知の逸脱) - - **Append-Only Journal Table = 行が不変の履歴的事実であり、UPDATE も再構築もしない台帳。** 特性: 新しい行は常に INSERT のみで追加され、既存行は原則書き換えない(1 行 = 1 回の出来事の記録)。再クロールで「最新化」されるという概念自体が無い — 古い行が古いという理由で消えたり書き換わったりしない。理由: 各行はそれ自体が起きた出来事の証跡であり、後続のクロールが新しい事実を追加することはあっても過去の事実を覆すことはない。例: `inventory_runs`(`--inventory` の監査ログ、UNIQUE 制約なし)。`network_outages` もこの分類に属するが、**唯一の例外として `ended_at` 列だけは復旧確定時(または次回 writer open 時の打ち切り)に一度だけ UPDATE される** — 「断がいつ終わったか」は開始時点では未知の事実であり、確定した時点で追記するのが自然な代わりに、この 1 列に限り append 後の書き換えを許容している(他の列は INSERT 時の値から変わらない) + - **Append-Only Journal Table = 行が不変の履歴的事実であり、UPDATE も再構築もしない台帳。** 特性: 新しい行は常に INSERT のみで追加され、既存行は原則書き換えない(1 行 = 1 回の出来事の記録)。再クロールで「最新化」されるという概念自体が無い — 古い行が古いという理由で消えたり書き換わったりしない。理由: 各行はそれ自体が起きた出来事の証跡であり、後続のクロールが新しい事実を追加することはあっても過去の事実を覆すことはない。例: `inventory_runs`(`--inventory` の監査ログ、UNIQUE 制約なし)。`network_outages` もこの分類に属するが、**唯一の例外として `ended_at` 列だけは復旧確定時(または次回 writer open 時の打ち切り)に一度だけ UPDATE される** — 「断がいつ終わったか」は開始時点では未知の事実であり、確定した時点で追記するのが自然な代わりに、この 1 列に限り append 後の書き換えを許容している(他の列は INSERT 時の値から変わらない)。`dedupe_cap_events`(issue #208)も同型の 1 列例外を持つ — `rejected_count` が `crawlEnd` で一度だけ確定する。`network_outages` との違いは、この列の NULL に「無限に続く区間」のような誤読リスクが無いため、`close-stale-open-network-outages.ts` 相当の boot-time reconciliation パスを意図的に持たないこと - **viewer は `process.exit` の例外** — 他コマンドはバッチ型で `cli.ts` 末尾の `process.exit` に到達するが、viewer は SIGINT/SIGTERM まで resolve しない常駐サーバ。シャットダウンは必ず `ArchiveManager.closeAll()` を通す - **`Promise.race` の負け側 timer は必ず `clearTimeout`** — 放置すると event loop を握って CLI の自然終了をブロックする。`delay()` は signal を取らないので race に使わない(実例: `fetch-destination.ts`、`close-browser-safely.ts`) - **decorator を使わない(legacy / Stage 3 とも)** — Vite 8 内蔵の oxc が transform せず素通しし、Vitest 4.1 でテストが全滅する。retry は `retryCall`、error 発火は `emitError` / `emitErrorAndRetry` の HOF で書く - **Playwright E2E(`viewer/e2e/`)をルート `vitest.config.ts` の `exclude` から外さない** — Vitest 非互換で `yarn test` が落ちる - **`--retry-failed` の収束は `PERMANENT_ERROR_KINDS` 除外が担保** — 永続失敗(NXDOMAIN / 期限切れ証明書等)を reset 対象から外さないとリトライ対象が減らない(`crawler/src/permanent-error-kinds.ts`、`database.ts` の `resetFailedPages`) - **DNS burn は session-success guard 付きでのみ行う** — resolver flip 事故でセッション全体が degenerate completion に陥るのを防ぐ(`crawler/src/crawler/should-burn-host.ts`)。加えて、断区間中に session-learned burn されたホストはゲート再開時に自動で un-burn される(`evict-outage-tainted-dns-burns.ts`)。preload-seeded burn(前セッション由来の確定死亡判定)はこの巻き戻しの対象外 — session-learned burn だけを区別するために `dns-burned-host-burn-timestamps.ts` が burn 時刻を別途記録する。次セッションへの持ち越し判定(`list-dns-burned-host-candidates.ts`)も、最新 DNS エラーの `createdAt` が断区間内なら候補から除外する +- **pagination 予測は元トークンの字面保全と同一ドキュメント内隣接比較を守らないと科学表記の無限 URL 空間を自己生成する**(issue #208、実測付き)— `generatePredictedUrls`(`crawler/src/crawler/generate-predicted-urls.ts`)が数値を `String()` でそのまま文字列化すると `Number.MAX_SAFE_INTEGER` 超で科学表記(`"1e+21"` 等)に化ける。実際のプロダクション事例では `paginationState`(`#handleResult` 内の pagination 検出状態)がクロール全体で 1 つ共有されていたため、無関係な 2 ページの anchor 同士を比較して `step` が毎ラウンド倍化し `1.7715854126052197e+120` まで膨張した。修正は 2 点: (1) 生成トークンが `Number.isSafeInteger` を満たし `/^\d+$/` にマッチし元の桁数+1 以内であることを保証(書式保全)、(2) `paginationState` を `#handleResult` 呼び出し単位(1 ページの anchor リスト内)に閉じ、ページを跨いだ比較を構造的に排除する(`crawler.ts`)。閾値によるガードは意図的に入れていない — ellipsis 付きページャ(`1 ... 5000 ... 10000`)の正当な飛びを誤って弾くリスクがあり、書式保全 + ページ内比較だけで実測上十分だった +- **同一クラスタ soft cap(`--dedupe-cap`、issue #208)は Misra-Gries 多数決カウンタ 1 スロット/shape で構成し、issue 原案の複数層メモリ機構を採用しない** — `DedupeCapTracker`(`crawler/src/crawler/dedupe/dedupe-cap-tracker.ts`)は shape ごとに `{metaSig, count}` を 1 つだけ持ち、`count` は一致観測数の下界であって上振れしない(false-positive が構造的に発生しない)。この性質により、年齢ベース失効・parent-path バケット完了検知という 2 層のメモリ解放機構が不要になる(1 shape あたり最大 1 スロットしか持たないため)。**既知の限界**: 厳密な過半数を持つ signature しか検出できない(Boyer-Moore 多数決の性質)ため、同一 shape のトラップが 2 種類の metaSig をほぼ均等に交互発生させるケースでは `count` が 0 近傍を往復し cap が発火しない。総観測数での backstop は正当な大規模セクション(`/product/{id}` 数千件等)を誤爆するため採用せず、この限界は受容している。並列クロールでは到着順序が非決定的なため、この均等交互ケースの発火/非発火自体も非決定的になる - **inventory の source 優先度は `crawled > inventory-seed > inventory-discovered`** — crawled 経由で到達可能なページは inventory 由来と見なさない。ingestion(pre-insert + audit 行 + `.bak` 削除)は 1 セットの原子的操作で、失敗時は `.bak` 復元で全て巻き戻る(`database.ts`、`crawler-orchestrator.ts` の `ingestionComplete`) - **Archive への書き込みは `WriteQueue` で直列化** — 複数イベントハンドラからの SQLite 書き込みロック競合を防ぐ。`crawlEnd` で `drain()` 必須(`crawler/src/write-queue.ts`) - **タールキャッシュは誰も evict しない** — read-only open の展開先 `/nitpicker/cache/` の寿命は OS の temp cleanup に委ねる設計。手動削除は `rm -rf` で安全(`archive.ts` の `openCached`)。`nitpicker cache list` / `cache clear` は同じ場所(tar 展開キャッシュ + analyze の `table` キャッシュ)を一覧・削除する診断用 CLI で、確認プロンプト無しの即時削除という設計思想も踏襲する(`cli/src/commands/cache.ts`) @@ -157,6 +160,21 @@ DOM構造類似性によるページのテンプレート分類(`--templates` 9. クラスタ分析: `query/src/list-page-template-clusters.ts`(`templateKey` ごとのページ数・共通ディレクトリ(`compute-common-directory.ts`)・共通CSS積集合(`compute-css-intersection.ts` + `collect-page-stylesheet-urls-by-page-id.ts`)をクラスタの実メンバーページから再計算する。`templateKey` 文字列自体は `@d-zero/page-cluster` のブロッキングキーで、`css:` 以下は SHA-256 ハッシュのため人間には読めない — これがクラスタ単位の再計算が必要な理由。共通CSSの積集合は `@d-zero/page-cluster` が実際にハッシュ化前に行う first-party フィルタ・文書頻度90%フィルタの簡易版で、サイト全体で共通するCSSも含まれうる — フィルタ済みの厳密な集合は `reason.distinctiveStylesheetUrls`(下記)で別途取得できるため、この2指標は置き換え関係ではなく併存する別メトリクスと `compute-css-intersection.ts` の JSDoc に明記。`page_templates` はどのアーカイブにも `createAdjunctTables` で常時作成されるため、`hasClassification`(`--templates` 実行済みか)はテーブル有無ではなく行数0件で判定する。`has-page-template-clusters-table.ts`(存在ガード)・`load-template-cluster-reasons.ts`(`decodeJsonRef` → `JSON.parse` → `is-template-cluster-reason.ts` の型ガードの順で検証、壊れた行は fail-closed でスキップ)・`summarize-template-cluster-reason.ts`(API 転送用に `structuralCoreTokens`/`shellTokens` を先頭N件へトリムし、`ClusterReason.blocking` の `css` エントリから `distinctiveStylesheetUrls` を導出)が `TemplateClusterSummary.reason`(`null` 許容)を組み立てる) 10. viewer 表示(クラスタ分析画面): `viewer/src/routes/register-template-clusters-route.ts`(`GET /api/template-clusters` 一発取得。`page_templates` は read model 非経由のためキャッシュは `viewer/src/template-clusters-cache.ts` のメモリ LRU のみ、stub mode はbypass)+ `viewer/web/routes/template-clusters-view.tsx`(`templateKey` ごとに `
` セクション表示、見出しは `reason.distinctiveStylesheetFileNames` 最優先(兄弟クラスタ間で同一になりうるため `siblingClusterKeys` 非空時はディレクトリを併記)→共通CSSファイル名→共通ディレクトリの順にフォールバック、`reason` があれば blocking 根拠・共通DOM構造・共通ランドマーク・兄弟クラスタも表示、無ければ「未保存」の案内を表示。Pages 一覧への `templateKey` フィルタ付きリンク)。列挙型ラベルは `get-blocking-kind-label.ts`/`get-landmark-type-label.ts`(`views..` 規約 + 生値フォールバック、`get-error-kind-label.ts` と同じパターン)。MCP 専用ツール・CLI サブコマンドは意図的に未実装 +### 同一クラスタ soft cap(issue #208)の変更 + +自己生成される無限 URL 空間(pagination 予測の暴走)への対処と、opt-in `--dedupe-cap` による同一メタデータクラスタの検出。前者は常時有効、後者は明示フラグでのみ動く。 + +1. `crawler/src/crawler/generate-predicted-urls.ts`(予測トークンの書式保全、常時有効。`Number.isSafeInteger` / `/^\d+$/` / 元の桁数+1 以内のガード)と `crawler/src/crawler/crawler.ts`(`#handleResult` 内でページ単位に `paginationState` を生成 — ページを跨いだ比較を排除) +2. `crawler/src/crawler/dedupe/`(opt-in `--dedupe-cap` 本体): `compute-shape-key.ts`(URL 形状キー、`decompose-url.ts` を再利用。同名の別ファイル `archive/populate-ref-tables/decompose-url.ts` と混同しないこと)/ `compute-meta-signature.ts`(title/description/og:title/og:url の SHA-1)/ `resolve-og-url-mismatch.ts`(og:url 絶対化して自 URL と比較)/ `dedupe-cap-tracker.ts`(Misra-Gries 多数決カウンタ + sticky Set + hard cap LRU)/ `is-shape-capped.ts` / `is-predicted-content-duplicate.ts`(常時有効、予測ページ同士の body_hash 一致で予測を打ち切る軽量機構) +3. ゲート配線(`crawler.ts`): addUrl クロージャ先頭(実 anchor 用)と JS-redirect rescue の直接 enqueue(`result.source === 'js-redirect'` 分岐)の 2 箇所。予測 URL 生成自体は起点 anchor と同一 shapeKey のため別ゲート不要(`#predictedShapeStopped` と `#dedupeCapTracker.isCapped` の OR 判定のみ) +4. `crawler/src/archive/create-adjunct-tables.ts`(`dedupe_cap_events` DDL)と `db-ops/dedupe-cap/`(insert / finalize / list-shape-keys)+ `archive.ts` / `database.ts` のファサード +5. `crawler-orchestrator.ts`: `dedupeCap` イベントの WriteQueue 配線(`Map` — `network_outages` のスカラー方式と違い複数 shape が同時に capped になり得るため)、`crawlEnd` での `rejected_count` 確定(`Crawler#getDedupeCapRejections()` を読む)、`append`/`inventory`/`retryFailed`/`resume` の 4 箇所での sticky Set preload(`Crawler` コンストラクタの `preloadedStickyShapeKeys` オプション経由。fresh crawl は対象外) +6. inventory seed は `Crawler#resume()` → `LinkList#resume()` 経由でゲート 2 箇所を通らないため、cap の影響を受けない(構造的に保証、追加コード不要) +7. CLI フラグ: `cli/src/commands/crawl.ts`(`--dedupe-cap` / `--dedupe-map-cap`)→ `cli/src/crawl/map-flags-to-crawl-config.ts` +8. 事後クエリ: `query/src/list-duplicate-body-clusters.ts`(`findDuplicateBodies` とは別関数 — 既存 CLI/MCP 利用者の出力契約を壊さないため。`body_hash` をそのまま `signature` として再利用し、issue が提案した「query に signature 定義を置いて crawler から import」は依存方向ルール違反のため採らない)/ `query/src/list-dedupe-cap-events.ts`(`list-network-outages.ts` と同型) +9. CLI サブコマンド `duplicate-clusters` / `dedupe-cap-events`(`map-flags-to-query-options.ts` / `dispatch-query.ts`)、MCP tool `find_duplicate_clusters`(`find_duplicates` 系の命名規約)/ `list_dedupe_cap_events` +10. viewer: `template-clusters` を軽量パターンの参照実装として使用(`register-duplicate-clusters-route.ts` + `duplicate-clusters-cache.ts`、`register-dedupe-cap-events-route.ts` はキャッシュなし・`network-outages` 型)。専用ナビ項目は `dedupe_cap_events` には作らず Duplicate Clusters ビュー内の通知として表示 + ### viewer のビュー / API 追加 1. `viewer/src/create-app.ts`(ルート登録)と `viewer/src/routes/register-*-route.ts`(query 1:1) From e8dcf76cd7b7afe1610ef4ce3dbcb3725b2376f7 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 04:27:18 +0900 Subject: [PATCH 09/10] fix(crawler): fix rejected_count finalization gap, stale body_hash comparison, and gate inconsistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses /code-review findings on the dedupe-cap feature (issue #208): - crawlEnd finalization now covers every shape capped this session, not only ones with a nonzero rejection count — a shape that capped with no further gate rejections (e.g. near the end of the crawl) previously kept `rejected_count: NULL` forever despite the crawl completing normally, indistinguishable from "the crawl never reached crawlEnd". - DedupeCapTracker#observe now updates a slot's tracked body_hash on every matching-metaSig observation instead of only at slot creation, so the body_hash confidence signal compares against the most recent observation rather than a potentially stale first one. - Gate 1 (anchor enqueue) no longer exempts metadata-only anchors from the same-cluster cap check. `--recursive=false` marks every anchor metadata-only, so the exemption silently disabled `--dedupe-cap` for anchor discovery in that mode while gate 2 (JS-redirect) had no such exemption — an inconsistency between the two discovery paths. - Reuses the predicted-content-duplicate check's body_hash computation for the dedupe-cap observation instead of hashing the same page html twice. - Fixes JSDoc referencing a `#preloadDedupeCapStickyShapeKeys` method that was never implemented (the four call sites each inline the same `archive.listDedupeCapShapeKeys()` call). --- .../crawler/src/crawler-orchestrator.spec.ts | 46 +++++++++++++++++++ .../crawler/src/crawler-orchestrator.ts | 26 ++++++++--- .../crawler/src/crawler/crawler.spec.ts | 41 +++++++++++++++++ .../@nitpicker/crawler/src/crawler/crawler.ts | 30 ++++++++++-- .../crawler/dedupe/dedupe-cap-tracker.spec.ts | 24 ++++++++++ .../src/crawler/dedupe/dedupe-cap-tracker.ts | 9 +++- 6 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts b/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts index a91c8a7e..f593688d 100644 --- a/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler-orchestrator.spec.ts @@ -1133,6 +1133,52 @@ describe('CrawlerOrchestrator.crawling: dedupeCap event handling (issue #208)', 3, ); }); + + it('拒否が一度も起きなかった(getDedupeCapRejectionsに現れない)今セッションcapped済みshapeもrejected_count=0でfinalizeされる(NULLのまま放置しない)', async () => { + // A shape that caps near the very end of the crawl (or whose remaining + // anchors were all already discovered before it capped) never enters + // `getDedupeCapRejections()` — regression guard for a bug where such a + // shape's row stayed `rejected_count: NULL` forever despite `crawlEnd` + // firing normally, indistinguishable from "the crawl never completed". + const insertDedupeCapEvent = vi.fn(() => Promise.resolve(99)); + const finalizeDedupeCapEvent = vi.fn(() => Promise.resolve()); + const accumulateDedupeCapRejectedCount = vi.fn(() => Promise.resolve()); + const fakeArchive = { + on: vi.fn(), + setConfig: vi.fn(() => Promise.resolve()), + getConfig: vi.fn(() => Promise.resolve({ analyze: [] })), + setUrlOrder: vi.fn(() => Promise.resolve()), + getResourceByUrl: vi.fn(() => Promise.resolve(null)), + insertDedupeCapEvent, + finalizeDedupeCapEvent, + accumulateDedupeCapRejectedCount, + filePath: '/tmp/orchestrator-dedupe-cap-zero-rejections-test.nitpicker', + } as unknown as Archive; + + const archiveModule = await import('./archive/archive.js'); + vi.spyOn(archiveModule.default, 'create').mockResolvedValueOnce(fakeArchive); + + fakeCrawlerDriver = (crawler) => { + crawler.handlers.get('dedupeCap')?.({ + shapeKey: 'example.com/late-cap/{n}/', + sampleUrl: 'https://example.com/late-cap/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 1, + observedCount: 2, + } as never); + // No further rejections for this shape — the default FakeCrawler + // stub already returns an empty Map, matching this scenario. + crawler.handlers.get('crawlEnd')?.(undefined as never); + }; + + await CrawlerOrchestrator.crawling(['https://example.com/'], { + cwd: '/tmp', + filePath: '/tmp/orchestrator-dedupe-cap-zero-rejections-test.nitpicker', + }); + + expect(finalizeDedupeCapEvent).toHaveBeenCalledWith(99, 0); + expect(accumulateDedupeCapRejectedCount).not.toHaveBeenCalled(); + }); }); describe('CrawlerOrchestrator.inventory: dedupeCap sticky preload wiring (issue #208)', () => { diff --git a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts index 03b8765e..41fc5752 100644 --- a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts +++ b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts @@ -136,9 +136,9 @@ interface CrawlConfig extends Config { /** * See {@link CrawlerOptions.preloadedStickyShapeKeys}. Set internally by * the four resuming-session static methods - * (`append`/`inventory`/`retryFailed`/`resume`) via - * `#preloadDedupeCapStickyShapeKeys`; not part of the public options a - * caller of those methods passes directly. + * (`append`/`inventory`/`retryFailed`/`resume`), each independently + * calling `archive.listDedupeCapShapeKeys()`; not part of the public + * options a caller of those methods passes directly. */ preloadedStickyShapeKeys: readonly string[]; } @@ -303,7 +303,7 @@ export class CrawlerOrchestrator extends EventEmitter { // Only the four resuming-session static methods // (`append`/`inventory`/`retryFailed`/`resume`) pass this — a // fresh `crawling()` has no archive history to seed from (see - // `#preloadDedupeCapStickyShapeKeys`'s JSDoc). + // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc). preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [], }); } @@ -526,8 +526,22 @@ export class CrawlerOrchestrator extends EventEmitter { writeQueue .enqueue(async () => { const rejections = this.#crawler.getDedupeCapRejections(); + // Finalize every shape capped THIS session (has an id in + // `#dedupeCapEventIds`), not just the ones with a nonzero + // rejection count — a shape that capped near the end of the + // crawl (or whose remaining anchors all happened to be + // discovered before it capped) never enters `rejections` at + // all, and would otherwise stay `rejected_count: NULL` forever + // despite the crawl completing normally, corrupting the "NULL + // means the crawl never reached crawlEnd" contract + // `list-dedupe-cap-events.ts` documents. + const shapeKeysToFinalize = new Set([ + ...this.#dedupeCapEventIds.keys(), + ...rejections.keys(), + ]); await Promise.all( - [...rejections].map(([shapeKey, rejectedCount]) => { + [...shapeKeysToFinalize].map((shapeKey) => { + const rejectedCount = rejections.get(shapeKey) ?? 0; const id = this.#dedupeCapEventIds.get(shapeKey); // A shape capped THIS session has an id here (the // `dedupeCap` event always enqueues an INSERT before any @@ -536,7 +550,7 @@ export class CrawlerOrchestrator extends EventEmitter { // never observed this session at all — it was preloaded // into `DedupeCapTracker`'s sticky set from an EARLIER // session's `dedupe_cap_events` row (see - // `#preloadDedupeCapStickyShapeKeys`'s JSDoc), so gate + // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc), so gate // rejections still accumulate for it but no new row (and // thus no id) is ever created. That earlier row's count is // accumulated onto by shape_key instead of overwritten. diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts b/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts index 4e3047c5..57cbe2e7 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.spec.ts @@ -945,6 +945,47 @@ describe('Crawler', () => { // assetAnchor has a different shape → unaffected, still routed to push. expect(push).toHaveBeenCalledWith(assetAnchor); }); + + it('--dedupe-cap かつ --recursive=false(anchorがmetadataOnlyになる)でも既にcapped済みのshapeはenqueueされない', async () => { + // With `recursive: false`, handle-scrape-end.ts marks EVERY anchor + // metadata-only (internal or not) — regression guard that gate 1 + // still blocks a capped shape's anchor in this mode instead of + // silently letting it through (which would make `--dedupe-cap` + // inconsistent with gate 2's JS-redirect check, which has no + // metadataOnly exclusion). + const { unshift } = await driveDeal(); + const { default: Crawler } = await import('./crawler.js'); + const { computeShapeKey } = await import('./dedupe/compute-shape-key.js'); + + const htmlAnchor = parseUrl('https://example.com/about')!; + const cappedShapeKey = computeShapeKey(htmlAnchor.withoutHashAndAuth)!; + + const fetchDestMod = await import('./fetch-destination.js'); + vi.spyOn(fetchDestMod, 'fetchDestination').mockResolvedValue( + nonHtmlResultWithAnchors([{ href: htmlAnchor, textContent: 'About' }]) as Awaited< + ReturnType + >, + ); + + const crawler = new Crawler({ + ...defaultOptions, + recursive: false, + dedupeCap: 100, + preloadedStickyShapeKeys: [cappedShapeKey], + }); + let crawlEndEmitted = false; + crawler.on('crawlEnd', () => { + crawlEndEmitted = true; + }); + + crawler.start([parseUrl('https://example.com/feed.xml')!]); + + await vi.waitFor(() => { + expect(crawlEndEmitted).toBe(true); + }); + + expect(unshift).not.toHaveBeenCalled(); + }); }); describe('predicted-page content-duplicate discard (always-on, issue #208)', () => { diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.ts b/packages/@nitpicker/crawler/src/crawler/crawler.ts index b4f5a341..b364e362 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.ts @@ -627,12 +627,17 @@ export default class Crawler extends EventEmitter { * queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}). * Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order. * @param concurrency - Current concurrency level, used to determine predicted URL count + * @param precomputedBodyHash - This page's body hash, if the caller already + * computed it (the predicted-content-duplicate check, A-3, computes it for + * every predicted page regardless of `--dedupe-cap`) — reused for the + * dedupe-cap observation below instead of hashing the same html twice. */ #handleResult( result: ScrapeResult, url: ExURL, enqueue: (...urls: ExURL[]) => Promise, concurrency?: number, + precomputedBodyHash?: Buffer | null, ) { switch (result.type) { case 'success': { @@ -672,7 +677,7 @@ export default class Crawler extends EventEmitter { const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth); const metaSig = computeMetaSignature(result.pageData.meta); if (shapeKey && metaSig) { - const bodyHash = computeBodyHash(result.pageData.html); + const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html); const ogUrlMismatch = resolveOgUrlMismatch( result.pageData.meta, result.pageData.url.href, @@ -703,11 +708,20 @@ export default class Crawler extends EventEmitter { // bypassing this closure entirely — so the predicted-URL // generation site below has its own equivalent check // (`shapeIsStopped`, combined with `#predictedShapeStopped`). - // External / metadata-only anchors are out of scope for the - // cap (issue #208: "cap 適用は internal only"). + // External anchors are out of scope for the cap (issue #208: + // "cap 適用は internal only"), enforced by the scope check + // below. Deliberately NOT also excluding `opts?.metadataOnly` + // (unlike the tracker's observation side, which does skip + // metadata-only pages — they carry no reliable signature): with + // `--recursive=false`, `handle-scrape-end.ts` marks EVERY anchor + // metadata-only, internal or not, so excluding them here would + // silently disable `--dedupe-cap` for anchor discovery whenever + // `--recursive=false` is set — while gate 2 (the JS-redirect + // direct enqueue below) has no such exclusion and would still + // cap the very same shape, an inconsistency between the two + // discovery paths. if ( this.#options.dedupeCap !== null && - !opts?.metadataOnly && findScopeEntry(newUrl, this.#scope, this.#options) !== null ) { const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth); @@ -1091,6 +1105,11 @@ export default class Crawler extends EventEmitter { const markBrowserScrape = () => { renderedInBrowser = true; }; + // Set by the predicted-content-duplicate check below (A-3) when it + // computes this page's body hash, so `#handleResult`'s dedupe-cap + // observation (also gated on this page's html) can reuse it instead + // of hashing the same html a second time. + let precomputedBodyHash: Buffer | null = null; try { const robotsAllowed = await this.#robotsChecker.isAllowed(url); @@ -1286,6 +1305,7 @@ export default class Crawler extends EventEmitter { const shapeKey = computeShapeKey(url.withoutHashAndAuth); if (shapeKey) { const bodyHash = computeBodyHash(result.pageData.html); + precomputedBodyHash = bodyHash; const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null; if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) { this.#predictedShapeStopped.add(shapeKey); @@ -1306,7 +1326,7 @@ export default class Crawler extends EventEmitter { } log('Saving results%dots%'); - this.#handleResult(result, url, enqueue, concurrency); + this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash); const parentSource = await this.#resolveParentSource(url); this.#handleResources(result.resources, parentSource); this.#handleConsoleLogs( diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts index 91604b6c..65e3715f 100644 --- a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.spec.ts @@ -116,6 +116,30 @@ describe('DedupeCapTracker', () => { }); }); + it('body_hash比較は常に直近の観測値と行う(スロット作成時の値と比較し続けない)', () => { + // A shape whose first page differs from an otherwise-identical run of + // later pages (e.g. a one-off warmup response) must still get the + // body_hash halving once the later pages start repeating — if the + // comparison target were never updated past slot creation, it would + // compare every later page against the stale first hash forever and + // never see a match. + const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }); + const firstBodyHash = Buffer.from('warmup-body'); + const repeatedBodyHash = Buffer.from('same-body'); + + // obs1: slot created with firstBodyHash, count=1. + tracker.observe(observation({ bodyHash: firstBodyHash })); + // obs2: differs from firstBodyHash → bodyHashMatches=false either way, + // but the fix updates the slot's tracked hash to repeatedBodyHash here. + expect(tracker.observe(observation({ bodyHash: repeatedBodyHash }))).toBeNull(); + // obs3: matches obs2's repeatedBodyHash → bodyHashMatches=true only if + // the slot's tracked hash was updated to the most recent observation. + // effectiveThreshold = ceil(5/2) = 3, count = 3 >= 3 → CAPPED. + const capped = tracker.observe(observation({ bodyHash: repeatedBodyHash })); + expect(capped?.effectiveThreshold).toBe(3); + expect(capped?.observedCount).toBe(3); + }); + it('og:url不一致でも実効閾値が半分(切り上げ)になる', () => { const tracker = new DedupeCapTracker({ cap: 5, mapCap: 100 }); diff --git a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts index 6c2823bc..2364186d 100644 --- a/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts +++ b/packages/@nitpicker/crawler/src/crawler/dedupe/dedupe-cap-tracker.ts @@ -121,8 +121,15 @@ export default class DedupeCapTracker { bodyHashMatches = false; } else if (existing.metaSig === metaSig) { existing.count++; - slot = existing; bodyHashMatches = existing.bodyHash.equals(bodyHash); + // Track the most recently observed body for this shape, not the + // one recorded when the slot was first created — otherwise a + // shape whose first page differs from an otherwise-identical run + // of later pages (e.g. a one-off warmup response) would compare + // every later page against that stale first hash forever and + // never see a match. + existing.bodyHash = bodyHash; + slot = existing; } else { existing.count--; if (existing.count <= 0) { From 112c6cd6e1aaa955b712e21e70b8071a392aef24 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 31 Jul 2026 04:27:47 +0900 Subject: [PATCH 10/10] fix(query): validate limit/offset and batch duplicate-cluster URL fetches Addresses /code-review findings on issue #208's query additions: - listDedupeCapEvents now runs limit/offset through resolveListLimit / resolveListOffset (already used by get-violations.ts / list-console-logs.ts) instead of a bare `?? default`, so a negative offset can no longer reach knex/libsql's synchronous throw and a negative/NaN limit can no longer silently disable paging. - listDuplicateBodyClusters fetches every surviving cluster's member URLs in one `whereIn(body_hash, ...)` query, grouped back into per-cluster lists in memory, instead of one query per cluster. --- .../query/src/list-dedupe-cap-events.spec.ts | 20 +++++ .../query/src/list-dedupe-cap-events.ts | 7 +- .../query/src/list-duplicate-body-clusters.ts | 77 ++++++++++++------- 3 files changed, 75 insertions(+), 29 deletions(-) diff --git a/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts b/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts index 653c377c..6eff661f 100644 --- a/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts +++ b/packages/@nitpicker/query/src/list-dedupe-cap-events.spec.ts @@ -114,6 +114,26 @@ describe('listDedupeCapEvents', () => { expect(secondPage.total).toBe(3); }); + it('負のofferやlimitを渡してもdefaultへフォールバックする(knexへ不正な値を渡さない)', async () => { + await archive.insertDedupeCapEvent({ + shapeKey: 'example.com/a/{n}/', + sampleUrl: 'https://example.com/a/1/', + bodyHash: Buffer.from('a'), + effectiveThreshold: 50, + observedCount: 100, + detectedAt: 1000, + }); + + await expect( + listDedupeCapEvents(archive, { limit: -1, offset: -1 }), + ).resolves.toEqual({ + items: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(Number) }), + ]), + total: 1, + }); + }); + it('returns every column, with body_hash as a hex string', async () => { const id = await archive.insertDedupeCapEvent({ shapeKey: 'example.com/full/{n}/', diff --git a/packages/@nitpicker/query/src/list-dedupe-cap-events.ts b/packages/@nitpicker/query/src/list-dedupe-cap-events.ts index 5c871121..c904425e 100644 --- a/packages/@nitpicker/query/src/list-dedupe-cap-events.ts +++ b/packages/@nitpicker/query/src/list-dedupe-cap-events.ts @@ -1,6 +1,9 @@ import type { DedupeCapEventEntry, ListDedupeCapEventsOptions } from './types.js'; import type { ArchiveAccessor } from '@nitpicker/crawler'; +import { resolveListLimit } from './resolve-list-limit.js'; +import { resolveListOffset } from './resolve-list-offset.js'; + /** * List recorded same-cluster-cap audit rows from the archive, newest first. * @@ -37,8 +40,8 @@ export async function listDedupeCapEvents( options: ListDedupeCapEventsOptions = {}, ): Promise<{ items: DedupeCapEventEntry[]; total: number }> { const knex = accessor.getKnex(); - const limit = options.limit ?? 100; - const offset = options.offset ?? 0; + const limit = resolveListLimit(options.limit, 100); + const offset = resolveListOffset(options.offset); const hasTable = await knex.schema.hasTable('dedupe_cap_events'); if (!hasTable) { diff --git a/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts b/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts index 45cbe714..7f2509f1 100644 --- a/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts +++ b/packages/@nitpicker/query/src/list-duplicate-body-clusters.ts @@ -31,8 +31,10 @@ const DEFAULT_SAMPLE_PAGES_LIMIT = 20; * `findDuplicateBodies` uses) cannot express "first N URLs per group" — * SQLite's `GROUP_CONCAT` has no `ORDER BY ... LIMIT` inside the aggregate. * Stage 1 computes the filtered/ranked cluster list from aggregate columns - * only (no per-row URL data). Stage 2 then fetches, per surviving cluster, - * every member URL — needed in full (not just `samplePagesLimit`) because + * only (no per-row URL data). Stage 2 then fetches every member URL for + * every surviving cluster in a single `whereIn(body_hash, ...)` query + * (grouped back into per-cluster lists in memory afterward, not one query + * per cluster) — needed in full (not just `samplePagesLimit`) because * `commonDirectories` must reflect the true distribution across the whole * cluster (see `computeDirectoryDistribution`'s own JSDoc on why a partial * sample would misrepresent a multi-section trap). `samplePages` is then a @@ -125,30 +127,51 @@ export async function listDuplicateBodyClusters( ogUrlMismatchRatio: number; }[]; - // Stage 2: per surviving cluster, fetch every member URL. - return Promise.all( - clusterRows.map(async (row) => { - // The driver only binds Buffer (not a plain Uint8Array) for a BLOB - // parameter — `row.bodyHash` as returned by stage 1 is a bare - // Uint8Array, so it must be re-wrapped before use in a `.where()`. - const bodyHash = Buffer.from(row.bodyHash); - const urlRows = (await knex('page_meta as pm') - .join('content_items as ci', 'ci.id', 'pm.page_id') - .join('url_refs as ur', 'ur.id', 'ci.url_id') - .select('ur.url as url') - .where({ 'ci.scraped': 1, 'ci.is_external': 0, 'pm.body_hash': bodyHash }) - .whereNull('ci.redirect_dest_id') - .whereNull('ci.alias_of_id') - .orderBy('ur.url', 'asc')) as { url: string }[]; - const urls = urlRows.map((r) => r.url); + if (clusterRows.length === 0) { + return []; + } + + // Stage 2: every member URL for every surviving cluster, fetched in ONE + // `whereIn` query rather than one query per cluster (the driver only + // binds Buffer, not a plain Uint8Array, for a BLOB parameter — `row.bodyHash` + // as returned by stage 1 is a bare Uint8Array, so each must be re-wrapped + // before use in `.whereIn()`), then grouped back into per-cluster URL + // lists in memory. `orderBy('ur.url', 'asc')` over the combined result set + // still yields each cluster's own URLs in ascending order, since rows are + // appended to their cluster's list in the order the single query returns + // them. + const bodyHashes = clusterRows.map((row) => Buffer.from(row.bodyHash)); + const urlRows = (await knex('page_meta as pm') + .join('content_items as ci', 'ci.id', 'pm.page_id') + .join('url_refs as ur', 'ur.id', 'ci.url_id') + .select('ur.url as url', 'pm.body_hash as bodyHash') + .where({ 'ci.scraped': 1, 'ci.is_external': 0 }) + .whereIn('pm.body_hash', bodyHashes) + .whereNull('ci.redirect_dest_id') + .whereNull('ci.alias_of_id') + .orderBy('ur.url', 'asc')) as { url: string; bodyHash: Uint8Array }[]; + + const urlsBySignature = new Map(); + for (const row of urlRows) { + const signature = Buffer.from(row.bodyHash).toString('hex'); + const urls = urlsBySignature.get(signature); + if (urls) { + urls.push(row.url); + } else { + urlsBySignature.set(signature, [row.url]); + } + } + + return clusterRows.map((row) => { + const signature = Buffer.from(row.bodyHash).toString('hex'); + const urls = urlsBySignature.get(signature) ?? []; - return { - signature: bodyHash.toString('hex'), - count: Number(row.cnt), - ogUrlMismatchRatio: Number(row.ogUrlMismatchRatio), - samplePages: urls.slice(0, samplePagesLimit), - commonDirectories: computeDirectoryDistribution(urls), - }; - }), - ); + return { + signature, + count: Number(row.cnt), + ogUrlMismatchRatio: Number(row.ogUrlMismatchRatio), + samplePages: urls.slice(0, samplePagesLimit), + commonDirectories: computeDirectoryDistribution(urls), + }; + }); }