From 82e9ec6cc0d364950662d4fa654686ead195497b Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Sun, 5 Apr 2026 11:38:16 +0900 Subject: [PATCH] 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 --- supabase/functions/_shared/visionAnalysis.ts | 34 +++++++++++++++---- .../functions/collect-channel-data/index.ts | 13 ++++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/supabase/functions/_shared/visionAnalysis.ts b/supabase/functions/_shared/visionAnalysis.ts index d53dab5..cf23f33 100644 --- a/supabase/functions/_shared/visionAnalysis.ts +++ b/supabase/functions/_shared/visionAnalysis.ts @@ -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; } } diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts index 6c96830..828f6ff 100644 --- a/supabase/functions/collect-channel-data/index.ts +++ b/supabase/functions/collect-channel-data/index.ts @@ -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}`); + } })); }