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: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ packages/
npx @nitpicker/cli crawl <URL> [<URL>...] [options] # Web サイトをクロールして .nitpicker ファイルを生成(複数 URL で multi-root)
npx @nitpicker/cli crawl <archive> --append <URL> [--append <URL>...] # 既存アーカイブに新しい起点 URL を追加クロール
npx @nitpicker/cli crawl <archive> --retry-failed [--no-recursive] # 既存アーカイブの失敗ページ(status -1/NULL・content-type NULL・5xx)を再取得
npx @nitpicker/cli crawl <archive> --inventory <urls.txt> # URL リストファイルと既存アーカイブを突合、新規 URL のみ取り込み(孤立ページ・未使用ファイル発見用)
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 を直列実行
Expand All @@ -67,6 +68,8 @@ npx @nitpicker/cli -v | --version # `@nitpicker/cli` の

> **`--append <URL>`**: 位置引数で指定された既存 `.nitpicker` を開き、`--append` の URL を新しい起点として追加クロールする(`--append` は繰り返し指定で複数 URL 可)。新スコープに該当する旧 external ページは internal として再スクレイプされる。失敗時は `<archive>.bak` から自動復元、成功時は `.bak` 削除。`--resume` / `--diff` / `--output` / `--list` / `--list-file` / `--single` との同時指定は不可。

> **`--inventory <urls.txt>`**: 位置引数で指定された既存 `.nitpicker` を開き、URL リストファイル中の **アーカイブにまだ無い URL だけ** を取り込む。HTML は puppeteer でレンダリング + 再帰クロール、非 HTML は HEAD のみで `resources` に直接登録。新規 page/resource には `source` 列に `'inventory-seed'`(リスト直接由来)または `'inventory-discovered'`(seed からのリンク follow / puppeteer サブリソース)がラベリングされる。既存行は touch しない(2 回目以降の inventory pass は非破壊、`inventory-seed` 行が demote されることはない)。スコープ外 URL は警告 skip。失敗時は `<archive>.bak` から自動復元、成功時 `.bak` 削除。`query isolated-pages` / `query unused-resources` の入力データを増やすのが主用途。`--append` / `--retry-failed` / `--resume` / `--diff` / `--output` / `--list` / `--list-file` / `--single` との同時指定は不可。
>
> **`--retry-failed`**: 位置引数で指定された既存 `.nitpicker` を開き、前回クロールで失敗したページだけを pending に戻して再取得する。失敗の定義は `status = -1`(ハード失敗 sentinel)/ `status IS NULL` / `contentType IS NULL` / `status` が 5xx(4xx は確定応答なので対象外)。internal/external 両方が対象で、external は scope 判定により metadata-only として再取得される。実装は append と同じ「再オープン+`.bak`+`Crawler.resume()`+`crawling()`」フローだが、新起点を足す代わりに `Archive.resetFailedPages()`(→ `Database.resetFailedPages`)で失敗ページを `scraped=0` に戻し、archived roots を seed にして失敗ページを resumedPending 経由で処理する(external 失敗ページを scope へ誤登録しないため)。**recursive はフラグ値(デフォルト true)が優先され、アーカイブ作成時の recursive 設定は継承しない**(`crawling(list, { recursive })` で明示注入)。それ以外の設定(scope/excludes/userAgent 等)は archived 設定を流用し、明示指定したフラグのみ上書き。`--resume` / `--append` / `--diff` / `--output` / `--list` / `--list-file` / `--single` との同時指定は不可。
>
> **Note**: 全ページが失敗していた(reset 後に `scraped=1` が 0 件になる)アーカイブでも取りこぼさないよう、`Crawler.start()` の resume 判定は `#resumedScraped` だけでなく `#resumedPending` も見る。`#resumedScraped` のみを見ると「全ページ失敗 retry」や「1ページもスクレイプせず中断した resume」を fresh crawl と誤判定し、pending を全部捨ててしまう。
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ npx @nitpicker/cli crawl existing.nitpicker --retry-failed
- list-mode archive(`--list` / `--list-file` で作成)への append は不可
- `--resume` / `--diff` / `--output` / `--list` / `--list-file` / `--single` と同時指定不可

