From c59c392c5e18bec3fe51fdc1a292054958de4623 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 09:25:43 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20--single=20=E3=83=95=E3=83=A9?= =?UTF-8?q?=E3=82=B0=E3=81=A7=E5=86=8D=E5=B8=B0=E3=82=AF=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E5=81=9C=E6=AD=A2=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --single フラグが CLI 定義に存在するが startCrawl の recursive 判定に 含まれておらず、フラグが無視されていた問題を修正。 isList と同様に flags.single が true の場合は recursive: false を設定する。 Closes #32 https://claude.ai/code/session_01MtQUvLdJfkZGTaQf6ThhSd --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 108 ++++++++++++++++++ packages/@nitpicker/cli/src/commands/crawl.ts | 2 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/@nitpicker/cli/src/commands/crawl.spec.ts diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts new file mode 100644 index 0000000..51eef68 --- /dev/null +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -0,0 +1,108 @@ +import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler'; + +import { beforeEach, describe, it, expect, vi } from 'vitest'; + +const mockCrawling = vi.fn(); +const mockWrite = vi.fn(); +const mockGarbageCollect = vi.fn(); + +vi.mock('@nitpicker/crawler', () => ({ + CrawlerOrchestrator: { + crawling: mockCrawling, + }, +})); + +vi.mock('../crawl/event-assignments.js', () => ({ + eventAssignments: vi.fn().mockResolvedValue(), +})); + +vi.mock('../crawl/debug.js', () => ({ + log: vi.fn(), + verbosely: vi.fn(), +})); + +/** + * Minimal flags matching the shape produced by the CLI parser. + * @param overrides + */ +function createFlags(overrides: Record = {}) { + return { + resume: undefined, + interval: undefined, + image: true, + fetchExternal: true, + parallels: undefined, + recursive: true, + scope: undefined, + exclude: undefined, + excludeKeyword: undefined, + excludeUrl: undefined, + disableQueries: undefined, + imageFileSizeThreshold: undefined, + single: undefined, + maxExcludedDepth: undefined, + retry: 3, + list: undefined, + listFile: undefined, + userAgent: undefined, + ignoreRobots: undefined, + output: undefined, + verbose: undefined, + silent: undefined, + diff: undefined, + ...overrides, + }; +} + +describe('startCrawl', () => { + beforeEach(() => { + vi.clearAllMocks(); + + const fakeOrchestrator = { + write: mockWrite.mockResolvedValue(), + garbageCollect: mockGarbageCollect, + archive: { filePath: '/tmp/test.nitpicker' }, + } as unknown as OrchestratorType; + + mockCrawling.mockImplementation((_urls, _opts, cb) => { + cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' }); + return Promise.resolve(fakeOrchestrator); + }); + }); + + it('--single フラグが true の場合、recursive: false で CrawlerOrchestrator.crawling を呼び出す', async () => { + const { startCrawl } = await import('./crawl.js'); + await startCrawl(['https://example.com'], createFlags({ single: true }) as never); + + expect(mockCrawling).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ recursive: false }), + expect.any(Function), + ); + }); + + it('--single フラグが未指定の場合、recursive はフラグの値がそのまま渡される', async () => { + const { startCrawl } = await import('./crawl.js'); + await startCrawl(['https://example.com'], createFlags({ recursive: true }) as never); + + expect(mockCrawling).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ recursive: true }), + expect.any(Function), + ); + }); + + it('--list モードでも recursive: false になる', async () => { + const { startCrawl } = await import('./crawl.js'); + await startCrawl( + ['https://example.com'], + createFlags({ list: ['https://example.com/a'] }) as never, + ); + + expect(mockCrawling).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ recursive: false, list: true }), + expect.any(Function), + ); + }); +}); diff --git a/packages/@nitpicker/cli/src/commands/crawl.ts b/packages/@nitpicker/cli/src/commands/crawl.ts index 3748a96..75f825d 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.ts @@ -184,7 +184,7 @@ export async function startCrawl(siteUrl: string[], flags: CrawlFlags): Promise< ...mapFlagsToCrawlConfig(flags), filePath: flags.output, list: isList, - recursive: isList ? false : flags.recursive, + recursive: isList || flags.single ? false : flags.recursive, }, (orchestrator, config) => { run( From 195d1e14d6f78e9156de52709ea8bbfc23e86b16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 09:41:56 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test:=20QA=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E4=BA=8B=E9=A0=85=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crawl.spec.ts: vi.resetModules() を追加しモジュールキャッシュの問題を解消 - crawl.spec.ts: as never キャストを型安全な CrawlFlags 型に置換 - crawl.spec.ts: 未使用の mockWrite/mockGarbageCollect トップレベル変数を削除 - crawl.spec.ts: --single と --recursive 同時指定のエッジケーステストを追加 - pipeline.spec.ts: --single フラグが startCrawl に正しく伝搬されるテストを追加 https://claude.ai/code/session_01MtQUvLdJfkZGTaQf6ThhSd --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 35 +++++++++++++------ .../cli/src/commands/pipeline.spec.ts | 15 ++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index 51eef68..58a030b 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -3,8 +3,6 @@ import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler import { beforeEach, describe, it, expect, vi } from 'vitest'; const mockCrawling = vi.fn(); -const mockWrite = vi.fn(); -const mockGarbageCollect = vi.fn(); vi.mock('@nitpicker/crawler', () => ({ CrawlerOrchestrator: { @@ -21,11 +19,13 @@ vi.mock('../crawl/debug.js', () => ({ verbosely: vi.fn(), })); +type CrawlFlags = Parameters[1]; + /** * Minimal flags matching the shape produced by the CLI parser. - * @param overrides + * @param overrides - Flag values to override defaults. */ -function createFlags(overrides: Record = {}) { +function createFlags(overrides: Partial = {}): CrawlFlags { return { resume: undefined, interval: undefined, @@ -51,16 +51,17 @@ function createFlags(overrides: Record = {}) { silent: undefined, diff: undefined, ...overrides, - }; + } as CrawlFlags; } describe('startCrawl', () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); const fakeOrchestrator = { - write: mockWrite.mockResolvedValue(), - garbageCollect: mockGarbageCollect, + write: vi.fn().mockResolvedValue(), + garbageCollect: vi.fn(), archive: { filePath: '/tmp/test.nitpicker' }, } as unknown as OrchestratorType; @@ -72,7 +73,7 @@ describe('startCrawl', () => { it('--single フラグが true の場合、recursive: false で CrawlerOrchestrator.crawling を呼び出す', async () => { const { startCrawl } = await import('./crawl.js'); - await startCrawl(['https://example.com'], createFlags({ single: true }) as never); + await startCrawl(['https://example.com'], createFlags({ single: true })); expect(mockCrawling).toHaveBeenCalledWith( ['https://example.com'], @@ -83,7 +84,7 @@ describe('startCrawl', () => { it('--single フラグが未指定の場合、recursive はフラグの値がそのまま渡される', async () => { const { startCrawl } = await import('./crawl.js'); - await startCrawl(['https://example.com'], createFlags({ recursive: true }) as never); + await startCrawl(['https://example.com'], createFlags({ recursive: true })); expect(mockCrawling).toHaveBeenCalledWith( ['https://example.com'], @@ -92,11 +93,25 @@ describe('startCrawl', () => { ); }); + it('--single と --recursive が同時指定された場合、--single が優先され recursive: false になる', async () => { + const { startCrawl } = await import('./crawl.js'); + await startCrawl( + ['https://example.com'], + createFlags({ single: true, recursive: true }), + ); + + expect(mockCrawling).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ recursive: false }), + expect.any(Function), + ); + }); + it('--list モードでも recursive: false になる', async () => { const { startCrawl } = await import('./crawl.js'); await startCrawl( ['https://example.com'], - createFlags({ list: ['https://example.com/a'] }) as never, + createFlags({ list: ['https://example.com/a'] }), ); expect(mockCrawling).toHaveBeenCalledWith( diff --git a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts index 45759ce..064aabe 100644 --- a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts +++ b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts @@ -237,6 +237,21 @@ describe('pipeline command', () => { expect(reportFn).toHaveBeenCalledWith([archivePath], expect.any(Object)); }); + it('passes --single flag to startCrawl', async () => { + vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker'); + vi.mocked(analyzeFn).mockResolvedValue(); + + await pipeline(['https://example.com'], { + ...defaultFlags, + single: true, + }); + + expect(startCrawlFn).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ single: true }), + ); + }); + it('propagates error when startCrawl rejects', async () => { const crawlError = new Error('Crawl failed'); vi.mocked(startCrawlFn).mockRejectedValue(crawlError); From f16172b03d471b63d3bbf74efd9f5e3c19625112 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 09:50:58 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20--single=20=E3=81=A8=20--list=20?= =?UTF-8?q?=E5=90=8C=E6=99=82=E6=8C=87=E5=AE=9A=E6=99=82=E3=81=AE=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E8=BF=BD=E5=8A=A0=E3=81=A8=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recursive 判定にコメントを追加し、--single が recursive を無効化する 意図を明確化 - --single と --list/--list-file の同時指定時に警告メッセージを出力 - 同時指定シナリオのテストを追加 https://claude.ai/code/session_01MtQUvLdJfkZGTaQf6ThhSd --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 73 ++++++++++++++++--- packages/@nitpicker/cli/src/commands/crawl.ts | 6 ++ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index 58a030b..cd0f8eb 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -1,6 +1,6 @@ import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; const mockCrawling = vi.fn(); @@ -54,21 +54,25 @@ function createFlags(overrides: Partial = {}): CrawlFlags { } as CrawlFlags; } +/** Sets up the fake orchestrator that mockCrawling returns. */ +function setupFakeOrchestrator() { + const fakeOrchestrator = { + write: vi.fn().mockResolvedValue(), + garbageCollect: vi.fn(), + archive: { filePath: '/tmp/test.nitpicker' }, + } as unknown as OrchestratorType; + + mockCrawling.mockImplementation((_urls, _opts, cb) => { + cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' }); + return Promise.resolve(fakeOrchestrator); + }); +} + describe('startCrawl', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); - - const fakeOrchestrator = { - write: vi.fn().mockResolvedValue(), - garbageCollect: vi.fn(), - archive: { filePath: '/tmp/test.nitpicker' }, - } as unknown as OrchestratorType; - - mockCrawling.mockImplementation((_urls, _opts, cb) => { - cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' }); - return Promise.resolve(fakeOrchestrator); - }); + setupFakeOrchestrator(); }); it('--single フラグが true の場合、recursive: false で CrawlerOrchestrator.crawling を呼び出す', async () => { @@ -121,3 +125,48 @@ describe('startCrawl', () => { ); }); }); + +describe('crawl', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + setupFakeOrchestrator(); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('--single と --list を同時指定した場合、警告を出力する', async () => { + const { crawl } = await import('./crawl.js'); + await crawl([], createFlags({ single: true, list: ['https://example.com/a'] })); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Warning: --single is ignored when --list or --list-file is specified.', + ); + }); + + it('--single と --list-file を同時指定した場合、警告を出力する', async () => { + const { crawl } = await import('./crawl.js'); + + vi.mock('@d-zero/readtext/list', () => ({ + readList: vi.fn().mockResolvedValue(['https://example.com/a']), + })); + + await crawl([], createFlags({ single: true, listFile: '/tmp/list.txt' })); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Warning: --single is ignored when --list or --list-file is specified.', + ); + }); + + it('--single のみの場合、警告を出力しない', async () => { + const { crawl } = await import('./crawl.js'); + await crawl(['https://example.com'], createFlags({ single: true })); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@nitpicker/cli/src/commands/crawl.ts b/packages/@nitpicker/cli/src/commands/crawl.ts index 75f825d..85b876a 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.ts @@ -184,6 +184,7 @@ export async function startCrawl(siteUrl: string[], flags: CrawlFlags): Promise< ...mapFlagsToCrawlConfig(flags), filePath: flags.output, list: isList, + // --single(単一ページモード)および --list モードでは再帰クロールを無効化 recursive: isList || flags.single ? false : flags.recursive, }, (orchestrator, config) => { @@ -290,6 +291,11 @@ export async function crawl(args: string[], flags: CrawlFlags) { return; } + if (flags.single && (flags.list?.length || flags.listFile)) { + // eslint-disable-next-line no-console + console.warn('Warning: --single is ignored when --list or --list-file is specified.'); + } + if (flags.listFile) { const list = await readList(path.resolve(process.cwd(), flags.listFile)); flags.list = list; From 38f6bc1e78b7d023825339408e0fc68f4b553b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 09:56:58 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20README=20=E3=81=AB=20--single=20?= =?UTF-8?q?=E3=83=95=E3=83=A9=E3=82=B0=E3=81=AE=E4=BD=BF=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 例セクションに --single の使用例が欠けていたため追加。 https://claude.ai/code/session_01MtQUvLdJfkZGTaQf6ThhSd --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8dacd7c..37e001b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ $ npx @nitpicker/cli crawl https://example.com $ npx @nitpicker/cli crawl https://example.com --interval 5000 $ npx @nitpicker/cli crawl https://example.com --parallels 50 $ npx @nitpicker/cli crawl https://example.com --no-image +$ npx @nitpicker/cli crawl https://example.com --single $ npx @nitpicker/cli crawl https://example.com --no-fetch-external $ npx @nitpicker/cli crawl https://example.com --no-recursive $ npx @nitpicker/cli crawl https://example.com --scope "www.example.com, www3.example.com, https://blog.example.com/blog"