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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,13 @@ crawler/src/

### @nitpicker/cli

`@d-zero/roar` ベースの統合 CLI。4つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。
`@d-zero/roar` ベースの統合 CLI。5つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。

- **`npx @nitpicker/cli crawl <URL>`**: Webサイトをクロールして `.nitpicker` ファイルを生成
- **`npx @nitpicker/cli analyze <file>`**: `.nitpicker` ファイルに対して analyze プラグインを実行。`--search-keywords`, `--axe-lang` 等のフラグで設定ファイルのプラグイン設定を上書き可能(`buildPluginOverrides()` → `Nitpicker.setPluginOverrides()` 経由)
- **`npx @nitpicker/cli report <file>`**: `.nitpicker` ファイルから Google Sheets レポートを生成
- **`npx @nitpicker/cli pipeline <URL>`**: crawl → analyze → report を直列実行。`startCrawl()` でアーカイブパスを取得し、そのパスを `analyze()` と `report()` に引き渡す。`--sheet` 指定時のみ report ステップを実行
- **`npx @nitpicker/cli query <file> <sub-command>`**: `.nitpicker` ファイルに対してクエリを実行し、結果を JSON で出力。`@nitpicker/query` の全関数を CLI から利用可能。12 のサブコマンド(`summary`, `pages`, `page-detail`, `html`, `links`, `resources`, `images`, `violations`, `duplicates`, `mismatches`, `headers`, `resource-referrers`)を提供

---

Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ packages/
## CLI コマンド

```sh
npx @nitpicker/cli crawl <URL> [options] # Web サイトをクロールして .nitpicker ファイルを生成
npx @nitpicker/cli analyze <file> [options] # .nitpicker ファイルに対して analyze プラグインを実行
npx @nitpicker/cli report <file> [options] # .nitpicker ファイルから Google Sheets レポートを生成
npx @nitpicker/cli pipeline <URL> [options] # crawl → analyze → report を直列実行
npx @nitpicker/cli crawl <URL> [options] # Web サイトをクロールして .nitpicker ファイルを生成
npx @nitpicker/cli analyze <file> [options] # .nitpicker ファイルに対して analyze プラグインを実行
npx @nitpicker/cli report <file> [options] # .nitpicker ファイルから Google Sheets レポートを生成
npx @nitpicker/cli pipeline <URL> [options] # crawl → analyze → report を直列実行
npx @nitpicker/cli query <file> <sub-command> [options] # .nitpicker ファイルに対してクエリを実行し JSON 出力
```

## 主要アーキテクチャ
Expand Down
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,101 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --strict