### `--inventory`: サーバーファイルリストとの突合

サーバー側で取得した URL リストファイル(1 行 1 URL、空行 / `#` コメント可)と既存
`.nitpicker` を突き合わせ、**まだアーカイブに無い URL だけを取り込む** モード。
クロールでは到達できなかった「孤立 LP」「使われていない置きっぱなしファイル」を
浮かび上がらせる用途。

```sh
npx @nitpicker/cli crawl <archive> --inventory <urls.txt>
```

- HTML 応答は puppeteer で描画して再帰クロールに乗せ、新規 page を `'inventory-seed'`、
そこから follow したリンク先を `'inventory-discovered'` のラベルで保存する
- 非 HTML 応答(PDF / 画像 / CSS / JS …)は HEAD のみで `resources` に直接登録、ラベルは `'inventory-seed'`
- 既存 `pages` / `resources` にすでにある URL は skip(2 回目以降の `--inventory` は新規分だけ処理)
- スコープ外 URL は警告して skip
- 結果は `query isolated-pages` / `query unused-resources` で見るのが想定動線(後述)
- `--append` / `--retry-failed` / `--resume` / `--diff` / `--output` / `--list` / `--list-file` / `--single` と同時指定不可

### `--retry-failed`: 失敗ページの再取得

前回クロールで失敗したページだけを再取得する。「サーバ側の一時障害やタイムアウトで取りこぼしたページを、フルクロールし直さずに回収する」ためのモード。
Expand Down
155 changes: 155 additions & 0 deletions packages/@nitpicker/cli/src/commands/crawl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ const mockCrawling = vi.fn();
const mockResume = vi.fn();
const mockAppend = vi.fn();
const mockRetryFailed = vi.fn();
const mockInventory = vi.fn();

vi.mock('@nitpicker/crawler', () => ({
CrawlerOrchestrator: {
crawling: mockCrawling,
resume: mockResume,
append: mockAppend,
retryFailed: mockRetryFailed,
inventory: mockInventory,
},
}));

Expand Down Expand Up @@ -61,6 +63,7 @@ function createFlags(overrides: Partial<CrawlFlags> = {}): CrawlFlags {
resume: undefined,
append: undefined,
retryFailed: undefined,
inventory: undefined,
interval: undefined,
image: true,
fetchExternal: true,
Expand Down Expand Up @@ -115,6 +118,11 @@ function setupFakeOrchestrator() {
return Promise.resolve(fakeOrchestrator);
});

mockInventory.mockImplementation((_path, _urls, _opts, cb) => {
cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' });
return Promise.resolve(fakeOrchestrator);
});

return fakeOrchestrator;
}

Expand Down Expand Up @@ -752,6 +760,153 @@ describe('crawl', () => {

expect(mockLog).toHaveBeenCalledWith('Options: %O', flags);
});

it('--inventory フラグでアーカイブと URL リストを CrawlerOrchestrator.inventory に渡す', async () => {
mockReadList.mockResolvedValueOnce(['https://example.com/hidden']);
const { crawl } = await import('./crawl.js');

await crawl(['/tmp/test.nitpicker'], createFlags({ inventory: '/tmp/urls.txt' }));

expect(mockInventory).toHaveBeenCalledWith(
'/tmp/test.nitpicker',
['https://example.com/hidden'],
expect.any(Object),
expect.any(Function),
);
});

it('--inventory で空ファイルの場合、エラーを投げる', async () => {
mockReadList.mockResolvedValueOnce([]);
const { crawl } = await import('./crawl.js');

await expect(
crawl(['/tmp/test.nitpicker'], createFlags({ inventory: '/tmp/empty.txt' })),
).rejects.toThrow('No URLs found in inventory file: /tmp/empty.txt');
});

