From ba30f23978f755ddeb913299cca960d1a842abb9 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Thu, 10 Sep 2026 20:17:36 +0900 Subject: [PATCH 1/5] feat(crawler): bump @d-zero/beholder to 5.0.0 and wire imageScan through Bumps @d-zero/beholder from 4.2.3 to 5.0.0 (breaking: PageData.imageScan is a new required field) and wires the scraped per-viewport image-scan outcome through insertPage's computeMainContentsDenormalized call. The three synthetic PageData builders (fetch-destination.ts, link-to-page-data.ts, resource-to-page-data.ts) set imageScan to { desktop: null, mobile: null } since none of them run an actual scan. The database.spec.ts round-trip test now exercises the real write path end-to-end (PageData.imageScan -> insertPage -> page_meta -> build-page- query.ts's SELECT) instead of writing page_meta directly. Co-Authored-By: Claude Sonnet 5 --- packages/@nitpicker/crawler/package.json | 2 +- .../crawler/src/archive/database.spec.ts | 28 +++++++++++++------ .../archive/db-ops/pages/write/insert-page.ts | 1 + .../crawler/src/crawler/fetch-destination.ts | 1 + .../crawler/src/crawler/link-to-page-data.ts | 1 + .../src/crawler/resource-to-page-data.ts | 1 + yarn.lock | 21 +++++++------- 7 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/@nitpicker/crawler/package.json b/packages/@nitpicker/crawler/package.json index 0ec8b339..b327dda8 100644 --- a/packages/@nitpicker/crawler/package.json +++ b/packages/@nitpicker/crawler/package.json @@ -30,7 +30,7 @@ "clean": "tsc --build --clean" }, "dependencies": { - "@d-zero/beholder": "4.2.3", + "@d-zero/beholder": "5.0.0", "@d-zero/dealer": "1.14.0", "@d-zero/fs": "0.2.7", "@d-zero/shared": "0.23.0", diff --git a/packages/@nitpicker/crawler/src/archive/database.spec.ts b/packages/@nitpicker/crawler/src/archive/database.spec.ts index dd2ec18b..b68b8065 100644 --- a/packages/@nitpicker/crawler/src/archive/database.spec.ts +++ b/packages/@nitpicker/crawler/src/archive/database.spec.ts @@ -109,11 +109,10 @@ describe('Pages', () => { }); it('round-trips page_meta.image_scan_desktop/mobile through getPages (build-page-query SELECT)', async () => { - // Crawler wiring that passes a scraped `PageData.imageScan` through to - // `computeMainContentsDenormalized` lands separately (it needs a - // `@d-zero/beholder` version that reports `imageScan`); this test - // isolates the read path by writing the `page_meta` columns directly, - // the same way that wiring will end up populating them. + // Exercises the real write path end-to-end: a scraped `PageData.imageScan` + // (as `@d-zero/beholder` reports it) flows through `insertPage` → + // `computeMainContentsDenormalized` → `page_meta`, and back out through + // `build-page-query.ts`'s SELECT. const db = await Database.connect({ filename: path.resolve(workingDir, 'tmp-image-scan.sqlite'), }); @@ -133,13 +132,26 @@ describe('Pages', () => { imageList: [], html: '', isSkipped: false, + mainContents: { + title: 'IMAGE_SCAN_ROUNDTRIP', + main: null, + wordCount: 0, + bodyWordCount: 0, + headings: [], + images: [], + tables: [], + buttons: [], + iframes: [], + videos: [], + audios: [], + canvases: [], + }, + scrollHeight: { desktop: 1000, mobile: 2000 }, + imageScan: { desktop: 0, mobile: 2 }, } as never, true, true, ); - await db - .getKnex()('page_meta') - .update({ image_scan_desktop: 0, image_scan_mobile: 2 }); const pages = await db.getPages(); expect(pages.length).toBe(1); diff --git a/packages/@nitpicker/crawler/src/archive/db-ops/pages/write/insert-page.ts b/packages/@nitpicker/crawler/src/archive/db-ops/pages/write/insert-page.ts index 8e01fe9f..3beb1fa4 100644 --- a/packages/@nitpicker/crawler/src/archive/db-ops/pages/write/insert-page.ts +++ b/packages/@nitpicker/crawler/src/archive/db-ops/pages/write/insert-page.ts @@ -95,6 +95,7 @@ export async function insertPage( page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length, + page.imageScan, ); const extras = deriveMetaExtras(page.meta); const now = Date.now(); diff --git a/packages/@nitpicker/crawler/src/crawler/fetch-destination.ts b/packages/@nitpicker/crawler/src/crawler/fetch-destination.ts index caf10554..bf557975 100644 --- a/packages/@nitpicker/crawler/src/crawler/fetch-destination.ts +++ b/packages/@nitpicker/crawler/src/crawler/fetch-destination.ts @@ -276,6 +276,7 @@ async function _fetchHead( html: '', mainContents: null, scrollHeight: null, + imageScan: { desktop: null, mobile: null }, isSkipped: false, }; }; diff --git a/packages/@nitpicker/crawler/src/crawler/link-to-page-data.ts b/packages/@nitpicker/crawler/src/crawler/link-to-page-data.ts index c6a73804..4b8993c6 100644 --- a/packages/@nitpicker/crawler/src/crawler/link-to-page-data.ts +++ b/packages/@nitpicker/crawler/src/crawler/link-to-page-data.ts @@ -52,6 +52,7 @@ export function linkToPageData(link: Link): PageData { html: '', mainContents: null, scrollHeight: null, + imageScan: { desktop: null, mobile: null }, isSkipped: false, }; } diff --git a/packages/@nitpicker/crawler/src/crawler/resource-to-page-data.ts b/packages/@nitpicker/crawler/src/crawler/resource-to-page-data.ts index 45cd36c0..fe77f3ca 100644 --- a/packages/@nitpicker/crawler/src/crawler/resource-to-page-data.ts +++ b/packages/@nitpicker/crawler/src/crawler/resource-to-page-data.ts @@ -77,6 +77,7 @@ export function resourceToPageData(params: ResourceToPageDataParams): PageData | html: '', mainContents: null, scrollHeight: null, + imageScan: { desktop: null, mobile: null }, isSkipped: false, }; } diff --git a/yarn.lock b/yarn.lock index 87cbec59..aa0f9435 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1170,16 +1170,17 @@ __metadata: languageName: node linkType: hard -"@d-zero/beholder@npm:4.2.3": - version: 4.2.3 - resolution: "@d-zero/beholder@npm:4.2.3" +"@d-zero/beholder@npm:5.0.0": + version: 5.0.0 + resolution: "@d-zero/beholder@npm:5.0.0" dependencies: - "@d-zero/puppeteer-page-scan": "npm:4.6.9" + "@d-zero/puppeteer-page-scan": "npm:4.7.0" + "@d-zero/puppeteer-scroll": "npm:4.0.12" "@d-zero/shared": "npm:0.23.0" debug: "npm:4.4.3" puppeteer: "npm:25.5.0" simple-wappalyzer: "npm:1.1.100" - checksum: 10c0/83fb2c087d75f11056519228e8bee9bd4a3dda4b96c162d0f1989babb44fe85a917cc6d27590b70cbdb3dbe1912d66fc40170911878fd3d68cbb118c77890165 + checksum: 10c0/6a10f66abf0d9da02c84f1d556dda33c283928b66f088ad5bae3157d5c9049abdaae517c4d322de473aa348bc9d66aac0388c63584cead9c5e28023e3b03fc6a languageName: node linkType: hard @@ -1339,16 +1340,16 @@ __metadata: languageName: node linkType: hard -"@d-zero/puppeteer-page-scan@npm:4.6.9": - version: 4.6.9 - resolution: "@d-zero/puppeteer-page-scan@npm:4.6.9" +"@d-zero/puppeteer-page-scan@npm:4.7.0": + version: 4.7.0 + resolution: "@d-zero/puppeteer-page-scan@npm:4.7.0" dependencies: "@d-zero/puppeteer-general-actions": "npm:1.2.7" "@d-zero/puppeteer-scroll": "npm:4.0.12" "@d-zero/shared": "npm:0.23.0" peerDependencies: puppeteer: 25.5.0 - checksum: 10c0/b335cb6633dd8b75e5baa0e533f795d890fbefeb05480b0339cc786312c1d0d96c3f1554cf3c5b109b1693e9613276aff3ef4a498b9292a853fc0d712093f8a3 + checksum: 10c0/70580cc953d9820dcd5e457c3259f1969d125801eb3ba1ec781312e15cde02282a3a4a9c22cf3bb8bd8b05134ab93e22322eadfd8df4a95e1ec64222ef3d3054 languageName: node linkType: hard @@ -3363,7 +3364,7 @@ __metadata: version: 0.0.0-use.local resolution: "@nitpicker/crawler@workspace:packages/@nitpicker/crawler" dependencies: - "@d-zero/beholder": "npm:4.2.3" + "@d-zero/beholder": "npm:5.0.0" "@d-zero/dealer": "npm:1.14.0" "@d-zero/fs": "npm:0.2.7" "@d-zero/shared": "npm:0.23.0" From af47250bf1724044041740d90ee2a409365b87b9 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Thu, 10 Sep 2026 20:18:18 +0900 Subject: [PATCH 2/5] feat(query): expose imageScan on PageDetail/PageListItem/list filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds page_meta.image_scan_desktop/mobile to the read side: PageDetail and PageListItem gain imageScanDesktop/imageScanMobile (mapped to the ImageScanOutcome name), PageMainContents gains a nested imageScan object alongside scrollHeight, and both listPages (live) and listViewerPages (read-model fast path) gain an imageScan filter — an OR across the desktop/mobile columns since a page's two viewports can differ. VIEWER_READ_MODEL_SCHEMA_VERSION bumps 33 -> 34: viewer_pages gains image_scan_desktop/mobile as nullable columns (copied from page_meta verbatim, not defaulted to 0 like the sibling count columns -- 0 ("ok") is a real, distinct outcome here, not a placeholder for "unmeasured"). sanitizeRedirectSourceRow nulls both columns like every other audit signal on a redirect-source row. hasFilterValue is now a type predicate so a caller narrows away `undefined` without an extra cast when building the desktop/mobile OR. Co-Authored-By: Claude Sonnet 5 --- .../src/apply-viewer-pages-filters.spec.ts | 111 +++++++++++++++++ .../query/src/apply-viewer-pages-filters.ts | 17 +++ packages/@nitpicker/query/src/categories.ts | 6 +- .../@nitpicker/query/src/get-page-detail.ts | 5 + .../query/src/get-page-main-contents.ts | 8 ++ .../@nitpicker/query/src/has-filter-value.ts | 8 +- .../query/src/image-scan-outcome.spec.ts | 18 ++- .../query/src/image-scan-outcome.ts | 37 ++++++ .../@nitpicker/query/src/list-pages.spec.ts | 116 ++++++++++++++++++ packages/@nitpicker/query/src/list-pages.ts | 17 +++ .../src/map-page-row-to-list-item.spec.ts | 2 + .../query/src/map-page-row-to-list-item.ts | 10 ++ packages/@nitpicker/query/src/query.ts | 6 +- packages/@nitpicker/query/src/types.ts | 33 +++++ .../build-viewer-read-model.ts | 14 +++ .../create-viewer-read-model-tables.ts | 8 ++ .../viewer-read-model-schema-version.ts | 8 +- 17 files changed, 418 insertions(+), 6 deletions(-) diff --git a/packages/@nitpicker/query/src/apply-viewer-pages-filters.spec.ts b/packages/@nitpicker/query/src/apply-viewer-pages-filters.spec.ts index 42ab38a1..53ddc196 100644 --- a/packages/@nitpicker/query/src/apply-viewer-pages-filters.spec.ts +++ b/packages/@nitpicker/query/src/apply-viewer-pages-filters.spec.ts @@ -839,3 +839,114 @@ describe('applyViewerPagesFilters — directory', () => { ); }); }); + +describe('applyViewerPagesFilters — imageScan', () => { + const workingDir = path.resolve( + __dirname, + '__test_fixtures_apply_viewer_pages_filters_image_scan__', + ); + const archiveFilePath = path.resolve( + workingDir, + 'apply-filters-image-scan-test.nitpicker', + ); + let archive: InstanceType; + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(workingDir, { recursive: true }); + archive = await Archive.create({ filePath: archiveFilePath, cwd: workingDir }); + await archive.setConfig(BASE_CONFIG); + + for (const url of [ + 'https://example.com/ok', + 'https://example.com/frame-lost', + 'https://example.com/none', + ]) { + await archive.setPage({ + url: parseUrl(url)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: '', + meta: META, + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + + const knex = archive.getKnex(); + const setImageScan = async ( + url: string, + desktop: number | null, + mobile: number | null, + ) => { + await knex('page_meta') + .whereIn( + 'page_id', + knex('content_items') + .select('id') + .whereIn('url_id', knex('url_refs').select('id').where('url', url)), + ) + .update({ image_scan_desktop: desktop, image_scan_mobile: mobile }); + }; + await setImageScan('https://example.com/ok', 0, 0); + await setImageScan('https://example.com/frame-lost', 0, 3); + + await buildViewerReadModel(archive); + }); + + afterAll(async () => { + if (archive) { + await archive.releaseHandle(); + } + const { rmSync } = await import('node:fs'); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('filters to only the page whose desktop or mobile scan matches the outcome', async () => { + const knex = archive.getKnex(); + const qb = knex('viewer_pages'); + applyViewerPagesFilters(qb, { imageScan: 'frame-lost' }); + const rows = await qb.select('url'); + expect(rows.map((r) => r.url)).toEqual(['https://example.com/frame-lost']); + }); + + it('accepts an array of outcomes (OR)', async () => { + const knex = archive.getKnex(); + const qb = knex('viewer_pages'); + applyViewerPagesFilters(qb, { imageScan: ['ok', 'frame-lost'] }); + const rows = await qb.select('url'); + expect(rows.map((r) => r.url).toSorted()).toEqual([ + 'https://example.com/frame-lost', + 'https://example.com/ok', + ]); + }); + + it('applies no restriction when imageScan is omitted', async () => { + const knex = archive.getKnex(); + const qb = knex('viewer_pages'); + applyViewerPagesFilters(qb, {}); + const rows = await qb.select('url'); + expect(rows.map((r) => r.url).toSorted()).toEqual([ + 'https://example.com/frame-lost', + 'https://example.com/none', + 'https://example.com/ok', + ]); + }); + + it('a page whose scan was never attempted has null image_scan_desktop/image_scan_mobile in the read model', async () => { + const knex = archive.getKnex(); + const row = await knex('viewer_pages') + .select('image_scan_desktop', 'image_scan_mobile') + .where('url', 'https://example.com/none') + .first(); + expect(row.image_scan_desktop).toBeNull(); + expect(row.image_scan_mobile).toBeNull(); + }); +}); diff --git a/packages/@nitpicker/query/src/apply-viewer-pages-filters.ts b/packages/@nitpicker/query/src/apply-viewer-pages-filters.ts index e60768fd..7cde635d 100644 --- a/packages/@nitpicker/query/src/apply-viewer-pages-filters.ts +++ b/packages/@nitpicker/query/src/apply-viewer-pages-filters.ts @@ -4,6 +4,7 @@ import type { Knex } from 'knex'; import { applyEqualityOrInFilter } from './apply-equality-or-in-filter.js'; import { hasFilterValue } from './has-filter-value.js'; import { HEADER_FLAG_COLUMN } from './header-presence-sql.js'; +import { imageScanOutcomeToCode } from './image-scan-outcome.js'; import { toFlagValues } from './to-flag-values.js'; /** @@ -61,6 +62,22 @@ export function applyViewerPagesFilters( if (options.noindex) { qb.where('robots_noindex', 1); } + if (hasFilterValue(options.imageScan)) { + // Unlike every other filter here, this is OR-across-columns (desktop OR + // mobile), not OR-across-values-on-one-column — `applyEqualityOrInFilter` + // only does the latter, so the two `image_scan_*` columns need their own + // `whereIn`/`orWhereIn` pair. + const codes = [ + ...new Set( + (Array.isArray(options.imageScan) ? options.imageScan : [options.imageScan]).map( + (outcome) => imageScanOutcomeToCode(outcome), + ), + ), + ]; + qb.where((builder) => { + builder.whereIn('image_scan_desktop', codes).orWhereIn('image_scan_mobile', codes); + }); + } applyEqualityOrInFilter(qb, 'is_dedupe_capped', toFlagValues(options.isDedupeCapped)); applyEqualityOrInFilter(qb, 'dedupe_cap_event_id', options.dedupeCapEventId); applyEqualityOrInFilter( diff --git a/packages/@nitpicker/query/src/categories.ts b/packages/@nitpicker/query/src/categories.ts index 77006a37..895d71aa 100644 --- a/packages/@nitpicker/query/src/categories.ts +++ b/packages/@nitpicker/query/src/categories.ts @@ -13,5 +13,9 @@ export { CONTENT_TYPE_CATEGORIES, classifyContentType } from './classify-content-type.js'; export type { ContentTypeCategory, ContentTypeCount } from './types.js'; -export { IMAGE_SCAN_OUTCOMES, imageScanCodeToOutcome } from './image-scan-outcome.js'; +export { + IMAGE_SCAN_OUTCOMES, + imageScanCodeToOutcome, + imageScanOutcomeToCode, +} from './image-scan-outcome.js'; export type { ImageScanOutcome } from './types.js'; diff --git a/packages/@nitpicker/query/src/get-page-detail.ts b/packages/@nitpicker/query/src/get-page-detail.ts index 9cf22d56..f912f434 100644 --- a/packages/@nitpicker/query/src/get-page-detail.ts +++ b/packages/@nitpicker/query/src/get-page-detail.ts @@ -7,6 +7,7 @@ import { dedupeCapEventIdSelectColumn } from './dedupe-cap-event-id-select-colum import { dedupeCapShapeKeySelectColumn } from './dedupe-cap-shape-key-select-column.js'; import { getPageConsoleLogs } from './get-page-console-logs.js'; import { hasDedupeCapEventIdColumn } from './has-dedupe-cap-event-id-column.js'; +import { imageScanCodeToOutcome } from './image-scan-outcome.js'; import { hasPageTemplatesTable, templateKeySelectColumn } from './page-templates-join.js'; import { requireAliasOfIdColumn } from './require-alias-of-id-column.js'; import { resolveAliasAndRedirectChain } from './resolve-alias-and-redirect-chain.js'; @@ -238,6 +239,8 @@ export async function getPageDetail( 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', + 'pm.image_scan_desktop as image_scan_desktop', + 'pm.image_scan_mobile as image_scan_mobile', templateKeySelectColumn(knex, hasPageTemplates), dedupeCapShapeKeySelectColumn(knex, hasDedupeCapColumn), dedupeCapEventIdSelectColumn(knex, hasDedupeCapColumn), @@ -411,6 +414,8 @@ export async function getPageDetail( mainContentCustomElementCount: page.main_content_custom_element_count, scrollHeightDesktop: page.scroll_height_desktop, scrollHeightMobile: page.scroll_height_mobile, + imageScanDesktop: imageScanCodeToOutcome(page.image_scan_desktop), + imageScanMobile: imageScanCodeToOutcome(page.image_scan_mobile), templateKey: page.templateKey, metaExtras, jsonLd: summarizeJsonLdRows(jsonLdRows), diff --git a/packages/@nitpicker/query/src/get-page-main-contents.ts b/packages/@nitpicker/query/src/get-page-main-contents.ts index b21c0c37..922deb4c 100644 --- a/packages/@nitpicker/query/src/get-page-main-contents.ts +++ b/packages/@nitpicker/query/src/get-page-main-contents.ts @@ -1,6 +1,8 @@ import type { PageMainContents } from './types.js'; import type { ArchiveAccessor } from '@nitpicker/crawler'; +import { imageScanCodeToOutcome } from './image-scan-outcome.js'; + /** * Retrieves the full main-content drill-down for the page at the given URL: * the detected main element's identity, scalar word/scroll-height metrics, @@ -41,6 +43,8 @@ export async function getPageMainContents( 'pm.main_content_body_word_count as bodyWordCount', 'pm.scroll_height_desktop as scrollHeightDesktop', 'pm.scroll_height_mobile as scrollHeightMobile', + 'pm.image_scan_desktop as imageScanDesktop', + 'pm.image_scan_mobile as imageScanMobile', ) .where('ur.url', url) .limit(1); @@ -88,6 +92,10 @@ export async function getPageMainContents( desktop: page.scrollHeightDesktop as number | null, mobile: page.scrollHeightMobile as number | null, }, + imageScan: { + desktop: imageScanCodeToOutcome(page.imageScanDesktop as number | null), + mobile: imageScanCodeToOutcome(page.imageScanMobile as number | null), + }, headings: headings.map((h) => ({ text: h.text, level: h.level })), images: images.map((i) => ({ src: i.src, alt: i.alt })), tables: tables.map((t) => ({ diff --git a/packages/@nitpicker/query/src/has-filter-value.ts b/packages/@nitpicker/query/src/has-filter-value.ts index 3315f3f6..87aae02a 100644 --- a/packages/@nitpicker/query/src/has-filter-value.ts +++ b/packages/@nitpicker/query/src/has-filter-value.ts @@ -7,7 +7,9 @@ * an array-typed field: `[]` is truthy in JavaScript even though it means * the same "no filter" as `undefined`. * @param value - A scalar, an array of scalars, or `undefined`/`null`. - * @returns Whether the value should be treated as an active filter. + * @returns Whether the value should be treated as an active filter. Narrows + * away `null`/`undefined` so callers can use the value without an + * additional cast or non-null assertion. * @example * if (hasFilterValue(options.contentTypeCategory)) { * applyEqualityOrInFilter(qb, 'content_category', options.contentTypeCategory); @@ -15,7 +17,9 @@ * qb.whereIn('content_category', ['html', 'unknown']); * } */ -export function hasFilterValue(value: T | readonly T[] | null | undefined): boolean { +export function hasFilterValue( + value: T | readonly T[] | null | undefined, +): value is T | readonly T[] { if (value == null) return false; return Array.isArray(value) ? value.length > 0 : true; } diff --git a/packages/@nitpicker/query/src/image-scan-outcome.spec.ts b/packages/@nitpicker/query/src/image-scan-outcome.spec.ts index 726b890d..90da3976 100644 --- a/packages/@nitpicker/query/src/image-scan-outcome.spec.ts +++ b/packages/@nitpicker/query/src/image-scan-outcome.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { IMAGE_SCAN_OUTCOMES, imageScanCodeToOutcome } from './image-scan-outcome.js'; +import { + IMAGE_SCAN_OUTCOMES, + imageScanCodeToOutcome, + imageScanOutcomeToCode, +} from './image-scan-outcome.js'; describe('imageScanCodeToOutcome', () => { it('maps each known IMAGE_SCAN_CODE integer to its outcome name', () => { @@ -22,6 +26,18 @@ describe('imageScanCodeToOutcome', () => { }); }); +describe('imageScanOutcomeToCode', () => { + it('is the exact inverse of imageScanCodeToOutcome for every known outcome', () => { + for (const outcome of IMAGE_SCAN_OUTCOMES) { + expect(imageScanCodeToOutcome(imageScanOutcomeToCode(outcome))).toBe(outcome); + } + }); + + it('maps "unknown" to the reserved 255 code', () => { + expect(imageScanOutcomeToCode('unknown')).toBe(255); + }); +}); + describe('IMAGE_SCAN_OUTCOMES', () => { it('lists every outcome name exactly once', () => { expect(IMAGE_SCAN_OUTCOMES).toEqual([ diff --git a/packages/@nitpicker/query/src/image-scan-outcome.ts b/packages/@nitpicker/query/src/image-scan-outcome.ts index 750dfe91..27b98787 100644 --- a/packages/@nitpicker/query/src/image-scan-outcome.ts +++ b/packages/@nitpicker/query/src/image-scan-outcome.ts @@ -60,3 +60,40 @@ export function imageScanCodeToOutcome(code: number | null): ImageScanOutcome | } } } + +/** + * Reverse of {@link imageScanCodeToOutcome}, for building a SQL `WHERE ... + * IN (...)` filter from a user-facing outcome name. `'unknown'` maps to + * `255` (`@d-zero/beholder`'s only currently-defined fallback code) rather + * than "every code not in the known set" — a filter that excludes a + * not-yet-invented future code is an acceptable gap; scanning the whole + * table for "not one of these five values" is not, for a facet this cheap. + * @param outcome - The outcome name to convert. + * @returns The `page_meta.image_scan_*` integer that produces this outcome. + * @example + * ```ts + * imageScanOutcomeToCode('nav-unsettled'); // 2 + * ``` + */ +export function imageScanOutcomeToCode(outcome: ImageScanOutcome): number { + switch (outcome) { + case 'ok': { + return 0; + } + case 'degraded': { + return 1; + } + case 'nav-unsettled': { + return 2; + } + case 'frame-lost': { + return 3; + } + case 'scroll-height-exceeded': { + return 4; + } + case 'unknown': { + return 255; + } + } +} diff --git a/packages/@nitpicker/query/src/list-pages.spec.ts b/packages/@nitpicker/query/src/list-pages.spec.ts index 89ecd11d..f06e6616 100644 --- a/packages/@nitpicker/query/src/list-pages.spec.ts +++ b/packages/@nitpicker/query/src/list-pages.spec.ts @@ -1096,3 +1096,119 @@ describe('listPages: onSortProgress (issue #294)', () => { expect(messages).toEqual([]); }); }); + +describe('listPages: imageScan filter', () => { + let archive: InstanceType; + const dir = path.resolve(__dirname, '__test_fixtures_list_pages_image_scan__'); + const archiveFilePath = path.resolve(dir, 'list-pages-image-scan-test.nitpicker'); + + beforeAll(async () => { + const { mkdirSync } = await import('node:fs'); + mkdirSync(dir, { recursive: true }); + archive = await Archive.create({ filePath: archiveFilePath, cwd: dir }); + await archive.setConfig({ + baseUrl: 'https://example.com', + name: 'test', + version: '0.13.0', + recursive: true, + interval: 0, + image: true, + fetchExternal: false, + parallels: 1, + roots: ['https://example.com'], + excludes: [], + excludeKeywords: [], + excludeUrls: [], + maxExcludedDepth: 0, + retry: 3, + fromList: false, + disableQueries: false, + userAgent: 'test', + ignoreRobots: false, + }); + + for (const url of [ + 'https://example.com/ok', + 'https://example.com/nav-unsettled', + 'https://example.com/none', + ]) { + await archive.setPage({ + url: parseUrl(url)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: '', + meta: { title: null }, + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + + const knex = archive.getKnex(); + // `setPage` has no imageScan parameter yet — this test only exercises + // the read/filter path, writing the page_meta columns directly (see + // the same approach in database.spec.ts's image-scan round-trip test). + const setImageScan = async ( + url: string, + desktop: number | null, + mobile: number | null, + ) => { + await knex('page_meta') + .whereIn( + 'page_id', + knex('content_items') + .select('id') + .whereIn('url_id', knex('url_refs').select('id').where('url', url)), + ) + .update({ image_scan_desktop: desktop, image_scan_mobile: mobile }); + }; + await setImageScan('https://example.com/ok', 0, 0); + await setImageScan('https://example.com/nav-unsettled', 0, 2); + }); + + afterAll(async () => { + await archive.close(); + const { rmSync } = await import('node:fs'); + rmSync(dir, { recursive: true, force: true }); + }); + + it('returns only pages whose desktop or mobile scan matches the requested outcome', async () => { + const result = await listPages(archive, { imageScan: 'nav-unsettled' }); + expect(result.items.map((p) => p.url)).toEqual(['https://example.com/nav-unsettled']); + }); + + it('accepts an array of outcomes (OR)', async () => { + const result = await listPages(archive, { imageScan: ['ok', 'nav-unsettled'] }); + expect(result.items.map((p) => p.url).toSorted()).toEqual([ + 'https://example.com/nav-unsettled', + 'https://example.com/ok', + ]); + }); + + it('omitting imageScan returns every page regardless of scan outcome', async () => { + const result = await listPages(archive); + expect(result.items.map((p) => p.url).toSorted()).toEqual([ + 'https://example.com/nav-unsettled', + 'https://example.com/none', + 'https://example.com/ok', + ]); + }); + + it('exposes the mapped outcome names on imageScanDesktop/imageScanMobile', async () => { + const result = await listPages(archive, { urlPattern: '%/ok' }); + expect(result.items[0]!.imageScanDesktop).toBe('ok'); + expect(result.items[0]!.imageScanMobile).toBe('ok'); + }); + + it('a page whose scan was never attempted has null imageScanDesktop/imageScanMobile', async () => { + const result = await listPages(archive, { urlPattern: '%/none' }); + expect(result.items[0]!.imageScanDesktop).toBeNull(); + expect(result.items[0]!.imageScanMobile).toBeNull(); + }); +}); diff --git a/packages/@nitpicker/query/src/list-pages.ts b/packages/@nitpicker/query/src/list-pages.ts index 8f7414e8..e058a15a 100644 --- a/packages/@nitpicker/query/src/list-pages.ts +++ b/packages/@nitpicker/query/src/list-pages.ts @@ -12,6 +12,7 @@ import { buildHeaderPresenceSelects } from './build-header-presence-selects.js'; import { applyCategoryFilter } from './content-type-rules.js'; import { hasDedupeCapEventIdColumn } from './has-dedupe-cap-event-id-column.js'; import { HEADER_PRESENCE_KEYS, headerPresenceExpression } from './header-presence-sql.js'; +import { imageScanOutcomeToCode } from './image-scan-outcome.js'; import { isDedupeCappedSelectColumn } from './is-dedupe-capped-select-column.js'; import { PAGE_LIST_SELECT_COLUMNS, @@ -171,6 +172,22 @@ export async function listPages( if (options.noindex) { baseQuery.where('pm.robots_noindex', 1); } + if (options.imageScan != null) { + const outcomes = Array.isArray(options.imageScan) + ? options.imageScan + : [options.imageScan]; + const codes = [ + ...new Set(outcomes.map((outcome) => imageScanOutcomeToCode(outcome))), + ]; + if (codes.length > 0) { + baseQuery.where((qb) => { + qb.whereIn('pm.image_scan_desktop', codes).orWhereIn( + 'pm.image_scan_mobile', + codes, + ); + }); + } + } if (options.isDedupeCapped != null) { if (hasDedupeCapColumn) { if (options.isDedupeCapped) { diff --git a/packages/@nitpicker/query/src/map-page-row-to-list-item.spec.ts b/packages/@nitpicker/query/src/map-page-row-to-list-item.spec.ts index 74f26481..8eacb7e1 100644 --- a/packages/@nitpicker/query/src/map-page-row-to-list-item.spec.ts +++ b/packages/@nitpicker/query/src/map-page-row-to-list-item.spec.ts @@ -60,6 +60,8 @@ function makeRow(overrides: Partial = {}): PageListRow { main_content_custom_element_count: null, scroll_height_desktop: null, scroll_height_mobile: null, + image_scan_desktop: null, + image_scan_mobile: null, console_error_count: null, firstCrawledAt: null, lastCrawledAt: null, diff --git a/packages/@nitpicker/query/src/map-page-row-to-list-item.ts b/packages/@nitpicker/query/src/map-page-row-to-list-item.ts index 90c2cbaf..c00365c6 100644 --- a/packages/@nitpicker/query/src/map-page-row-to-list-item.ts +++ b/packages/@nitpicker/query/src/map-page-row-to-list-item.ts @@ -1,5 +1,7 @@ import type { PageListItem, PageListRow } from './types.js'; +import { imageScanCodeToOutcome } from './image-scan-outcome.js'; + /** * Legacy column-name list retained for pre-0.13 fallback callers (kept as * a stringified `pages.*` list). 0.13 readers should prefer @@ -57,6 +59,8 @@ export const PAGE_LIST_COLUMNS: readonly string[] = [ 'main_content_custom_element_count', 'scroll_height_desktop', 'scroll_height_mobile', + 'image_scan_desktop', + 'image_scan_mobile', 'console_error_count', 'firstCrawledAt', 'lastCrawledAt', @@ -142,6 +146,8 @@ export const PAGE_LIST_SELECT_COLUMNS: readonly string[] = [ 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', + 'pm.image_scan_desktop as image_scan_desktop', + 'pm.image_scan_mobile as image_scan_mobile', 'pm.console_error_count as console_error_count', 'ci.first_crawled_at as firstCrawledAt', 'ci.last_crawled_at as lastCrawledAt', @@ -213,6 +219,8 @@ function sanitizeRedirectSourcePageListRow(row: PageListRow): PageListRow { main_content_custom_element_count: null, scroll_height_desktop: null, scroll_height_mobile: null, + image_scan_desktop: null, + image_scan_mobile: null, console_error_count: null, hasCSP: 0, hasXFrameOptions: 0, @@ -289,6 +297,8 @@ export function mapPageRowToListItem(row: PageListRow): PageListItem { mainContentCustomElementCount: source.main_content_custom_element_count, scrollHeightDesktop: source.scroll_height_desktop, scrollHeightMobile: source.scroll_height_mobile, + imageScanDesktop: imageScanCodeToOutcome(source.image_scan_desktop), + imageScanMobile: imageScanCodeToOutcome(source.image_scan_mobile), consoleErrorCount: source.console_error_count, firstCrawledAt: source.firstCrawledAt, lastCrawledAt: source.lastCrawledAt, diff --git a/packages/@nitpicker/query/src/query.ts b/packages/@nitpicker/query/src/query.ts index d2ec0237..9ea1ac42 100644 --- a/packages/@nitpicker/query/src/query.ts +++ b/packages/@nitpicker/query/src/query.ts @@ -23,7 +23,11 @@ export { computeStylesheetFileNames } from './compute-stylesheet-file-names.js'; export { classifyContentType, CONTENT_TYPE_CATEGORIES } from './classify-content-type.js'; export { CONTENT_TYPE_RULES } from './content-type-rules.js'; export type { ContentTypeRule, MimeMatcher } from './content-type-rules.js'; -export { IMAGE_SCAN_OUTCOMES, imageScanCodeToOutcome } from './image-scan-outcome.js'; +export { + IMAGE_SCAN_OUTCOMES, + imageScanCodeToOutcome, + imageScanOutcomeToCode, +} from './image-scan-outcome.js'; export { countDuplicateGroups } from './count-duplicate-groups.js'; export { countPagesByJsonLdType } from './count-pages-by-jsonld-type.js'; export { countPagesByTechnology } from './count-pages-by-technology.js'; diff --git a/packages/@nitpicker/query/src/types.ts b/packages/@nitpicker/query/src/types.ts index 695ac443..25ad90ac 100644 --- a/packages/@nitpicker/query/src/types.ts +++ b/packages/@nitpicker/query/src/types.ts @@ -844,6 +844,14 @@ export interface ListPagesOptions { * archive predating this feature deterministically matches zero rows. */ dedupeCapEventId?: number; + /** + * Filter to pages where the desktop OR mobile `` element scan (see + * `@d-zero/beholder`'s `IMAGE_SCAN_CODE`) matched one of these outcomes — + * a page's two viewports can differ, so this is an OR across both + * `page_meta.image_scan_desktop` / `image_scan_mobile` columns, not a + * per-viewport filter. Omit for no filter. + */ + imageScan?: ImageScanOutcome | readonly ImageScanOutcome[]; /** URL pattern to search (SQL LIKE pattern). */ urlPattern?: string; /** Directory path prefix to filter by. */ @@ -984,6 +992,8 @@ export interface PageListRow { main_content_custom_element_count: number | null; scroll_height_desktop: number | null; scroll_height_mobile: number | null; + image_scan_desktop: number | null; + image_scan_mobile: number | null; console_error_count: number | null; firstCrawledAt: number | null; lastCrawledAt: number | null; @@ -1158,6 +1168,10 @@ export interface PageListItem { scrollHeightDesktop: number | null; /** `document.body.scrollHeight` at the mobile-small preset (denormalised). */ scrollHeightMobile: number | null; + /** `` element scan outcome at the desktop-compact preset (denormalised), or `null` when not attempted. */ + imageScanDesktop: ImageScanOutcome | null; + /** `` element scan outcome at the mobile-small preset (denormalised), or `null` when not attempted. */ + imageScanMobile: ImageScanOutcome | null; /** * Count of `pageerror`+`error` console log occurrences on this page * (denormalised, issue #228), or `null` on a page that predates the @@ -1359,6 +1373,14 @@ export interface ListViewerPagesOptions { * as a link destination, not a facet. */ dedupeCapEventId?: number; + /** + * Filter to pages where the desktop OR mobile `` element scan + * matched one of these outcomes, or any of several (OR). Backed by + * `viewer_pages.image_scan_desktop` / `image_scan_mobile` (nullable — + * `null` on an archive predating this feature, or a page whose scan was + * never attempted). + */ + imageScan?: ImageScanOutcome | ImageScanOutcome[]; /** * Filter to redirect-source rows (true), non-redirect-source rows * (false), or both (OR — equivalent to no filter). Backed by @@ -1670,6 +1692,10 @@ export interface PageDetail { scrollHeightDesktop: number | null; /** `document.body.scrollHeight` at the mobile-small preset (denormalised). */ scrollHeightMobile: number | null; + /** `` element scan outcome at the desktop-compact preset (denormalised; full detail via `getPageMainContents`), or `null` when not attempted. */ + imageScanDesktop: ImageScanOutcome | null; + /** `` element scan outcome at the mobile-small preset (denormalised; full detail via `getPageMainContents`), or `null` when not attempted. */ + imageScanMobile: ImageScanOutcome | null; /** DOM-structure template group key from `--templates` classification, or null if never classified. */ templateKey: string | null; @@ -1938,6 +1964,13 @@ export interface PageMainContents { /** Height at the mobile-small preset, or `null` if unmeasured. */ mobile: number | null; }; + /** `` element scan outcome at desktop-compact and mobile-small presets. */ + imageScan: { + /** Outcome at the desktop-compact preset, or `null` when not attempted. */ + desktop: ImageScanOutcome | null; + /** Outcome at the mobile-small preset, or `null` when not attempted. */ + mobile: ImageScanOutcome | null; + }; /** Headings within the main region, in DOM order. */ headings: MainContentHeadingEntry[]; /** Images within the main region, in DOM order. */ diff --git a/packages/@nitpicker/query/src/viewer-read-model/build-viewer-read-model.ts b/packages/@nitpicker/query/src/viewer-read-model/build-viewer-read-model.ts index 8ce7bef2..ef4a6175 100644 --- a/packages/@nitpicker/query/src/viewer-read-model/build-viewer-read-model.ts +++ b/packages/@nitpicker/query/src/viewer-read-model/build-viewer-read-model.ts @@ -178,6 +178,10 @@ interface PagesSourceRow { scroll_height_desktop: number | null; /** Denormalised mobile-small scroll height, or `null` when unrendered. */ scroll_height_mobile: number | null; + /** Denormalised desktop-compact image-scan outcome code, or `null` when not attempted. */ + image_scan_desktop: number | null; + /** Denormalised mobile-small image-scan outcome code, or `null` when not attempted. */ + image_scan_mobile: number | null; /** * Denormalised `pageerror`+`error` console log occurrence count (issue * #228), or `null` on a page that predates the feature / has never been @@ -291,6 +295,10 @@ interface ViewerPageInsertRow { scroll_height_desktop: number; /** `PagesSourceRow.scroll_height_mobile`, defaulted to `0` when `null`. */ scroll_height_mobile: number; + /** Copied from `PagesSourceRow.image_scan_desktop` verbatim (nullable) — filter-only, see the DDL comment. */ + image_scan_desktop: number | null; + /** Copied from `PagesSourceRow.image_scan_mobile` verbatim (nullable). */ + image_scan_mobile: number | null; /** `PagesSourceRow.console_error_count`, defaulted to `0` when `null`. */ console_error_count: number; /** Copied from `PagesSourceRow.lang` verbatim (nullable) — filter-only, see the DDL comment. */ @@ -518,6 +526,8 @@ function sanitizeRedirectSourceRow(row: PagesSourceRow): PagesSourceRow { main_content_custom_element_count: null, scroll_height_desktop: null, scroll_height_mobile: null, + image_scan_desktop: null, + image_scan_mobile: null, console_error_count: null, lang: null, hasCSP: 0, @@ -577,6 +587,8 @@ function toViewerPageInsertRow( main_content_custom_element_count: row.main_content_custom_element_count ?? 0, scroll_height_desktop: row.scroll_height_desktop ?? 0, scroll_height_mobile: row.scroll_height_mobile ?? 0, + image_scan_desktop: row.image_scan_desktop, + image_scan_mobile: row.image_scan_mobile, console_error_count: row.console_error_count ?? 0, lang: row.lang, has_csp: row.hasCSP, @@ -1002,6 +1014,8 @@ export async function buildViewerReadModel( 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', + 'pm.image_scan_desktop as image_scan_desktop', + 'pm.image_scan_mobile as image_scan_mobile', 'pm.console_error_count as console_error_count', 'pm.lang as lang', ...buildHeaderPresenceSelects(trx), diff --git a/packages/@nitpicker/query/src/viewer-read-model/create-viewer-read-model-tables.ts b/packages/@nitpicker/query/src/viewer-read-model/create-viewer-read-model-tables.ts index 46290c94..acddd18e 100644 --- a/packages/@nitpicker/query/src/viewer-read-model/create-viewer-read-model-tables.ts +++ b/packages/@nitpicker/query/src/viewer-read-model/create-viewer-read-model-tables.ts @@ -98,6 +98,14 @@ export async function createViewerReadModelTables(trx: Knex): Promise { main_content_custom_element_count integer not null default 0, scroll_height_desktop integer not null default 0, scroll_height_mobile integer not null default 0, + -- page_meta.image_scan_desktop/mobile verbatim (nullable), unlike the + -- 0-defaulted counters above: 0 ("ok") is a real, distinct outcome + -- here, not a placeholder for "unmeasured" — collapsing NULL to 0 + -- would make an unattempted scan indistinguishable from a + -- successful one in both sort and filter. Same nullable-verbatim + -- treatment as the lang column below. + image_scan_desktop integer, + image_scan_mobile integer, -- page_meta.console_error_count (pageerror+error occurrences, -- issue #228), same NULL-to-0 defaulting rationale as the -- main_content_*/scroll_height_* columns above: sort/filter only, diff --git a/packages/@nitpicker/query/src/viewer-read-model/viewer-read-model-schema-version.ts b/packages/@nitpicker/query/src/viewer-read-model/viewer-read-model-schema-version.ts index 32160786..656441ee 100644 --- a/packages/@nitpicker/query/src/viewer-read-model/viewer-read-model-schema-version.ts +++ b/packages/@nitpicker/query/src/viewer-read-model/viewer-read-model-schema-version.ts @@ -44,5 +44,11 @@ * `--list`'s non-recursive mode, while still admitting a listed root's * redirect destination even when that destination URL was never itself on * the list. + * + * 33 -> 34 (per-viewport image-scan outcome): added `viewer_pages.image_scan_desktop` + * / `image_scan_mobile` (nullable `INTEGER`, `@d-zero/beholder`'s + * `IMAGE_SCAN_CODE` copied verbatim from `page_meta`, unlike the `?? 0` + * count columns — `null` distinguishes "scan never attempted" from `0` + * ("ok"), the same reasoning as the source `page_meta` columns). */ -export const VIEWER_READ_MODEL_SCHEMA_VERSION = 33; +export const VIEWER_READ_MODEL_SCHEMA_VERSION = 34; From cd8ec3b57c14588a33682e6004dd9428227d99f2 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Thu, 10 Sep 2026 20:19:00 +0900 Subject: [PATCH 3/5] feat(viewer): surface imageScan filter, badges, and page-detail display Adds the imageScan checklist filter to the Pages view (server-side toImageScanOutcome parses the query param; both the fast-path listViewerPages and live listPages routes wire it through), two new page list columns (imageScanDesktop/Mobile), and an ImageScanBadge component (three severity tiers: ok/scroll-height-exceeded neutral, degraded/ nav-unsettled warn, frame-lost/unknown danger) shown alongside scroll height in the page-detail main-content summary. get-image-scan-label.ts mirrors get-error-kind-label.ts's localised-label- with-raw-fallback pattern; en/ja labels live under views.imageScan. Co-Authored-By: Claude Sonnet 5 --- .../to-image-scan-outcome.spec.ts | 28 +++++ .../src/query-params/to-image-scan-outcome.ts | 24 ++++ .../src/routes/register-pages-route.spec.ts | 107 ++++++++++++++++++ .../viewer/src/routes/register-pages-route.ts | 3 + .../components/demo-page-detail-fixtures.ts | 2 + .../components/image-scan-badge.stories.tsx | 33 ++++++ .../web/components/image-scan-badge.tsx | 36 ++++++ .../main-content-summary.stories.tsx | 24 ++++ .../web/components/main-content-summary.tsx | 24 +++- .../web/i18n/get-image-scan-label.spec.ts | 46 ++++++++ .../viewer/web/i18n/get-image-scan-label.ts | 24 ++++ .../viewer/web/i18n/translations.ts | 20 ++++ .../viewer/web/routes/page-detail-view.tsx | 1 + .../viewer/web/routes/pages-view.tsx | 38 ++++++- packages/@nitpicker/viewer/web/styles.css | 34 ++++++ packages/@nitpicker/viewer/web/types.ts | 2 + 16 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.spec.ts create mode 100644 packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.ts create mode 100644 packages/@nitpicker/viewer/web/components/image-scan-badge.stories.tsx create mode 100644 packages/@nitpicker/viewer/web/components/image-scan-badge.tsx create mode 100644 packages/@nitpicker/viewer/web/i18n/get-image-scan-label.spec.ts create mode 100644 packages/@nitpicker/viewer/web/i18n/get-image-scan-label.ts diff --git a/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.spec.ts b/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.spec.ts new file mode 100644 index 00000000..72b0dd34 --- /dev/null +++ b/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; + +import { toImageScanOutcome } from './to-image-scan-outcome.js'; + +describe('toImageScanOutcome', () => { + it('returns undefined for missing input', () => { + expect(toImageScanOutcome()).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(toImageScanOutcome('')).toBeUndefined(); + }); + + it('returns the narrowed value for every known outcome', () => { + expect(toImageScanOutcome('ok')).toBe('ok'); + expect(toImageScanOutcome('degraded')).toBe('degraded'); + expect(toImageScanOutcome('nav-unsettled')).toBe('nav-unsettled'); + expect(toImageScanOutcome('frame-lost')).toBe('frame-lost'); + expect(toImageScanOutcome('scroll-height-exceeded')).toBe('scroll-height-exceeded'); + expect(toImageScanOutcome('unknown')).toBe('unknown'); + }); + + it('returns undefined for unknown values (silent drop)', () => { + expect(toImageScanOutcome('OK')).toBeUndefined(); + expect(toImageScanOutcome('bogus')).toBeUndefined(); + expect(toImageScanOutcome('__proto__')).toBeUndefined(); + }); +}); diff --git a/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.ts b/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.ts new file mode 100644 index 00000000..0c816bd5 --- /dev/null +++ b/packages/@nitpicker/viewer/src/query-params/to-image-scan-outcome.ts @@ -0,0 +1,24 @@ +import type { ImageScanOutcome } from '@nitpicker/query/categories'; + +import { IMAGE_SCAN_OUTCOMES } from '@nitpicker/query/categories'; + +/** + * Parses a raw query-string value into an {@link ImageScanOutcome}. + * + * Same silent-drop convention as `toContentTypeCategory`: an unrecognised or + * missing value returns `undefined` rather than throwing, so a stale + * bookmark or a hostile client sending an arbitrary string gets the + * un-filtered list instead of a 500. + * @param raw - The raw query-string value. + * @returns The narrowed outcome or `undefined`. + */ +export function toImageScanOutcome( + raw: string | undefined, +): ImageScanOutcome | undefined { + if (!raw) { + return undefined; + } + return (IMAGE_SCAN_OUTCOMES as readonly string[]).includes(raw) + ? (raw as ImageScanOutcome) + : undefined; +} diff --git a/packages/@nitpicker/viewer/src/routes/register-pages-route.spec.ts b/packages/@nitpicker/viewer/src/routes/register-pages-route.spec.ts index 281346c3..a42c082e 100644 --- a/packages/@nitpicker/viewer/src/routes/register-pages-route.spec.ts +++ b/packages/@nitpicker/viewer/src/routes/register-pages-route.spec.ts @@ -410,6 +410,113 @@ describe('registerPagesRoute (integration)', () => { }); }); + describe('imageScan filter (fast path, dedicated viewer_pages columns)', () => { + const workingDir = path.resolve( + __dirname, + '__test_fixtures_register_pages_route_image_scan__', + ); + let archive: Awaited>; + let manager: InstanceType; + 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); + for (const letter of ['a', 'b', 'c']) { + await archive.setPage({ + url: parseUrl(`https://example.com/${letter}`)!, + redirectPaths: [], + isExternal: false, + isTarget: true, + status: 200, + statusText: 'OK', + contentType: 'text/html', + contentLength: 100, + responseHeaders: {}, + html: '', + meta: { ...META, title: letter.toUpperCase() }, + anchorList: [], + imageList: [], + isSkipped: false, + }); + } + // `setPage` has no imageScan parameter yet — write page_meta directly + // (mirrors the same approach in the query package's own specs), then + // build the read model on this writable connection BEFORE opening the + // separate read-only manager connection `buildFixture` normally uses, + // so the fast path picks up these values from the start. + const knex = archive.getKnex(); + const setImageScan = async ( + letter: string, + desktop: number | null, + mobile: number | null, + ) => { + await knex('page_meta') + .whereIn( + 'page_id', + knex('content_items') + .select('id') + .whereIn( + 'url_id', + knex('url_refs') + .select('id') + .where('url', `https://example.com/${letter}`), + ), + ) + .update({ image_scan_desktop: desktop, image_scan_mobile: mobile }); + }; + await setImageScan('a', 0, 0); + await setImageScan('b', 0, 3); + await buildViewerReadModel(archive); + + 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-pages-route-spec', + }); + }); + + afterAll(async () => { + await manager.closeAll(); + const { rmSync } = await import('node:fs'); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('returns only the page whose desktop or mobile scan matches the outcome', async () => { + const res = await app.request('/api/pages?imageScan=frame-lost'); + const body = (await res.json()) as { items: { url: string }[] }; + expect(body.items.map((i) => i.url)).toEqual(['https://example.com/b']); + }); + + it('OR-filters across a repeated imageScan query param', async () => { + const res = await app.request('/api/pages?imageScan=ok&imageScan=frame-lost'); + const body = (await res.json()) as { items: { url: string }[] }; + expect(body.items.map((i) => i.url).toSorted()).toEqual([ + 'https://example.com/a', + 'https://example.com/b', + ]); + }); + + it('drops an invalid imageScan value from a repeated query param instead of erroring', async () => { + const res = await app.request('/api/pages?imageScan=bogus&imageScan=frame-lost'); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: { url: string }[] }; + expect(body.items.map((i) => i.url)).toEqual(['https://example.com/b']); + }); + }); + describe('isExternal/missingTitle filters OR-combine across a repeated query param', () => { const workingDir = path.resolve( __dirname, diff --git a/packages/@nitpicker/viewer/src/routes/register-pages-route.ts b/packages/@nitpicker/viewer/src/routes/register-pages-route.ts index e96ab309..4e98a099 100644 --- a/packages/@nitpicker/viewer/src/routes/register-pages-route.ts +++ b/packages/@nitpicker/viewer/src/routes/register-pages-route.ts @@ -12,6 +12,7 @@ import { buildLivePagesCursors } from '../query-params/build-live-pages-cursors. import { parseLivePagesCursor } from '../query-params/parse-live-pages-cursor.js'; import { toBoolean } from '../query-params/to-boolean.js'; import { toContentTypeCategory } from '../query-params/to-content-type-category.js'; +import { toImageScanOutcome } from '../query-params/to-image-scan-outcome.js'; import { toMultiValue } from '../query-params/to-multi-value.js'; import { toNumber } from '../query-params/to-number.js'; import { toPageSortBy } from '../query-params/to-page-sort-by.js'; @@ -79,6 +80,7 @@ export function registerPagesRoute(app: Hono, context: ArchiveContext): void { toBoolean, ), hasHSTS: toMultiValue(c.req.queries('hasHSTS'), toBoolean), + imageScan: toMultiValue(c.req.queries('imageScan'), toImageScanOutcome), isDedupeCapped: toMultiValue(c.req.queries('isDedupeCapped'), toBoolean), dedupeCapEventId: toNumber(q.dedupeCapEventId), isRedirectSource: toMultiValue(c.req.queries('isRedirectSource'), toBoolean), @@ -120,6 +122,7 @@ export function registerPagesRoute(app: Hono, context: ArchiveContext): void { hasXFrameOptions: toBoolean(q.hasXFrameOptions), hasXContentTypeOptions: toBoolean(q.hasXContentTypeOptions), hasHSTS: toBoolean(q.hasHSTS), + imageScan: toImageScanOutcome(q.imageScan), isDedupeCapped: toBoolean(q.isDedupeCapped), dedupeCapEventId: toNumber(q.dedupeCapEventId), urlPattern: q.urlPattern, diff --git a/packages/@nitpicker/viewer/web/components/demo-page-detail-fixtures.ts b/packages/@nitpicker/viewer/web/components/demo-page-detail-fixtures.ts index 44636c34..147b47a0 100644 --- a/packages/@nitpicker/viewer/web/components/demo-page-detail-fixtures.ts +++ b/packages/@nitpicker/viewer/web/components/demo-page-detail-fixtures.ts @@ -90,6 +90,8 @@ export function buildDemoPageDetail(overrides?: Partial): PageDetail mainContentCanvasCount: 0, scrollHeightDesktop: 1200, scrollHeightMobile: 2400, + imageScanDesktop: 'ok', + imageScanMobile: 'ok', templateKey: null, metaExtras: {}, jsonLd: { count: 0, types: [], parseErrorCount: 0 }, diff --git a/packages/@nitpicker/viewer/web/components/image-scan-badge.stories.tsx b/packages/@nitpicker/viewer/web/components/image-scan-badge.stories.tsx new file mode 100644 index 00000000..55c38637 --- /dev/null +++ b/packages/@nitpicker/viewer/web/components/image-scan-badge.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ImageScanBadge } from './image-scan-badge.js'; + +const meta = { + component: ImageScanBadge, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The common case: the scan completed normally. */ +export const Ok: Story = { args: { outcome: 'ok' } }; + +/** Network never went idle, but the frame stayed usable — data was still collected. */ +export const Degraded: Story = { args: { outcome: 'degraded' } }; + +/** Network never settled and the post-timeout frame check also failed. */ +export const NavUnsettled: Story = { args: { outcome: 'nav-unsettled' } }; + +/** The frame or session was lost mid-scan. */ +export const FrameLost: Story = { args: { outcome: 'frame-lost' } }; + +/** `scrollHeight` exceeded the scan's guard; a deterministic, non-retryable outcome. */ +export const ScrollHeightExceeded: Story = { + args: { outcome: 'scroll-height-exceeded' }, +}; + +/** An error occurred that does not match any known category. */ +export const Unknown: Story = { args: { outcome: 'unknown' } }; + +/** No scan was ever attempted for this viewport — renders nothing. */ +export const NotAttempted: Story = { args: { outcome: null } }; diff --git a/packages/@nitpicker/viewer/web/components/image-scan-badge.tsx b/packages/@nitpicker/viewer/web/components/image-scan-badge.tsx new file mode 100644 index 00000000..f1d0cab5 --- /dev/null +++ b/packages/@nitpicker/viewer/web/components/image-scan-badge.tsx @@ -0,0 +1,36 @@ +import type { ImageScanOutcome } from '@nitpicker/query/categories'; + +import { getImageScanLabel } from '../i18n/get-image-scan-label.js'; +import { useI18n } from '../i18n/use-i18n.js'; + +/** + * Visual badge for one viewport's `@d-zero/beholder` `` element scan + * outcome (`page_meta.image_scan_desktop` / `image_scan_mobile`). + * + * Three severity tiers, matching how actionable each outcome is: + * + * - `ok` — neutral styling; the common case, should not draw the eye. + * - `degraded` / `nav-unsettled` — warn styling; the scan produced data (or + * may be worth a `--retry-failed` pass) but network activity never + * settled cleanly. + * - `frame-lost` / `unknown` — danger styling; the scan was abandoned + * outright. + * - `scroll-height-exceeded` — neutral styling; a deterministic, page-shape + * outcome that a retry cannot change, not a failure to investigate. + * @param props + * @param props.outcome - The image-scan outcome, or `null` when the scan was + * never attempted (renders nothing). + */ +export function ImageScanBadge({ outcome }: { outcome: ImageScanOutcome | null }) { + const { t } = useI18n(); + if (outcome === null) { + return null; + } + const label = getImageScanLabel(outcome, t); + const className = `image-scan-badge image-scan-badge--${outcome}`; + return ( + + {label} + + ); +} diff --git a/packages/@nitpicker/viewer/web/components/main-content-summary.stories.tsx b/packages/@nitpicker/viewer/web/components/main-content-summary.stories.tsx index ef21f913..3a7001c3 100644 --- a/packages/@nitpicker/viewer/web/components/main-content-summary.stories.tsx +++ b/packages/@nitpicker/viewer/web/components/main-content-summary.stories.tsx @@ -16,6 +16,7 @@ export const Default: Story = { wordCount: 1240, bodyWordCount: 1580, scrollHeight: { desktop: 3200, mobile: 5400 }, + imageScan: { desktop: 'ok', mobile: 'ok' }, }, }; @@ -26,5 +27,28 @@ export const NoMainContent: Story = { wordCount: 0, bodyWordCount: 0, scrollHeight: { desktop: null, mobile: null }, + imageScan: { desktop: null, mobile: null }, + }, +}; + +/** The mobile viewport's image scan degraded — network never settled, but the frame was still usable. */ +export const MobileImageScanDegraded: Story = { + args: { + selector: 'main#content', + wordCount: 1240, + bodyWordCount: 1580, + scrollHeight: { desktop: 3200, mobile: 5400 }, + imageScan: { desktop: 'ok', mobile: 'degraded' }, + }, +}; + +/** The mobile viewport's image scan was abandoned outright (frame/session lost). */ +export const MobileImageScanFrameLost: Story = { + args: { + selector: 'main#content', + wordCount: 1240, + bodyWordCount: 1580, + scrollHeight: { desktop: 3200, mobile: null }, + imageScan: { desktop: 'ok', mobile: 'frame-lost' }, }, }; diff --git a/packages/@nitpicker/viewer/web/components/main-content-summary.tsx b/packages/@nitpicker/viewer/web/components/main-content-summary.tsx index a9d41e1c..c5963b41 100644 --- a/packages/@nitpicker/viewer/web/components/main-content-summary.tsx +++ b/packages/@nitpicker/viewer/web/components/main-content-summary.tsx @@ -1,5 +1,9 @@ +import type { ImageScanOutcome } from '@nitpicker/query/categories'; + import { useI18n } from '../i18n/use-i18n.js'; +import { ImageScanBadge } from './image-scan-badge.js'; + /** Props for {@link MainContentSummary}. */ export interface MainContentSummaryProps { /** Diagnostic tag+id+class selector for the detected main-content element, or `null` when none was found. */ @@ -15,18 +19,26 @@ export interface MainContentSummaryProps { /** Height at the mobile-small preset, or `null` if unmeasured. */ mobile: number | null; }; + /** `` element scan outcome at the desktop-compact and mobile-small presets. */ + imageScan: { + /** Outcome at the desktop-compact preset, or `null` when not attempted. */ + desktop: ImageScanOutcome | null; + /** Outcome at the mobile-small preset, or `null` when not attempted. */ + mobile: ImageScanOutcome | null; + }; } /** * Summary metrics for a page's detected main-content region: selector, word - * counts, and scroll heights. The region's headings/images/tables/etc. are - * each their own component — this one covers only the scalar metrics. + * counts, scroll heights, and image-scan outcomes. The region's headings/ + * images/tables/etc. are each their own component — this one covers only + * the scalar metrics. * @param props - The main-content summary metrics. * @returns The summary `
` element. */ export function MainContentSummary(props: MainContentSummaryProps) { const { t } = useI18n(); - const { selector, wordCount, bodyWordCount, scrollHeight } = props; + const { selector, wordCount, bodyWordCount, scrollHeight, imageScan } = props; return (
{t('views.pageDetail.mainContentSelector')}
@@ -39,6 +51,12 @@ export function MainContentSummary(props: MainContentSummaryProps) {
{scrollHeight.desktop ?? '—'} / {scrollHeight.mobile ?? '—'}
+
{t('views.pageDetail.mainContentImageScan')}
+
+ {' '} + + {imageScan.desktop === null && imageScan.mobile === null && '—'} +
); } diff --git a/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.spec.ts b/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.spec.ts new file mode 100644 index 00000000..a9d4e76e --- /dev/null +++ b/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.spec.ts @@ -0,0 +1,46 @@ +import type { ImageScanOutcome } from '@nitpicker/query/categories'; + +import { describe, expect, it } from 'vitest'; + +import { getImageScanLabel } from './get-image-scan-label.js'; + +/** + * Build a stub translate fn that returns whatever `dictionary` maps to and + * falls back to the key itself when missing — mirroring the real `t()`. + * @param dictionary - key → label mapping. + * @returns A translate function compatible with {@link getImageScanLabel}. + */ +function tStub(dictionary: Record) { + return (key: string) => dictionary[key] ?? key; +} + +describe('getImageScanLabel', () => { + it('looks up the localised label via `views.imageScan.`', () => { + const t = tStub({ + 'views.imageScan.ok': 'OK', + 'views.imageScan.frame-lost': 'Frame/session lost', + }); + expect(getImageScanLabel('ok', t)).toBe('OK'); + expect(getImageScanLabel('frame-lost', t)).toBe('Frame/session lost'); + }); + + it('falls back to the raw outcome when no translation is registered', () => { + const t = tStub({}); + expect(getImageScanLabel('ok', t)).toBe('ok'); + }); + + it('covers every outcome in the ImageScanOutcome union', () => { + const outcomes: ImageScanOutcome[] = [ + 'ok', + 'degraded', + 'nav-unsettled', + 'frame-lost', + 'scroll-height-exceeded', + 'unknown', + ]; + const t = tStub({}); + for (const outcome of outcomes) { + expect(getImageScanLabel(outcome, t)).toBe(outcome); + } + }); +}); diff --git a/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.ts b/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.ts new file mode 100644 index 00000000..10029ee1 --- /dev/null +++ b/packages/@nitpicker/viewer/web/i18n/get-image-scan-label.ts @@ -0,0 +1,24 @@ +import type { I18nValue } from '../types.js'; +import type { ImageScanOutcome } from '@nitpicker/query/categories'; + +/** + * Lookup the localised label for an {@link ImageScanOutcome}. + * + * Used by the Pages view's checklist filter and by `MainContentSummary`'s + * per-viewport badges to keep the outcome labels identical across both + * screens — translators only have to update one bag of strings under + * `views.imageScan`. + * + * Unknown / future outcomes fall back to the raw outcome string so a new + * outcome introduced in `@d-zero/beholder` does not display blank in older + * viewer builds. + * @param outcome - The image-scan outcome to label. + * @param t - The active translate function (from `useI18n()`). + * @returns The localised, human-readable label. + */ +export function getImageScanLabel(outcome: ImageScanOutcome, t: I18nValue['t']): string { + const key = `views.imageScan.${outcome}`; + const label = t(key); + // `t()` returns the key itself when no translation is found. + return label === key ? outcome : label; +} diff --git a/packages/@nitpicker/viewer/web/i18n/translations.ts b/packages/@nitpicker/viewer/web/i18n/translations.ts index 20ce50da..347ae1ea 100644 --- a/packages/@nitpicker/viewer/web/i18n/translations.ts +++ b/packages/@nitpicker/viewer/web/i18n/translations.ts @@ -183,6 +183,14 @@ export const translations: Record> = { other: 'Other', unknown: 'Unknown / Errored', }, + imageScan: { + ok: 'OK', + degraded: 'Degraded (network never settled)', + 'nav-unsettled': 'Navigation unsettled', + 'frame-lost': 'Frame/session lost', + 'scroll-height-exceeded': 'Scroll height exceeded', + unknown: 'Unknown error', + }, pages: { title: 'Pages', description: @@ -198,6 +206,7 @@ export const translations: Record> = { filterMissingTitle: 'Missing title', filterExternal: 'External pages', filterContentType: 'Content-Type', + filterImageScan: 'Image scan outcome', filterDedupeCapped: 'Dedupe-cap trap', directoryFilterNotice: 'Showing pages under {directory}', dedupeCapEventFilterNotice: @@ -249,6 +258,7 @@ export const translations: Record> = { mainContentWordCount: 'Main content words', mainContentBodyWordCount: 'Body words', mainContentScrollHeight: 'Scroll height (desktop / mobile)', + mainContentImageScan: 'Image scan (desktop / mobile)', mainContentHeadings: 'Headings', mainContentImages: 'Images', mainContentTables: 'Tables', @@ -680,6 +690,14 @@ export const translations: Record> = { other: 'その他', unknown: '不明 / エラー', }, + imageScan: { + ok: 'OK', + degraded: '劣化(ネットワーク未収束)', + 'nav-unsettled': 'ナビゲーション未収束', + 'frame-lost': 'フレーム/セッション消失', + 'scroll-height-exceeded': 'スクロール高さ超過', + unknown: '不明なエラー', + }, pages: { title: 'ページ', description: @@ -695,6 +713,7 @@ export const translations: Record> = { filterMissingTitle: 'タイトル欠損', filterExternal: '外部ページ', filterContentType: 'Content-Type', + filterImageScan: '画像スキャン結果', filterDedupeCapped: 'Dedupe-capトラップ', directoryFilterNotice: '「{directory}」配下のページを表示中', dedupeCapEventFilterNotice: @@ -746,6 +765,7 @@ export const translations: Record> = { mainContentWordCount: 'メインコンテンツ文字数', mainContentBodyWordCount: 'body 全体文字数', mainContentScrollHeight: 'スクロール高さ(デスクトップ / モバイル)', + mainContentImageScan: '画像スキャン(デスクトップ / モバイル)', mainContentHeadings: '見出し', mainContentImages: '画像', mainContentTables: 'テーブル', diff --git a/packages/@nitpicker/viewer/web/routes/page-detail-view.tsx b/packages/@nitpicker/viewer/web/routes/page-detail-view.tsx index 18419921..c83e4781 100644 --- a/packages/@nitpicker/viewer/web/routes/page-detail-view.tsx +++ b/packages/@nitpicker/viewer/web/routes/page-detail-view.tsx @@ -112,6 +112,7 @@ export function PageDetailView() { wordCount={mainContents.data.wordCount} bodyWordCount={mainContents.data.bodyWordCount} scrollHeight={mainContents.data.scrollHeight} + imageScan={mainContents.data.imageScan} /> diff --git a/packages/@nitpicker/viewer/web/routes/pages-view.tsx b/packages/@nitpicker/viewer/web/routes/pages-view.tsx index ea0fa70b..48c04ae3 100644 --- a/packages/@nitpicker/viewer/web/routes/pages-view.tsx +++ b/packages/@nitpicker/viewer/web/routes/pages-view.tsx @@ -1,9 +1,12 @@ import type { CellContext, ColumnDef, PagesFilter } from '../types.js'; import type { PageListFacets, PageListItem } from '@nitpicker/query'; -import type { ContentTypeCategory } from '@nitpicker/query/categories'; +import type { ContentTypeCategory, ImageScanOutcome } from '@nitpicker/query/categories'; import type { HeaderPresence } from '@nitpicker/query/header-presence'; -import { CONTENT_TYPE_CATEGORIES } from '@nitpicker/query/categories'; +import { + CONTENT_TYPE_CATEGORIES, + IMAGE_SCAN_OUTCOMES, +} from '@nitpicker/query/categories'; import { HEADER_PRESENCE_KEYS } from '@nitpicker/query/header-presence'; import { useMemo } from 'react'; import { useNavigate } from 'react-router'; @@ -79,6 +82,11 @@ export function PagesView() { const hasXFrameOptions = params.getAll('hasXFrameOptions'); const hasXContentTypeOptions = params.getAll('hasXContentTypeOptions'); const hasHSTS = params.getAll('hasHSTS'); + const imageScan = params + .getAll('imageScan') + .filter((value): value is ImageScanOutcome => + (IMAGE_SCAN_OUTCOMES as readonly string[]).includes(value), + ); const isDedupeCapped = params.getAll('isDedupeCapped'); const dedupeCapEventIdParam = params.get('dedupeCapEventId'); const parsedDedupeCapEventId = dedupeCapEventIdParam @@ -109,6 +117,7 @@ export function PagesView() { hasXFrameOptions, hasXContentTypeOptions, hasHSTS, + imageScan, isDedupeCapped, dedupeCapEventId, templateKey, @@ -325,6 +334,18 @@ export function PagesView() { size: 130, cell: textCell, }, + { + accessorKey: 'imageScanDesktop', + header: 'image scan (desktop)', + size: 130, + cell: textCell, + }, + { + accessorKey: 'imageScanMobile', + header: 'image scan (mobile)', + size: 130, + cell: textCell, + }, { accessorKey: 'hasCSP', header: 'CSP', size: 70, cell: boolCell }, { accessorKey: 'hasXFrameOptions', @@ -448,6 +469,18 @@ export function PagesView() { checked: contentTypeCategory.includes(category), })), ); + addChecklistFilter( + controls, + { params, updateMany }, + 'imageScan', + 'imageScan', + t('views.pages.filterImageScan'), + IMAGE_SCAN_OUTCOMES.map((outcome) => ({ + value: outcome, + label: t(`views.imageScan.${outcome}` as const), + checked: imageScan.includes(outcome), + })), + ); addChecklistFilter( controls, { params, updateMany }, @@ -537,6 +570,7 @@ export function PagesView() { hasHSTS, hasXContentTypeOptions, hasXFrameOptions, + imageScan, isDedupeCapped, isExternal, lang, diff --git a/packages/@nitpicker/viewer/web/styles.css b/packages/@nitpicker/viewer/web/styles.css index 5c9430f9..52f6c9da 100644 --- a/packages/@nitpicker/viewer/web/styles.css +++ b/packages/@nitpicker/viewer/web/styles.css @@ -1451,6 +1451,40 @@ h2 { background: color-mix(in srgb, var(--badge-warn-fg) 10%, transparent); } +/* + * Per-viewport image-scan outcome badge (`ImageScanBadge`). Three severity + * tiers: `ok`/`scroll-height-exceeded` neutral (nothing to investigate), + * `degraded`/`nav-unsettled` warn (network never settled), `frame-lost`/ + * `unknown` danger (scan abandoned outright). + */ +.image-scan-badge { + display: inline flow-root; + padding-block: 1px; + padding-inline: 6px; + font-family: monospace; + font-size: var(--font-size-xs); + white-space: nowrap; + border-radius: 3px; +} + +.image-scan-badge--ok, +.image-scan-badge--scroll-height-exceeded { + color: var(--text-dim); + background: color-mix(in srgb, var(--text-dim) 14%, transparent); +} + +.image-scan-badge--degraded, +.image-scan-badge--nav-unsettled { + color: var(--badge-warn-fg); + background: var(--badge-warn-bg); +} + +.image-scan-badge--frame-lost, +.image-scan-badge--unknown { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 16%, transparent); +} + /* Directory tree view — selecting a node navigates to the Pages view (filtered to that subtree) rather than showing a page list in place, so this is a single-pane layout that scrolls with the page (`.content`), diff --git a/packages/@nitpicker/viewer/web/types.ts b/packages/@nitpicker/viewer/web/types.ts index 6351d85f..20bb2078 100644 --- a/packages/@nitpicker/viewer/web/types.ts +++ b/packages/@nitpicker/viewer/web/types.ts @@ -47,6 +47,7 @@ export type PagesFilter = Omit< | 'hasXFrameOptions' | 'hasXContentTypeOptions' | 'hasHSTS' + | 'imageScan' | 'isDedupeCapped' > & { status?: string | readonly string[]; @@ -59,6 +60,7 @@ export type PagesFilter = Omit< hasXFrameOptions?: string | readonly string[]; hasXContentTypeOptions?: string | readonly string[]; hasHSTS?: string | readonly string[]; + imageScan?: string | readonly string[]; isDedupeCapped?: string | readonly string[]; }; From bfa00f651e6c461eb34f150518c48245c250ea6f Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Thu, 10 Sep 2026 20:19:54 +0900 Subject: [PATCH 4/5] feat(mcp-server): expose imageScan filter on list_pages/get_page_detail Adds an imageScan enum parameter to list_pages' input schema (OR across the desktop/mobile columns, same semantics as the query-layer filter) and documents the new imageScanDesktop/Mobile fields on get_page_detail's description. Co-Authored-By: Claude Sonnet 5 --- .../mcp-server/src/mcp-server.spec.ts | 16 ++++++++++++++++ .../mcp-server/src/tool-definitions.ts | 17 +++++++++++++++-- 2 files changed, 31 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 0fd751d5..0ae68a59 100644 --- a/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts +++ b/packages/@nitpicker/mcp-server/src/mcp-server.spec.ts @@ -183,6 +183,7 @@ describe('createServer', () => { canvases: [], }, scrollHeight: { desktop: 3200, mobile: 5400 }, + imageScan: { desktop: 0, mobile: 2 }, }); await archive.setPage({ @@ -406,6 +407,19 @@ describe('createServer', () => { expect(data.items[0].hasCSP).toBe(true); }); + it('list_pages は imageScan でフィルタする', async () => { + const result = await callTool(server, 'list_pages', { + archiveId, + imageScan: 'nav-unsettled', + }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.items).toHaveLength(1); + expect(data.items[0].url).toBe('https://example.com'); + expect(data.items[0].imageScanDesktop).toBe('ok'); + expect(data.items[0].imageScanMobile).toBe('nav-unsettled'); + }); + it('get_page_detail でページ詳細を取得する', async () => { const result = await callTool(server, 'get_page_detail', { archiveId, @@ -417,6 +431,8 @@ describe('createServer', () => { expect(data.title).toBe('Home'); expect(data.outboundLinks).toBeDefined(); expect(data.outboundLinks.length).toBe(1); + expect(data.imageScanDesktop).toBe('ok'); + expect(data.imageScanMobile).toBe('nav-unsettled'); }); it('get_page_detail で存在しないページは "Page not found." を返す', async () => { diff --git a/packages/@nitpicker/mcp-server/src/tool-definitions.ts b/packages/@nitpicker/mcp-server/src/tool-definitions.ts index f4e4cf71..0351ae60 100644 --- a/packages/@nitpicker/mcp-server/src/tool-definitions.ts +++ b/packages/@nitpicker/mcp-server/src/tool-definitions.ts @@ -54,7 +54,7 @@ export const toolDefinitions: Tool[] = [ { name: 'list_pages', description: - 'List pages with rich filtering: by status code (exact or range), missing metadata (title, description), noindex flag, security header presence (CSP / X-Frame-Options / X-Content-Type-Options / HSTS), dedupe-cap trap membership, URL patterns, directory paths. Supports sorting and pagination. Use for questions like "show me all 404 pages", "pages without descriptions", "internal pages missing CSP", or "which pages got swept up in a --dedupe-cap trap". For large sites, set `limit` to keep the response bounded — to dump the whole list use the CLI (`nitpicker query pages`) and pipe through `jq` instead of pulling everything through MCP.', + 'List pages with rich filtering: by status code (exact or range), missing metadata (title, description), noindex flag, security header presence (CSP / X-Frame-Options / X-Content-Type-Options / HSTS), dedupe-cap trap membership, image-scan outcome, URL patterns, directory paths. Supports sorting and pagination. Use for questions like "show me all 404 pages", "pages without descriptions", "internal pages missing CSP", "which pages got swept up in a --dedupe-cap trap", or "which pages had their mobile image scan abandoned". For large sites, set `limit` to keep the response bounded — to dump the whole list use the CLI (`nitpicker query pages`) and pipe through `jq` instead of pulling everything through MCP.', inputSchema: { type: 'object' as const, properties: { @@ -132,6 +132,19 @@ export const toolDefinitions: Tool[] = [ description: 'Restrict to one Content-Type category. When set, the default HTML-or-null base filter is relaxed so non-HTML categories (PDF, image, archive…) become reachable through this listing — useful for audits like "show every PDF in scope". csv groups .csv + .tsv; word groups .doc + .docx; excel groups .xls + .xlsx; powerpoint groups .ppt + .pptx; json groups JSON + YAML; text groups .txt + .md.', }, + imageScan: { + type: 'string', + enum: [ + 'ok', + 'degraded', + 'nav-unsettled', + 'frame-lost', + 'scroll-height-exceeded', + 'unknown', + ], + description: + 'Filter to pages where the desktop OR mobile element scan matched this outcome (a page\'s two viewports can differ, so this is OR across both, not per-viewport). "ok"/"degraded" mean the scan produced data (degraded = network never settled but the frame was still usable); "nav-unsettled"/"frame-lost"/"unknown" mean the scan was abandoned for a transient reason and the page is a --retry-failed candidate; "scroll-height-exceeded" is a deterministic, page-shape-driven skip that a retry cannot change.', + }, urlPattern: { type: 'string', description: 'URL pattern to search (SQL LIKE: use % as wildcard)', @@ -156,7 +169,7 @@ export const toolDefinitions: Tool[] = [ { name: 'get_page_detail', description: - "Get full details for a specific page URL: ~47 flat meta fields (title, description, OG, Twitter, robots, link, charset, manifest, themeColor, fb_app_id, verification_google, format_detection, og:image:alt/width/height, og:locale, og:article timestamps, twitter:site/creator, etc.), `metaExtras` JSON (referrer, viewport parsed, httpEquiv, apple, msapplication, verification.{bing|yandex|...}, geo, citation, hreflang alternates, others.*, originTrial), JSON-LD/SpeculationRules **summary** (count + unique @types + parseErrorCount), confidence-combined **technology roll-up** (technology/category/version/confidence/signalCount per detected technology, confidence descending — no per-signal evidence), main-content **aggregate counts only** (mainContentSelector, mainContentWordCount/BodyWordCount, mainContentHeadingCount/ImageCount/TableCount/ButtonCount/IframeCount/VideoCount/AudioCount/CanvasCount/CustomElementCount, scrollHeightDesktop/Mobile — null when the page was never rendered), outbound links, redirect sources, response headers, `isDedupeCapped`/`dedupeCapShapeKey` (whether --dedupe-cap captured this page's URL shape as a same-cluster crawl trap, and which shape), and within-archive timestamps (firstCrawledAt / lastCrawledAt). Inbound links are NOT included here — a page's referrer count can reach the hundreds of thousands on a large site; use `list_inbound_links` instead. Raw JSON-LD entries, per-signal technology evidence, and the main-content child-entity arrays are also NOT included — fetch them via `get_page_jsonld` / `get_page_technologies` / `get_page_main_contents`. Use when drilling down into a specific page.", + "Get full details for a specific page URL: ~47 flat meta fields (title, description, OG, Twitter, robots, link, charset, manifest, themeColor, fb_app_id, verification_google, format_detection, og:image:alt/width/height, og:locale, og:article timestamps, twitter:site/creator, etc.), `metaExtras` JSON (referrer, viewport parsed, httpEquiv, apple, msapplication, verification.{bing|yandex|...}, geo, citation, hreflang alternates, others.*, originTrial), JSON-LD/SpeculationRules **summary** (count + unique @types + parseErrorCount), confidence-combined **technology roll-up** (technology/category/version/confidence/signalCount per detected technology, confidence descending — no per-signal evidence), main-content **aggregate counts only** (mainContentSelector, mainContentWordCount/BodyWordCount, mainContentHeadingCount/ImageCount/TableCount/ButtonCount/IframeCount/VideoCount/AudioCount/CanvasCount/CustomElementCount, scrollHeightDesktop/Mobile, imageScanDesktop/Mobile — the desktop/mobile element scan outcome ('ok'/'degraded'/'nav-unsettled'/'frame-lost'/'scroll-height-exceeded'/'unknown'), null when the page was never rendered or the scan was never attempted), outbound links, redirect sources, response headers, `isDedupeCapped`/`dedupeCapShapeKey` (whether --dedupe-cap captured this page's URL shape as a same-cluster crawl trap, and which shape), and within-archive timestamps (firstCrawledAt / lastCrawledAt). Inbound links are NOT included here — a page's referrer count can reach the hundreds of thousands on a large site; use `list_inbound_links` instead. Raw JSON-LD entries, per-signal technology evidence, and the main-content child-entity arrays are also NOT included — fetch them via `get_page_jsonld` / `get_page_technologies` / `get_page_main_contents`. Use when drilling down into a specific page.", inputSchema: { type: 'object' as const, properties: { From 1d37501a2be1529af0eaa039d2f057d3da24f668 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Fri, 11 Sep 2026 01:22:50 +0900 Subject: [PATCH 5/5] fix(test-server): regenerate the report/query e2e fixture for viewer read model v34 VIEWER_READ_MODEL_SCHEMA_VERSION bumped 33 -> 34 in this branch makes the committed report-query-fixture.nitpicker's pre-built read model stale, so `report -H`/`query` refuse to run against it (requireViewerReadModel throws "missing or stale"). Regenerated the fixture the same way as originally documented in fixtures/README.md (crawl the same two routes on a fixed port 49375, viewer-build --force, repack with tar's portable option) so its read model matches the new schema version. Co-Authored-By: Claude Sonnet 5 --- .../fixtures/report-query-fixture.nitpicker | Bin 3296256 -> 3296256 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/test-server/src/__tests__/e2e/fixtures/report-query-fixture.nitpicker b/packages/test-server/src/__tests__/e2e/fixtures/report-query-fixture.nitpicker index 3e9860fe5ed936fd1a26abc8c75200b9bce8b381..15b352a93c482df738047d32abb24d386c34c468 100644 GIT binary patch delta 14419 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 BRW<+s delta 3960 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 AIsgCw