fix: KI-Analyse deckt standardmäßig ALLE Inhalte ab (kein Limit-Risiko)

Das per-Scan-Limit (max_pages/images_per_scan) war ein Sicherheitsrisiko:
Inhalte jenseits des Fensters blieben über viele Scans ungeprüft. Eine
KI-Bildanalyse ist aber nur sinnvoll, wenn ALLE Bilder geprüft werden.

Neu, sauber getrennt:
- Geänderte/neue/geflaggte Inhalte werden IMMER sofort geprüft (Echtzeit-
  Schutz), ungeachtet jeder Drossel — auch wenn das Limit klein ist.
- Die Limits drosseln NUR den historischen Altbestand und sind per Default
  0 = unbegrenzt → der erste Scan deckt den kompletten Bestand ab. Dank
  Hash-Cache eine einmalige Ausgabe; Folge-Scans prüfen nur noch Neues.
- Positiver Wert bleibt als optionale Drossel nur für sehr große Sites.

Tests: 257 grün (+2: geänderte Seite trotz Drossel=1 geprüft; image-Bestand
backlog=0 deckt alle Bilder in einem Scan ab). Default-Werte 20/15 → 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 04:12:11 +02:00
commit 369a3b8774
5 changed files with 83 additions and 31 deletions

View file

