diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c21286d..1c0c4ee 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1012,6 +1012,25 @@ Wirkung: Der Erst-Scan einer Site (der den ganzen Bestand prüft) wird deutlich rate-limited Free-Modelle sofort übersprungen werden und die verbleibenden Anfragen nebenläufig laufen. +### Vollständige Bildabdeckung (kein blinder Fleck) + +Pro Scan werden maximal `image.max_images_per_scan` Bilder geprüft (Kostendeckel). Damit jenseits +dieses Fensters **kein Bild dauerhaft ungeprüft** bleibt — sonst könnte ein Angreifer z. B. ein +strafbares Logo auf einer ansonsten stabilen Seite platzieren —, holt **jeder** Scan zusätzlich +noch nicht analysierte Bilder nach, bis der gesamte Bildbestand abgedeckt ist. Reihenfolge: +zuerst Bilder auf geänderten Seiten und bereits geflaggte, dann mit dem Restbudget der noch +unbekannte Rest. + +**Initiale Einrichtung großer Bildbestände:** Hat eine Site sehr viele Bilder (z. B. 200), dauert +die volle Abdeckung mehrere Scans. Zum schnelleren Aufbau kann `max_images_per_scan` vorübergehend +erhöht werden. In dieser Phase markiert die KI auch legitime Inhalte (z. B. Marken-/Partnerlogos +auf Firmenseiten) — diese werden einmalig mit `ai-dismiss --hash ` akzeptiert. Da die Quittung +am **Byte-Hash** hängt, wird ein später ausgetauschtes Bild (andere Bytes → neuer Hash) automatisch +neu geprüft und nicht stillschweigend mitakzeptiert. + +Hinweis zur Byte-Änderung bekannter Bilder auf stabilen Seiten: Diese deckt die Datei-Prüfung +(`check-assets`) ab — sie schlägt an, wenn sich der Inhalt einer bekannten Datei ändert. + Audio- und Video-Prüfung sind als abschaltbare Erweiterungspunkte vorbereitet (`ai_analysis.audio` / `ai_analysis.video`, default deaktiviert) und können aktiviert werden, sobald eine Website solche Mediendateien direkt einbindet. diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index 7f0edd7..abe56f9 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -363,7 +363,6 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e if not current_img_urls: return False - has_image_entries = any(e.get("kind") == "image" for e in entries.values()) changed = {pd["url"] for pd in diff.get("page_diffs", [])} | set(diff.get("new_internal_urls", [])) changed_img_urls = _all_image_urls(snap, only_pages=changed) # Markierte Bilder erneut holen: ersetztes Bild → neue Bytes → Neubewertung @@ -373,13 +372,18 @@ 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 - if has_image_entries: - fetch_urls = sorted((changed_img_urls | flagged_img_urls) & current_img_urls) - else: - fetch_urls = sorted(current_img_urls) # Erstlauf: vollständiger Sweep - - fetch_urls = fetch_urls[: img_cfg.get("max_images_per_scan", 15)] + # Priorität: geänderte/geflaggte Bilder zuerst, dann mit dem Restbudget Abdeckung aufbauen. + priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls) + coverage = sorted(coverage_urls - set(priority)) + budget = img_cfg.get("max_images_per_scan", 15) + fetch_urls = (priority + coverage)[:budget] if not fetch_urls: return False diff --git a/tests/test_ai_analyzer.py b/tests/test_ai_analyzer.py index b185a30..f8821b4 100644 --- a/tests/test_ai_analyzer.py +++ b/tests/test_ai_analyzer.py @@ -208,6 +208,28 @@ class TestImages: assert res["findings"][0]["kind"] == "image" assert res["findings"][0]["asset_url"] == "https://x.de/bild.jpg" + def test_image_coverage_builds_over_scans(self, tmp_path, monkeypatch): + """Bilder jenseits des Budgets dürfen kein blinder Fleck sein: + jeder Scan deckt weitere, noch nie analysierte Bilder ab.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test") + bm = BaselineManager(tmp_path) + cfg = self._cfg_img() + cfg["ai_analysis"]["image"]["max_images_per_scan"] = 2 + # 5 Bilder auf einer Seite, Budget 2 → volle Abdeckung über 3 Scans + imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(5)] + snap = _snap("kurz", img=imgs) + hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None} + for i in range(5)} + + analysed = set() + for _ in range(3): # drei aufeinanderfolgende Scans + with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \ + patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()): + run_ai_analysis(cfg, bm, snap, {}) + analysed = {e["url"] for e in bm.load_ai_ledger()["entries"].values() + if e.get("kind") == "image"} + assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt + def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "test") bm = BaselineManager(tmp_path)