fix(search): enrich missing SearXNG thumbnails with og:image
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s

SearXNG liefert je nach Seite mal ein thumbnail/img_src mit, mal nicht —
bei Chefkoch-Treffern hatten deshalb zufällig die Hälfte der Kacheln
einen Platzhalter, obwohl die Vorschau dann sehr wohl ein Bild fand.

searchWeb() holt jetzt für jeden Treffer ohne Thumbnail parallel
(max. 6 gleichzeitig, 4 s Timeout pro Request) die Seite und extrahiert
das og:image- oder twitter:image-Meta-Tag. Ergebnis wird 30 min
in-memory gecacht, damit wiederholte Suchen nicht wieder die gleichen
Seiten laden.

Tests:
- Neuer Test: Treffer ohne Thumbnail wird via og:image angereichert.
- Neuer Test: Treffer mit Thumbnail bleibt unverändert (keine Fetch).
- Bestehende Tests deaktivieren Enrichment via enrichThumbnails:false,
  damit sie keine echten Chefkoch-URLs aufrufen.
This commit is contained in:
hsiegeln
2026-04-17 17:55:53 +02:00
parent 3cd22544d3
commit 6a784488f5
2 changed files with 97 additions and 6 deletions

View File

@@ -77,10 +77,61 @@ function looksLikeRecipePage(url: string): boolean {
}
}
const OG_IMAGE_RE =
/<meta[^>]+(?:property|name)=["']og:image(?::url)?["'][^>]+content=["']([^"']+)["']/i;
const OG_IMAGE_RE_REV =
/<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']og:image(?::url)?["']/i;
const TWITTER_IMAGE_RE =
/<meta[^>]+(?:property|name)=["']twitter:image["'][^>]+content=["']([^"']+)["']/i;
function extractOgImage(html: string): string | null {
const m = OG_IMAGE_RE.exec(html) ?? OG_IMAGE_RE_REV.exec(html) ?? TWITTER_IMAGE_RE.exec(html);
if (!m) return null;
try {
return new URL(m[1]).toString();
} catch {
return null;
}
}
type ThumbCacheEntry = { image: string | null; expires: number };
const thumbCache = new Map<string, ThumbCacheEntry>();
const THUMB_TTL_MS = 30 * 60 * 1000;
async function enrichThumbnail(url: string): Promise<string | null> {
const now = Date.now();
const cached = thumbCache.get(url);
if (cached && cached.expires > now) return cached.image;
let image: string | null = null;
try {
const html = await fetchText(url, { timeoutMs: 4_000, maxBytes: 256 * 1024 });
image = extractOgImage(html);
} catch {
image = null;
}
thumbCache.set(url, { image, expires: now + THUMB_TTL_MS });
return image;
}
async function enrichMissingThumbnails(hits: WebHit[]): Promise<void> {
const queue = hits.filter((h) => !h.thumbnail);
if (queue.length === 0) return;
const LIMIT = 6;
const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => {
while (queue.length > 0) {
const h = queue.shift();
if (!h) break;
const image = await enrichThumbnail(h.url);
if (image) h.thumbnail = image;
}
});
await Promise.all(workers);
}
export async function searchWeb(
db: Database.Database,
query: string,
opts: { searxngUrl?: string; limit?: number } = {}
opts: { searxngUrl?: string; limit?: number; enrichThumbnails?: boolean } = {}
): Promise<WebHit[]> {
const trimmed = query.trim();
if (!trimmed) return [];
@@ -131,5 +182,8 @@ export async function searchWeb(
});
if (hits.length >= limit) break;
}
if (opts.enrichThumbnails !== false) {
await enrichMissingThumbnails(hits);
}
return hits;
}