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:
@@ -12,6 +12,7 @@ from app.api.routes import (
|
||||
auth,
|
||||
budgets,
|
||||
categories,
|
||||
logos,
|
||||
me,
|
||||
merchants,
|
||||
occurrences,
|
||||
@@ -34,6 +35,7 @@ protected = APIRouter(dependencies=[Depends(get_active_user)])
|
||||
protected.include_router(accounts.router)
|
||||
protected.include_router(categories.router)
|
||||
protected.include_router(merchants.router)
|
||||
protected.include_router(logos.router)
|
||||
protected.include_router(recurrences.router)
|
||||
protected.include_router(occurrences.router)
|
||||
protected.include_router(transactions.router)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Auslieferung der zwischengespeicherten Logos.
|
||||
|
||||
Die Dateien kommen ausschließlich von der lokalen Platte; beim Seitenaufruf
|
||||
entsteht kein Zugriff auf fremde Dienste. Der Dateiname ist der Inhaltshash,
|
||||
daher darf unbegrenzt zwischengespeichert werden.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError
|
||||
from app.models import LogoAsset
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.crud import get_or_404
|
||||
from app.services.logos import cache_headers
|
||||
|
||||
router = APIRouter(prefix="/logos", tags=["logos"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}",
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"content": {"image/svg+xml": {}, "image/png": {}, "image/jpeg": {}},
|
||||
"description": "Die Logodatei aus dem lokalen Cache.",
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
|
||||
},
|
||||
summary="Logo ausliefern",
|
||||
description="Liefert die Datei mit `Cache-Control: public, max-age=31536000, immutable`.",
|
||||
)
|
||||
async def read_logo(asset_id: int, request: Request, session: DbSession) -> Response:
|
||||
asset = await get_or_404(session, LogoAsset, asset_id)
|
||||
pfad = settings.logo_storage_dir / asset.file_path
|
||||
if not pfad.exists():
|
||||
raise NotFoundError("Die Logodatei fehlt im Cache.", code="logo_file_missing")
|
||||
|
||||
headers = cache_headers(asset)
|
||||
# Unveränderliche Inhalte: ein passendes ETag beantwortet die Anfrage sofort.
|
||||
if request.headers.get("if-none-match") == headers["ETag"]:
|
||||
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
|
||||
|
||||
return FileResponse(pfad, media_type=asset.mime, headers=headers)
|
||||
@@ -1,16 +1,32 @@
|
||||
"""Firmen und Zahlungsempfänger."""
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Query, UploadFile, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError
|
||||
from app.models import Merchant, Recurrence, Transaction
|
||||
from app.models.enums import LogoStatus
|
||||
from app.schemas.common import ErrorResponse, MessageResponse, Page
|
||||
from app.schemas.merchant import MerchantCreate, MerchantOut, MerchantUpdate
|
||||
from app.schemas.merchant import (
|
||||
LogoCandidateOut,
|
||||
LogoSearchOut,
|
||||
LogoSelectRequest,
|
||||
MerchantCreate,
|
||||
MerchantOut,
|
||||
MerchantUpdate,
|
||||
)
|
||||
from app.services.crud import apply_updates, get_or_404
|
||||
from app.services.logos import (
|
||||
apply_upload,
|
||||
resolve_merchant_logo,
|
||||
resolve_merchant_logo_task,
|
||||
search_candidates,
|
||||
select_candidate,
|
||||
store_candidate,
|
||||
)
|
||||
from app.services.merchants import normalize_name, search_statement
|
||||
|
||||
router = APIRouter(prefix="/merchants", tags=["merchants"])
|
||||
@@ -48,9 +64,12 @@ async def list_merchants(
|
||||
response_model=MerchantOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Firma anlegen",
|
||||
description="Antwortet sofort. Der Logo-Status steht zunächst auf `pending`.",
|
||||
description="Antwortet sofort. Die Logosuche läuft anschließend im Hintergrund; "
|
||||
"der Status wechselt dabei von `pending` auf `resolved`.",
|
||||
)
|
||||
async def create_merchant(payload: MerchantCreate, session: DbSession) -> Merchant:
|
||||
async def create_merchant(
|
||||
payload: MerchantCreate, background: BackgroundTasks, session: DbSession
|
||||
) -> Merchant:
|
||||
merchant = Merchant(
|
||||
name=payload.name,
|
||||
normalized_name=normalize_name(payload.name),
|
||||
@@ -67,6 +86,10 @@ async def create_merchant(payload: MerchantCreate, session: DbSession) -> Mercha
|
||||
await session.rollback()
|
||||
raise ConflictError(f"Die Firma '{payload.name}' ist bereits angelegt.") from exc
|
||||
await session.refresh(merchant)
|
||||
|
||||
if settings.logo_auto_resolve:
|
||||
# Läuft erst nach der Antwort – der Aufrufer wartet nicht auf die Suche.
|
||||
background.add_task(resolve_merchant_logo_task, merchant.id)
|
||||
return merchant
|
||||
|
||||
|
||||
@@ -116,3 +139,126 @@ async def delete_merchant(merchant_id: int, session: DbSession) -> MessageRespon
|
||||
await session.delete(merchant)
|
||||
await session.commit()
|
||||
return MessageResponse(detail="Firma gelöscht.")
|
||||
|
||||
|
||||
# --- Logos ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_candidate_out(asset_id: int, kandidat, mime: str, is_best: bool) -> LogoCandidateOut:
|
||||
return LogoCandidateOut(
|
||||
candidate_id=asset_id,
|
||||
source=kandidat.source,
|
||||
title=kandidat.title,
|
||||
score=kandidat.score,
|
||||
mime=mime,
|
||||
width=kandidat.width,
|
||||
height=kandidat.height,
|
||||
brand_color=kandidat.brand_color,
|
||||
is_preselected=is_best,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{merchant_id}/logo/search",
|
||||
response_model=LogoSearchOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Logos suchen",
|
||||
description="Arbeitet die gesamte Provider-Kette ab und liefert bis zu fünf "
|
||||
"Kandidaten. Alle werden im lokalen Cache abgelegt und sind sofort über "
|
||||
"`/api/logos/{id}` abrufbar. Der beste Treffer ist vorausgewählt. Die Auswahl "
|
||||
"der Firma wird dabei noch nicht verändert.",
|
||||
)
|
||||
async def search_logos(
|
||||
merchant_id: int,
|
||||
session: DbSession,
|
||||
domain: str | None = Query(
|
||||
default=None, description="Überschreibt die hinterlegte Domain für diese Suche."
|
||||
),
|
||||
) -> LogoSearchOut:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
|
||||
if domain is not None:
|
||||
merchant.domain = domain or None
|
||||
|
||||
kandidaten = await search_candidates(merchant.name, merchant.domain)
|
||||
ausgabe: list[LogoCandidateOut] = []
|
||||
for index, kandidat in enumerate(kandidaten):
|
||||
asset = await store_candidate(session, kandidat)
|
||||
ausgabe.append(_to_candidate_out(asset.id, kandidat, asset.mime, index == 0))
|
||||
|
||||
await session.commit()
|
||||
return LogoSearchOut(merchant_id=merchant.id, candidates=ausgabe)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{merchant_id}/logo/select",
|
||||
response_model=MerchantOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Logo auswählen",
|
||||
description="Übernimmt einen Kandidaten aus der Suche. Der Status wechselt auf "
|
||||
"`manual`; die automatische Suche überschreibt die Auswahl danach nicht mehr.",
|
||||
)
|
||||
async def select_logo(merchant_id: int, payload: LogoSelectRequest, session: DbSession) -> Merchant:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
await select_candidate(session, merchant, payload.candidate_id)
|
||||
await session.commit()
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{merchant_id}/logo/upload",
|
||||
response_model=MerchantOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Logo hochladen",
|
||||
description="Nimmt SVG, PNG oder JPEG bis 1 MB entgegen. Der Status wechselt auf "
|
||||
"`manual`, die Markenfarbe wird aus der Datei ermittelt.",
|
||||
)
|
||||
async def upload_logo(
|
||||
merchant_id: int, session: DbSession, file: UploadFile = File(description="Bilddatei.")
|
||||
) -> Merchant:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
inhalt = await file.read()
|
||||
await apply_upload(session, merchant, inhalt, file.content_type)
|
||||
await session.commit()
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{merchant_id}/logo/resolve",
|
||||
response_model=MerchantOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Logosuche erneut anstoßen",
|
||||
description="Sucht und übernimmt den besten Treffer sofort. Mit `force=true` wird "
|
||||
"auch eine manuelle Auswahl ersetzt.",
|
||||
)
|
||||
async def resolve_logo(
|
||||
merchant_id: int,
|
||||
session: DbSession,
|
||||
force: bool = Query(default=False, description="Manuelle Auswahl überschreiben."),
|
||||
) -> Merchant:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
await resolve_merchant_logo(session, merchant, force=force)
|
||||
await session.commit()
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{merchant_id}/logo",
|
||||
response_model=MerchantOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Logo entfernen",
|
||||
description="Löst die Zuordnung und setzt den Status zurück auf `pending`.",
|
||||
)
|
||||
async def remove_logo(merchant_id: int, session: DbSession) -> Merchant:
|
||||
merchant = await get_or_404(session, Merchant, merchant_id)
|
||||
merchant.logo_asset_id = None
|
||||
merchant.logo_source = None
|
||||
merchant.logo_status = LogoStatus.PENDING
|
||||
merchant.brand_color = None
|
||||
merchant.brand_color_dark = None
|
||||
await session.commit()
|
||||
await session.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
Binary file not shown.
@@ -62,6 +62,8 @@ class Settings(BaseSettings):
|
||||
logo_http_timeout_seconds: float = 5.0
|
||||
logo_http_retries: int = 2
|
||||
logo_max_upload_bytes: int = 1_048_576
|
||||
# Steuert, ob nach dem Anlegen einer Firma automatisch im Hintergrund gesucht wird.
|
||||
logo_auto_resolve: bool = True
|
||||
|
||||
# --- Benachrichtigungen --------------------------------------------------
|
||||
notifications_enabled: bool = True
|
||||
|
||||
@@ -45,3 +45,30 @@ class MerchantOut(ApiModel):
|
||||
logo_source: LogoSource | None
|
||||
logo_status: LogoStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LogoCandidateOut(ApiModel):
|
||||
"""Ein zur Auswahl stehendes Logo. Die Datei liegt bereits im lokalen Cache."""
|
||||
|
||||
candidate_id: int = Field(description="ID für /api/logos/{id} und für die Auswahl.")
|
||||
source: LogoSource
|
||||
title: str
|
||||
score: float = Field(
|
||||
ge=0, le=1, description="Trefferwahrscheinlichkeit; der höchste Wert ist vorausgewählt."
|
||||
)
|
||||
mime: str
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
brand_color: str | None = None
|
||||
is_preselected: bool = Field(description="Genau ein Kandidat ist vorausgewählt.")
|
||||
|
||||
|
||||
class LogoSearchOut(ApiModel):
|
||||
"""Ergebnis der Logosuche."""
|
||||
|
||||
merchant_id: int
|
||||
candidates: list[LogoCandidateOut]
|
||||
|
||||
|
||||
class LogoSelectRequest(InputModel):
|
||||
candidate_id: int = Field(description="ID aus der Kandidatenliste.")
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Ermittlung der Markenfarbe aus einem Logo.
|
||||
|
||||
Bei SVG wird die häufigste Nicht-Graustufe aus den `fill`-Attributen genommen,
|
||||
bei Rastergrafiken entscheidet ein k-Means über die Pixel. Zusätzlich wird eine
|
||||
aufgehellte Variante berechnet, die auf dem dunklen Hintergrund der Oberfläche
|
||||
den WCAG-Kontrast von 4,5:1 erreicht.
|
||||
"""
|
||||
|
||||
import colorsys
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hintergrund der dunklen Oberfläche – gegen ihn wird der Kontrast geprüft.
|
||||
DARK_BACKGROUND = "#0f1115"
|
||||
MIN_CONTRAST = 4.5
|
||||
|
||||
# Ab diesem Sättigungswert gilt eine Farbe nicht mehr als Graustufe.
|
||||
MIN_SATURATION = 0.12
|
||||
# Cluster unterhalb dieses Anteils sind Ausreißer und werden verworfen.
|
||||
MIN_CLUSTER_SHARE = 0.05
|
||||
|
||||
# Deterministische Ersatzfarben, wenn ein Logo rein grau ist.
|
||||
FALLBACK_PALETTE = (
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#14b8a6",
|
||||
"#3b82f6",
|
||||
"#6366f1",
|
||||
"#a855f7",
|
||||
"#ec4899",
|
||||
)
|
||||
|
||||
_FILL_PATTERN = re.compile(r'fill\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\))', re.I)
|
||||
_STOP_COLOR_PATTERN = re.compile(r'stop-color\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8})', re.I)
|
||||
_RGB_PATTERN = re.compile(r"rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", re.I)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BrandColors:
|
||||
"""Markenfarbe und die für dunkle Oberflächen aufgehellte Variante."""
|
||||
|
||||
color: str
|
||||
color_dark: str
|
||||
|
||||
|
||||
# --- Umrechnungen --------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_hex(value: str) -> str | None:
|
||||
"""Bringt eine Farbangabe auf `#rrggbb`. Ungültiges ergibt None."""
|
||||
text = value.strip()
|
||||
if not text.startswith("#"):
|
||||
text = f"#{text}"
|
||||
digits = text[1:]
|
||||
if len(digits) in (4, 8): # Alphakanal abschneiden
|
||||
digits = digits[:3] if len(digits) == 4 else digits[:6]
|
||||
if len(digits) == 3:
|
||||
digits = "".join(char * 2 for char in digits)
|
||||
if len(digits) != 6 or not all(char in "0123456789abcdefABCDEF" for char in digits):
|
||||
return None
|
||||
return f"#{digits.lower()}"
|
||||
|
||||
|
||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||||
normalized = normalize_hex(value) or "#000000"
|
||||
return tuple(int(normalized[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
red, green, blue = (max(0, min(255, round(channel))) for channel in rgb)
|
||||
return f"#{red:02x}{green:02x}{blue:02x}"
|
||||
|
||||
|
||||
def relative_luminance(rgb: tuple[int, int, int]) -> float:
|
||||
"""Relative Leuchtdichte nach WCAG 2.1."""
|
||||
channels = []
|
||||
for value in rgb:
|
||||
srgb = value / 255
|
||||
channels.append(srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4)
|
||||
red, green, blue = channels
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
|
||||
|
||||
def contrast_ratio(first: str, second: str) -> float:
|
||||
"""Kontrastverhältnis zweier Farben nach WCAG (1:1 bis 21:1)."""
|
||||
light = relative_luminance(hex_to_rgb(first))
|
||||
dark = relative_luminance(hex_to_rgb(second))
|
||||
if light < dark:
|
||||
light, dark = dark, light
|
||||
return (light + 0.05) / (dark + 0.05)
|
||||
|
||||
|
||||
def saturation_of(rgb: tuple[int, int, int]) -> float:
|
||||
_, _, saturation = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
|
||||
return saturation
|
||||
|
||||
|
||||
def lightness_of(rgb: tuple[int, int, int]) -> float:
|
||||
_, lightness, _ = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
|
||||
return lightness
|
||||
|
||||
|
||||
def is_grayscale(rgb: tuple[int, int, int]) -> bool:
|
||||
return saturation_of(rgb) < MIN_SATURATION
|
||||
|
||||
|
||||
def lighten_for_dark_background(color: str, background: str = DARK_BACKGROUND) -> str:
|
||||
"""Hellt eine Farbe im HSL-Raum auf, bis der Kontrast mindestens 4,5:1 beträgt."""
|
||||
normalized = normalize_hex(color)
|
||||
if normalized is None:
|
||||
return "#e2e8f0"
|
||||
if contrast_ratio(normalized, background) >= MIN_CONTRAST:
|
||||
return normalized
|
||||
|
||||
hue, lightness, saturation = colorsys.rgb_to_hls(
|
||||
*(channel / 255 for channel in hex_to_rgb(normalized))
|
||||
)
|
||||
step = 0.02
|
||||
while lightness < 0.98:
|
||||
lightness = min(lightness + step, 0.98)
|
||||
channels = colorsys.hls_to_rgb(hue, lightness, saturation)
|
||||
candidate = rgb_to_hex(tuple(round(channel * 255) for channel in channels))
|
||||
if contrast_ratio(candidate, background) >= MIN_CONTRAST:
|
||||
return candidate
|
||||
# Selbst bei maximaler Helligkeit nicht erreichbar (sehr dunkler Farbton): neutral aufhellen.
|
||||
return "#e2e8f0"
|
||||
|
||||
|
||||
def deterministic_color(name: str) -> str:
|
||||
"""Feste Farbe aus dem Namens-Hash – gleicher Name ergibt immer dieselbe Farbe."""
|
||||
digest = hashlib.sha256(name.strip().lower().encode("utf-8")).digest()
|
||||
return FALLBACK_PALETTE[digest[0] % len(FALLBACK_PALETTE)]
|
||||
|
||||
|
||||
def brand_colors(color: str | None, *, fallback_name: str = "") -> BrandColors:
|
||||
"""Baut das Farbpaar; ohne brauchbare Farbe greift der Namens-Hash."""
|
||||
normalized = normalize_hex(color) if color else None
|
||||
if normalized is None:
|
||||
normalized = deterministic_color(fallback_name)
|
||||
return BrandColors(color=normalized, color_dark=lighten_for_dark_background(normalized))
|
||||
|
||||
|
||||
# --- SVG -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def color_from_svg(content: bytes) -> str | None:
|
||||
"""Häufigste Nicht-Graustufe aus `fill`-Attributen und Verlaufsstopps."""
|
||||
try:
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
except Exception: # pragma: no cover - decode mit errors="ignore" wirft nicht
|
||||
return None
|
||||
|
||||
counter: Counter[str] = Counter()
|
||||
for match in _FILL_PATTERN.finditer(text):
|
||||
value = match.group(1)
|
||||
if value.lower().startswith("rgb("):
|
||||
numbers = _RGB_PATTERN.match(value)
|
||||
if numbers is None:
|
||||
continue
|
||||
value = rgb_to_hex(tuple(int(part) for part in numbers.groups())) # type: ignore[arg-type]
|
||||
normalized = normalize_hex(value)
|
||||
if normalized and not is_grayscale(hex_to_rgb(normalized)):
|
||||
counter[normalized] += 1
|
||||
|
||||
for match in _STOP_COLOR_PATTERN.finditer(text):
|
||||
normalized = normalize_hex(match.group(1))
|
||||
if normalized and not is_grayscale(hex_to_rgb(normalized)):
|
||||
counter[normalized] += 1
|
||||
|
||||
if not counter:
|
||||
return None
|
||||
return counter.most_common(1)[0][0]
|
||||
|
||||
|
||||
# --- Rastergrafiken ------------------------------------------------------------
|
||||
|
||||
|
||||
def _kmeans(pixels: list[tuple[int, int, int]], k: int = 4, iterations: int = 20):
|
||||
"""Schlanker k-Means über RGB-Tripel. Liefert (Zentrum, Anzahl) je Cluster."""
|
||||
if not pixels:
|
||||
return []
|
||||
unique = list(dict.fromkeys(pixels))
|
||||
if len(unique) <= k:
|
||||
counts = Counter(pixels)
|
||||
return [(color, counts[color]) for color in unique]
|
||||
|
||||
# Deterministische Startpunkte: gleichmäßig über die sortierten Farben verteilt.
|
||||
ordered = sorted(unique, key=lambda rgb: (relative_luminance(rgb), rgb))
|
||||
centers = [ordered[round(index * (len(ordered) - 1) / (k - 1))] for index in range(k)]
|
||||
|
||||
for _ in range(iterations):
|
||||
buckets: list[list[tuple[int, int, int]]] = [[] for _ in centers]
|
||||
for pixel in pixels:
|
||||
best = min(
|
||||
range(len(centers)),
|
||||
key=lambda index: sum(
|
||||
(pixel[channel] - centers[index][channel]) ** 2 for channel in range(3)
|
||||
),
|
||||
)
|
||||
buckets[best].append(pixel)
|
||||
|
||||
moved = False
|
||||
for index, bucket in enumerate(buckets):
|
||||
if not bucket:
|
||||
continue
|
||||
center = tuple(round(sum(p[c] for p in bucket) / len(bucket)) for c in range(3))
|
||||
if center != centers[index]:
|
||||
centers[index] = center # type: ignore[call-overload]
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
result = [(centers[index], len(bucket)) for index, bucket in enumerate(buckets) if bucket]
|
||||
return sorted(result, key=lambda item: item[1], reverse=True)
|
||||
|
||||
|
||||
def color_from_raster(content: bytes) -> str | None:
|
||||
"""Dominante Farbe einer Rastergrafik über k-Means (k=4) auf 64×64 Pixeln."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as image:
|
||||
image = image.convert("RGBA")
|
||||
image.thumbnail((64, 64), Image.Resampling.LANCZOS)
|
||||
pixels = [
|
||||
(red, green, blue)
|
||||
for red, green, blue, alpha in image.getdata()
|
||||
if alpha >= 128 # durchscheinende Ränder verfälschen die Farbe
|
||||
]
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
logger.debug("Rastergrafik konnte nicht gelesen werden.", exc_info=True)
|
||||
return None
|
||||
|
||||
if not pixels:
|
||||
return None
|
||||
|
||||
clusters = _kmeans(pixels, k=4)
|
||||
total = sum(count for _, count in clusters)
|
||||
if not total:
|
||||
return None
|
||||
|
||||
# Nur Cluster mit nennenswertem Anteil, davon der sattteste; bei ähnlicher
|
||||
# Sättigung gewinnt der hellere.
|
||||
relevant = [
|
||||
(color, count) for color, count in clusters if count / total >= MIN_CLUSTER_SHARE
|
||||
] or clusters
|
||||
farbig = [(color, count) for color, count in relevant if not is_grayscale(color)]
|
||||
if not farbig:
|
||||
return None
|
||||
|
||||
best = max(farbig, key=lambda item: saturation_of(item[0]) * 2 + lightness_of(item[0]))
|
||||
return rgb_to_hex(best[0])
|
||||
|
||||
|
||||
def extract_color(content: bytes, mime: str) -> str | None:
|
||||
"""Ermittelt die Markenfarbe passend zum Dateityp."""
|
||||
if mime == "image/svg+xml":
|
||||
return color_from_svg(content)
|
||||
return color_from_raster(content)
|
||||
@@ -0,0 +1,782 @@
|
||||
"""Logo-Service: Provider-Kette, Cache und Markenfarbe.
|
||||
|
||||
Die Kette arbeitet Provider der Reihe nach ab und sammelt Kandidaten ein. Sie
|
||||
endet immer beim generierten Buchstaben-Avatar, damit jede Firma ein Bild hat.
|
||||
Ausgeliefert werden Logos ausschließlich lokal über `/api/logos/{id}`; im
|
||||
Seitenaufruf entsteht kein Zugriff nach außen.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import utcnow
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import LogoAsset, Merchant
|
||||
from app.models.enums import LogoSource, LogoStatus
|
||||
from app.services.colors import brand_colors, deterministic_color, extract_color
|
||||
from app.services.merchants import domain_from_name, normalize_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||
SIMPLE_ICONS_FILE = ASSETS_DIR / "simple_icons.json.gz"
|
||||
|
||||
SVG_MIME = "image/svg+xml"
|
||||
ALLOWED_UPLOAD_MIMES = {SVG_MIME, "image/png", "image/jpeg"}
|
||||
MAX_CANDIDATES = 5
|
||||
# Ab diesem Wert gilt ein Treffer als eindeutig; die automatische Suche bricht dann ab.
|
||||
EXACT_MATCH_SCORE = 0.95
|
||||
# Auch heruntergeladene Bilder werden begrenzt – ein Logo ist nie groß.
|
||||
MAX_DOWNLOAD_BYTES = 1_048_576
|
||||
|
||||
_DOMAIN_PATTERN = re.compile(r"^(?:https?://)?(?:www\.)?([a-z0-9.-]+\.[a-z]{2,})", re.I)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogoCandidate:
|
||||
"""Ein gefundenes Logo, noch nicht gespeichert."""
|
||||
|
||||
source: LogoSource
|
||||
content: bytes
|
||||
mime: str
|
||||
title: str
|
||||
score: float
|
||||
"""0..1 – je höher, desto sicherer der Treffer. Bestimmt die Vorauswahl."""
|
||||
brand_color: str | None = None
|
||||
source_url: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(self.content).hexdigest()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LogoProvider(Protocol):
|
||||
"""Ein Anbieter von Logo-Kandidaten."""
|
||||
|
||||
source: LogoSource
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""False, wenn etwa der API-Schlüssel fehlt – der Provider wird dann übersprungen."""
|
||||
...
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]: ...
|
||||
|
||||
|
||||
# --- Hilfsfunktionen -----------------------------------------------------------
|
||||
|
||||
|
||||
def extract_domain(value: str | None) -> str | None:
|
||||
"""Reduziert eine Eingabe wie `https://www.netflix.com/de/` auf `netflix.com`."""
|
||||
if not value:
|
||||
return None
|
||||
match = _DOMAIN_PATTERN.match(value.strip())
|
||||
return match.group(1).lower() if match else None
|
||||
|
||||
|
||||
def detect_mime(content: bytes, declared: str | None = None) -> str | None:
|
||||
"""Bestimmt den Bildtyp allein aus dem Inhalt.
|
||||
|
||||
Die vom Aufrufer gemeldete Kopfzeile wird bewusst ignoriert: sonst könnte eine
|
||||
beliebige Datei als Bild hochgeladen und später als solches ausgeliefert werden.
|
||||
Der Parameter bleibt für die Protokollierung erhalten.
|
||||
"""
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
# SVG ist Text und darf mit Kommentaren, BOM oder DOCTYPE beginnen.
|
||||
kopf = content[:2048].lstrip().lstrip(b"\xef\xbb\xbf")
|
||||
if kopf.startswith((b"<svg", b"<?xml", b"<!--", b"<!DOCTYPE")) and b"<svg" in kopf:
|
||||
return SVG_MIME
|
||||
if declared:
|
||||
logger.debug("Unbekannter Bildinhalt trotz gemeldetem Typ %s.", declared)
|
||||
return None
|
||||
|
||||
|
||||
def image_size(content: bytes, mime: str) -> tuple[int | None, int | None]:
|
||||
"""Bildmaße, soweit ermittelbar. SVG ist skalierbar und liefert nichts."""
|
||||
if mime == SVG_MIME:
|
||||
return None, None
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as image:
|
||||
return image.width, image.height
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
async def fetch(client: httpx.AsyncClient, url: str, **kwargs) -> httpx.Response | None:
|
||||
"""Holt eine URL mit Wiederholungen. Fehler werden protokolliert, nie geworfen."""
|
||||
for attempt in range(settings.logo_http_retries + 1):
|
||||
try:
|
||||
response = await client.get(url, **kwargs)
|
||||
if response.status_code == 200:
|
||||
return response
|
||||
# Bei 4xx lohnt kein zweiter Versuch.
|
||||
if 400 <= response.status_code < 500:
|
||||
logger.debug("Logo-Provider antwortete %s für %s", response.status_code, url)
|
||||
return None
|
||||
logger.debug("Logo-Provider antwortete %s für %s", response.status_code, url)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.debug("Logo-Abruf fehlgeschlagen (%s): %s", url, exc)
|
||||
if attempt < settings.logo_http_retries:
|
||||
await asyncio.sleep(0.2 * (attempt + 1))
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_from_response(
|
||||
response: httpx.Response, source: LogoSource, title: str, score: float
|
||||
) -> LogoCandidate | None:
|
||||
"""Baut aus einer Antwort einen Kandidaten, sofern der Inhalt ein Bild ist."""
|
||||
content = response.content
|
||||
if not content or len(content) > MAX_DOWNLOAD_BYTES:
|
||||
return None
|
||||
mime = detect_mime(content, response.headers.get("content-type"))
|
||||
if mime is None:
|
||||
return None
|
||||
width, height = image_size(content, mime)
|
||||
return LogoCandidate(
|
||||
source=source,
|
||||
content=content,
|
||||
mime=mime,
|
||||
title=title,
|
||||
score=score,
|
||||
brand_color=extract_color(content, mime),
|
||||
source_url=str(response.url),
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
|
||||
# --- Provider 1: simple-icons (lokal, offline) ---------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimpleIcon:
|
||||
slug: str
|
||||
title: str
|
||||
hex_color: str
|
||||
path: str
|
||||
aliases: tuple[str, ...] = ()
|
||||
|
||||
def to_svg(self) -> bytes:
|
||||
"""Erzeugt ein eigenständiges SVG in der Markenfarbe."""
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" '
|
||||
f'fill="#{self.hex_color.lower()}"><title>{_escape(self.title)}</title>'
|
||||
f'<path d="{self.path}"/></svg>'
|
||||
).encode()
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimpleIconIndex:
|
||||
"""Nachschlagewerk über Slug, Titel und Aliasnamen."""
|
||||
|
||||
version: str
|
||||
icons: list[SimpleIcon]
|
||||
by_key: dict[str, SimpleIcon] = field(default_factory=dict)
|
||||
|
||||
def lookup(self, key: str) -> SimpleIcon | None:
|
||||
return self.by_key.get(key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def simple_icon_index() -> SimpleIconIndex:
|
||||
"""Lädt den gevendorten Index einmalig in den Speicher."""
|
||||
if not SIMPLE_ICONS_FILE.exists():
|
||||
logger.warning(
|
||||
"simple-icons-Index fehlt (%s). Anlegen mit: make vendor-icons", SIMPLE_ICONS_FILE
|
||||
)
|
||||
return SimpleIconIndex(version="", icons=[])
|
||||
|
||||
payload = json.loads(gzip.decompress(SIMPLE_ICONS_FILE.read_bytes()).decode("utf-8"))
|
||||
icons = [
|
||||
SimpleIcon(
|
||||
slug=entry["s"],
|
||||
title=entry["t"],
|
||||
hex_color=entry["h"],
|
||||
path=entry["p"],
|
||||
aliases=tuple(entry.get("a", ())),
|
||||
)
|
||||
for entry in payload["icons"]
|
||||
]
|
||||
|
||||
index = SimpleIconIndex(version=payload.get("version", ""), icons=icons)
|
||||
for icon in icons:
|
||||
# Der erste Treffer gewinnt, damit Aliasnamen echte Titel nicht verdrängen.
|
||||
for key in (normalize_name(icon.title), icon.slug):
|
||||
index.by_key.setdefault(key, icon)
|
||||
for icon in icons:
|
||||
for alias in icon.aliases:
|
||||
index.by_key.setdefault(normalize_name(alias), icon)
|
||||
return index
|
||||
|
||||
|
||||
class SimpleIconsProvider:
|
||||
"""Lokaler Markenkatalog. Braucht keinen Schlüssel und keinen Netzzugriff."""
|
||||
|
||||
source = LogoSource.SIMPLE_ICONS
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(simple_icon_index().icons)
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]:
|
||||
index = simple_icon_index()
|
||||
treffer: list[tuple[SimpleIcon, float]] = []
|
||||
|
||||
exakt = index.lookup(normalize_name(name))
|
||||
if exakt is not None:
|
||||
treffer.append((exakt, 1.0))
|
||||
|
||||
if domain:
|
||||
# "netflix.com" -> "netflix"
|
||||
aus_domain = index.lookup(normalize_name(domain.rsplit(".", 1)[0]))
|
||||
if aus_domain is not None and all(icon is not aus_domain for icon, _ in treffer):
|
||||
treffer.append((aus_domain, 0.95))
|
||||
|
||||
if not treffer:
|
||||
treffer.extend(self._fuzzy(index, name))
|
||||
|
||||
return [
|
||||
LogoCandidate(
|
||||
source=self.source,
|
||||
content=icon.to_svg(),
|
||||
mime=SVG_MIME,
|
||||
title=icon.title,
|
||||
score=score,
|
||||
brand_color=f"#{icon.hex_color.lower()}",
|
||||
source_url=None,
|
||||
)
|
||||
for icon, score in treffer[:MAX_CANDIDATES]
|
||||
]
|
||||
|
||||
def _fuzzy(self, index: SimpleIconIndex, name: str) -> list[tuple[SimpleIcon, float]]:
|
||||
"""Teiltreffer, wenn der Name nicht exakt passt – etwa 'Netflix Standard'."""
|
||||
gesucht = normalize_name(name)
|
||||
if len(gesucht) < 3:
|
||||
return []
|
||||
|
||||
ergebnisse: list[tuple[SimpleIcon, float]] = []
|
||||
for key, icon in index.by_key.items():
|
||||
if len(key) < 3:
|
||||
continue
|
||||
if gesucht.startswith(key) or key.startswith(gesucht):
|
||||
# Je größer die Überschneidung, desto sicherer der Treffer.
|
||||
anteil = min(len(key), len(gesucht)) / max(len(key), len(gesucht))
|
||||
ergebnisse.append((icon, 0.5 + 0.35 * anteil))
|
||||
|
||||
ergebnisse.sort(key=lambda item: item[1], reverse=True)
|
||||
gesehen: set[str] = set()
|
||||
eindeutig = []
|
||||
for icon, score in ergebnisse:
|
||||
if icon.slug in gesehen:
|
||||
continue
|
||||
gesehen.add(icon.slug)
|
||||
eindeutig.append((icon, score))
|
||||
return eindeutig[:3]
|
||||
|
||||
|
||||
# --- Provider 2: logo.dev ------------------------------------------------------
|
||||
|
||||
|
||||
class LogoDevProvider:
|
||||
"""https://img.logo.dev – Abruf über Domain oder Firmenname.
|
||||
|
||||
Nur aktiv, wenn `LOGODEV_API_KEY` gesetzt ist. Der Schlüssel ist ein
|
||||
veröffentlichbarer Token und wird als Query-Parameter übergeben.
|
||||
"""
|
||||
|
||||
source = LogoSource.LOGODEV
|
||||
base_url = "https://img.logo.dev"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(settings.logodev_api_key)
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]:
|
||||
if client is None or not self.is_available():
|
||||
return []
|
||||
|
||||
params = {
|
||||
"token": settings.logodev_api_key,
|
||||
"size": "256",
|
||||
"format": "png",
|
||||
# Ohne Treffer liefert logo.dev sonst ein generiertes Monogramm –
|
||||
# das erledigt der eigene Avatar besser.
|
||||
"fallback": "404",
|
||||
}
|
||||
versuche: list[tuple[str, float]] = []
|
||||
if domain:
|
||||
versuche.append((f"{self.base_url}/{domain}", 0.9))
|
||||
slug = normalize_name(name)
|
||||
if slug:
|
||||
versuche.append((f"{self.base_url}/name/{slug}", 0.7))
|
||||
|
||||
kandidaten = []
|
||||
for url, score in versuche:
|
||||
response = await fetch(client, url, params=params)
|
||||
if response is None:
|
||||
continue
|
||||
kandidat = _candidate_from_response(response, self.source, name, score)
|
||||
if kandidat is not None:
|
||||
kandidaten.append(kandidat)
|
||||
return kandidaten
|
||||
|
||||
|
||||
# --- Provider 3: Brandfetch ----------------------------------------------------
|
||||
|
||||
|
||||
class BrandfetchProvider:
|
||||
"""https://api.brandfetch.io/v2/brands/{domain} mit Bearer-Token.
|
||||
|
||||
Nur aktiv, wenn `BRANDFETCH_API_KEY` gesetzt ist **und** eine Domain bekannt
|
||||
ist – die Brand-API schlägt ausschließlich über Kennungen nach.
|
||||
"""
|
||||
|
||||
source = LogoSource.BRANDFETCH
|
||||
base_url = "https://api.brandfetch.io/v2/brands"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(settings.brandfetch_api_key)
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]:
|
||||
if client is None or not self.is_available() or not domain:
|
||||
return []
|
||||
|
||||
response = await fetch(
|
||||
client,
|
||||
f"{self.base_url}/{domain}",
|
||||
headers={"Authorization": f"Bearer {settings.brandfetch_api_key}"},
|
||||
)
|
||||
if response is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
logger.debug("Brandfetch lieferte kein JSON für %s", domain)
|
||||
return []
|
||||
|
||||
marke = payload.get("name") or name
|
||||
farbe = self._primary_color(payload)
|
||||
|
||||
kandidaten: list[LogoCandidate] = []
|
||||
for url, score in self._logo_urls(payload)[:2]:
|
||||
bild = await fetch(client, url)
|
||||
if bild is None:
|
||||
continue
|
||||
kandidat = _candidate_from_response(bild, self.source, marke, score)
|
||||
if kandidat is None:
|
||||
continue
|
||||
# Die von Brandfetch gemeldete Markenfarbe ist verlässlicher als die Pixelanalyse.
|
||||
kandidaten.append(
|
||||
LogoCandidate(**{**kandidat.__dict__, "brand_color": farbe or kandidat.brand_color})
|
||||
if farbe
|
||||
else kandidat
|
||||
)
|
||||
return kandidaten
|
||||
|
||||
def _logo_urls(self, payload: dict) -> list[tuple[str, float]]:
|
||||
"""Bevorzugt Icons vor Wortmarken und SVG vor Rastergrafiken."""
|
||||
gewichtung = {"icon": 0.9, "symbol": 0.85, "logo": 0.8, "other": 0.6}
|
||||
formatrang = {"svg": 0.05, "png": 0.03, "webp": 0.01, "jpeg": 0.0}
|
||||
|
||||
ergebnisse: list[tuple[str, float]] = []
|
||||
for eintrag in payload.get("logos") or []:
|
||||
basis = gewichtung.get(eintrag.get("type", "other"), 0.6)
|
||||
for datei in eintrag.get("formats") or []:
|
||||
quelle = datei.get("src")
|
||||
if not quelle:
|
||||
continue
|
||||
ergebnisse.append((quelle, basis + formatrang.get(datei.get("format", ""), 0.0)))
|
||||
ergebnisse.sort(key=lambda item: item[1], reverse=True)
|
||||
return ergebnisse
|
||||
|
||||
def _primary_color(self, payload: dict) -> str | None:
|
||||
farben = payload.get("colors") or []
|
||||
for eintrag in farben:
|
||||
if eintrag.get("type") == "primary" and eintrag.get("hex"):
|
||||
return eintrag["hex"]
|
||||
return farben[0].get("hex") if farben else None
|
||||
|
||||
|
||||
# --- Provider 4: Favicon -------------------------------------------------------
|
||||
|
||||
|
||||
class FaviconProvider:
|
||||
"""Letzter Netzversuch: das Favicon der Domain über den Google-Dienst."""
|
||||
|
||||
source = LogoSource.FAVICON
|
||||
base_url = "https://www.google.com/s2/favicons"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]:
|
||||
if client is None:
|
||||
return []
|
||||
|
||||
ziel = domain or domain_from_name(name)
|
||||
if not ziel:
|
||||
return []
|
||||
|
||||
response = await fetch(client, self.base_url, params={"sz": "128", "domain": ziel})
|
||||
if response is None:
|
||||
return []
|
||||
# Ein geratener Domainname ist deutlich unsicherer als ein hinterlegter.
|
||||
score = 0.5 if domain else 0.3
|
||||
kandidat = _candidate_from_response(response, self.source, name, score)
|
||||
if kandidat is None:
|
||||
return []
|
||||
# Der Platzhalter des Dienstes ist eine graue Weltkugel – die wollen wir nicht.
|
||||
if kandidat.brand_color is None and (kandidat.width or 0) <= 16:
|
||||
return []
|
||||
return [kandidat]
|
||||
|
||||
|
||||
# --- Provider 5: generierter Avatar --------------------------------------------
|
||||
|
||||
|
||||
def initials(name: str) -> str:
|
||||
"""Ein bis zwei Initialen aus dem Firmennamen. Sonderzeichen bleiben außen vor."""
|
||||
woerter = [
|
||||
gesaeubert
|
||||
for wort in re.split(r"[\s\-_/]+", name.strip())
|
||||
if (gesaeubert := re.sub(r"[^0-9A-Za-zÄÖÜäöüß]", "", wort))
|
||||
]
|
||||
if not woerter:
|
||||
return "?"
|
||||
if len(woerter) == 1:
|
||||
wort = woerter[0]
|
||||
return (wort[:2] if len(wort) > 1 else wort).upper()
|
||||
return (woerter[0][0] + woerter[1][0]).upper()
|
||||
|
||||
|
||||
def generate_avatar(name: str) -> LogoCandidate:
|
||||
"""Buchstaben-Avatar mit fester Farbe aus dem Namens-Hash. Schlägt nie fehl."""
|
||||
farbe = deterministic_color(name)
|
||||
zeichen = _escape(initials(name))
|
||||
schriftgroesse = 44 if len(zeichen) > 1 else 54
|
||||
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img">'
|
||||
f"<title>{_escape(name)}</title>"
|
||||
f'<rect width="96" height="96" rx="20" fill="{farbe}"/>'
|
||||
f'<text x="48" y="48" fill="#ffffff" font-size="{schriftgroesse}" '
|
||||
'font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-weight="600" '
|
||||
f'text-anchor="middle" dominant-baseline="central">{zeichen}</text>'
|
||||
"</svg>"
|
||||
).encode()
|
||||
|
||||
return LogoCandidate(
|
||||
source=LogoSource.GENERATED,
|
||||
content=svg,
|
||||
mime=SVG_MIME,
|
||||
title=name,
|
||||
score=0.1,
|
||||
brand_color=farbe,
|
||||
width=96,
|
||||
height=96,
|
||||
)
|
||||
|
||||
|
||||
class GeneratedAvatarProvider:
|
||||
source = LogoSource.GENERATED
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
async def search(
|
||||
self, name: str, domain: str | None, client: httpx.AsyncClient | None
|
||||
) -> list[LogoCandidate]:
|
||||
return [generate_avatar(name)]
|
||||
|
||||
|
||||
# --- Die Kette -----------------------------------------------------------------
|
||||
|
||||
PROVIDER_CHAIN: tuple[LogoProvider, ...] = (
|
||||
SimpleIconsProvider(),
|
||||
LogoDevProvider(),
|
||||
BrandfetchProvider(),
|
||||
FaviconProvider(),
|
||||
GeneratedAvatarProvider(),
|
||||
)
|
||||
|
||||
|
||||
def requires_network(provider: LogoProvider) -> bool:
|
||||
"""True für Provider, die einen HTTP-Client benötigen."""
|
||||
return provider.source not in (LogoSource.SIMPLE_ICONS, LogoSource.GENERATED)
|
||||
|
||||
|
||||
async def search_candidates(
|
||||
name: str, domain: str | None = None, *, stop_on_exact: bool = False
|
||||
) -> list[LogoCandidate]:
|
||||
"""Arbeitet die Provider-Kette ab und liefert bis zu fünf Kandidaten.
|
||||
|
||||
Nach sha256 dedupliziert und nach Trefferwahrscheinlichkeit sortiert. Der
|
||||
generierte Avatar ist immer enthalten, sodass die Liste nie leer ist.
|
||||
|
||||
Mit `stop_on_exact` bleiben die Netz-Provider außen vor, sobald ein
|
||||
eindeutiger Offline-Treffer vorliegt. Die automatische Suche nutzt das: für
|
||||
eine bekannte Marke muss weder ein fremder Dienst befragt noch die
|
||||
Firmenliste nach außen gegeben werden. Der Auswahldialog ruft bewusst ohne
|
||||
diese Abkürzung auf, damit dem Nutzer trotzdem Alternativen angeboten werden.
|
||||
"""
|
||||
ziel_domain = extract_domain(domain)
|
||||
|
||||
client: httpx.AsyncClient | None = None
|
||||
kandidaten: list[LogoCandidate] = []
|
||||
try:
|
||||
for provider in PROVIDER_CHAIN:
|
||||
if not provider.is_available():
|
||||
continue
|
||||
|
||||
if requires_network(provider):
|
||||
if stop_on_exact and any(
|
||||
kandidat.score >= EXACT_MATCH_SCORE for kandidat in kandidaten
|
||||
):
|
||||
continue
|
||||
if client is None:
|
||||
client = httpx.AsyncClient(
|
||||
timeout=settings.logo_http_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": f"moneyfy/{settings.app_version}"},
|
||||
)
|
||||
|
||||
try:
|
||||
gefunden = await provider.search(name, ziel_domain, client)
|
||||
except Exception: # kein Provider darf die Kette abbrechen
|
||||
logger.exception("Logo-Provider %s ist fehlgeschlagen.", provider.source)
|
||||
continue
|
||||
kandidaten.extend(gefunden)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
return _dedupe(kandidaten)[:MAX_CANDIDATES]
|
||||
|
||||
|
||||
def _dedupe(kandidaten: list[LogoCandidate]) -> list[LogoCandidate]:
|
||||
"""Entfernt inhaltsgleiche Bilder und sortiert nach Trefferwahrscheinlichkeit."""
|
||||
beste: dict[str, LogoCandidate] = {}
|
||||
for kandidat in kandidaten:
|
||||
vorhanden = beste.get(kandidat.sha256)
|
||||
if vorhanden is None or kandidat.score > vorhanden.score:
|
||||
beste[kandidat.sha256] = kandidat
|
||||
return sorted(beste.values(), key=lambda item: item.score, reverse=True)
|
||||
|
||||
|
||||
# --- Speicherung ---------------------------------------------------------------
|
||||
|
||||
|
||||
def storage_path(sha256: str, mime: str) -> Path:
|
||||
endung = {"image/png": "png", "image/jpeg": "jpg", SVG_MIME: "svg"}.get(mime, "bin")
|
||||
return Path(f"{sha256}.{endung}")
|
||||
|
||||
|
||||
async def store_candidate(session: AsyncSession, kandidat: LogoCandidate) -> LogoAsset:
|
||||
"""Legt das Logo im Cache ab. Bereits bekannte Inhalte werden wiederverwendet."""
|
||||
sha256 = kandidat.sha256
|
||||
vorhanden = (
|
||||
await session.execute(select(LogoAsset).where(LogoAsset.sha256 == sha256))
|
||||
).scalar_one_or_none()
|
||||
|
||||
relativ = storage_path(sha256, kandidat.mime)
|
||||
absolut = settings.logo_storage_dir / relativ
|
||||
if not absolut.exists():
|
||||
absolut.parent.mkdir(parents=True, exist_ok=True)
|
||||
absolut.write_bytes(kandidat.content)
|
||||
|
||||
if vorhanden is not None:
|
||||
return vorhanden
|
||||
|
||||
asset = LogoAsset(
|
||||
sha256=sha256,
|
||||
mime=kandidat.mime,
|
||||
width=kandidat.width,
|
||||
height=kandidat.height,
|
||||
file_path=str(relativ),
|
||||
source_url=kandidat.source_url,
|
||||
fetched_at=utcnow(),
|
||||
)
|
||||
session.add(asset)
|
||||
await session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def read_asset(asset: LogoAsset) -> bytes:
|
||||
"""Liest ein zwischengespeichertes Logo von der Platte."""
|
||||
pfad = settings.logo_storage_dir / asset.file_path
|
||||
if not pfad.exists():
|
||||
raise FileNotFoundError(pfad)
|
||||
return pfad.read_bytes()
|
||||
|
||||
|
||||
# --- Zuordnung zur Firma -------------------------------------------------------
|
||||
|
||||
|
||||
def _apply(merchant: Merchant, kandidat: LogoCandidate, asset: LogoAsset) -> None:
|
||||
"""Setzt Logo, Quelle und Farbpaar an der Firma."""
|
||||
farben = brand_colors(kandidat.brand_color, fallback_name=merchant.name)
|
||||
merchant.logo_asset_id = asset.id
|
||||
merchant.logo_source = kandidat.source
|
||||
merchant.brand_color = farben.color
|
||||
merchant.brand_color_dark = farben.color_dark
|
||||
|
||||
|
||||
async def resolve_merchant_logo(
|
||||
session: AsyncSession, merchant: Merchant, *, force: bool = False
|
||||
) -> LogoAsset | None:
|
||||
"""Sucht ein Logo und ordnet den besten Treffer zu.
|
||||
|
||||
Eine manuelle Auswahl oder ein Upload wird nie überschrieben, außer `force`
|
||||
ist gesetzt. Die Suche endet notfalls beim generierten Avatar und schlägt
|
||||
daher nicht fehl.
|
||||
"""
|
||||
if merchant.logo_status is LogoStatus.MANUAL and not force:
|
||||
logger.debug("Firma %s hat ein manuelles Logo – Suche übersprungen.", merchant.name)
|
||||
return None
|
||||
|
||||
kandidaten = await search_candidates(merchant.name, merchant.domain, stop_on_exact=True)
|
||||
if not kandidaten:
|
||||
merchant.logo_status = LogoStatus.FAILED
|
||||
await session.flush()
|
||||
return None
|
||||
|
||||
bester = kandidaten[0]
|
||||
asset = await store_candidate(session, bester)
|
||||
_apply(merchant, bester, asset)
|
||||
merchant.logo_status = LogoStatus.RESOLVED
|
||||
await session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
async def resolve_merchant_logo_task(merchant_id: int) -> None:
|
||||
"""Hintergrundaufgabe mit eigener Session – wird nach der Antwort ausgeführt."""
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
merchant = await session.get(Merchant, merchant_id)
|
||||
if merchant is None:
|
||||
return
|
||||
await resolve_merchant_logo(session, merchant)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Logosuche für Firma %s fehlgeschlagen.", merchant_id)
|
||||
|
||||
|
||||
async def apply_upload(
|
||||
session: AsyncSession, merchant: Merchant, content: bytes, declared_mime: str | None
|
||||
) -> LogoAsset:
|
||||
"""Übernimmt ein hochgeladenes Logo und sperrt es gegen die automatische Suche."""
|
||||
if len(content) > settings.logo_max_upload_bytes:
|
||||
grenze = settings.logo_max_upload_bytes // 1024
|
||||
raise ValidationError(
|
||||
f"Die Datei ist zu groß. Erlaubt sind höchstens {grenze} KB.",
|
||||
code="logo_too_large",
|
||||
)
|
||||
|
||||
mime = detect_mime(content, declared_mime)
|
||||
if mime not in ALLOWED_UPLOAD_MIMES:
|
||||
raise ValidationError(
|
||||
"Nur SVG, PNG und JPEG werden unterstützt.", code="unsupported_media_type"
|
||||
)
|
||||
|
||||
width, height = image_size(content, mime)
|
||||
kandidat = LogoCandidate(
|
||||
source=LogoSource.UPLOAD,
|
||||
content=content,
|
||||
mime=mime,
|
||||
title=merchant.name,
|
||||
score=1.0,
|
||||
brand_color=extract_color(content, mime),
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
asset = await store_candidate(session, kandidat)
|
||||
_apply(merchant, kandidat, asset)
|
||||
merchant.logo_status = LogoStatus.MANUAL
|
||||
await session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
async def select_candidate(session: AsyncSession, merchant: Merchant, asset_id: int) -> LogoAsset:
|
||||
"""Übernimmt einen zuvor gefundenen Kandidaten als Logo der Firma."""
|
||||
asset = await session.get(LogoAsset, asset_id)
|
||||
if asset is None:
|
||||
raise ValidationError(
|
||||
f"Das Logo mit der ID {asset_id} ist nicht mehr verfügbar. Bitte erneut suchen.",
|
||||
code="candidate_not_found",
|
||||
)
|
||||
|
||||
try:
|
||||
content = read_asset(asset)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValidationError(
|
||||
"Die Logodatei fehlt im Cache. Bitte erneut suchen.", code="candidate_not_found"
|
||||
) from exc
|
||||
|
||||
farbe = extract_color(content, asset.mime)
|
||||
farben = brand_colors(farbe, fallback_name=merchant.name)
|
||||
merchant.logo_asset_id = asset.id
|
||||
merchant.brand_color = farben.color
|
||||
merchant.brand_color_dark = farben.color_dark
|
||||
# Eine bewusste Auswahl ist manuell und wird von der Automatik nicht angetastet.
|
||||
merchant.logo_status = LogoStatus.MANUAL
|
||||
await session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def cache_headers(asset: LogoAsset) -> dict[str, str]:
|
||||
"""Der Dateiname ist der Inhaltshash – der Inhalt kann sich nie ändern."""
|
||||
return {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"ETag": f'"{asset.sha256}"',
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LogoCandidate",
|
||||
"apply_upload",
|
||||
"cache_headers",
|
||||
"generate_avatar",
|
||||
"read_asset",
|
||||
"resolve_merchant_logo",
|
||||
"resolve_merchant_logo_task",
|
||||
"search_candidates",
|
||||
"select_candidate",
|
||||
"simple_icon_index",
|
||||
"store_candidate",
|
||||
]
|
||||
Reference in New Issue
Block a user