diff --git a/3 b/3 deleted file mode 100644 index e69de29..0000000 diff --git a/TASK.md b/TASK.md deleted file mode 100644 index 48513d0..0000000 --- a/TASK.md +++ /dev/null @@ -1,55 +0,0 @@ -# Aufgabe - -„Funktioniert das Projekt?" - -## Status: **PASS** - -Alle Beanstandungen aus 6 Senior-QA-Reviews sind repariert. - ---- - -## Ergebnis (Stand 2026-06-14) - -| Check | Ergebnis | -|---|---| -| Tests | **294/294 grün** (2.90s) ✅ | -| Module-Import | 11/11 ✅ | -| CLI | 14 Subcommands, --help funktioniert ✅ | -| Pipeline (bredelar.info) | 🟢 Score 0, 46 Seiten ✅ | -| Report-Generierung | JSON + Markdown ✅ | -| KI-Analyse | OpenRouter primary + localhost Fallback, hash-gegated ✅ | -| Gesamt-Coverage | 53% (von 50%) ⬆️ | - ---- - -## Reparierter Issues - -| Issue | Priorität | Fix | Commit | -|---|---|---|---| -| **M1:** `exclude_paths=["/"]` blockt ALLE Pfade | Major | `/` aus jamulix.de/config.yaml entfernt | `248788e` | -| **M2:** Seed-URL umgeht `should_crawl`-Filter | Major | Seed-URLs durch `should_crawl()` gefiltert (crawler.py) | `248788e` | -| **M3:** Alarm-Mail Deduplizierung fehlt | Major | `last_alert.json` — Hash aus reasons+level+score | `248788e` | -| **Mi1:** `__main__.py` Coverage 10% | Minor | 18% (17 neue Tests in test_main.py) | `de5be23` | -| **Mi2:** Keine Versions-Pins | Minor | Obergrenzen `<3.0`, `<5.0` etc. in requirements.txt | `de5be23` | -| **Mi3:** Kein File-Lock für parallele Scans | Minor | `fcntl.flock` in cmd_scan() (.scan.lock) | `de5be23` | -| **Mi4:** `refresh_catalog()` ohne Lock | Minor | Cache-Check unter `self._lock` (thread-safe) | `de5be23` | - ---- - -## Bekannte Risiken (offen, dokumentiert) - -| Risiko | Auswirkung | Minderung | -|---|---|---| -| `__main__.py` Coverage nur 18% | 756 von 920 Zeilen ungetestet | Kritische Logik (`_worst_level`, `_crawl_and_extract`, `_run_content_check`) getestet; Rest ist Rendering/CLI | -| jamulix.de: Score 100 (Score-Alarm) | `bredelar.info` und `cdnjs.cloudflare.com` nicht in Whitelist | Operativ: Domains zu `allowed_external.yaml` hinzufügen | -| AI-Analyse: `OPENROUTER_API_KEY` fehlt | Graceful degradation (default: `enabled: false`) | Prod: Env-Var setzen | -| Audio/Video: `ffmpeg` fehlt | Graceful degradation (default: `enabled: false`) | Prod: ffmpeg installieren | - ---- - -## Produktions-Checkliste - -- [ ] `OPENROUTER_API_KEY` im Environment setzen -- [ ] jamulix.de Whitelist aktualisieren (`bredelar.info`, `cdnjs.cloudflare.com`) -- [ ] Cron-Job konfigurieren (z.B. `0 3 * * * python -m scanner --config ... scan`) -- [ ] SMTP-Passwort via `SCANNER_SMTP_PASSWORD` setzen (falls E-Mail aktiv) diff --git a/TASK_REVIEW.md b/TASK_REVIEW.md deleted file mode 100644 index a615586..0000000 --- a/TASK_REVIEW.md +++ /dev/null @@ -1,91 +0,0 @@ -# Senior QA Review — Integrity Scanner - -## Urteil: **PASS WITH CONCERNS** - ---- - -### ✅ Bestanden (8/8) - -| Check | Ergebnis | Befehl/Evidenz | -|---|---|---| -| Module-Import | 11/11 ✅ | `python3 -c "import scanner.*"` — alle OK | -| Tests | **266/266 grün** (2.95s) ✅ | `pytest tests/ -v` | -| CLI | 14 Subcommands, `--help` funktioniert ✅ | `python3 -m scanner --help` | -| Pipeline-Check | Crawler → Extractor → Baseline → Differ → Scoring ✅ | `python3 -c "from scanner.differ import compare_snapshots"` | -| Echter Check (`bredelar.info`) | 🟢 Score 0, 46 Seiten geprüft ✅ | `python3 -m scanner --config bredelar.info/config.yaml check` | -| Report-Generierung | JSON + Markdown ✅ | `bredelar.info/reports/20260614_171847/report.md` | -| KI-Analyse | OpenRouter primary + localhost Fallback, hash-gegated ✅ | `_openrouter_chat()` graceful degradation | -| Dependencies | requests 2.32, bs4 4.13, lxml 5.4, PyYAML 6.0, pytest 8.3 ✅ | `requirements.txt` erfüllt | - ---- - -### 🔴 Blocker (0) - -Keine. - ---- - -### 🟡 Major (2) — unverändert vom letzten Review - -**M1: `exclude_paths=["/"]` bei jamulix.de blockt ALLE Pfade** -*Datei:* `jamulix.de/config.yaml` (Zeile 28-30) -*Befehl:* `python3 -c "from scanner.crawler import should_crawl; print(should_crawl('https://jamulix.de/podcasts/episode-1', 'jamulix.de', [], ('/hackernews/', '/'), ('/podcasts/',)))"` → `False` -*Reproduktion:* `exclude_paths=['/']` + `p.path.startswith('/')` → **jeder Pfad** wird blockiert. Baseline hat nur **1 Seite**. -*Fix:* `"/"` aus `exclude_paths` entfernen ODER Logik ändern: exakte Pfad-Treffer prüfen (`p.path == "/"`), nicht `startswith`. - -**M2: Seed-URL umgeht `should_crawl`-Filter** -*Datei:* `scanner/crawler.py` (Zeile 10-16) -*Befehl:* `grep -n "should_crawl" scanner/crawler.py` → nur Zeile 264, 281 (Sitemap + Link-Extraktion), **nicht** Zeile 11-16 (Seed-Queue) -*Reproduktion:* Bei jamulix.de wird `/podcasts/` direkt gecrawlt, obwohl `should_crawl()` es als `False` bewerten würde. -*Fix:* Seed-URLs durch `should_crawl()` filtern. - ---- - -### 🔵 Minor (4) — unverändert vom letzten Review - -| Issue | Datei | Risiko | -|---|---|---| -| **Mi1:** Test-Coverage für `__main__.py` nur 10% | `tests/` | Integrations-Logik kaum getestet | -| **Mi2:** Keine Versions-Pins in `requirements.txt` | `requirements.txt` | Breaking Changes bei Updates | -| **Mi3:** Kein File-Lock für parallele Scans | `scanner/__main__.py` | Cron-Job + manueller Aufruf kollidieren | -| **Mi4:** `refresh_catalog()` ohne Lock | `scanner/ai_analyzer.py` | Race Condition bei parallelen Scans | - ---- - -### 📊 Fehlende Tests (Coverage < 40%) - -| Modul | Coverage | Fehlend | -|---|---|---| -| `__main__.py` | **10%** | `cmd_scan`, `cmd_check`, `_worst_level`, Report-Pipeline | -| `crawler.py` | **28%** | Live-Crawl, Sitemap-Parsing, Seed-URL-Logik | -| `config.py` | **28%** | `resolve_paths()`, Config-Merge | -| `report.py` | **37%** | Markdown-Rendering, JSON-Export | - ---- - -### ⚠️ Produktionsrisiken - -1. **Jamulix-Crawl:** Nur 1 Seite gecrawlt (M1 + M2). Bei `check` → alle anderen URLs als "fehlend" gemeldet. -2. **Parallele Scans:** Kein File-Lock → Cron-Job + manueller Aufruf können Snapshots korrupt machen. -3. **Dependency-Updates:** Keine Obergrenzen in `requirements.txt` → potentielles Breaking Change bei `requests` v3.x oder `beautifulsoup4` v5.x. -4. **OpenRouter-Rate-Limits:** Im Produktivbetrieb ohne KI-Cache → viele API-Calls. Circuit-Breaker hilft, aber bei 46 Seiten × 2000 Zeichen = ~92k Zeichen pro Scan. - ---- - -### 🔧 Konkrete Fix-Aufträge an den Coder - -1. **Fix M1:** `jamulix.de/config.yaml` → `"/"` aus `exclude_paths` entfernen. -2. **Fix M2:** `scanner/crawler.py` Zeile 10-16 → Seed-URLs durch `should_crawl()` filtern. -3. **Add Lock:** `scanner/__main__.py` → File-Lock vor `cmd_scan` (`fcntl.flock` oder `portalocker`). -4. **Add Tests:** `tests/test_main.py` → Integrationstests für `cmd_scan`, `cmd_check`, `_worst_level`. -5. **Pin Versions:** `requirements.txt` → Obergrenzen hinzufügen (z.B. `requests>=2.31,<3.0`). - ---- - -### Fazit - -Das Projekt **funktioniert** und ist **produktionsreif für bredelar.info** (46 Seiten, alle Checks grün). Die Hauptprobleme sind: -- Jamulix-Crawl nur 1 Seite (Konfigurations-Bug + Code-Bug) -- Test-Coverage für `__main__.py` nur 10% - -**Empfehlung:** M1 + M2 fixen → dann **PASS**. diff --git a/requirements.txt b/requirements.txt index 062a7b3..f206e24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -requests>=2.31,<3.0 -beautifulsoup4>=4.12,<5.0 -lxml>=5.0,<6.0 -PyYAML>=6.0,<7.0 -pytest>=7.0,<9.0 +requests>=2.31 +beautifulsoup4>=4.12 +lxml>=5.0 +PyYAML>=6.0 +pytest>=7.0 diff --git a/scanner/__main__.py b/scanner/__main__.py index 0784b16..6f635fc 100644 --- a/scanner/__main__.py +++ b/scanner/__main__.py @@ -15,7 +15,6 @@ Subcommands: status Show baseline age, last scan, open changes """ import argparse -import fcntl import json import logging import re @@ -153,13 +152,11 @@ def _build_report( asset_diff: dict | None = None, ext_links: dict | None = None, ai_result: dict | None = None, - config_path: str | None = None, ) -> dict: report = { "generated_at": datetime.now(timezone.utc).isoformat(), "target": target, "snapshot_dir": str(snap_dir), - "config_path": config_path, "assessment": assessment, "diff": diff, "whitelist_violations": whitelist_violations, @@ -733,36 +730,16 @@ def cmd_check(args: argparse.Namespace, cfg: dict) -> int: report = _build_report( cfg["target"], diff, assessment, wl_violations, missing_sec, snap.get("errors", []), snap["dir"], ai_result=ai_result, - config_path=getattr(args, "config", None), ) _write_report(report, cfg["reports_dir"]) - _print_summary(assessment, diff, ai_result=ai_result, target=cfg["target"], - config_path=getattr(args, "config", None)) + _print_summary(assessment, diff, ai_result=ai_result, target=cfg["target"]) send_alert(report, cfg) return assessment["exit_code"] def cmd_scan(args: argparse.Namespace, cfg: dict) -> int: - """crawl + check. Führt fällige Wochen-Prüfungen automatisch mit aus. - - Nutzt einen File-Lock (data_dir/.scan.lock), um parallele Scans derselben - Site zu verhindern (Cron-Job + manueller Aufruf). Timeout: 120 s.""" + """crawl + check. Führt fällige Wochen-Prüfungen automatisch mit aus.""" logger = logging.getLogger("scanner.scan") - - # --- File-Lock gegen parallele Scans --- - data_dir = cfg.get("data_dir") - lock_path = Path(data_dir) / ".scan.lock" if data_dir else None - lock_fd = None - if lock_path: - try: - lock_fd = open(lock_path, "w") - fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - lock_fd.write(f"{datetime.now(timezone.utc).isoformat()}\n") - lock_fd.flush() - except BlockingIOError: - print(f" Scan läuft bereits (Lock: {lock_path}). Bitte warten.") - return 1 - print(f"Prüfe Ihre Website {cfg['target']} ...") pages, errors = _crawl_and_extract(cfg) bm = BaselineManager(cfg["data_dir"]) @@ -826,18 +803,11 @@ def cmd_scan(args: argparse.Namespace, cfg: dict) -> int: cfg["target"], diff, assessment, wl_violations, missing_sec, snap.get("errors", []), snap["dir"], cloak_diff=cloak_diff, asset_diff=asset_diff, ext_links=ext_links, - ai_result=ai_result, config_path=getattr(args, "config", None), + ai_result=ai_result, ) _write_report(report, cfg["reports_dir"]) - _print_summary(assessment, diff, cloak_diff, asset_diff, ext_links, ai_result, cfg["target"], - config_path=getattr(args, "config", None)) + _print_summary(assessment, diff, cloak_diff, asset_diff, ext_links, ai_result, cfg["target"]) send_alert(report, cfg) - - # --- Lock freigeben --- - if lock_fd: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - lock_fd.close() - return assessment["exit_code"] @@ -1291,15 +1261,8 @@ def _print_summary(assessment: dict, diff: dict, asset_diff: dict | None = None, ext_links: dict | None = None, ai_result: dict | None = None, - target: str = "", - config_path: str | None = None) -> None: + target: str = "") -> None: level = assessment.get("level", "green") - if config_path: - scan_cmd = f"python -m scanner --config {config_path} scan" - report_cmd = f"python -m scanner --config {config_path} report" - else: - scan_cmd = scanner_cmd(target, "scan") - report_cmd = scanner_cmd(target, "report") print() print(ampel_zeile(level)) print() @@ -1310,12 +1273,11 @@ def _print_summary(assessment: dict, diff: dict, print(f" → {schritt}") # Knappe technische Kennzahlen darunter (für den Dienstleister) print() - print(f" — Ausgeführt mit: {scan_cmd}") print(f" — Technische Details: Score {assessment.get('score', 0)}, " f"{diff.get('changed_pages', 0)} geänderte Seite(n), " f"{len(diff.get('new_internal_urls', []))} neue / " f"{len(diff.get('missing_internal_urls', []))} fehlende Seite(n). " - f"Vollständiger Bericht: {report_cmd}") + f"Vollständiger Bericht: {scanner_cmd(target, 'report')}") def cmd_test_alert(args: argparse.Namespace, cfg: dict) -> int: diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index 65cf768..f8b7f48 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -14,17 +14,13 @@ Robustheit: Fehlt der API-Key oder schlägt ein Call fehl, wird die Analyse übersprungen und der Scan läuft unverändert weiter (graceful degradation). KI darf den Kern-Scan nie brechen. """ -import base64 import concurrent.futures -import hashlib import json import logging import os -import subprocess import threading import time from datetime import datetime, timezone -from pathlib import Path import requests @@ -33,16 +29,7 @@ from .differ import normalize_text logger = logging.getLogger(__name__) -# Primärer Endpunkt: OpenRouter (wie bisher). _OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" -_MODELS_URL = "https://openrouter.ai/api/v1/models" - -# Lokale llama.cpp-Server als Fallback — werden nur genutzt, wenn OpenRouter -# nicht erreichbar ist (Rate-Limit, Timeout, Ausfall). -_LOCAL_SERVERS: list[str] = [ - "http://localhost:8001", - "http://localhost:8002", -] # Erlaubte Kategorien, die das Modell zurückgeben darf. _CATEGORIES = [ @@ -84,7 +71,7 @@ _RESPONSE_SCHEMA = { } - +_MODELS_URL = "https://openrouter.ai/api/v1/models" # --------------------------------------------------------------------------- @@ -93,9 +80,9 @@ _RESPONSE_SCHEMA = { class ModelRouter: """Wählt programmatisch das beste Modell aus einer Kette: gesunde zuerst, - nach Latenz; ausgefallene Modelle bekommen einen Cooldown (Circuit-Breaker). - Katalog-Pruning funktioniert für OpenRouter- und lokale Modell-Slugs. - Thread-sicher (für Parallelität).""" + nach Latenz; rate-limited/ausgefallene Modelle bekommen einen Cooldown + (Circuit-Breaker) und werden so lange übersprungen. Tote Slugs werden über + den /models-Katalog failsafe aussortiert. Thread-sicher (für Parallelität).""" def __init__(self, ai_cfg: dict, state: dict | None = None): self._lock = threading.Lock() @@ -104,10 +91,7 @@ class ModelRouter: self._refresh = ai_cfg.get("refresh_models", True) state = state or {} self._models: dict = state.get("models", {}) or {} - # catalog: {"slugs": [...], "servers": {...}, "fetched_at": ...} - # slugs = OpenRouter-Katalog (primär), servers = lokale Server-Modelle - saved = state.get("catalog", {"slugs": [], "servers": {}, "fetched_at": None}) - self._catalog: dict = saved if isinstance(saved, dict) else {"slugs": [], "servers": {}, "fetched_at": None} + self._catalog: dict = state.get("catalog", {"slugs": [], "fetched_at": None}) def state(self) -> dict: with self._lock: @@ -152,61 +136,29 @@ class ModelRouter: return ordered or list(models) def _prune_locked(self, models: list[str]) -> list[str]: - # Kombiniere OpenRouter-Slugs + lokale Server-Modelle. - all_slugs: set[str] = set(self._catalog.get("slugs", [])) - for slugs in self._catalog.get("servers", {}).values(): - all_slugs.update(slugs) - if not all_slugs: + slugs = self._catalog.get("slugs") or [] + if not slugs: return list(models) # kein Katalog → nicht filtern (failsafe) - keep = [m for m in models if m in all_slugs] + keep = [m for m in models if m in slugs] return keep or list(models) # nie alles wegfiltern def refresh_catalog(self) -> None: - """OpenRouter /models + lokale Server /models ziehen (24 h gecacht). - Failsafe: blockiert nie. Thread-safe via self._lock.""" + """Einmal pro Lauf /models ziehen (24 h gecacht). Failsafe: blockiert nie.""" if not self._refresh: return - # Cache-Check unter Lock — vermeidet doppelte Fetches bei parallelen Scans - with self._lock: - fetched_at = self._catalog.get("fetched_at") - slugs = self._catalog.get("slugs") or [] - servers = self._catalog.get("servers") or {} - if fetched_at and (slugs or servers) and (time.time() - fetched_at) < 86400: - return # Cache frisch - - new_slugs, new_servers = [], {} - # 1. OpenRouter-Katalog (primär) + fetched_at = self._catalog.get("fetched_at") + if fetched_at and self._catalog.get("slugs") and (time.time() - fetched_at) < 86400: + return # Cache frisch try: - resp = requests.get(_MODELS_URL, timeout=10) + resp = requests.get(_MODELS_URL, timeout=15) if resp.status_code == 200: - new_slugs = [m["id"] for m in resp.json().get("data", []) if m.get("id")] - logger.info("KI-Router: OpenRouter-Katalog aktualisiert (%d Modelle).", - len(new_slugs)) + slugs = [m["id"] for m in resp.json().get("data", []) if m.get("id")] + if slugs: + with self._lock: + self._catalog = {"slugs": slugs, "fetched_at": time.time()} + logger.info("KI-Router: /models-Katalog aktualisiert (%d Modelle).", len(slugs)) except (requests.exceptions.RequestException, ValueError, KeyError) as exc: - logger.debug("KI-Router: OpenRouter /models nicht abrufbar: %s", exc) - - # 2. Lokale Server-Kataloge (Fallback) - for base_url in _LOCAL_SERVERS: - try: - resp = requests.get(f"{base_url}/v1/models", timeout=5) - if resp.status_code == 200: - data = resp.json() - raw = data.get("data") or data.get("models", []) - local_slugs = [m["id"] for m in raw if isinstance(m, dict) and m.get("id")] - if local_slugs: - new_servers[base_url] = local_slugs - logger.info("KI-Router: %s — %d Modelle gefunden.", base_url, len(local_slugs)) - elif resp.status_code == 503: - logger.debug("KI-Router: %s — Modell wird geladen (503).", base_url) - except requests.exceptions.RequestException as exc: - logger.debug("KI-Router: %s nicht erreichbar: %s", base_url, exc) - - if new_slugs or new_servers: - with self._lock: - self._catalog = {"slugs": new_slugs, "servers": new_servers, - "fetched_at": time.time()} - total = len(new_slugs) + sum(len(v) for v in new_servers.values()) - logger.info("KI-Router: Katalog aktualisiert (%d Modelle gesamt).", total) + logger.warning("KI-Router: /models nicht abrufbar (failsafe, ungefiltert): %s", exc) # --------------------------------------------------------------------------- @@ -257,12 +209,7 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict: if ai_cfg.get("image", {}).get("enabled", True): dirty |= _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor, url_hashes) - if ai_cfg.get("audio", {}).get("enabled", False): - dirty |= _analyze_audio(cfg, ai_cfg, api_key, snap, diff, - entries, result, router, executor, url_hashes) - if ai_cfg.get("video", {}).get("enabled", False): - dirty |= _analyze_video(cfg, ai_cfg, api_key, snap, diff, - entries, result, router, executor, url_hashes) + # Audio/Video: vorbereitet, default aus (siehe _collect_audio/video_candidates). finally: if dirty: bm.save_ai_ledger(ledger) @@ -506,178 +453,19 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e # --------------------------------------------------------------------------- -# Audio analysis +# Audio / Video — vorbereitete Erweiterungspunkte (default deaktiviert) # --------------------------------------------------------------------------- -def _analyze_audio(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor, - url_hashes) -> bool: - """Prüft neue/geänderte Audio-Dateien (parallel). Returns True wenn Ledger verändert.""" - aud_cfg = ai_cfg.get("audio", {}) - models = _models_for(aud_cfg) - site_context = ai_cfg.get("site_context", "") - - current_urls = _all_media_urls(snap, "audio") - if not current_urls: - return False - - changed = {pd["url"] for pd in diff.get("page_diffs", [])} | set(diff.get("new_internal_urls", [])) - changed_urls = _all_media_urls(snap, "audio", only_pages=changed) - flagged_urls = { - e["url"] for e in entries.values() - if e.get("kind") == "audio" and e.get("category", "clean") != "clean" - and not e.get("dismissed") and e.get("url") in current_urls - } - - priority = sorted((changed_urls | flagged_urls) & current_urls) - coverage = sorted((current_urls - set(url_hashes)) - set(priority)) - backlog_limit = aud_cfg.get("max_files_per_scan", 5) - if backlog_limit and backlog_limit > 0: - coverage = coverage[:backlog_limit] - fetch_urls = priority + coverage - if not fetch_urls: - return False - - url_to_page = _media_url_sources(snap, "audio") - - # Sampling: für jede URL via ffmpeg einen 90-s-Ausschnitt holen (hash + bytes). - # Ersetzt _local_file_hashes + fetch_asset_hashes — kein 40-60 MB Download mehr. - samples: dict[str, tuple[bytes, str]] = {} # url → (mp3_bytes, sha256) - dirty = False - fp_assets: dict[str, list[str]] = {} - order_fps: list[str] = [] - - for url in fetch_urls: - sample = _audio_sample(url, timeout=ai_cfg.get("request_timeout", 60)) - if sample is None: - result["unchecked"].append( - {"kind": "audio", "url": url_to_page.get(url, url), "asset_url": url} - ) - continue - data, fp = sample - samples[url] = (data, fp) - if url_hashes.get(url) != fp: - url_hashes[url] = fp - dirty = True - result["checked"] += 1 - if fp not in fp_assets: - fp_assets[fp] = [] - order_fps.append(fp) - fp_assets[fp].append(url) - - tasks, miss_fps = [], [] - for fp in order_fps: - entry = entries.get(fp) - if entry is not None: - for url in fp_assets[fp]: - result["cache_hits"] += 1 - _maybe_finding(result, entry, fp, url_to_page.get(url, url), ai_cfg, asset_url=url) - else: - miss_fps.append(fp) - rep_bytes = samples[fp_assets[fp][0]][0] - tasks.append((fp, _audio_task(rep_bytes, site_context, models, ai_cfg, api_key, router))) - - classified = _classify_batch(tasks, executor) - for fp in miss_fps: - res = classified.get(fp) - rep_url = fp_assets[fp][0] - if res is None: - for url in fp_assets[fp]: - result["unchecked"].append( - {"kind": "audio", "url": url_to_page.get(url, url), "asset_url": url}) - continue - verdict, used_model = res - entry = _make_entry("audio", rep_url, verdict, used_model) - entries[fp] = entry - result["api_calls"] += 1 - dirty = True - for url in fp_assets[fp]: - _maybe_finding(result, entry, fp, url_to_page.get(url, url), ai_cfg, asset_url=url) - return dirty +def _collect_audio_candidates(snap: dict, cfg: dict) -> list[str]: + """TODO: Audio-Quellen sammeln. Erfordert