Image Save Fix — Implementation Plan
Overview
Add a fallback to the Firecrawl scrape path: when scraped.metadata?.ogImage is null (common on WordPress blogs without an SEO plugin), extract the first recipe-relevant <img> from the scraped HTML and use it as the image source. The rest of the pipeline — archiveImage → Supabase Storage → image_url — is unchanged.
Current State Analysis
- Firecrawl returns
metadata.ogImage = nullfor pages without<meta property="og:image">(confirmed live onpolskiekulinaria.pl). src/inngest/functions.ts:116setsogImage = scraped.metadata?.ogImagewith no fallback; when null,archiveImageis never called andimage_urlstays null.- The Blogspot path already has an identical fallback in
src/lib/blogger-feed.ts:56-58: first<img src>from raw post HTML. - Firecrawl is configured with
onlyMainContent: true+BLOG_EXCLUDE_TAGS(nav,header,aside,.related-posts, etc.) so the returned HTML is clean article content — the first<img>is virtually always the recipe’s featured photo.
Desired End State
Recipes from WordPress blogs without og:image (e.g. polskiekulinaria.pl) get their first article image archived to Supabase Storage and stored in image_url, exactly as blogs with og:image do today.
Key Findings
src/lib/recipe-image-archive.tsis the canonical home for image helpers (archiveImage,extractStoragePath) —extractFirstImagebelongs there.- Many WP lazy-load plugins (Jetpack, WP Rocket, Smush) write the real URL to
data-lazy-srcordata-srcand use a 1px placeholder insrc— the regex must check lazy attrs first. onlyMainContent: truein Firecrawl already removes nav/header/sidebar noise, so no need for complex image-size heuristics.
What We Are NOT Doing
- No change to the Blogspot path (
blogger-feed.tsalready has first-image fallback) - No change to
archiveImage,extractStoragePath, or the Storage bucket - No DB schema changes
- No image resizing or additional MIME validation (existing 5 MB + MIME check in
archiveImageapplies) - No filtering beyond URL-pattern skip — no
width/heightattribute parsing
Implementation Approach
- Add
extractFirstImage(html)tosrc/lib/recipe-image-archive.ts— iterates<img>tags, prefersdata-lazy-src > data-src > src, skips gravatar/avatar/logo URLs. - Import it in
src/inngest/functions.tsand wire as a null-coalescing fallback on theogImageassignment (line 116). Import and first usage go in the same Edit (no-unused-imports hook).
Phase 1: Add helper and wire fallback
Required Changes
1. src/lib/recipe-image-archive.ts — add extractFirstImage
Cel: Export a new function that returns the URL of the first recipe-relevant image found in scraped HTML.
Kontrakt: export function extractFirstImage(html: string): string | null
- Iterates all
<img>tags in document order. - For each tag, resolves the URL in priority order:
data-lazy-src→data-src→src(lazy attrs hold the real URL whensrcis a 1px placeholder). - Skips any URL matching
/gravatar|avatar|\/logo/i. - Returns the first non-skipped URL, or
nullif none found.
export function extractFirstImage(html: string): string | null { const imgRegex = /<img\b[^>]+>/gi const SKIP = /gravatar|avatar|\/logo/i let m: RegExpExecArray | null while ((m = imgRegex.exec(html)) !== null) { const tag = m[0] const url = /data-lazy-src=["']([^"']+)["']/i.exec(tag)?.[1] ?? /data-src=["']([^"']+)["']/i.exec(tag)?.[1] ?? /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1] if (url && !SKIP.test(url)) return url } return null}2. src/inngest/functions.ts — import + fallback on line 116
Cel: When Firecrawl returns no og:image, use extractFirstImage(html) as fallback so the image archiving pipeline can still run.
Kontrakt: In the same Edit — add extractFirstImage to the existing import from @/lib/recipe-image-archive (line 6) and replace the ogImage assignment at line 116:
// before (line 116):ogImage = scraped.metadata?.ogImage
// after:ogImage = scraped.metadata?.ogImage ?? extractFirstImage(html) ?? undefinedextractFirstImage returns string | null; the trailing ?? undefined normalises to string | undefined matching the declared type of ogImage.
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript typecheck passes:
npm run typecheck - No lint errors:
npm run lint
Weryfikacja ręczna
- Add
https://polskiekulinaria.pl/tiramisu-przepis-na-klasyczny-wloski-deser/as a new recipe → after Inngest completes,recipes.image_urlis a*.supabase.co/storage/v1/object/public/recipe-images/...URL (not null, not an external URL). - Recipe image displays correctly on recipe card and detail page.
- Adding a recipe from a site with
og:imageis unaffected (path unchanged — fallback never triggers whenog:imageis present). - Blogspot recipe extraction is unaffected (Blogspot path bypasses Firecrawl entirely).
Strategia testowania
Testy manualne
- Dodaj
https://polskiekulinaria.pl/tiramisu-przepis-na-klasyczny-wloski-deser/jako nowy przepis. - Poczekaj na zakończenie Inngest (
recipe/extract). - Sprawdź
recipes.image_urlw Supabase dashboard — powinien być CDN URL (/storage/v1/object/public/recipe-images/…). - Otwórz przepis w aplikacji — zdjęcie powinno się wyświetlić na karcie i stronie szczegółów.
- Dodaj przepis z bloga z
og:image(np. mecooks.pl) i zweryfikuj, że zachowanie jest identyczne jak przed zmianą.
Regesja
- Blogspot URL (np. z
.blogspot.com) — ekstrakcja i obraz bez zmian. - URL bez żadnych
<img>w treści —image_url = null, brak błędu.
Referencje
- Research:
context/changes/image-save-fix/research.md - Wzorzec reużyty z:
src/lib/blogger-feed.ts:56-58 - Helper file:
src/lib/recipe-image-archive.ts - Firecrawl options:
src/lib/firecrawl.ts:30-62
Postęp
Konwencja:
- [ ]oczekujące,- [x]wykonane. Dodaj— <commit sha>, gdy krok zostanie zrealizowany.
Faza 1: Add helper and wire fallback
Automatyczne
- 1.1 TypeScript typecheck passes:
npm run typecheck - 1.2 No lint errors:
npm run lint
Ręczne
- 1.3 polskiekulinaria.pl recipe →
image_urlis Supabase Storage CDN URL after extraction — 0dc5738 - 1.4 Recipe with
og:image— behavior unchanged — 0dc5738 - 1.5 Blogspot recipe — behavior unchanged — 0dc5738