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
parent
79950925a1
commit
82e9ec6cc0
|
|
@ -42,12 +42,17 @@ export interface VisionAnalysisResult {
|
||||||
* Firecrawl v2 returns a GCS URL (not base64). We download it and convert to base64
|
* Firecrawl v2 returns a GCS URL (not base64). We download it and convert to base64
|
||||||
* so Gemini Vision can consume it via inlineData.
|
* so Gemini Vision can consume it via inlineData.
|
||||||
*/
|
*/
|
||||||
|
// Track per-screenshot errors for debugging
|
||||||
|
export const screenshotErrors: string[] = [];
|
||||||
|
|
||||||
async function captureScreenshot(
|
async function captureScreenshot(
|
||||||
url: string,
|
url: string,
|
||||||
firecrawlKey: string,
|
firecrawlKey: string,
|
||||||
): Promise<{ screenshotUrl: string; base64: string } | null> {
|
): Promise<{ screenshotUrl: string; base64: string } | null> {
|
||||||
try {
|
try {
|
||||||
console.log(`[vision] Capturing screenshot: ${url}`);
|
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)
|
// Firecrawl v2: use "screenshot@fullPage" format (no separate screenshotOptions)
|
||||||
const res = await fetchWithRetry(`${FIRECRAWL_BASE}/scrape`, {
|
const res = await fetchWithRetry(`${FIRECRAWL_BASE}/scrape`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -64,13 +69,17 @@ async function captureScreenshot(
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text().catch(() => "");
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const screenshotUrl: string | null = data.data?.screenshot || null;
|
const screenshotUrl: string | null = data.data?.screenshot || null;
|
||||||
if (!screenshotUrl) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,21 +92,32 @@ async function captureScreenshot(
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
});
|
});
|
||||||
if (!imgRes.ok) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const imgBuffer = await imgRes.arrayBuffer();
|
const imgBuffer = await imgRes.arrayBuffer();
|
||||||
const bytes = new Uint8Array(imgBuffer);
|
const bytes = new Uint8Array(imgBuffer);
|
||||||
|
|
||||||
// Use Deno's standard base64 encoding (efficient for large binaries)
|
// Convert to base64 — build binary string in chunks, then encode once
|
||||||
const { encode: encodeBase64 } = await import("https://deno.land/std@0.224.0/encoding/base64.ts");
|
const chunkSize = 8192;
|
||||||
const base64 = encodeBase64(bytes);
|
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)`);
|
console.log(`[vision] Screenshot captured & converted: ${url} (${Math.round(base64.length / 1024)}KB base64, ${Math.round(bytes.length / 1024)}KB raw)`);
|
||||||
|
|
||||||
return { screenshotUrl, base64 };
|
return { screenshotUrl, base64 };
|
||||||
} catch (err) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
||||||
import { PERPLEXITY_MODEL } from "../_shared/config.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";
|
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
|
|
@ -395,17 +395,22 @@ Deno.serve(async (req) => {
|
||||||
channelData.visionPerPage = vision.perPage;
|
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 => ({
|
channelData.screenshots = screenshots.map(ss => ({
|
||||||
id: ss.id,
|
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,
|
channel: ss.channel,
|
||||||
capturedAt: ss.capturedAt,
|
capturedAt: ss.capturedAt,
|
||||||
caption: ss.caption,
|
caption: ss.caption,
|
||||||
sourceUrl: ss.sourceUrl,
|
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}`);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue