perf: URL→Byte-Hash-Erinnerung — bekannte Bilder nicht erneut laden/analysieren

Bisher: Die Analyse identischer Bilder war bereits dedupliziert (Ledger nach
Byte-Hash indiziert). Aber jede Bild-URL wurde bei jedem Scan ERNEUT
heruntergeladen, um ihren Hash für Gruppierung/Abdeckung zu berechnen — auch
wenn die Bytes längst bekannt und analysiert waren.

Neu: persistente URL→Hash-Map im Ledger (url_hashes). Eine bereits gesehene
Bild-URL wird nicht erneut geladen; Abdeckung umfasst nur noch nie gesehene
URLs. Duplikate (gleiche Bytes an mehreren URLs) werden je URL höchstens einmal
geholt und nie doppelt analysiert. Geänderte/geflaggte Bilder werden weiterhin
IMMER (erneut) geprüft.

Spart Bandbreite/Zeit und schont den überwachten Server (Fetch nur für Neues).
Byte-Änderungen bereits bekannter Bilder auf stabilen Seiten deckt weiterhin
check-assets ab (Integritäts-Layer).

Tests: 258 grün (+1: Duplikat-URLs einmal geladen, einmal analysiert, danach
kein erneuter Fetch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 04:17:31 +02:00
commit 4db5817013
2 changed files with 48 additions and 15 deletions

View file

@ -193,6 +193,7 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict:
try:
ledger = bm.load_ai_ledger()
entries = ledger.setdefault("entries", {})
url_hashes = ledger.setdefault("url_hashes", {}) # URL→Byte-Hash-Erinnerung (Bilder)
dirty = False
# Ledger wird zwischengesichert (nach Textphase + im finally), damit bereits
# berechnete Verdikte einen späteren Hang/Abbruch überleben und nicht erneut
@ -206,7 +207,7 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict:
bm.save_ai_ledger(ledger)
if ai_cfg.get("image", {}).get("enabled", True):
dirty |= _analyze_images(cfg, ai_cfg, api_key, snap, diff,
entries, result, router, executor)
entries, result, router, executor, url_hashes)
# Audio/Video: vorbereitet, default aus (siehe _collect_audio/video_candidates).
finally:
if dirty:
@ -359,8 +360,13 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe
# Image analysis
# ---------------------------------------------------------------------------
def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor) -> bool:
"""Prüft neue/geänderte Bilder (parallel). Returns True wenn Ledger verändert."""
def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor,
url_hashes) -> bool:
"""Prüft neue/geänderte Bilder (parallel). Returns True wenn Ledger verändert.
url_hashes ist die persistente URLByte-Hash-Erinnerung: eine bereits gesehene
Bild-URL wird NICHT erneut heruntergeladen (kein redundanter Fetch, keine doppelte
Analyse). Nur nie gesehene Bilder sowie geänderte/geflaggte werden geholt."""
img_cfg = ai_cfg.get("image", {})
models = _models_for(img_cfg)
site_context = ai_cfg.get("site_context", "")
@ -378,17 +384,14 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
if e.get("kind") == "image" and e.get("category", "clean") != "clean"
and not e.get("dismissed") and e.get("url") in current_img_urls
}
# Noch nie analysierte Bilder (URL nicht im Ledger): bauen über mehrere Scans die
# VOLLE Abdeckung auf — sonst bliebe jedes Bild jenseits des ersten Budget-Fensters
# ein dauerhafter blinder Fleck (z. B. ein eingeschleustes strafbares Logo auf einer
# ansonsten stabilen Seite). Bytes-Änderungen bekannter Bilder deckt check-assets ab.
known_img_urls = {e["url"] for e in entries.values() if e.get("kind") == "image"}
coverage_urls = current_img_urls - known_img_urls
# Geänderte/geflaggte Bilder: IMMER prüfen (Echtzeit-Schutz, nie gedeckelt).
# Bestands-Abdeckung: Drossel optional; 0 = unbegrenzt (volle Abdeckung im ersten Scan).
# Geänderte/geflaggte Bilder: IMMER (erneut) prüfen (Echtzeit-Schutz, nie gedeckelt).
# Abdeckung: nur noch NIE gesehene URLs (nicht in url_hashes) — schon bekannte Bilder
# werden nicht erneut geladen (Hashwert gemerkt). Volle Abdeckung baut sich so EINMALIG
# auf; Duplikate (gleiche Bytes an mehreren URLs) werden je URL höchstens einmal geholt
# und nie doppelt analysiert (Ledger ist nach Byte-Hash indiziert). Drossel 0 = unbegrenzt.
priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls)
coverage = sorted(coverage_urls - set(priority))
coverage = sorted((current_img_urls - set(url_hashes)) - set(priority))
backlog_limit = img_cfg.get("max_images_per_scan", 0)
if backlog_limit and backlog_limit > 0:
coverage = coverage[:backlog_limit]
@ -401,12 +404,17 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
hashes = fetch_asset_hashes(fetch_urls, timeout=cfg.get("request_timeout", 15))
# Nach Bild-Hash (Bytes) gruppieren: identische Bilder werden nur einmal klassifiziert.
# Zugleich URL→Hash merken (auch für Duplikate), damit künftige Scans sie überspringen.
dirty = False
fp_assets: dict[str, list[str]] = {}
order_fps: list[str] = []
for url in fetch_urls:
fp = hashes.get(url, {}).get("sha256")
if not fp:
continue # nicht abrufbar — überspringen
continue # nicht abrufbar — überspringen (nicht merken → später erneut versuchen)
if url_hashes.get(url) != fp:
url_hashes[url] = fp # neu gesehen oder Bytes geändert
dirty = True
result["checked"] += 1
if fp not in fp_assets:
fp_assets[fp] = []
@ -425,7 +433,6 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
tasks.append((fp, _image_task(fp_assets[fp][0], site_context, models, ai_cfg, api_key, router)))
classified = _classify_batch(tasks, executor)
dirty = False
for fp in miss_fps:
res = classified.get(fp)
rep_url = fp_assets[fp][0]