@ -1012,24 +1012,28 @@ 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)
### Vollständige Abdeckung (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.
Eine KI-Bildanalyse ist nur sinnvoll, wenn **alle** Bilder geprüft werden — sonst bliebe ein
Bild jenseits eines Limits dauerhaft ungeprüft (z. B. ein eingeschleustes strafbares Logo auf
einer ansonsten stabilen Seite). Deshalb gilt:
**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 <fp>` 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.
- **Geänderte/neue Inhalte werden IMMER sofort geprüft** — ungeachtet jeder Drossel. Fügt jemand
ein Bild auf einer Seite ein, ändert sich diese Seite → das Bild wird im selben Scan analysiert.
- **`max_pages_per_scan` / `max_images_per_scan` drosseln nur den historischen Altbestand**, und
ihr **Default ist `0` = unbegrenzt**: Der erste Scan deckt den kompletten Bestand ab. Dank des
Hash-Caches ist das eine einmalige Ausgabe (wenige Cent); Folge-Scans prüfen nur noch Neues.
- Ein **positiver** Wert ist nur für *sehr große* Sites (z. B. 10.000 Bilder) gedacht, um die
erste Volldurchsicht über mehrere Scans zu strecken. Selbst dann werden geänderte/neue Inhalte
weiter sofort geprüft.
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.
**Initiale Einrichtung:** Beim ersten vollen Durchlauf markiert die KI auch legitime Inhalte
(z. B. Marken-/Partnerlogos auf Firmenseiten) — diese werden einmalig mit `ai-dismiss --hash <fp>`
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 zusätzlich 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,

View file

@ -169,14 +169,18 @@ ai_analysis:
- "meta-llama/llama-3.3-70b-instruct:free" # Stufe 2: free
- "google/gemini-2.5-flash-lite" # Stufe 3: günstig bezahlt, zuverlässig
min_chars: 200
max_pages_per_scan: 20
# Drossel NUR für den Altbestand; 0 = unbegrenzt (volle Abdeckung im ersten Scan).
# Geänderte/neue Seiten werden IMMER geprüft. Positiver Wert nur für sehr große Sites.
max_pages_per_scan: 0
image:
enabled: true
models:
- "google/gemma-4-31b-it:free" # Stufe 1: free, multimodal + OCR
- "nvidia/nemotron-nano-12b-v2-vl:free" # Stufe 2: free, Vision
- "google/gemini-2.5-flash-lite" # Stufe 3: günstig bezahlt, Vision
max_images_per_scan: 15
# Drossel NUR für den Bild-Altbestand; 0 = unbegrenzt. Geänderte/neue/geflaggte
# Bilder werden IMMER geprüft. Positiver Wert nur für sehr große Bildbestände.
max_images_per_scan: 0
# Audio/Video: vorbereitet, default aus (Medien auf statischen Seiten selten).
audio:
enabled: false

View file

@ -317,7 +317,10 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe
order_fps.append(fp)
fp_urls[fp].append(url)
budget = text_cfg.get("max_pages_per_scan", 20)
# Drossel NUR für den historischen Bestand; 0 = unbegrenzt. Geänderte/neue Seiten
# werden IMMER geprüft (Echtzeit-Schutz), ungeachtet des Limits.
backlog_limit = text_cfg.get("max_pages_per_scan", 0)
backlog_used = 0
tasks, miss_fps = [], []
for fp in order_fps:
entry = entries.get(fp)
@ -325,11 +328,14 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe
for url in fp_urls[fp]:
result["cache_hits"] += 1
_maybe_finding(result, entry, fp, url, ai_cfg)
elif budget > 0:
miss_fps.append(fp)
tasks.append((fp, _text_task(fp_text[fp], site_context, models, ai_cfg, api_key, router)))
budget -= 1
# else: Kostendeckel erreicht — nächster Scan holt den Rest nach
continue
is_changed = any(u in changed for u in fp_urls[fp])
if not is_changed and backlog_limit and backlog_used >= backlog_limit:
continue # Bestands-Drossel erreicht — nächster Scan holt den Rest nach
miss_fps.append(fp)
tasks.append((fp, _text_task(fp_text[fp], site_context, models, ai_cfg, api_key, router)))
if not is_changed:
backlog_used += 1
classified = _classify_batch(tasks, executor)
dirty = False
@ -379,11 +385,14 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e
known_img_urls = {e["url"] for e in entries.values() if e.get("kind") == "image"}
coverage_urls = current_img_urls - known_img_urls
# Priorität: geänderte/geflaggte Bilder zuerst, dann mit dem Restbudget Abdeckung aufbauen.
# Geänderte/geflaggte Bilder: IMMER prüfen (Echtzeit-Schutz, nie gedeckelt).
# Bestands-Abdeckung: Drossel optional; 0 = unbegrenzt (volle Abdeckung im ersten Scan).
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]
backlog_limit = img_cfg.get("max_images_per_scan", 0)
if backlog_limit and backlog_limit > 0:
coverage = coverage[:backlog_limit]
fetch_urls = priority + coverage
if not fetch_urls:
return False

View file

@ -119,7 +119,9 @@ DEFAULT_CONFIG: dict = {
"google/gemini-2.5-flash-lite", # Stufe 3: günstig bezahlt, zuverlässig
],
"min_chars": 200, # nur Seitentexte ab dieser Größe prüfen
"max_pages_per_scan": 20, # Kostendeckel pro Lauf
# Drossel NUR für den historischen Bestand; 0 = unbegrenzt (volle Abdeckung im
# ersten Scan). Geänderte/neue Seiten werden IMMER geprüft, ungeachtet dieses Werts.
"max_pages_per_scan": 0,
},
"image": {
"enabled": True,
@ -128,7 +130,9 @@ DEFAULT_CONFIG: dict = {
"nvidia/nemotron-nano-12b-v2-vl:free", # Stufe 2: free, Vision
"google/gemini-2.5-flash-lite", # Stufe 3: günstig bezahlt, Vision
],
"max_images_per_scan": 15,
# Drossel NUR für den Bild-Altbestand; 0 = unbegrenzt (volle Abdeckung im ersten
# Scan). Geänderte/neue/geflaggte Bilder werden IMMER geprüft, ungeachtet des Werts.
"max_images_per_scan": 0,
},
# Vorbereitet, default aus — Medien sind auf statischen Heimat-Seiten selten.
"audio": {

View file

@ -230,6 +230,22 @@ class TestImages:
if e.get("kind") == "image"}
assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt
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")
bm = BaselineManager(tmp_path)
cfg = self._cfg_img()
cfg["ai_analysis"]["image"]["max_images_per_scan"] = 0 # unbegrenzt
imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(7)]
hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None}
for i in range(7)}
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("kurz", img=imgs), {})
imgcount = sum(1 for e in bm.load_ai_ledger()["entries"].values()
if e.get("kind") == "image")
assert imgcount == 7
def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
@ -421,6 +437,21 @@ class TestParallel:
assert len(res["findings"]) == 2 # aber Fund für beide URLs
assert {f["url"] for f in res["findings"]} == {"https://x.de/a", "https://x.de/b"}
def test_changed_page_always_analyzed_despite_backlog_limit(self, tmp_path, monkeypatch):
"""Geänderte Seiten werden IMMER geprüft — die Bestands-Drossel gilt nicht für sie."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = _cfg()
cfg["ai_analysis"]["text"]["max_pages_per_scan"] = 1 # strenge Bestands-Drossel
pages = {f"https://x.de/p{i}": {"url": f"https://x.de/p{i}", "status": 200,
"text": _LONG + str(i), "links": {"img": []}}
for i in range(5)}
diff = {"new_internal_urls": ["https://x.de/p3"], "page_diffs": []}
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
run_ai_analysis(cfg, bm, {"pages": pages}, diff)
urls = {e["url"] for e in bm.load_ai_ledger()["entries"].values()}
assert "https://x.de/p3" in urls # geänderte Seite trotz Drossel=1 geprüft
def test_findings_sorted_deterministically(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)