crawl コマンドと同じ終了コード体系に従う。詳細は [crawl の終了コード](#終了コード) を参照。

### Query

`.nitpicker` アーカイブファイルに対してクエリを実行し、結果を JSON で出力する。MCP サーバーと同等のクエリ機能を CLI から利用できる。

```sh
$ npx @nitpicker/cli query <file> <sub-command> [options]
```

#### サブコマンド

| サブコマンド | 説明 | 必須オプション |
| -------------------- | -------------------------------------------------------------- | -------------- |
| `summary` | サイト全体の概要(ページ数、ステータス分布、メタデータ充足率) | なし |
| `pages` | ページ一覧(ステータス・メタデータ欠損・noindex 等で絞り込み) | なし |
| `page-detail` | 特定ページの全詳細(メタデータ、リンク、リダイレクト) | `--url` |
| `html` | ページの HTML スナップショットを取得 | `--url` |
| `links` | リンク分析(broken / external / orphaned) | `--type` |
| `resources` | サブリソース一覧(CSS, JS, 画像、フォント) | なし |
| `images` | 画像一覧(alt 欠損、寸法欠損、オーバーサイズ検出) | なし |
| `violations` | 分析プラグインの違反データ | なし |
| `duplicates` | 重複タイトル・説明の検出 | なし |
| `mismatches` | メタデータ不一致の検出 | `--type` |
| `headers` | セキュリティヘッダーチェック | なし |
| `resource-referrers` | 特定リソースを参照しているページの特定 | `--url` |

#### オプション

| オプション | 値 | デフォルト | 説明 |
| ---------------------- | ------ | ---------- | --------------------------------------------------------------------------------------------------- |
| `--limit` `-l` | 数値 | なし | 最大結果数 |
| `--offset` `-o` | 数値 | なし | スキップする結果数 |
| `--url` | URL | なし | 対象 URL(`page-detail`, `html`, `resource-referrers` で必須) |
| `--status` | 数値 | なし | HTTP ステータスコードで絞り込み |
| `--statusMin` | 数値 | なし | 最小 HTTP ステータスコード(以上) |
| `--statusMax` | 数値 | なし | 最大 HTTP ステータスコード(以下) |
| `--isExternal` | なし | なし | 外部ページのみ表示 |
| `--missingTitle` | なし | なし | title 欠損ページのみ表示 |
| `--missingDescription` | なし | なし | description 欠損ページのみ表示 |
| `--noindex` | なし | なし | noindex ページのみ表示 |
| `--urlPattern` | 文字列 | なし | URL パターンで絞り込み(SQL LIKE パターン) |
| `--directory` | 文字列 | なし | ディレクトリパスプレフィックスで絞り込み |
| `--sortBy` | 文字列 | なし | ソートフィールド(`url`, `status`, `title`) |
| `--sortOrder` | 文字列 | なし | ソート方向(`asc`, `desc`) |
| `--type` | 文字列 | なし | `links`: `broken`, `external`, `orphaned` / `mismatches`: `canonical`, `og:title`, `og:description` |
| `--contentType` | 文字列 | なし | Content-Type プレフィックスで絞り込み(例: `text/css`) |
| `--missingAlt` | なし | なし | alt 属性欠損の画像のみ表示 |
| `--missingDimensions` | なし | なし | 寸法欠損の画像のみ表示 |
| `--oversizedThreshold` | 数値 | なし | 指定寸法を超える画像のみ表示 |
| `--validator` | 文字列 | なし | バリデータ名で絞り込み(例: `axe`, `markuplint`) |
| `--severity` | 文字列 | なし | 重要度で絞り込み |
| `--rule` | 文字列 | なし | ルール ID で絞り込み |
| `--field` | 文字列 | `title` | 重複チェック対象フィールド(`title`, `description`) |
| `--missingOnly` | なし | なし | セキュリティヘッダー欠損ページのみ表示 |
| `--maxLength` | 数値 | なし | 返却する HTML の最大長 |
| `--pretty` | なし | なし | JSON 出力を整形表示 |

> **`--type` フラグの使い分け**: `links` サブコマンドでは `broken`, `external`, `orphaned` のいずれか、`mismatches` サブコマンドでは `canonical`, `og:title`, `og:description` のいずれかを指定する。

#### 例

```sh
# サイト概要を取得
$ npx @nitpicker/cli query site.nitpicker summary

# ページ一覧(ステータスコード 404 で絞り込み)
$ npx @nitpicker/cli query site.nitpicker pages --status 404

# 特定ページの詳細
$ npx @nitpicker/cli query site.nitpicker page-detail --url "https://example.com/about"

# HTML スナップショット取得(最大 10000 文字)
$ npx @nitpicker/cli query site.nitpicker html --url "https://example.com" --maxLength 10000

# リンク切れ一覧
$ npx @nitpicker/cli query site.nitpicker links --type broken

# alt 欠損画像の一覧
$ npx @nitpicker/cli query site.nitpicker images --missingAlt

# アクセシビリティ違反の一覧
$ npx @nitpicker/cli query site.nitpicker violations --validator axe

# 重複タイトルの検出
$ npx @nitpicker/cli query site.nitpicker duplicates --field title

# canonical 不一致の検出
$ npx @nitpicker/cli query site.nitpicker mismatches --type canonical

# セキュリティヘッダー欠損ページ
$ npx @nitpicker/cli query site.nitpicker headers --missingOnly

# 整形出力
$ npx @nitpicker/cli query site.nitpicker summary --pretty
```

### MCP Server

`.nitpicker` アーカイブファイルを AI アシスタント(Claude 等)から直接クエリするための [Model Context Protocol](https://modelcontextprotocol.io/) サーバー。14 のツールを提供し、サイト構造・メタデータ・リンク・リソース・画像・セキュリティヘッダーなどを対話的に分析できる。
Expand Down
1 change: 1 addition & 0 deletions packages/@nitpicker/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@nitpicker/analyze-textlint": "0.5.0",
"@nitpicker/core": "0.5.0",
"@nitpicker/crawler": "0.5.0",
"@nitpicker/query": "0.5.0",
"@nitpicker/report-google-sheets": "0.5.1",
"ansi-colors": "4.1.3",
"debug": "4.4.3",
Expand Down
6 changes: 6 additions & 0 deletions packages/@nitpicker/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parseCli } from '@d-zero/roar';
import { analyze, commandDef as analyzeDef } from './commands/analyze.js';
import { crawl, commandDef as crawlDef } from './commands/crawl.js';
import { pipeline, commandDef as pipelineDef } from './commands/pipeline.js';
import { query, commandDef as queryDef } from './commands/query.js';
import { report, commandDef as reportDef } from './commands/report.js';
import { ExitCode } from './exit-code.js';
import { formatCliError } from './format-cli-error.js';
Expand All @@ -16,6 +17,7 @@ const cli = parseCli({
analyze: analyzeDef,
report: reportDef,
pipeline: pipelineDef,
query: queryDef,
},
onError: () => true,
});
Expand All @@ -38,6 +40,10 @@ try {
await pipeline(cli.args, cli.flags);
break;
}
case 'query': {
await query(cli.args, cli.flags);
break;
}
}
} catch (error) {
formatCliError(error, true);
Expand Down
168 changes: 168 additions & 0 deletions packages/@nitpicker/cli/src/commands/query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import path from 'node:path';

import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';

import { formatCliError as formatCliErrorFn } from '../format-cli-error.js';
import { dispatchQuery as dispatchQueryFn } from '../query/dispatch-query.js';

import { query } from './query.js';

vi.mock('@nitpicker/query', () => ({
ArchiveManager: vi.fn().mockImplementation(function (this: {
open: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}) {
this.open = vi.fn().mockResolvedValue({
archiveId: 'archive_1',
accessor: {},
});
this.close = vi.fn().mockResolvedValue();
}),
}));

vi.mock('../query/dispatch-query.js', () => ({
dispatchQuery: vi
.fn()
.mockResolvedValue({ baseUrl: 'https://example.com', totalPages: 5 }),
}));

vi.mock('../format-cli-error.js', () => ({
formatCliError: vi.fn(),
}));

/** Sentinel error thrown by the process.exit mock to halt execution. */
class ExitError extends Error {
/** The exit code passed to process.exit(). */
readonly code: number;
constructor(code: number) {
super(`process.exit(${code})`);
this.code = code;
}
}

describe('query command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new ExitError(code as number);
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('exits with error when no file path is provided', async () => {
await expect(query([], {} as never)).rejects.toThrow(ExitError);

expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No .nitpicker file specified.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Usage: nitpicker query <file> <sub-command> [options]',
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('exits with error when no sub-command is provided', async () => {
await expect(query(['test.nitpicker'], {} as never)).rejects.toThrow(ExitError);

expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No sub-command specified.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Valid sub-commands:'),
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('exits with error for unknown sub-command', async () => {
await expect(query(['test.nitpicker', 'unknown'], {} as never)).rejects.toThrow(
ExitError,
);

expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Unknown sub-command: unknown');
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('outputs JSON result to stdout on success', async () => {
await query(['test.nitpicker', 'summary'], { pretty: undefined } as never);

expect(dispatchQueryFn).toHaveBeenCalledWith(
expect.anything(),
'summary',
expect.objectContaining({ pretty: undefined }),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }),
);
});

it('exits with error when ArchiveManager.open fails', async () => {
const { ArchiveManager } = await import('@nitpicker/query');
vi.mocked(ArchiveManager).mockImplementationOnce(function (this: {
open: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}) {
this.open = vi.fn().mockRejectedValue(new Error('Failed to open archive'));
this.close = vi.fn().mockResolvedValue();
} as never);

await expect(
query(['test.nitpicker', 'summary'], { pretty: undefined } as never),
).rejects.toThrow(ExitError);

expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('resolves relative file path via process.cwd()', async () => {
const { ArchiveManager } = await import('@nitpicker/query');
await query(['relative/test.nitpicker', 'summary'], { pretty: undefined } as never);

const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value;
expect(managerInstance.open).toHaveBeenCalledWith(
path.resolve(process.cwd(), 'relative/test.nitpicker'),
);
});

it('uses absolute file path as-is', async () => {
const { ArchiveManager } = await import('@nitpicker/query');
await query(['/absolute/test.nitpicker', 'summary'], { pretty: undefined } as never);

const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value;
expect(managerInstance.open).toHaveBeenCalledWith('/absolute/test.nitpicker');
});

it('pretty-prints when --pretty is set', async () => {
await query(['test.nitpicker', 'summary'], { pretty: true } as never);

expect(consoleLogSpy).toHaveBeenCalledWith(
JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }, null, 2),
);
});

it('closes archive after successful query', async () => {
const { ArchiveManager } = await import('@nitpicker/query');
await query(['test.nitpicker', 'summary'], { pretty: undefined } as never);

const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value;
expect(managerInstance.close).toHaveBeenCalledWith('archive_1');
});

it('closes archive even when dispatch throws', async () => {
vi.mocked(dispatchQueryFn).mockRejectedValueOnce(new Error('Query failed'));
const { ArchiveManager } = await import('@nitpicker/query');

await expect(
query(['test.nitpicker', 'summary'], { pretty: undefined } as never),
).rejects.toThrow(ExitError);

const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value;
expect(managerInstance.close).toHaveBeenCalledWith('archive_1');
expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
Loading
Loading