Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@nitpicker/crawler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 20 additions & 8 deletions packages/@nitpicker/crawler/src/archive/database.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ async function _fetchHead(
html: '',
mainContents: null,
scrollHeight: null,
imageScan: { desktop: null, mobile: null },
isSkipped: false,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function linkToPageData(link: Link): PageData {
html: '',
mainContents: null,
scrollHeight: null,
imageScan: { desktop: null, mobile: null },
isSkipped: false,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function resourceToPageData(params: ResourceToPageDataParams): PageData |
html: '',
mainContents: null,
scrollHeight: null,
imageScan: { desktop: null, mobile: null },
isSkipped: false,
};
}
16 changes: 16 additions & 0 deletions packages/@nitpicker/mcp-server/src/mcp-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ describe('createServer', () => {
canvases: [],
},
scrollHeight: { desktop: 3200, mobile: 5400 },
imageScan: { desktop: 0, mobile: 2 },
});

await archive.setPage({
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/@nitpicker/mcp-server/src/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 <img> 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)',
Expand All @@ -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 <img> 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: {
Expand Down
111 changes: 111 additions & 0 deletions packages/@nitpicker/query/src/apply-viewer-pages-filters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Archive>;

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: '<html></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();
});
});
17 changes: 17 additions & 0 deletions packages/@nitpicker/query/src/apply-viewer-pages-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion packages/@nitpicker/query/src/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 5 additions & 0 deletions packages/@nitpicker/query/src/get-page-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions packages/@nitpicker/query/src/get-page-main-contents.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => ({
Expand Down
8 changes: 6 additions & 2 deletions packages/@nitpicker/query/src/has-filter-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
* 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);
* } else {
* qb.whereIn('content_category', ['html', 'unknown']);
* }
*/
export function hasFilterValue<T>(value: T | readonly T[] | null | undefined): boolean {
export function hasFilterValue<T>(
value: T | readonly T[] | null | undefined,
): value is T | readonly T[] {
if (value == null) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
Loading
Loading