feat(logos): Provider-Kette, lokaler Cache und Markenfarben

- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
  erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
  mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
  aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:54:21 +02:00
co-authored by Claude Opus 5
parent b586d27b77
commit 8edb69fe6a
16 changed files with 2039 additions and 7 deletions
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Erzeugt den lokalen simple-icons-Index aus dem npm-Paket.
Das npm-Paket enthält rund 3.500 einzelne SVG-Dateien (etwa 15 MB). Für den
Betrieb genügen Slug, Titel, Markenfarbe, Pfaddaten und Aliasnamen daraus wird
eine einzelne gzip-komprimierte JSON-Datei von etwa 2 MB.
Aufruf: python scripts/vendor_simple_icons.py [--version 16] [--keep-tmp]
"""
import argparse
import gzip
import json
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
TARGET = Path(__file__).resolve().parent.parent / "app" / "assets" / "simple_icons.json.gz"
PATH_PATTERN = re.compile(r'<path\s+d="([^"]+)"')
def download(version: str, workdir: Path) -> Path:
"""Lädt das npm-Paket herunter und entpackt es."""
result = subprocess.run(
["npm", "pack", f"simple-icons@{version}"],
cwd=workdir,
capture_output=True,
text=True,
check=True,
)
archive = workdir / result.stdout.strip().splitlines()[-1]
with tarfile.open(archive) as tar:
tar.extractall(workdir, filter="data")
return workdir / "package"
def collect_aliases(entry: dict) -> list[str]:
"""Sammelt alternative Schreibweisen aus den Metadaten."""
names: list[str] = []
aliases = entry.get("aliases") or {}
for key in ("aka", "alt", "old", "dup"):
value = aliases.get(key)
if isinstance(value, list):
names.extend(item if isinstance(item, str) else item.get("title", "") for item in value)
elif isinstance(value, dict):
names.extend(str(item) for item in value.values())
return [name for name in names if name]
def build(package: Path) -> list[dict]:
"""Baut den kompakten Index aus Metadaten und SVG-Pfaden."""
metadata = json.loads((package / "data" / "simple-icons.json").read_text(encoding="utf-8"))
icons: list[dict] = []
for entry in metadata:
svg_file = package / "icons" / f"{entry['slug']}.svg"
if not svg_file.exists():
print(f" übersprungen (keine SVG-Datei): {entry['slug']}", file=sys.stderr)
continue
match = PATH_PATTERN.search(svg_file.read_text(encoding="utf-8"))
if not match:
print(f" übersprungen (kein Pfad): {entry['slug']}", file=sys.stderr)
continue
icon = {
"s": entry["slug"],
"t": entry["title"],
"h": entry["hex"],
"p": match.group(1),
}
aliases = collect_aliases(entry)
if aliases:
icon["a"] = aliases
icons.append(icon)
return icons
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", default="16", help="npm-Version von simple-icons.")
parser.add_argument("--keep-tmp", action="store_true", help="Arbeitsverzeichnis behalten.")
args = parser.parse_args()
workdir = Path(tempfile.mkdtemp(prefix="simple-icons-"))
try:
print(f"Lade simple-icons@{args.version}")
package = download(args.version, workdir)
version = json.loads((package / "package.json").read_text())["version"]
icons = build(package)
payload = {"version": version, "icons": icons}
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
TARGET.parent.mkdir(parents=True, exist_ok=True)
TARGET.write_bytes(gzip.compress(raw, 9))
print(
f"{len(icons)} Icons aus simple-icons {version} geschrieben nach "
f"{TARGET.relative_to(TARGET.parent.parent.parent)} "
f"({TARGET.stat().st_size / 1_000_000:.2f} MB)."
)
return 0
finally:
if not args.keep_tmp:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())