diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index d39230504..5117f73d2 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -309,6 +309,35 @@ describe("captionImagesWithGemini — OpenRouter provider", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("classifies non-provider pipeline failures without leaking the local path", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-caption-no-assets-")); + dirs.push(dir); + vi.stubEnv("OPENROUTER_API_KEY", "or-unused-key"); + + const warnings: string[] = []; + let outcome: VisionCaptionOutcome | undefined; + const captions = await captionImagesWithGemini(dir, () => {}, warnings, { + onOutcome: (value) => { + outcome = value; + }, + }); + + expect(captions).toEqual({}); + expect(warnings).toEqual(["OpenRouter captioning failed internally; captions omitted."]); + expect(warnings.join(" ")).not.toContain(dir); + expect(outcome).toEqual({ + timedOutRequests: 0, + failedRequests: 0, + budgetExhausted: false, + internalError: true, + }); + if (!outcome) throw new Error("Expected vision caption outcome"); + expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({ + status: "degraded", + reason: "internal-error", + }); + }); + it("skips captioning entirely when no provider key is present", async () => { const dir = makeProjectWithImages(); dirs.push(dir); diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 7b9a909a6..acea6b11e 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -21,6 +21,8 @@ export interface VisionCaptionOutcome { timedOutRequests: number; failedRequests: number; budgetExhausted: boolean; + /** Failure outside provider request handling (filesystem, module, or programming error). */ + internalError?: boolean; } interface VisionCaptionOptions { @@ -36,11 +38,14 @@ export function resolveVisionPhaseCompletion( | { status: "completed" } | { status: "degraded"; - reason: "budget-exhausted" | "request-timeout" | "provider-error"; + reason: "budget-exhausted" | "request-timeout" | "provider-error" | "internal-error"; } { if (outcome.budgetExhausted || remainingMs <= 0) { return { status: "degraded", reason: "budget-exhausted" }; } + if (outcome.internalError) { + return { status: "degraded", reason: "internal-error" }; + } if (outcome.timedOutRequests > 0) { return { status: "degraded", reason: "request-timeout" }; } @@ -245,12 +250,15 @@ export async function captionImagesWithGemini( let timedOutCount = 0; let failedRequestCount = 0; let budgetExhausted = false; + let internalError = false; const reportOutcome = (): void => { - options.onOutcome?.({ + const outcome: VisionCaptionOutcome = { timedOutRequests: timedOutCount, failedRequests: failedRequestCount, budgetExhausted, - }); + }; + if (internalError) outcome.internalError = true; + options.onOutcome?.(outcome); }; if (options.skipVision) { reportOutcome(); @@ -546,8 +554,8 @@ export async function captionImagesWithGemini( ); } } catch { - failedRequestCount = Math.max(1, failedRequestCount); - warnings.push(`${providerName} captioning failed; captions omitted.`); + internalError = true; + warnings.push(`${providerName} captioning failed internally; captions omitted.`); } reportOutcome(); diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index 15d8e3d4a..b57be6e4a 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -267,7 +267,10 @@ export async function captureWebsite( httpStatus: navigationResponse?.status() ?? null, ...pageContentCheck, }); - if (blockedReason) throw new Error(blockedReason); + if (blockedReason) { + phase("navigation", "degraded", "blocked"); + throw new Error(blockedReason); + } phase("navigation", "completed"); phase("core-extraction", "started"); diff --git a/packages/cli/src/capture/pageBlockDetection.test.ts b/packages/cli/src/capture/pageBlockDetection.test.ts index 0c2cd9f0d..bf4eaef9a 100644 --- a/packages/cli/src/capture/pageBlockDetection.test.ts +++ b/packages/cli/src/capture/pageBlockDetection.test.ts @@ -23,6 +23,16 @@ describe("detectBlockedPage", () => { hasChallengeElement: false, }, }, + { + label: "classic Cloudflare interstitial", + evidence: { + httpStatus: 503, + title: "Attention Required! | Cloudflare", + textLength: 120, + bodyChildCount: 3, + hasChallengeElement: false, + }, + }, ])("rejects a minimal protection page: $label", ({ evidence }) => { expect(detectBlockedPage(evidence)).toMatch(/capture blocked/i); }); diff --git a/packages/cli/src/capture/pageBlockDetection.ts b/packages/cli/src/capture/pageBlockDetection.ts index 435a9c5d3..fc18797f2 100644 --- a/packages/cli/src/capture/pageBlockDetection.ts +++ b/packages/cli/src/capture/pageBlockDetection.ts @@ -16,7 +16,7 @@ export function detectBlockedPage(evidence: PageLoadEvidence): string | undefine const hasBlockedStatus = evidence.httpStatus === 401 || evidence.httpStatus === 403 || evidence.httpStatus === 429; const hasBlockedTitle = - /^(?:(?:error\s*)?(?:401|403|429)(?:\s*(?:[-:—]\s*)?(?:forbidden|unauthorized|access denied|too many requests))?|forbidden|access denied|attention required|just a moment(?:\.{3})?)(?:\s*[|—-]\s*(?:cloudflare|sucuri website firewall))?$/i.test( + /^(?:(?:error\s*)?(?:401|403|429)(?:\s*(?:[-:—]\s*)?(?:forbidden|unauthorized|access denied|too many requests))?|forbidden|access denied|attention required!?|just a moment(?:\.{3})?)(?:\s*[|—-]\s*(?:cloudflare|sucuri website firewall))?$/i.test( evidence.title.trim(), ); diff --git a/packages/cli/src/capture/types.ts b/packages/cli/src/capture/types.ts index 9efda669d..f64b5f318 100644 --- a/packages/cli/src/capture/types.ts +++ b/packages/cli/src/capture/types.ts @@ -26,7 +26,13 @@ export interface CapturePhaseProgress { status: "started" | "completed" | "degraded"; /** Null before the post-navigation budget begins. */ remainingMs: number | null; - reason?: "budget-exhausted" | "disabled" | "request-timeout" | "provider-error"; + reason?: + | "budget-exhausted" + | "disabled" + | "request-timeout" + | "provider-error" + | "internal-error" + | "blocked"; } export interface CaptureOptions {