it('--inventory に無効な URL が含まれる場合、エラーを投げる', async () => {
mockReadList.mockResolvedValueOnce(['https://example.com', 'not-a-url']);
const { crawl } = await import('./crawl.js');

await expect(
crawl(['/tmp/test.nitpicker'], createFlags({ inventory: '/tmp/urls.txt' })),
).rejects.toThrow('Invalid URL: "not-a-url"');
});

it('--inventory と位置引数なしの場合、エラーを投げる', async () => {
const { crawl } = await import('./crawl.js');

await expect(crawl([], createFlags({ inventory: '/tmp/urls.txt' }))).rejects.toThrow(
'--inventory requires the archive path as the positional argument',
);
});

it('--inventory と複数位置引数の場合、エラーを投げる', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/a.nitpicker', '/tmp/b.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt' }),
),
).rejects.toThrow(
'--inventory takes exactly one positional argument (the archive path).',
);
});

it('--inventory と --append の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({
inventory: '/tmp/urls.txt',
append: ['https://example.com/new'],
}),
),
).rejects.toThrow('--inventory and --append cannot be used together');
});

it('--inventory と --retry-failed の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt', retryFailed: true }),
),
).rejects.toThrow('--inventory and --retry-failed cannot be used together');
});

it('--inventory と --resume の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
[],
createFlags({ inventory: '/tmp/urls.txt', resume: '/tmp/_nitpicker-stub' }),
),
).rejects.toThrow('--resume and --inventory cannot be used together');
});

it('--inventory と --diff の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/a.nitpicker', '/tmp/b.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt', diff: true }),
),
).rejects.toThrow('--diff cannot be combined with --inventory');
});

it('--inventory と --output の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt', output: '/tmp/out.nitpicker' }),
),
).rejects.toThrow('--output flag is not supported with --inventory');
});

it('--inventory と --list-file の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt', listFile: '/tmp/list.txt' }),
),
).rejects.toThrow('--inventory cannot be combined with --list-file');
});

it('--inventory と --list の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({
inventory: '/tmp/urls.txt',
list: ['https://example.com'],
}),
),
).rejects.toThrow('--inventory cannot be combined with --list');
});

it('--inventory と --single の同時指定はエラー', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl(
['/tmp/test.nitpicker'],
createFlags({ inventory: '/tmp/urls.txt', single: true }),
),
).rejects.toThrow('--inventory cannot be combined with --single');
});
});

/** Sentinel error thrown by the process.exit mock to halt execution. */
Expand Down
103 changes: 103 additions & 0 deletions packages/@nitpicker/cli/src/commands/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const commandDef = {
type: 'boolean',
desc: 'Retry crawl: re-fetch failed pages (missing status/content-type or a 5xx status) in the positional archive; use --no-recursive to skip re-crawling newly found URLs',
},
inventory: {
type: 'string',
desc: 'Inventory crawl: take a server-side URL list file and import only URLs that the positional archive does not yet track. HTML responses are rendered + recursively crawled; non-HTML URLs are HEAD-probed and stored directly. Use with `query isolated-pages` / `unused-resources` to surface orphan pages / unused files.',
},
interval: {
type: 'number',
shortFlag: 'I',
Expand Down Expand Up @@ -336,6 +340,57 @@ async function appendCrawl(archivePath: string, newUrls: string[], flags: CrawlF
}
}

