"""Config loading with deep-merge against defaults.""" from pathlib import Path import yaml DEFAULT_CONFIG: dict = { "target": "https://bredelar.info", "user_agent": "integrity-scanner/1.0 (+security-monitoring)", "request_timeout": 20, "data_dir": "data", "reports_dir": "reports", "logs_dir": "logs", "config_dir": "config", "crawl": { "max_pages": 200, "delay_seconds": 1.0, "respect_robots_txt": False, "skip_extensions": [ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico", ".pdf", ".zip", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp4", ".mp3", ".webm", ".avi", ".mov", ], "exclude_paths": [], "sitemap": True, }, "scoring": { "new_external_domain": 50, "new_internal_url": 30, "missing_internal_url": 20, "hidden_content": 40, "unexpected_canonical": 35, "meta_refresh": 40, "unexpected_jsonld": 35, "large_text_addition": 20, "new_inline_script_suspicious": 30, "comment_links": 25, "new_broken_link": 20, "suspicious_filename": 60, "cloaking_extra_link": 60, "cloaking_extra_text": 40, "cloaking_vary_ua": 25, "changed_script": 40, "changed_stylesheet": 30, "changed_asset": 20, # KI-Inhaltsanalyse (additiv; Gesamt-Level aus KI ist auf gelb gedeckelt) "ai_pornography": 50, "ai_defamation_illegal": 50, "ai_propaganda": 40, "ai_hidden_spam": 40, "ai_off_topic_commercial": 30, "ai_contradiction": 20, "ai_suspicious_image": 40, }, "thresholds": { "yellow": 20, "red": 60, "large_text_block_chars": 200, }, "periodic_checks": { "enabled": True, "interval_days": 7, "cloak_check": True, "ext_links": True, "assets": True, }, "alerting": { "min_level": "yellow", "email": { "enabled": False, "smtp_host": "linix.de", "smtp_port": 587, "smtp_tls": True, "smtp_user": "eliza", "smtp_password_env": "SCANNER_SMTP_PASSWORD", "from": "eliza@linix.de", "to": ["dieter.schlueter@linix.de"], }, "webhook": { "enabled": False, "url": "", }, }, # KI-gestützte semantische Inhaltsanalyse (OpenRouter). Optional, opt-in. # Hash-gegated: nur neue/geänderte Inhalte lösen einen API-Call aus — siehe # ai_ledger.json. Der API-Key kommt NUR aus der Umgebungsvariable (nie in config.yaml). "ai_analysis": { "enabled": False, "api_key_env": "OPENROUTER_API_KEY", "site_context": "", # Themenbeschreibung der Website (Pflicht wenn enabled) "ai_confidence_min": 0.7, # Mindest-Konfidenz, damit ein Fund gewertet wird # KI-Funde sind auf Gelb gedeckelt — AUSSER diese schwerwiegenden Kategorien # lösen bei hoher Schwere + hoher Konfidenz ROT aus. Leere Liste = immer max. Gelb. "red_categories": ["pornography", "defamation_illegal"], "red_min_severity": "high", "red_min_confidence": 0.9, "request_timeout": 60, # Ceiling (auch für Asset-Fetch der Bilder) "attempt_timeout": 20, # HARTES Wanduhr-Limit je Modell-Versuch → Eskalation "max_retries": 1, # Wiederholungen der ganzen Modell-Kette bei Komplettausfall "retry_backoff_seconds": 2.0, # Parallelität: gleichzeitige API-Requests. I/O-gebunden → an die Rate-Limits der # API gebunden, NICHT an CPU-Kerne (vhost wie 24-Kern-Maschine gleich steuerbar). "max_concurrency": 8, # Adaptiver Router: ausgefallene/rate-limited Modelle werden nach so vielen Fehlern # für die Cooldown-Dauer übersprungen; das schnellste gesunde Modell zuerst. "breaker_failure_threshold": 2, "breaker_cooldown_seconds": 120, "refresh_models": True, # tote Slugs failsafe über OpenRouter /models aussortieren # Verhalten, wenn Inhalte trotz Retry NICHT geprüft werden konnten (KI nicht erreichbar): # "warn" = sichtbarer Hinweis in Report/Terminal/E-Mail (Level bleibt unberührt) # "yellow" = zusätzlich Gesamt-Level auf mindestens Gelb anheben # Für ein Sicherheitswerkzeug gilt: "konnte nicht prüfen" ≠ "sauber". "unchecked_level": "warn", # Modell-Kette mit zweifacher Eskalation: Stufe 1+2 free, Stufe 3 günstig bezahlt. # Schlägt ein Modell fehl oder antwortet zu langsam, wird zur nächsten Stufe eskaliert. "text": { "enabled": True, "models": [ "qwen/qwen3-next-80b-a3b-instruct:free", # Stufe 1: free "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, # nur Seitentexte ab dieser Größe prüfen "max_pages_per_scan": 20, # Kostendeckel pro Lauf }, "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, }, # Vorbereitet, default aus — Medien sind auf statischen Heimat-Seiten selten. "audio": { "enabled": False, "models": ["google/gemini-2.5-flash"], "max_files_per_scan": 5, }, "video": { "enabled": False, "models": ["google/gemini-2.5-flash"], "max_files_per_scan": 3, }, }, "normalization": { "strip_html_comments": True, "collapse_whitespace": True, "ignore_selectors": [], "ignore_patterns": [], }, "allowed_jsonld_types": [ "Organization", "WebSite", "WebPage", "Article", "BreadcrumbList", "ItemList", "LocalBusiness", "Place", "Person", "Event", "FAQPage", "Question", "Answer", "ImageObject", "SiteLinksSearchBox", "ContactPage", "AboutPage", ], "security_headers": [ "Content-Security-Policy", "Strict-Transport-Security", "X-Content-Type-Options", "Referrer-Policy", ], } def load_config(config_file: str = "config.yaml") -> dict: """Load config from YAML, deep-merged on top of defaults.""" cfg = _deep_copy(DEFAULT_CONFIG) p = Path(config_file) if p.exists(): with p.open(encoding="utf-8") as f: user_cfg = yaml.safe_load(f) or {} _deep_update(cfg, user_cfg) return cfg def resolve_paths(cfg: dict, base_dir: Path) -> dict: """Resolve relative paths in config against base_dir.""" for key in ("data_dir", "reports_dir", "logs_dir", "config_dir"): cfg[key] = str(base_dir / cfg[key]) return cfg def _deep_copy(d: dict) -> dict: import copy return copy.deepcopy(d) def _deep_update(base: dict, override: dict) -> None: for k, v in override.items(): if isinstance(v, dict) and isinstance(base.get(k), dict): _deep_update(base[k], v) elif v is None and isinstance(base.get(k), list): pass # YAML null on a list field = "not set" → keep default else: base[k] = v