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:
76
backend/src/routes/og-scrape.ts
Normal file
76
backend/src/routes/og-scrape.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { scrapeOgData } from '../services/og-scraper.service.js';
|
||||
import { getDb } from '../db/connection.js';
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||
|
||||
export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||
// Preview: Just fetch OG data without downloading
|
||||
app.get('/api/og-preview', async (request, reply) => {
|
||||
const { url } = request.query as { url?: string };
|
||||
if (!url) return reply.status(400).send({ error: 'url parameter required' });
|
||||
|
||||
try {
|
||||
const data = await scrapeOgData(url);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return reply.status(502).send({ error: `Failed to scrape: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Download OG image and attach to recipe
|
||||
app.post('/api/recipes/:id/fetch-image', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { url } = request.body as { url: string };
|
||||
|
||||
if (!url) return reply.status(400).send({ error: 'url required' });
|
||||
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
||||
if (!recipe) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
|
||||
try {
|
||||
// Scrape OG data
|
||||
const ogData = await scrapeOgData(url);
|
||||
if (!ogData.image) return reply.status(404).send({ error: 'No image found at URL' });
|
||||
|
||||
// Download image
|
||||
const imgRes = await fetch(ogData.image, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!imgRes.ok) throw new Error(`Image download failed: ${imgRes.status}`);
|
||||
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||
|
||||
// Process with sharp → WebP, max 1200px wide
|
||||
const imgDir = path.join(DATA_DIR, 'images', 'recipes', id);
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
const imgPath = path.join(imgDir, 'hero.webp');
|
||||
|
||||
await sharp(buffer)
|
||||
.resize(1200, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(imgPath);
|
||||
|
||||
// Update recipe
|
||||
const imageUrl = `/images/recipes/${id}/hero.webp`;
|
||||
db.prepare('UPDATE recipes SET image_url = ?, source_url = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||
.run(imageUrl, url, id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
image_url: imageUrl,
|
||||
og_title: ogData.title,
|
||||
og_description: ogData.description,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return reply.status(502).send({ error: `Failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user