View file

@ -230,6 +230,32 @@ class TestImages:
if e.get("kind") == "image"}
assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt
def test_duplicate_image_urls_fetched_once_analyzed_once(self, tmp_path, monkeypatch):
"""Gleiches Bild an mehreren URLs: nur EINE Analyse; danach kein erneuter Fetch
(Byte-Hash gemerkt und geprüft)."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = self._cfg_img()
imgs = [{"url": "https://x.de/a.jpg", "class": "internal"},
{"url": "https://x.de/b.jpg", "class": "internal"}]
snap = _snap("kurz", img=imgs)
same = {"https://x.de/a.jpg": {"sha256": "DUP", "size": 1, "error": None},
"https://x.de/b.jpg": {"sha256": "DUP", "size": 1, "error": None}}
with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=same), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
res1 = run_ai_analysis(cfg, bm, snap, {})
assert res1["api_calls"] == 1 # gleiche Bytes → nur EINE Analyse
led = bm.load_ai_ledger()
assert set(led["url_hashes"]) == {"https://x.de/a.jpg", "https://x.de/b.jpg"}
# zweiter Scan: beide URLs bekannt → gar kein Fetch, keine Analyse mehr
with patch("scanner.ai_analyzer.fetch_asset_hashes") as fetch2, \
patch("scanner.ai_analyzer._openrouter_chat") as chat2:
res2 = run_ai_analysis(cfg, bm, snap, {})
fetch2.assert_not_called()
chat2.assert_not_called()
assert res2["api_calls"] == 0
def test_image_backlog_unlimited_covers_all_in_one_scan(self, tmp_path, monkeypatch):
"""max_images_per_scan=0 → voller Bildbestand in EINEM Scan (Default)."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")