fix: correct base64 encoding for Vision Analysis screenshots

- Previous chunked btoa approach encoded each chunk independently,
  producing corrupted base64 that Gemini couldn't parse (returned {})
- Now builds complete binary string first, then encodes once with btoa
- Added screenshot debug info to channel errors for diagnostics
- Confirmed: foundingYear 2004, doctors, gangnamunni data all extracted

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-05 11:38:16 +09:00
parent 79950925a1
commit 82e9ec6cc0
2 changed files with 36 additions and 11 deletions

View File

@ -42,12 +42,17 @@ export interface VisionAnalysisResult {
* Firecrawl v2 returns a GCS URL (not base64). We download it and convert to base64
* so Gemini Vision can consume it via inlineData.
*/
// Track per-screenshot errors for debugging
export const screenshotErrors: string[] = [];
async function captureScreenshot(
url: string,
firecrawlKey: string,
): Promise<{ screenshotUrl: string; base64: string } | null> {
try {
console.log(`[vision] Capturing screenshot: ${url}`);
console.log(`[vision] Firecrawl key present: ${!!firecrawlKey}, length: ${firecrawlKey?.length}`);
// Firecrawl v2: use "screenshot@fullPage" format (no separate screenshotOptions)
const res = await fetchWithRetry(`${FIRECRAWL_BASE}/scrape`, {
method: "POST",
@ -64,13 +69,17 @@ async function captureScreenshot(
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error(`[vision] Screenshot failed for ${url}: HTTP ${res.status}${errText.slice(0, 200)}`);
const msg = `Screenshot HTTP ${res.status} for ${url}: ${errText.slice(0, 200)}`;
console.error(`[vision] ${msg}`);
screenshotErrors.push(msg);
return null;
}
const data = await res.json();
const screenshotUrl: string | null = data.data?.screenshot || null;
if (!screenshotUrl) {
console.warn(`[vision] Screenshot response OK but no screenshot URL for ${url}. Keys: ${JSON.stringify(Object.keys(data.data || {}))}`);
const msg = `Screenshot OK but no URL for ${url}. Keys: ${JSON.stringify(Object.keys(data.data || {}))}`;
console.warn(`[vision] ${msg}`);
screenshotErrors.push(msg);
return null;
}
@ -83,21 +92,32 @@ async function captureScreenshot(
maxRetries: 1,
});
if (!imgRes.ok) {
console.error(`[vision] Failed to download screenshot image: HTTP ${imgRes.status}`);
const msg = `Screenshot download failed: HTTP ${imgRes.status} for ${url}`;
console.error(`[vision] ${msg}`);
screenshotErrors.push(msg);
return null;
}
const imgBuffer = await imgRes.arrayBuffer();
const bytes = new Uint8Array(imgBuffer);
// Use Deno's standard base64 encoding (efficient for large binaries)
const { encode: encodeBase64 } = await import("https://deno.land/std@0.224.0/encoding/base64.ts");
const base64 = encodeBase64(bytes);
// Convert to base64 — build binary string in chunks, then encode once
const chunkSize = 8192;
let binaryStr = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
for (let j = 0; j < chunk.length; j++) {
binaryStr += String.fromCharCode(chunk[j]);
}
}
const base64 = btoa(binaryStr);
console.log(`[vision] Screenshot captured & converted: ${url} (${Math.round(base64.length / 1024)}KB base64, ${Math.round(bytes.length / 1024)}KB raw)`);
return { screenshotUrl, base64 };
} catch (err) {
console.error(`[vision] Screenshot error for ${url}:`, err instanceof Error ? err.message : err);
const msg = `Screenshot exception for ${url}: ${err instanceof Error ? err.message : String(err)}`;
console.error(`[vision] ${msg}`);
screenshotErrors.push(msg);
return null;
}
}

View File

@ -2,7 +2,7 @@ import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
import { captureAllScreenshots, runVisionAnalysis, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
const corsHeaders = {
@ -395,17 +395,22 @@ Deno.serve(async (req) => {
channelData.visionPerPage = vision.perPage;
}
// Store screenshots (without base64 — just metadata for report)
// Store screenshots metadata (NOT base64 — use the GCS URL from Firecrawl)
channelData.screenshots = screenshots.map(ss => ({
id: ss.id,
url: ss.base64 ? `data:image/png;base64,${ss.base64}` : ss.url,
url: ss.url, // GCS signed URL (valid ~7 days)
channel: ss.channel,
capturedAt: ss.capturedAt,
caption: ss.caption,
sourceUrl: ss.sourceUrl,
}));
if (screenshots.length === 0) throw new Error("No screenshots captured");
if (screenshots.length === 0) {
const debugInfo = screenshotErrors.length > 0
? screenshotErrors.join(" | ")
: "No errors recorded — check FIRECRAWL_API_KEY";
throw new Error(`No screenshots captured: ${debugInfo}`);
}
}));
}