From a10ebefb7594a195ac0d35ab735e9634d75b586b Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sat, 18 Apr 2026 14:34:17 +0200
Subject: [PATCH] fix(favicons): HTML--Parsing vor /favicon.ico
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Emmi kocht einfach (und viele andere WordPress-Seiten) liefert unter
/favicon.ico ein Hoster-Default — Zahnrad-Artige Grafik — während das
eigentliche Site-Icon nur per im
auftaucht.
Jetzt ziehen wir die Icon-Kandidaten erst aus der Homepage, sortieren
nach "sweet spot" 32–192 px und fallen bei Fehlschlag wie bisher auf
/favicon.ico und dann Google s2/favicons zurück.
Migration 011 setzt alle favicon_path=NULL, damit existierende
(falsche) Favicons beim nächsten Start neu geladen werden.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../011_clear_favicon_for_rerun.sql | 8 ++
src/lib/server/domains/favicons.ts | 77 ++++++++++++++++++-
2 files changed, 81 insertions(+), 4 deletions(-)
create mode 100644 src/lib/server/db/migrations/011_clear_favicon_for_rerun.sql
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)}`);
}