/**
* Inventory-mode dispatch: read the URL list file, hand it to
* {@link CrawlerOrchestrator.inventory}, and surface the result through
* the same `run` progress reporter as the other crawl modes.
*
* The URL list file is parsed by `@d-zero/readtext/list`, which strips
* blank lines and `#` comments — same conventions as `--list-file`.
* @param archivePath - Path to the existing `.nitpicker` archive (positional).
* @param listFile - Path to the URL list file passed via `--inventory`.
* @param flags - Parsed CLI flags from the `crawl` command.
*/
async function inventoryCrawl(archivePath: string, listFile: string, flags: CrawlFlags) {
const list = await readList(path.resolve(process.cwd(), listFile));
if (list.length === 0) {
throw new Error(`No URLs found in inventory file: ${listFile}`);
}
validateUrls(list);
const errStack: (CrawlerError | Error)[] = [];

const orchestrator = await CrawlerOrchestrator.inventory(
archivePath,
list,
{
...mapFlagsToCrawlConfig(flags),
list: false,
},
(orchestrator, config) => {
run(
`${archivePath} (inventory: ${listFile})`,
orchestrator,
config,
flags.verbose ? 'verbose' : flags.silent ? 'silent' : 'normal',
).catch((error) => errStack.push(error));
},
);

try {
await orchestrator.write();
} finally {
await orchestrator.archive.close();
orchestrator.garbageCollect();
}

if (errStack.length > 0) {
const error = new CrawlAggregateError(errStack);
// eslint-disable-next-line no-console
console.error(`\n${error.message}`);
throw error;
}
}

/**
* Re-fetch failed pages in an existing `.nitpicker` archive and re-crawl.
*
Expand Down Expand Up @@ -420,6 +475,7 @@ export async function crawl(args: string[], flags: CrawlFlags) {
log('Options: %O', flags);

const hasAppendFlag = !!flags.append && flags.append.length > 0;
const hasInventoryFlag = !!flags.inventory;

if (flags.diff) {
if (hasAppendFlag) {
Expand All @@ -428,6 +484,9 @@ export async function crawl(args: string[], flags: CrawlFlags) {
if (flags.retryFailed) {
throw new Error('--diff cannot be combined with --retry-failed.');
}
if (hasInventoryFlag) {
throw new Error('--diff cannot be combined with --inventory.');
}
if (args.length !== 2) {
throw new Error('--diff takes exactly two file paths to compare');
}
Expand Down Expand Up @@ -463,10 +522,54 @@ export async function crawl(args: string[], flags: CrawlFlags) {
'--resume and --retry-failed cannot be used together. Pick the existing-archive mode that fits your task.',
);
}
if (hasInventoryFlag) {
throw new Error(
'--resume and --inventory cannot be used together. Pick the existing-archive mode that fits your task.',
);
}
await resumeCrawl(flags.resume, flags);
return;
}

if (hasInventoryFlag) {
if (hasAppendFlag) {
throw new Error(
'--inventory and --append cannot be used together. Pick the existing-archive mode that fits your task.',
);
}
if (flags.retryFailed) {
throw new Error(
'--inventory and --retry-failed cannot be used together. Pick the existing-archive mode that fits your task.',
);
}
if (flags.output) {
throw new Error(
'--output flag is not supported with --inventory. The archive path is the positional argument being inventoried.',
);
}
if (flags.listFile) {
throw new Error('--inventory cannot be combined with --list-file.');
}
if (hasListFlag) {
throw new Error('--inventory cannot be combined with --list.');
}
if (flags.single) {
throw new Error('--inventory cannot be combined with --single.');
}
if (args.length === 0) {
throw new Error(
'--inventory requires the archive path as the positional argument (usage: crawl <archive> --inventory <urls.txt>).',
);
}
if (args.length > 1) {
throw new Error(
'--inventory takes exactly one positional argument (the archive path).',
);
}
await inventoryCrawl(args[0]!, flags.inventory!, flags);
return;
}

if (hasAppendFlag) {
if (flags.retryFailed) {
throw new Error(
Expand Down
1 change: 1 addition & 0 deletions packages/@nitpicker/cli/src/commands/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export async function pipeline(args: string[], flags: PipelineFlags) {
resume: undefined,
append: [],
retryFailed: false,
inventory: undefined,
diff: undefined,
});
} catch (error) {
Expand Down
Loading
Loading