diff --git a/src/lib/server/db/migrations/011_clear_favicon_for_rerun.sql b/src/lib/server/db/migrations/011_clear_favicon_for_rerun.sql new file mode 100644 index 0000000..f2eb4dc --- /dev/null +++ b/src/lib/server/db/migrations/011_clear_favicon_for_rerun.sql @@ -0,0 +1,8 @@ +-- Der Favicon-Fetcher versucht ab jetzt zuerst die -Tags +-- aus der Homepage, weil WordPress-Seiten (z.B. Emmi kocht einfach) unter +-- /favicon.ico ein generisches Zahnrad-Default des Hosters ausliefern und +-- das eigentliche Site-Icon erst im auftaucht. Einmalig alle +-- gespeicherten Favicon-Pfade zurücksetzen, damit sie mit der neuen +-- Heuristik neu geladen werden. Alte Dateien bleiben als Orphans im +-- IMAGE_DIR, sind aber harmlos. +UPDATE allowed_domain SET favicon_path = NULL; diff --git a/src/lib/server/domains/favicons.ts b/src/lib/server/domains/favicons.ts index 34bb576..dad1f62 100644 --- a/src/lib/server/domains/favicons.ts +++ b/src/lib/server/domains/favicons.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { fetchBuffer } from '../http'; +import { fetchBuffer, fetchText } from '../http'; import { listDomains, setDomainFavicon } from './repository'; const EXT_BY_CONTENT_TYPE: Record = { @@ -33,14 +33,83 @@ async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: s } } +// Parst -Tags aus dem . WordPress-Seiten liefern +// oft ein generisches /favicon.ico (Zahnrad-Default vom Hoster oder Plugin), +// während das eigentliche Site-Icon per eingebunden ist. +// Darum zuerst den Head durchsehen, nicht blind /favicon.ico nehmen. +type IconLink = { href: string; size: number; isApple: boolean }; + +function extractIconLinks(html: string, baseUrl: string): IconLink[] { + const head = html.slice(0, 300_000); + const icons: IconLink[] = []; + const linkRe = /]*>/gi; + for (const m of head.matchAll(linkRe)) { + const tag = m[0]; + const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i); + if (!relMatch) continue; + const rel = relMatch[1].toLowerCase(); + const isApple = rel.includes('apple-touch-icon'); + if (!isApple && !/\b(shortcut\s+icon|icon)\b/.test(rel)) continue; + const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i); + if (!hrefMatch) continue; + const raw = hrefMatch[1].trim(); + if (!raw || raw.startsWith('data:')) continue; + let href: string; + try { + href = new URL(raw, baseUrl).toString(); + } catch { + continue; + } + let size = 0; + const sizesMatch = tag.match(/\bsizes\s*=\s*["']([^"']+)["']/i); + if (sizesMatch) { + const sm = sizesMatch[1].match(/(\d+)\s*x\s*\d+/i); + if (sm) size = Number(sm[1]); + } + if (!size && isApple) size = 180; + icons.push({ href, size, isApple }); + } + return icons; +} + +// Holt Icon-Kandidaten per HTML-Parse. 32–192 px bevorzugt (für 24×24-Darstellung +// ist das sharp genug, ohne SVG-Wahnsinn); alles außerhalb landet am Ende. +async function resolveIconsFromHtml(domain: string): Promise { + try { + const baseUrl = `https://${domain}/`; + const html = await fetchText(baseUrl, { + timeoutMs: 3_500, + maxBytes: 256 * 1024, + allowTruncate: true + }); + const icons = extractIconLinks(html, baseUrl); + if (icons.length === 0) return []; + const sweet = (s: number) => s >= 32 && s <= 192; + icons.sort((a, b) => { + if (sweet(a.size) && !sweet(b.size)) return -1; + if (!sweet(a.size) && sweet(b.size)) return 1; + return b.size - a.size; + }); + return icons.map((i) => i.href); + } catch { + return []; + } +} + async function fetchFaviconBytes( domain: string ): Promise<{ data: Uint8Array; contentType: string | null } | null> { - // 1. Versuche /favicon.ico direkt (klassisch, funktioniert auf fast allen Seiten). + // 1. Aus der Homepage die -Kandidaten ziehen — das + // ist normalerweise das "echte" Site-Icon, nicht der Hoster-Default. + const htmlIcons = await resolveIconsFromHtml(domain); + for (const url of htmlIcons) { + const got = await tryFetch(url); + if (got) return got; + } + // 2. Klassiker: /favicon.ico. Viele ältere Seiten haben nur den. const direct = await tryFetch(`https://${domain}/favicon.ico`); if (direct) return direct; - // 2. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas und - // geben SVG/PNG in der gewünschten Größe. + // 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas. return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`); }