fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann

- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:34:58 +02:00
parent 7188c5b958
commit b15c74632b
12 changed files with 307 additions and 112 deletions
@@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
type FetchHtmlResult = {
html: string;
finalUrl: string;
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
ok: boolean;
};
function isPrivateIpv4(address: string): boolean {
@@ -202,7 +204,11 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
}
}
function extractIconFromHtml(html: string, baseUrl: string): string | null {
function extractIconFromHtml(
html: string,
baseUrl: string,
linkTagsOnly = false,
): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -238,6 +244,10 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
if (imageSrc) return imageSrc;
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
if (linkTagsOnly) return null;
const metaImage = metaTags
.map((tag) => parseAttributes(tag))
.map((a) => ({
@@ -266,7 +276,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
*/
async function fetchWithRedirectGuard(
pageUrl: URL,
options: { accept: string; timeoutMs: number; userAgent?: string },
options: {
accept: string;
timeoutMs: number;
userAgent?: string;
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
allowErrorStatus?: boolean;
},
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
let currentUrl = pageUrl;
@@ -298,7 +314,7 @@ async function fetchWithRedirectGuard(
continue;
}
if (!response.ok) return null;
if (!response.ok && !options.allowErrorStatus) return null;
return { response, finalUrl: currentUrl };
} catch {
@@ -315,6 +331,9 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*',
timeoutMs: HTML_FETCH_TIMEOUT_MS,
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
allowErrorStatus: true,
});
if (!result) return null;
@@ -328,6 +347,7 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
};
}
@@ -350,7 +370,10 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback;
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback;
return (
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
fallback
);
} catch {
return fallback;
}