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
1 change: 1 addition & 0 deletions .github/workflows/viewer-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ jobs:
- run: yarn workspace @nitpicker/viewer test:e2e:stub
- run: yarn workspace @nitpicker/viewer test:e2e:directory-tree
- run: yarn workspace @nitpicker/viewer test:e2e:template-clusters
- run: yarn workspace @nitpicker/viewer test:e2e:inbound-links
8 changes: 5 additions & 3 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

148 changes: 84 additions & 64 deletions packages/@nitpicker/cli/docs/query.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/@nitpicker/cli/src/commands/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,19 @@ export const commandDef = {
},
cursor: {
type: 'string',
desc: 'Opaque pagination cursor from a previous result (resource-referrers, duplicates, mismatches)',
desc: 'Opaque pagination cursor from a previous result (resource-referrers, inbound-links, duplicates, mismatches)',
},
direction: {
type: 'string',
desc: 'Direction to walk from --cursor: next (default) or prev (duplicates, mismatches)',
desc: 'Direction to walk from --cursor: next (default) or prev (inbound-links, duplicates, mismatches)',
},
pagesLimit: {
type: 'number',
desc: 'Inline member-page URL sample size per duplicate group (duplicates). Defaults to 20.',
},
url: {
type: 'string',
desc: 'Target URL for page-detail, html, resource-referrers, or page-console-logs queries',
desc: 'Target URL for page-detail, inbound-links, html, resource-referrers, or page-console-logs queries',
},
status: {
type: 'number',
Expand Down
61 changes: 61 additions & 0 deletions packages/@nitpicker/cli/src/query/dispatch-query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ vi.mock('@nitpicker/query', () => ({
.mockResolvedValue({ total: 0, channelSource: 'none', groups: [] }),
listPages: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }),
getPageDetail: vi.fn().mockResolvedValue({ url: 'https://example.com', status: 200 }),
listInboundLinks: vi.fn().mockResolvedValue({
url: 'https://example.com',
items: [],
total: 0,
limit: 100,
offset: 0,
nextCursor: null,
prevCursor: null,
}),
getPageHtml: vi.fn().mockResolvedValue({ html: '<html></html>', truncated: false }),
listLinks: vi.fn().mockResolvedValue({ items: [], total: 0 }),
listResources: vi
Expand Down Expand Up @@ -112,6 +121,58 @@ describe('dispatchQuery', () => {
).rejects.toThrow('Page not found: https://missing.example.com');
});

it('dispatches inbound-links sub-command', async () => {
const { listInboundLinks } = await import('@nitpicker/query');
const result = await dispatchQuery(mockAccessor, 'inbound-links', {
url: 'https://example.com',
} as never);
expect(result).toEqual({
url: 'https://example.com',
items: [],
total: 0,
limit: 100,
offset: 0,
nextCursor: null,
prevCursor: null,
});
expect(listInboundLinks).toHaveBeenCalledWith(mockAccessor, {
url: 'https://example.com',
limit: undefined,
offset: undefined,
cursor: undefined,
direction: undefined,
});
});

it('dispatches inbound-links sub-command with limit, offset, cursor, direction', async () => {
const { listInboundLinks } = await import('@nitpicker/query');
await dispatchQuery(mockAccessor, 'inbound-links', {
url: 'https://example.com',
limit: 10,
offset: 20,
cursor: 'abc',
direction: 'prev',
} as never);
expect(listInboundLinks).toHaveBeenCalledWith(mockAccessor, {
url: 'https://example.com',
limit: 10,
offset: 20,
cursor: 'abc',
direction: 'prev',
});
});

it('throws when inbound-links returns null', async () => {
const { listInboundLinks } = await import('@nitpicker/query');
vi.mocked(listInboundLinks).mockResolvedValueOnce(null);

await expect(
dispatchQuery(mockAccessor, 'inbound-links', {
url: 'https://missing.example.com',
} as never),
).rejects.toThrow('Page not found: https://missing.example.com');
});

it('dispatches html sub-command', async () => {
const { getPageHtml } = await import('@nitpicker/query');
const result = await dispatchQuery(mockAccessor, 'html', {
Expand Down
21 changes: 21 additions & 0 deletions packages/@nitpicker/cli/src/query/dispatch-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
getTagInventory,
getViolations,
listConsoleLogs,
listInboundLinks,
listInventoryRuns,
listIsolatedClustersFastPath,
listIsolatedPagesFastPath,
Expand Down Expand Up @@ -85,6 +86,26 @@ export async function dispatchQuery(
}
return result;
}
case 'inbound-links': {
const { url, limit, offset, cursor, direction } = options as {
url: string;
limit?: number;
offset?: number;
cursor?: string;
direction?: 'next' | 'prev';
};
const result = await listInboundLinks(accessor, {
url,
limit,
offset,
cursor,
direction,
});
if (!result) {
throw new Error(`Page not found: ${url}`);
}
return result;
}
case 'html': {
const { url, maxLength } = options as { url: string; maxLength?: number };
const result = await getPageHtml(accessor, url, maxLength);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,39 @@ describe('mapFlagsToQueryOptions', () => {
);
});

it('requires --url for inbound-links', () => {
expect(() => mapFlagsToQueryOptions('inbound-links', {})).toThrow(
'--url is required for the inbound-links sub-command',
);
});

it('throws for invalid inbound-links direction', () => {
expect(() =>
mapFlagsToQueryOptions('inbound-links', {
url: 'https://example.com',
direction: 'sideways',
}),
).toThrow('Invalid --direction value');
});

it('returns url/limit/offset/cursor/direction for inbound-links', () => {
expect(
mapFlagsToQueryOptions('inbound-links', {
url: 'https://example.com',
limit: 10,
offset: 20,
cursor: 'abc',
direction: 'prev',
}),
).toEqual({
url: 'https://example.com',
limit: 10,
offset: 20,
cursor: 'abc',
direction: 'prev',
});
});

it('requires --url for html', () => {
expect(() => mapFlagsToQueryOptions('html', {})).toThrow(
'--url is required for the html sub-command',
Expand Down
17 changes: 17 additions & 0 deletions packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ export function mapFlagsToQueryOptions(
}
return { url: flags.url };
}
case 'inbound-links': {
if (!flags.url) {
throw new Error('--url is required for the inbound-links sub-command.');
}
if (flags.direction != null && !['next', 'prev'].includes(flags.direction)) {
throw new Error(
`Invalid --direction value: ${flags.direction}. Must be one of: next, prev`,
);
}
return {
url: flags.url,
limit: flags.limit,
offset: flags.offset,
cursor: flags.cursor,
direction: flags.direction as 'next' | 'prev' | undefined,
};
}
case 'html': {
if (!flags.url) {
throw new Error('--url is required for the html sub-command.');
Expand Down
2 changes: 2 additions & 0 deletions packages/@nitpicker/cli/src/query/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type QuerySubCommand =
| 'summary'
| 'pages'
| 'page-detail'
| 'inbound-links'
| 'html'
| 'links'
| 'resources'
Expand Down Expand Up @@ -40,6 +41,7 @@ export const VALID_SUB_COMMANDS = [
'summary',
'pages',
'page-detail',
'inbound-links',
'html',
'links',
'resources',
Expand Down
27 changes: 14 additions & 13 deletions packages/@nitpicker/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,20 @@ stdio トランスポートで起動し、`.nitpicker` アーカイブを開い

## 主なツール

| ツール | 説明 |
| ------------------- | ----------------------------------------------------------------- |
| `open_archive` | `.nitpicker` ファイルを開き、archiveIdを返す |
| `close_archive` | アーカイブを閉じる |
| `get_summary` | サイト全体の概要統計 |
| `list_pages` | ページ一覧 |
| `get_page_detail` | 指定ページの詳細 |
| `get_page_html` | HTMLスナップショット |
| `list_links` | リンク一覧 |
| `list_resources` | リソース一覧 |
| `list_images` | 画像一覧 |
| `get_violations` | 分析プラグインの違反結果 |
| `list_console_logs` | 捕捉したconsoleログ・ページエラー(内容ごとに全ページ横断で集約) |
| ツール | 説明 |
| -------------------- | ----------------------------------------------------------------- |
| `open_archive` | `.nitpicker` ファイルを開き、archiveIdを返す |
| `close_archive` | アーカイブを閉じる |
| `get_summary` | サイト全体の概要統計 |
| `list_pages` | ページ一覧 |
| `get_page_detail` | 指定ページの詳細 |
| `list_inbound_links` | 指定ページへの被リンク一覧 |
| `get_page_html` | HTMLスナップショット |
| `list_links` | リンク一覧 |
| `list_resources` | リソース一覧 |
| `list_images` | 画像一覧 |
| `get_violations` | 分析プラグインの違反結果 |
| `list_console_logs` | 捕捉したconsoleログ・ページエラー(内容ごとに全ページ横断で集約) |

## 関連リンク

Expand Down
44 changes: 40 additions & 4 deletions packages/@nitpicker/mcp-server/src/mcp-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'node:path';

import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
import { Archive } from '@nitpicker/crawler';
import { buildViewerReadModel } from '@nitpicker/query';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { createServer } from './mcp-server.js';
Expand Down Expand Up @@ -253,6 +254,8 @@ describe('createServer', () => {
],
);

await buildViewerReadModel(archive);

await archive.write();
await archive.close();

Expand All @@ -264,11 +267,12 @@ describe('createServer', () => {
rmSync(workingDir, { recursive: true, force: true });
});

it('ListTools で31個のツールが返される', async () => {
it('ListTools で32個のツールが返される', async () => {
const result = await listTools(server);
expect(result.tools).toHaveLength(31);
expect(result.tools).toHaveLength(32);
const names = result.tools.map((t) => t.name);
expect(names).toContain('open_archive');
expect(names).toContain('list_inbound_links');
expect(names).toContain('close_archive');
expect(names).toContain('get_summary');
expect(names).toContain('list_isolated_clusters');
Expand Down Expand Up @@ -397,8 +401,6 @@ describe('createServer', () => {
expect(data.title).toBe('Home');
expect(data.outboundLinks).toBeDefined();
expect(data.outboundLinks.length).toBe(1);
expect(data.inboundLinks).toBeDefined();
expect(data.inboundLinks.length).toBe(0);
});

it('get_page_detail で存在しないページは "Page not found." を返す', async () => {
Expand All @@ -409,6 +411,40 @@ describe('createServer', () => {
expect(result.content[0]!.text).toBe('Page not found.');
});

it('list_inbound_links でページへの被リンクを取得する', async () => {
const result = await callTool(server, 'list_inbound_links', {
archiveId,
url: 'https://example.com/about',
});
expect(result.isError).toBeUndefined();
const data = JSON.parse(result.content[0]!.text);
expect(data.total).toBe(1);
expect(data.items).toHaveLength(1);
expect(data.items[0].url).toBe('https://example.com');
expect(data.items[0].textContent).toBe('About us');
expect(data.items[0].count).toBe(1);
});

it('list_inbound_links は limit: 0 で件数のみ返す', async () => {
const result = await callTool(server, 'list_inbound_links', {
archiveId,
url: 'https://example.com/about',
limit: 0,
});
expect(result.isError).toBeUndefined();
const data = JSON.parse(result.content[0]!.text);
expect(data.total).toBe(1);
expect(data.items).toHaveLength(0);
});

it('list_inbound_links で存在しないページは "Page not found." を返す', async () => {
const result = await callTool(server, 'list_inbound_links', {
archiveId,
url: 'https://example.com/nonexistent',
});
expect(result.content[0]!.text).toBe('Page not found.');
});

it('get_page_html で HTML スナップショットを取得する', async () => {
const result = await callTool(server, 'get_page_html', {
archiveId,
Expand Down
14 changes: 14 additions & 0 deletions packages/@nitpicker/mcp-server/src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getTagInventory,
getViolations,
listConsoleLogs,
listInboundLinks,
listIsolatedClustersFastPath,
listIsolatedPagesFastPath,
listLinks,
Expand Down Expand Up @@ -256,6 +257,19 @@ export function createServer() {
}
return jsonResult(result);
}
case 'list_inbound_links': {
const accessor = manager.get(requireString(args, 'archiveId'));
const url = requireString(args, 'url');
const result = await listInboundLinks(accessor, {
url,
limit: optionalNumber(args, 'limit'),
cursor: optionalString(args, 'cursor'),
});
if (!result) {
return textResult('Page not found.');
}
return jsonResult(result);
}
case 'get_page_html': {
const accessor = manager.get(requireString(args, 'archiveId'));
const url = requireString(args, 'url');
Expand Down
Loading
Loading