From 1712263fd1fbd2eda977a4dc9c2ed720cc3bacfa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:31:42 +0200 Subject: [PATCH] feat(search): HQ-Thumbnails durch immer aktive og:image-Extraktion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher: nur Treffer ohne SearXNG-Thumbnail wurden mit dem Seiten-Bild angereichert. Treffer mit Thumbnail behielten das kleine 150-200 px- Bildchen aus dem Such-Engine-Index. Jetzt: Alle Treffer durchlaufen die Enrichment-Pipeline. Wenn die Seite ein og:image/JSON-LD/Content-Bild hat (und das hat sie bei Rezept-Seiten praktisch immer), wird das kleine SearXNG-Thumbnail damit überschrieben. Wenn die Seite kein Bild liefert, bleibt das SearXNG-Thumbnail als Fallback erhalten. Das ist das gleiche Bild, das auch die Vorschau anzeigt — Suchergebnis und Vorschau sind jetzt visuell konsistent. Performance: Pro erster Suche bis zu ~6 Sekunden zusätzliche Latenz (max 6 parallel, je 4 s Timeout). Der 30-min In-Memory-Cache macht Wiederholsuchen instant. Tests (98/98): - Neu: SearXNG-Thumbnail wird durch og:image ersetzt. - Neu: SearXNG-Thumbnail bleibt erhalten, wenn Seite kein Bild hat. - Alt ("leaves existing thumbnails untouched") entfernt — Verhalten hat sich bewusst umgekehrt. --- src/lib/server/search/searxng.ts | 12 ++++--- tests/integration/searxng.test.ts | 55 +++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/lib/server/search/searxng.ts b/src/lib/server/search/searxng.ts index dbea01e..e88dfdb 100644 --- a/src/lib/server/search/searxng.ts +++ b/src/lib/server/search/searxng.ts @@ -198,9 +198,13 @@ async function enrichThumbnail(url: string): Promise { return image; } -async function enrichMissingThumbnails(hits: WebHit[]): Promise { - const queue = hits.filter((h) => !h.thumbnail); - if (queue.length === 0) return; +async function enrichAllThumbnails(hits: WebHit[]): Promise { + // Always fetch the page image even when SearXNG gave us a thumbnail — + // the search engine's thumbnail is typically 150-200px, while og:image + // / JSON-LD image on the page is the full-resolution recipe photo. + // The 30-min URL cache makes repeat searches instant. + if (hits.length === 0) return; + const queue = [...hits]; const LIMIT = 6; const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => { while (queue.length > 0) { @@ -268,7 +272,7 @@ export async function searchWeb( if (hits.length >= limit) break; } if (opts.enrichThumbnails !== false) { - await enrichMissingThumbnails(hits); + await enrichAllThumbnails(hits); } return hits; } diff --git a/tests/integration/searxng.test.ts b/tests/integration/searxng.test.ts index 0b778fc..ccbc069 100644 --- a/tests/integration/searxng.test.ts +++ b/tests/integration/searxng.test.ts @@ -140,19 +140,48 @@ describe('searchWeb', () => { } }); - it('leaves existing thumbnails untouched (no enrichment fetch)', async () => { - const db = openInMemoryForTest(); - addDomain(db, 'chefkoch.de'); - respondWith([ - { - url: 'https://www.chefkoch.de/rezepte/1/x.html', - title: 'X', - thumbnail: 'https://cdn.chefkoch/x.jpg' - } - ]); - // enrichment enabled, but thumbnail is set → no fetch expected, no hang - const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl }); - expect(hits[0].thumbnail).toBe('https://cdn.chefkoch/x.jpg'); + it('upgrades low-res SearXNG thumbnail with HQ og:image from page', async () => { + const pageServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end( + '' + ); + }); + await new Promise((r) => pageServer.listen(0, '127.0.0.1', r)); + const addr = pageServer.address() as AddressInfo; + const pageUrl = `http://127.0.0.1:${addr.port}/dish`; + try { + const db = openInMemoryForTest(); + addDomain(db, '127.0.0.1'); + respondWith([ + { url: pageUrl, title: 'Dish', thumbnail: 'https://searxng-cdn/small-thumb.jpg' } + ]); + const hits = await searchWeb(db, 'dish', { searxngUrl: baseUrl }); + expect(hits[0].thumbnail).toBe('https://cdn.example/hq.jpg'); + } finally { + await new Promise((r) => pageServer.close(() => r())); + } + }); + + it('keeps SearXNG thumbnail when page has no image', async () => { + const pageServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end('no images here'); + }); + await new Promise((r) => pageServer.listen(0, '127.0.0.1', r)); + const addr = pageServer.address() as AddressInfo; + const pageUrl = `http://127.0.0.1:${addr.port}/noimg`; + try { + const db = openInMemoryForTest(); + addDomain(db, '127.0.0.1'); + respondWith([ + { url: pageUrl, title: 'X', thumbnail: 'https://searxng-cdn/fallback.jpg' } + ]); + const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl }); + expect(hits[0].thumbnail).toBe('https://searxng-cdn/fallback.jpg'); + } finally { + await new Promise((r) => pageServer.close(() => r())); + } }); it('filters out forum/magazine/listing URLs', async () => {