feat: OG image scraper - auto-fetch recipe images from Pinterest/URLs

- New backend service: og-scraper.service.ts (extracts og:image, og:title, og:description)
- Pinterest support via Twitterbot UA (gets original resolution from i.pinimg.com)
- Works with Chefkoch, Allrecipes, blogs, any site with og:image meta tags
- GET /api/og-preview?url= for preview
- POST /api/recipes/:id/fetch-image to download + process with sharp
- Frontend: 'Bild holen' button appears when source URL is filled
- Auto-fills title & description from OG data if empty
- Images processed to WebP, max 1200px wide
This commit is contained in:
clawd
2026-02-18 10:15:18 +00:00
parent ee452efa6a
commit 60ca01fb94
8 changed files with 216 additions and 10 deletions

View File

@@ -72,3 +72,20 @@ export function uploadRecipeImage(id: string, file: File) {
return r.json() as Promise<{ image_url: string }>
})
}
export interface OgData {
image?: string
title?: string
description?: string
}
export function fetchOgPreview(url: string) {
return apiFetch<OgData>(`/og-preview?url=${encodeURIComponent(url)}`)
}
export function fetchImageFromUrl(recipeId: string, url: string) {
return apiFetch<{ ok: boolean; image_url: string; og_title?: string; og_description?: string }>(
`/recipes/${recipeId}/fetch-image`,
{ method: 'POST', body: JSON.stringify({ url }) }
)
}

View File

@@ -2,8 +2,8 @@ import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical } from 'lucide-react'
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage } from '../api/recipes'
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical, Link, Loader2 } from 'lucide-react'
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes'
import { fetchCategories } from '../api/categories'
import type { RecipeFormData } from '../api/recipes'
import type { Ingredient, Step } from '../api/types'
@@ -61,6 +61,7 @@ export function RecipeFormPage() {
{ key: nextKey(), instruction: '' },
])
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [fetchingOg, setFetchingOg] = useState(false)
// Populate form when editing
useEffect(() => {
@@ -331,16 +332,51 @@ export function RecipeFormPage() {
</div>
</div>
{/* Source URL */}
{/* Source URL + OG Fetch */}
<div>
<label className={labelClass}>Quelle (URL)</label>
<input
type="url"
value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)}
placeholder="https://pinterest.com/..."
className={inputClass}
/>
<div className="flex gap-2">
<input
type="url"
value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)}
placeholder="https://pinterest.com/..."
className={`${inputClass} flex-1`}
/>
{sourceUrl.trim() && !imagePreview && (
<button
type="button"
disabled={fetchingOg}
onClick={async () => {
setFetchingOg(true)
try {
const og = await fetchOgPreview(sourceUrl.trim())
if (og.image) {
setImagePreview(og.image)
setImageUrl(og.image)
toast.success('Bild gefunden! 📸')
} else {
toast.error('Kein Bild auf der Seite gefunden')
}
// Auto-fill title & description if empty
if (!title.trim() && og.title) setTitle(og.title)
if (!description.trim() && og.description) setDescription(og.description)
} catch {
toast.error('Konnte die Seite nicht laden')
} finally {
setFetchingOg(false)
}
}}
className="bg-secondary text-white px-4 py-3 rounded-xl font-medium min-h-[44px] min-w-[44px] flex items-center justify-center disabled:opacity-50 whitespace-nowrap gap-2"
>
{fetchingOg ? <Loader2 size={18} className="animate-spin" /> : <Link size={18} />}
{!fetchingOg && <span className="hidden sm:inline text-sm">Bild holen</span>}
</button>
)}
</div>
{sourceUrl.trim() && (
<p className="text-xs text-warm-grey mt-1">💡 Pinterest, Chefkoch, Blogs Bild wird automatisch geholt</p>
)}
</div>
{/* Ingredients */}