console-error-scanner 1.16.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. console_error_scanner/__init__.py +5 -0
  2. console_error_scanner/__main__.py +370 -0
  3. console_error_scanner/app.py +1852 -0
  4. console_error_scanner/app.tcss +91 -0
  5. console_error_scanner/i18n.py +74 -0
  6. console_error_scanner/locale/de.json +417 -0
  7. console_error_scanner/locale/en.json +417 -0
  8. console_error_scanner/models/__init__.py +1 -0
  9. console_error_scanner/models/history.py +220 -0
  10. console_error_scanner/models/robots.py +154 -0
  11. console_error_scanner/models/scan_result.py +265 -0
  12. console_error_scanner/models/settings.py +242 -0
  13. console_error_scanner/models/sitemap.py +347 -0
  14. console_error_scanner/models/whitelist.py +226 -0
  15. console_error_scanner/screens/__init__.py +1 -0
  16. console_error_scanner/screens/diet_advisor.py +128 -0
  17. console_error_scanner/screens/error_detail.py +164 -0
  18. console_error_scanner/screens/history.py +187 -0
  19. console_error_scanner/screens/scan_summary.py +234 -0
  20. console_error_scanner/screens/settings.py +292 -0
  21. console_error_scanner/screens/top_errors.py +304 -0
  22. console_error_scanner/screens/whitelist.py +154 -0
  23. console_error_scanner/services/__init__.py +1 -0
  24. console_error_scanner/services/image_clipboard.py +104 -0
  25. console_error_scanner/services/preview_service.py +291 -0
  26. console_error_scanner/services/rate_limit.py +56 -0
  27. console_error_scanner/services/reporter.py +561 -0
  28. console_error_scanner/services/scanner.py +965 -0
  29. console_error_scanner/services/site_score.py +151 -0
  30. console_error_scanner/widgets/__init__.py +1 -0
  31. console_error_scanner/widgets/bar_chart.py +60 -0
  32. console_error_scanner/widgets/preview_panel.py +356 -0
  33. console_error_scanner/widgets/results_table.py +594 -0
  34. console_error_scanner/widgets/stats_panel.py +387 -0
  35. console_error_scanner/widgets/summary_panel.py +176 -0
  36. console_error_scanner-1.16.1.dist-info/METADATA +485 -0
  37. console_error_scanner-1.16.1.dist-info/RECORD +41 -0
  38. console_error_scanner-1.16.1.dist-info/WHEEL +5 -0
  39. console_error_scanner-1.16.1.dist-info/entry_points.txt +2 -0
  40. console_error_scanner-1.16.1.dist-info/licenses/LICENSE +202 -0
  41. console_error_scanner-1.16.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1852 @@
1
+ """Hauptanwendung fuer Console Error Scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import dataclasses
8
+ import re
9
+ import time
10
+ import traceback
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from urllib.parse import urlparse
14
+
15
+ from rich.markup import escape as escape_markup
16
+ from textual import on, work
17
+ from textual.app import App, ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.containers import Horizontal, Vertical
20
+ from textual.screen import ModalScreen
21
+ from textual.timer import Timer
22
+ from textual.widget import Widget
23
+ from textual.widgets import Button, Footer, Header
24
+ from textual_themes import THEME_DISPLAY_NAMES, register_all
25
+ from textual_widgets import (
26
+ DISCLAIMER_VERSION,
27
+ ClickableLinksMixin,
28
+ ContextMenuItem,
29
+ ContextMenuScreen,
30
+ CrashGuard,
31
+ DisclaimerScreen,
32
+ DisclaimerStore,
33
+ HorizontalSplitter,
34
+ LogPanel,
35
+ LogRouter,
36
+ UrlInputScreen,
37
+ VerticalSplitter,
38
+ )
39
+
40
+ from . import __author__, __version__, __year__
41
+ from .i18n import current_language, t
42
+ from .models.history import History, HistoryEntry
43
+ from .models.robots import RobotsChecker
44
+ from .models.scan_result import PageStatus, ScanResult, ScanSummary
45
+ from .models.settings import CRASH_LOG_NAME, SETTINGS_FILE, Settings, parse_cookies
46
+ from .models.sitemap import SitemapError, SitemapParser, discover_sitemap, is_local_file, is_sitemap_url
47
+ from .models.whitelist import Whitelist
48
+ from .services.reporter import Reporter
49
+ from .services.scanner import Scanner
50
+ from .widgets.preview_panel import PreviewPanel
51
+ from .widgets.results_table import ResultsTable
52
+ from .widgets.stats_panel import StatsPanel
53
+ from .widgets.summary_panel import SummaryHeader
54
+
55
+
56
+ class ConsoleErrorScannerApp(CrashGuard, ClickableLinksMixin, LogRouter, App):
57
+ """TUI-Anwendung zum Scannen von Websites auf Console- und HTTP-Fehler."""
58
+
59
+ CSS_PATH = "app.tcss"
60
+ TITLE = f"Console Error Scanner v{__version__}"
61
+
62
+ BINDINGS = [
63
+ Binding("q,Q", "quit", "placeholder", key_display="q"),
64
+ # Konvention ueber alle Michael-TUIs: c = Crawl/Scan starten, s = Settings.
65
+ Binding("c,C", "start_scan", "placeholder", key_display="c"),
66
+ # Nur waehrend eines Laufs in der Fusszeile - siehe check_action.
67
+ Binding("x,X", "cancel_scan", "placeholder", key_display="x"),
68
+ Binding("m,M", "load_sitemap_file", "placeholder", key_display="m"),
69
+ Binding("s,S", "show_settings", "placeholder", key_display="s"),
70
+ Binding("h,H", "show_history", "placeholder", key_display="h"),
71
+ Binding("r,R", "save_reports", "placeholder", key_display="r"),
72
+ Binding("j,J", "export_jira", "placeholder", key_display="j"),
73
+ Binding("w,W", "show_whitelist", "placeholder", key_display="w"),
74
+ Binding("e,E", "toggle_errors", "placeholder", key_display="e"),
75
+ Binding("t,T", "cycle_theme", "placeholder", key_display="t"),
76
+ Binding("l,L", "toggle_log", "placeholder", key_display="l"),
77
+ Binding("d,D", "copy_details", "placeholder", key_display="d"),
78
+ Binding("z,Z", "show_summary", "placeholder", key_display="z"),
79
+ Binding("question_mark", "show_http_codes", "placeholder", key_display="?"),
80
+ Binding("f10", "show_top_errors", "placeholder", key_display="F10"),
81
+ Binding("slash", "focus_filter", "Filter", key_display="/", show=False),
82
+ Binding("escape", "unfocus_filter", "placeholder", show=False),
83
+ Binding("i,I", "show_about", "placeholder", key_display="i"),
84
+ ]
85
+
86
+ # action -> i18n-Schluessel (description + tooltip).
87
+ _BINDING_I18N: dict[str, str] = {
88
+ "quit": "quit",
89
+ "start_scan": "crawl",
90
+ "cancel_scan": "cancel_scan",
91
+ "load_sitemap_file": "load_sitemap",
92
+ "show_settings": "settings",
93
+ "show_history": "history",
94
+ "save_reports": "report",
95
+ "export_jira": "jira",
96
+ "show_whitelist": "whitelist",
97
+ "toggle_errors": "errors_only",
98
+ "cycle_theme": "theme",
99
+ "toggle_log": "log",
100
+ "copy_details": "copy_details",
101
+ "show_top_errors": "top_errors",
102
+ "show_summary": "summary",
103
+ "show_http_codes": "http_codes",
104
+ "show_about": "info",
105
+ }
106
+
107
+ def __init__(
108
+ self,
109
+ sitemap_url: str = "",
110
+ concurrency: int | None = None,
111
+ timeout: int | None = None,
112
+ output_json: str = "",
113
+ output_html: str = "",
114
+ headless: bool = True,
115
+ url_filter: str = "",
116
+ console_level: str = "",
117
+ user_agent: str = "",
118
+ cookies: list[dict[str, str]] | None = None,
119
+ whitelist_path: str = "",
120
+ accept_consent: bool | None = None,
121
+ trigger_lazy_load: bool | None = None,
122
+ respect_robots: bool | None = None,
123
+ ) -> None:
124
+ super().__init__()
125
+
126
+ # Sprache fuer den CrashGuard-Fehlerdialog
127
+ self.crash_guard_lang = current_language()
128
+
129
+ # Alle Retro-Themes aus textual-themes registrieren (Ctrl+P → "theme")
130
+ register_all(self)
131
+
132
+ # Persistierte Einstellungen laden
133
+ self._settings = Settings.load()
134
+
135
+ # Zustimmung zum Haftungshinweis liegt neben den Einstellungen.
136
+ self._disclaimer = DisclaimerStore(SETTINGS_FILE.parent / "disclaimer.json")
137
+
138
+ # CLI ueberschreibt Settings; sonst Settings-Default.
139
+ self.sitemap_url = sitemap_url
140
+ self.concurrency = concurrency if concurrency is not None else self._settings.concurrency
141
+ self.timeout = timeout if timeout is not None else self._settings.timeout
142
+ self.output_json = output_json
143
+ self.output_html = output_html
144
+ # headless: CLI --no-headless erzwingt False; sonst Settings (no_headless invertiert).
145
+ self.headless = headless if not headless else (not self._settings.no_headless)
146
+ self.url_filter = url_filter
147
+ self.console_level = console_level or self._settings.console_level
148
+ self.user_agent = user_agent or self._settings.user_agent
149
+ # Cookies: CLI gewinnt; sonst aus dem Settings-String.
150
+ self.cookies = cookies if cookies else parse_cookies(self._settings.cookies)
151
+ # Whitelist-Pfad: CLI gewinnt, sonst Settings, sonst whitelist.json im CWD.
152
+ self.whitelist_path = whitelist_path or self._settings.whitelist_path
153
+ self.accept_consent = accept_consent if accept_consent is not None else self._settings.accept_consent
154
+ self.trigger_lazy_load = (
155
+ trigger_lazy_load if trigger_lazy_load is not None else self._settings.trigger_lazy_load
156
+ )
157
+ self.respect_robots = respect_robots if respect_robots is not None else self._settings.respect_robots
158
+ # Drosselung: abgeschaltet entspricht 0 Aufrufen/Minute, also kein Limit.
159
+ self.rate_per_minute = self._settings.rate_per_minute if self._settings.rate_limit_enabled else 0
160
+ self.show_preview: bool = self._settings.show_preview
161
+ # Schwellwert (MB) fuer die Gross-Seiten-Hervorhebung in der Tabelle.
162
+ self.size_warn_mb: int = self._settings.size_warn_mb
163
+ # Optionaler Corporate-Proxy (Zscaler) fuer httpx + Playwright.
164
+ self.proxy_url: str = self._settings.proxy_url
165
+
166
+ # Theme aus Settings uebernehmen. Ein gespeichertes Theme kann
167
+ # verschwunden sein: die Bibliothek wurde herabgestuft, das Theme
168
+ # umbenannt, oder es kam aus einer noch nicht veroeffentlichten
169
+ # Fassung. Ohne diese Pruefung wirft Textual InvalidThemeError und
170
+ # die Anwendung startet gar nicht mehr.
171
+ self._discarded_theme = ""
172
+ if self._settings.theme in self.available_themes:
173
+ self.theme = self._settings.theme
174
+ elif self._settings.theme:
175
+ self._discarded_theme = self._settings.theme
176
+
177
+ self._urls: list[str] = []
178
+ self._results: list[ScanResult] = []
179
+ self._scanner: Scanner | None = None
180
+ self._whitelist: Whitelist | None = None
181
+ # Aktionen des Whitelist-Kontextmenues (Index -> (kind, pattern)).
182
+ self._wl_ctx_actions: list[tuple[str, str]] = []
183
+ self._sitemap_loading: bool = False
184
+ self._sitemap_timer: Timer | None = None
185
+ self._sitemap_dots: int = 0
186
+ self._scan_running: bool = False
187
+ # Merkt den Abbruch ueber das Ende des Laufs hinaus: nach dem Aufraeumen
188
+ # ist der Scanner weg, die Auswertung muss aber wissen, dass der Lauf
189
+ # unvollstaendig ist.
190
+ self._scan_cancelled: bool = False
191
+ self._scan_start_time: float = 0
192
+ self._scan_current: int = 0
193
+ self._scan_total: int = 0
194
+ self._scan_progress_timer: Timer | None = None
195
+ # Startklar nach Sitemap-Laden / History-Auswahl -> Footer-Taste "c"
196
+ # blinkt, bis der Scan startet.
197
+ self._scan_ready: bool = False
198
+ self._attention_on: bool = False
199
+ self._preview_service = None # PreviewService lazy beim ersten Bild
200
+
201
+ self._apply_binding_i18n()
202
+
203
+ def _apply_binding_i18n(self) -> None:
204
+ """Setzt uebersetzte Description + Tooltip auf jedes Footer-Binding."""
205
+ for bindings_list in self._bindings.key_to_bindings.values():
206
+ for i, binding in enumerate(bindings_list):
207
+ key_i18n = self._BINDING_I18N.get(binding.action)
208
+ if key_i18n is None:
209
+ continue
210
+ bindings_list[i] = dataclasses.replace(
211
+ binding,
212
+ description=t(f"binding.{key_i18n}"),
213
+ tooltip=t(f"tooltip.{key_i18n}"),
214
+ )
215
+
216
+ def compose(self) -> ComposeResult:
217
+ """Erstellt das UI-Layout."""
218
+ yield Header()
219
+ yield SummaryHeader(
220
+ id="summary",
221
+ concurrency=self.concurrency,
222
+ timeout=self.timeout,
223
+ console_level=self.console_level,
224
+ accept_consent=self.accept_consent,
225
+ trigger_lazy_load=self.trigger_lazy_load,
226
+ whitelist_active=bool(self.whitelist_path),
227
+ )
228
+
229
+ with Horizontal(id="main-container"):
230
+ with Vertical(id="left-panel"):
231
+ yield ResultsTable(id="results-table")
232
+ yield HorizontalSplitter(target_id="results-table", min_size=5, id="log-splitter")
233
+ yield LogPanel(
234
+ lang=current_language(),
235
+ export_name="console-error-scanner",
236
+ id="scan-log",
237
+ )
238
+
239
+ yield VerticalSplitter(target_id="left-panel", min_size=40, id="main-splitter")
240
+ with Vertical(id="right-panel"):
241
+ yield PreviewPanel(id="preview-panel")
242
+ yield HorizontalSplitter(target_id="preview-panel", min_size=5, id="preview-splitter")
243
+ yield StatsPanel(id="stats-panel")
244
+
245
+ yield Footer()
246
+
247
+ def _handle_exception(self, error: Exception) -> None:
248
+ """Schreibt den Traceback auf Platte, bevor der Fehlerdialog laeuft.
249
+
250
+ Der CrashGuard zeigt den Traceback nur im Dialog an. Faellt dieser beim
251
+ Neuaufbau selbst mit (struktureller Defekt), geht der Bericht verloren und
252
+ der naechste Absturz ist wieder undiagnostizierbar - unter Windows bleibt
253
+ dann nur Maus-Steuerzeichen-Muell im Terminal zurueck. Die Datei ueberlebt
254
+ auch den harten Absturzpfad von Textual.
255
+ """
256
+ with contextlib.suppress(Exception):
257
+ self._persist_crash(error)
258
+ super()._handle_exception(error)
259
+
260
+ def _persist_crash(self, error: BaseException) -> None:
261
+ """Haengt den Traceback mit Zeitstempel an die Absturz-Datei an."""
262
+ report = "".join(traceback.format_exception(type(error), error, error.__traceback__))
263
+ path = SETTINGS_FILE.parent / CRASH_LOG_NAME
264
+ path.parent.mkdir(parents=True, exist_ok=True)
265
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
266
+ header = f"\n===== {stamp} - console-error-scanner v{__version__} =====\n"
267
+ with path.open("a", encoding="utf-8") as handle:
268
+ handle.write(header + report)
269
+
270
+ def _ask_disclaimer(self) -> None:
271
+ """Holt den Haftungshinweis ein, solange er nicht (in dieser Fassung) bestaetigt ist."""
272
+ if self._disclaimer.accepted_version == DISCLAIMER_VERSION:
273
+ return
274
+ self.push_screen(
275
+ DisclaimerScreen(
276
+ app_name=f"console-error-scanner {__version__}",
277
+ lang=current_language(),
278
+ author=__author__,
279
+ footer=f"© {__year__} {__author__} · github.com/michaelblaess/console-error-scanner",
280
+ ),
281
+ callback=self._on_disclaimer,
282
+ )
283
+
284
+ def _on_disclaimer(self, accepted: bool | None) -> None:
285
+ """Ohne Zustimmung wird das Programm beendet - der Hinweis ist nicht optional."""
286
+ if not accepted:
287
+ self.exit()
288
+ return
289
+ self._disclaimer.record()
290
+
291
+ def on_mount(self) -> None:
292
+ """Initialisierung nach dem Starten."""
293
+ # Preview-Panel nur einblenden, wenn die Vorschau aktiviert ist -
294
+ # der Splitter ueber dem StatsPanel verschwindet dann mit.
295
+ with contextlib.suppress(Exception):
296
+ self.query_one("#preview-panel", PreviewPanel).display = self.show_preview
297
+ self.query_one("#preview-splitter", HorizontalSplitter).display = self.show_preview
298
+
299
+ # Versionsinfo + Konfiguration ins Log
300
+ self._write_log(f"[bold]{t('log.version', version=__version__)}[/bold]")
301
+ self._log_theme()
302
+ consent_info = t("log.consent_on") if self.accept_consent else t("log.consent_off")
303
+ scroll_info = t("log.scroll_on") if self.trigger_lazy_load else t("log.scroll_off")
304
+ if not self.respect_robots:
305
+ self._write_log(t("log.robots_ignored"))
306
+ # Last-Hinweis frueh ins Protokoll: ohne Limit haengt die Rate allein
307
+ # davon ab, wie schnell das Ziel antwortet.
308
+ if self.rate_per_minute > 0:
309
+ self._write_log(t("log.rate_on", count=self.rate_per_minute))
310
+ else:
311
+ self._write_log(t("log.rate_off", count=self.concurrency))
312
+
313
+ self._ask_disclaimer()
314
+ self._write_log(
315
+ t(
316
+ "log.config",
317
+ concurrency=self.concurrency,
318
+ timeout=self.timeout,
319
+ level=self.console_level,
320
+ consent=consent_info,
321
+ scroll=scroll_info,
322
+ )
323
+ )
324
+
325
+ # Whitelist laden (wenn angegeben oder whitelist.json im CWD vorhanden)
326
+ if not self.whitelist_path:
327
+ cwd_whitelist = Path("whitelist.json")
328
+ if cwd_whitelist.is_file():
329
+ self.whitelist_path = str(cwd_whitelist)
330
+ self._settings.whitelist_path = self.whitelist_path
331
+
332
+ self._load_whitelist_silent()
333
+
334
+ # Focus auf die Tabelle setzen
335
+ with contextlib.suppress(Exception):
336
+ from textual.widgets import DataTable
337
+
338
+ table = self.query_one("#results-data", DataTable)
339
+ table.focus()
340
+
341
+ # Gross-Seiten-Schwellwert an die Tabelle geben (vor dem ersten Laden).
342
+ with contextlib.suppress(Exception):
343
+ self.query_one("#results-table", ResultsTable).set_size_warn_mb(self.size_warn_mb)
344
+
345
+ # Blinkender Footer-Hinweis lenkt den Blick auf die naechste sinnvolle
346
+ # Aktion: ist nach Sitemap-Laden / History-Auswahl startklar, blinkt "c".
347
+ # Manuelles Toggle per Timer statt ANSI-blink (Windows-Terminal-robust).
348
+ self.set_interval(0.6, self._tick_attention)
349
+
350
+ if self.sitemap_url:
351
+ self._load_sitemap()
352
+
353
+ def _tick_attention(self) -> None:
354
+ """Schaltet die Highlight-Klasse auf der Scan-Taste an/aus (Blink)."""
355
+ target = "start_scan" if self._scan_ready and not self._scan_running else ""
356
+ self._attention_on = not self._attention_on if target else False
357
+ key = self._footer_key("start_scan")
358
+ if key is not None:
359
+ key.set_class(target == "start_scan" and self._attention_on, "-attention")
360
+
361
+ def _footer_key(self, action: str) -> Widget | None:
362
+ """Findet die Footer-Taste zu einer Aktion (oder None).
363
+
364
+ Args:
365
+ action:
366
+ Name der Action (z.B. "start_scan").
367
+
368
+ Returns:
369
+ Das FooterKey-Widget oder None, falls der Footer gerade fehlt.
370
+ """
371
+ with contextlib.suppress(Exception):
372
+ for footer_key in self.query("FooterKey"):
373
+ if getattr(footer_key, "action", "") == action:
374
+ return footer_key
375
+ return None
376
+
377
+ def _load_whitelist_silent(self) -> None:
378
+ """Laedt die Whitelist (oder leert sie) ohne Exceptions hochzuwerfen."""
379
+ if not self.whitelist_path:
380
+ self._whitelist = None
381
+ return
382
+ try:
383
+ self._whitelist = Whitelist.load(self.whitelist_path)
384
+ self._write_log(
385
+ f"[green]{t('log.whitelist_loaded', count=len(self._whitelist), path=self.whitelist_path)}[/green]"
386
+ )
387
+ except Exception as e:
388
+ self._write_log(f"[red]{t('log.whitelist_error', error=e)}[/red]")
389
+ self._whitelist = None
390
+
391
+ # --- Sitemap-Loader -----------------------------------------------------
392
+
393
+ def _start_sitemap_loading(self) -> None:
394
+ """Startet die Lade-Animation fuer die Sitemap."""
395
+ self._sitemap_loading = True
396
+ self._sitemap_dots = 0
397
+ self._sitemap_start = time.monotonic()
398
+ self.sub_title = t("subtitle.loading_sitemap")
399
+ self.refresh_bindings()
400
+ self._sitemap_timer = self.set_interval(0.5, self._tick_sitemap_loading)
401
+
402
+ def _tick_sitemap_loading(self) -> None:
403
+ self._sitemap_dots += 1
404
+ dots = "." * self._sitemap_dots
405
+ self.sub_title = t("subtitle.loading_sitemap_dots", dots=dots)
406
+ # Jede ~3 Sekunden zusaetzlich eine Status-Zeile ins Log -
407
+ # der sub_title oben rechts ist auf vielen Terminals leicht zu uebersehen.
408
+ if self._sitemap_dots > 0 and self._sitemap_dots % 6 == 0:
409
+ elapsed = int(time.monotonic() - self._sitemap_start)
410
+ self._write_log(f"[dim]{t('log.sitemap_still_loading', seconds=elapsed)}[/dim]")
411
+
412
+ def _stop_sitemap_loading(self) -> None:
413
+ if self._sitemap_timer is not None:
414
+ self._sitemap_timer.stop()
415
+ self._sitemap_timer = None
416
+ self._sitemap_loading = False
417
+ self._sitemap_dots = 0
418
+ self.refresh_bindings()
419
+
420
+ @work(exclusive=True, group="sitemap")
421
+ async def _load_sitemap(self) -> None:
422
+ """Laedt die Sitemap und zeigt die URLs an."""
423
+ self._start_sitemap_loading()
424
+
425
+ if is_local_file(self.sitemap_url):
426
+ self._write_log(t("log.loading_local_sitemap", url=self.sitemap_url))
427
+ elif not is_sitemap_url(self.sitemap_url):
428
+ self._write_log(t("log.searching_sitemap", url=self.sitemap_url))
429
+ try:
430
+ self.sitemap_url = await discover_sitemap(
431
+ self.sitemap_url,
432
+ cookies=self.cookies,
433
+ log=self._write_log,
434
+ proxy=self.proxy_url,
435
+ )
436
+ except SitemapError as e:
437
+ self._stop_sitemap_loading()
438
+ self._write_log(f"[red]{t('log.sitemap_error', error=e)}[/red]")
439
+ self.app.push_screen(_SitemapErrorScreen(t("sitemap_error.sitemap_error", error=e)))
440
+ return
441
+ self._write_log(t("log.loading_sitemap_url", url=self.sitemap_url))
442
+ else:
443
+ self._write_log(t("log.loading_sitemap_url", url=self.sitemap_url))
444
+
445
+ try:
446
+ parser = SitemapParser(
447
+ self.sitemap_url, url_filter=self.url_filter, cookies=self.cookies, proxy=self.proxy_url
448
+ )
449
+ self._urls = await parser.parse()
450
+ except SitemapError as e:
451
+ self._stop_sitemap_loading()
452
+ self._write_log(f"[red]{t('log.sitemap_error', error=e)}[/red]")
453
+ self.app.push_screen(_SitemapErrorScreen(t("sitemap_error.sitemap_error", error=e)))
454
+ return
455
+ except Exception as e:
456
+ self._stop_sitemap_loading()
457
+ self._write_log(f"[red]{t('log.unexpected_error', error=e)}[/red]")
458
+ self.app.push_screen(_SitemapErrorScreen(t("sitemap_error.unexpected_error", error=e)))
459
+ return
460
+
461
+ self._stop_sitemap_loading()
462
+
463
+ if not self._urls:
464
+ self._write_log(f"[yellow]{t('log.no_urls_in_sitemap')}[/yellow]")
465
+ self.notify(t("notify.no_urls"), severity="warning")
466
+ return
467
+
468
+ self._write_log(f"[green]{t('log.urls_loaded', count=len(self._urls))}[/green]")
469
+
470
+ # robots.txt gilt fuer die SEITEN. Die von ihnen geladenen Ressourcen werden
471
+ # bewusst nicht geprueft: sie liegen oft auf einer CDN-Domain mit eigener
472
+ # robots.txt, und wer die Seite ausliefern darf, liefert sie ohnehin mit aus.
473
+ if self.respect_robots and self._urls:
474
+ robots = RobotsChecker()
475
+ await robots.load(self._urls[0], cookies=self.cookies, proxy=self.proxy_url)
476
+ allowed = [url for url in self._urls if robots.is_allowed(url)]
477
+ blocked = len(self._urls) - len(allowed)
478
+ if blocked:
479
+ self._write_log(f"[yellow]{t('log.robots_blocked', blocked=blocked, total=len(self._urls))}[/yellow]")
480
+ self._urls = allowed
481
+ else:
482
+ self._write_log(t("log.robots_clear"))
483
+
484
+ if not self._urls:
485
+ self._write_log(f"[yellow]{t('log.no_urls_in_sitemap')}[/yellow]")
486
+ self.notify(t("notify.no_urls"), severity="warning")
487
+ return
488
+
489
+ self._results = [ScanResult(url=url) for url in self._urls]
490
+
491
+ summary = self.query_one("#summary", SummaryHeader)
492
+ summary.set_sitemap(self.link_markup(self.sitemap_url, self.sitemap_url), len(self._urls))
493
+
494
+ table = self.query_one("#results-table", ResultsTable)
495
+ table.load_results(self._results)
496
+
497
+ self.sub_title = t("subtitle.urls_count", count=len(self._urls))
498
+
499
+ # Sitemap geladen -> startklar: Footer-Taste "c" blinkt, bis der Scan
500
+ # startet. Deckt auch die History-Auswahl ab (laeuft ueber _load_sitemap).
501
+ self._scan_ready = True
502
+
503
+ # Auto-Scan starten wenn CLI-Reports angefordert
504
+ if self.output_json or self.output_html:
505
+ self.action_start_scan()
506
+
507
+ # --- Scan ---------------------------------------------------------------
508
+
509
+ def action_cancel_scan(self) -> None:
510
+ """Bricht den laufenden Scan ab; die bisherigen Ergebnisse bleiben stehen.
511
+
512
+ Vorher liess sich ein Lauf nur mit 'q' stoppen - und damit war auch die
513
+ Tabelle weg. Der Abbruch ist kooperativ: angestossene Seiten laufen zu
514
+ Ende, neue werden nicht mehr begonnen.
515
+ """
516
+ if not self._scan_running or self._scanner is None:
517
+ self.notify(t("notify.no_scan_active"), severity="warning")
518
+ return
519
+ if self._scan_cancelled: # schon angefordert - der Lauf ebbt gerade ab
520
+ return
521
+ self._scan_cancelled = True
522
+ self._scanner.cancel()
523
+ self._write_log(f"[yellow]{t('log.cancel_requested')}[/yellow]")
524
+ self.notify(t("notify.scan_cancelling"))
525
+ self.sub_title = t("subtitle.cancelling")
526
+ self.refresh_bindings()
527
+
528
+ def action_start_scan(self) -> None:
529
+ """Startet einen neuen Scan. Ohne Sitemap-URL erst URL abfragen."""
530
+ if self._scan_running:
531
+ self.notify(t("notify.scan_running"), severity="warning")
532
+ return
533
+ if self._urls:
534
+ self._run_scan()
535
+ return
536
+ # Keine URLs geladen → URL abfragen, dann Sitemap laden
537
+ self.push_screen(
538
+ UrlInputScreen(
539
+ initial=self.sitemap_url,
540
+ lang=current_language(),
541
+ title=t("url_input.title"),
542
+ prompt=t("url_input.prompt"),
543
+ placeholder=t("url_input.placeholder"),
544
+ ),
545
+ callback=self._on_url_entered,
546
+ )
547
+
548
+ def _on_url_entered(self, url: str | None) -> None:
549
+ """Callback des URL-Dialogs: uebernimmt URL und laedt die Sitemap."""
550
+ if url is None:
551
+ return
552
+ self.sitemap_url = url
553
+ self.sub_title = url
554
+ self._load_sitemap()
555
+
556
+ def action_load_sitemap_file(self) -> None:
557
+ """Oeffnet einen File-Open-Dialog (textual-fspicker) zur Sitemap-Auswahl."""
558
+ if self._scan_running or self._sitemap_loading:
559
+ self.notify(t("notify.scan_running"), severity="warning")
560
+ return
561
+ from textual_fspicker import FileOpen, Filters
562
+
563
+ self.push_screen(
564
+ FileOpen(
565
+ location=str(Path.cwd()),
566
+ title=t("file_picker.title"),
567
+ open_button=t("file_picker.open_button"),
568
+ cancel_button=t("file_picker.cancel_button"),
569
+ filters=Filters(
570
+ (t("file_picker.filter_xml"), lambda p: p.suffix.lower() == ".xml"),
571
+ (t("file_picker.filter_all"), lambda p: True),
572
+ ),
573
+ ),
574
+ callback=self._on_sitemap_file_chosen,
575
+ )
576
+
577
+ def _on_sitemap_file_chosen(self, path: Path | None) -> None:
578
+ """Callback des File-Picker-Dialogs: uebernimmt Datei und laedt die Sitemap."""
579
+ if path is None:
580
+ return
581
+ self.sitemap_url = str(path)
582
+ self.sub_title = path.name
583
+ self._load_sitemap()
584
+
585
+ @work(exclusive=True, group="scan")
586
+ async def _run_scan(self) -> None:
587
+ """Fuehrt den eigentlichen Scan aller URLs aus."""
588
+ if self._scan_running:
589
+ return
590
+ if not self._urls:
591
+ self.notify(t("notify.no_urls_loaded"), severity="error")
592
+ return
593
+
594
+ self._scan_running = True
595
+ self._scan_cancelled = False
596
+ self._scan_ready = False
597
+ self._scan_start_time = time.monotonic()
598
+ self.refresh_bindings() # 'x Scan abbrechen' einblenden, 'c' ausblenden
599
+ self._scan_current = 0
600
+ self._scan_total = len(self._results)
601
+ self._scan_progress_timer = self.set_interval(0.5, self._tick_scan_progress)
602
+
603
+ # Laufende Preview-Worker abbrechen + Panel leeren - das Sidecar-
604
+ # Browser-Tab darf nicht parallel zum Scan rendern (kostet CPU/RAM
605
+ # und macht ohnehin keinen Sinn waehrend der Cursor durch alle
606
+ # Zeilen wandert).
607
+ for worker in list(self.workers):
608
+ if getattr(worker, "group", None) == "preview":
609
+ worker.cancel()
610
+ with contextlib.suppress(Exception):
611
+ self.query_one("#preview-panel", PreviewPanel).clear()
612
+
613
+ # Log-Panel einblenden + leeren
614
+ log_panel = self.query_one("#scan-log", LogPanel)
615
+ log_panel.show()
616
+ log_panel.clear_log()
617
+ self.query_one("#log-splitter", HorizontalSplitter).remove_class("hidden")
618
+
619
+ # Ergebnisse zuruecksetzen (gleiche Objekte behalten!)
620
+ for result in self._results:
621
+ result.status = PageStatus.PENDING
622
+ result.http_status_code = 0
623
+ result.load_time_ms = 0
624
+ result.page_size_bytes = 0
625
+ result.errors.clear()
626
+ result.retry_count = 0
627
+
628
+ table = self.query_one("#results-table", ResultsTable)
629
+ table.load_results(self._results)
630
+
631
+ # History-Eintrag speichern
632
+ with contextlib.suppress(Exception):
633
+ History.add(
634
+ HistoryEntry(
635
+ sitemap_url=self.sitemap_url,
636
+ concurrency=self.concurrency,
637
+ timeout=self.timeout,
638
+ console_level=self.console_level,
639
+ url_filter=self.url_filter,
640
+ user_agent=self.user_agent,
641
+ cookies=list(self.cookies),
642
+ whitelist_path=self.whitelist_path,
643
+ accept_consent=self.accept_consent,
644
+ trigger_lazy_load=self.trigger_lazy_load,
645
+ )
646
+ )
647
+ self._write_log(f"[dim]{t('log.history_updated')}[/dim]")
648
+
649
+ self._scanner = Scanner(
650
+ concurrency=self.concurrency,
651
+ timeout=self.timeout,
652
+ headless=self.headless,
653
+ console_level=self.console_level,
654
+ user_agent=self.user_agent,
655
+ cookies=self.cookies,
656
+ accept_consent=self.accept_consent,
657
+ trigger_lazy_load=self.trigger_lazy_load,
658
+ proxy=self.proxy_url,
659
+ rate_per_minute=self.rate_per_minute,
660
+ )
661
+
662
+ def on_result(result: ScanResult) -> None:
663
+ self._on_scan_result(result)
664
+
665
+ def on_log(msg: str) -> None:
666
+ self._write_log(msg)
667
+
668
+ def on_progress(current: int, total: int) -> None:
669
+ self._scan_current = current
670
+ self._scan_total = total
671
+
672
+ try:
673
+ await self._scanner.scan_urls(
674
+ self._results,
675
+ on_result=on_result,
676
+ on_log=on_log,
677
+ on_progress=on_progress,
678
+ )
679
+ except Exception as e:
680
+ self._write_log(f"[red]{t('log.sitemap_error', error=e)}[/red]")
681
+ self.notify(t("notify.scan_error", error=e), severity="error")
682
+ finally:
683
+ self._scan_running = False
684
+ self._scanner = None
685
+ if self._scan_progress_timer is not None:
686
+ self._scan_progress_timer.stop()
687
+ self._scan_progress_timer = None
688
+ self.refresh_bindings()
689
+
690
+ # Abgebrochen: die bereits geprueften Seiten bleiben in der Tabelle, damit
691
+ # man sie ansehen kann. Keine Zusammenfassung und kein Site-Score - beide
692
+ # wuerden ein Teilergebnis wie ein vollstaendiges aussehen lassen.
693
+ if self._scan_cancelled:
694
+ # Seiten, die beim Abbruch mitten im Laden waren, stehen sonst dauerhaft
695
+ # auf "wird gescannt" - sie wurden aber nicht geprueft.
696
+ for result in self._results:
697
+ if result.status is PageStatus.SCANNING:
698
+ result.status = PageStatus.PENDING
699
+ self.query_one("#results-table", ResultsTable).load_results(self._results)
700
+ scanned = sum(1 for r in self._results if r.status is not PageStatus.PENDING)
701
+ self._write_log(f"[yellow]{t('log.scan_cancelled', count=scanned)}[/yellow]")
702
+ self.notify(t("notify.scan_cancelled", count=scanned), severity="warning")
703
+ self.sub_title = t("subtitle.cancelled")
704
+ return
705
+
706
+ duration_ms = int((time.monotonic() - self._scan_start_time) * 1000)
707
+ duration_text = _format_duration(duration_ms)
708
+ summary_data = ScanSummary.from_results(self.sitemap_url, self._results, duration_ms)
709
+
710
+ self._write_log(f"\n[bold green]{t('log.scan_complete', duration=duration_text)}[/bold green]")
711
+ ignored_info = f" | Ignored: {summary_data.total_ignored}" if summary_data.total_ignored > 0 else ""
712
+ self._write_log(
713
+ t(
714
+ "log.scan_result",
715
+ errors_pages=summary_data.urls_with_errors,
716
+ console=summary_data.total_console_errors,
717
+ http_404=summary_data.total_http_404,
718
+ http_4xx=summary_data.total_http_4xx,
719
+ http_5xx=summary_data.total_http_5xx,
720
+ ignored=ignored_info,
721
+ )
722
+ )
723
+ if summary_data.total_ignored > 0:
724
+ self._write_log(f"[dim]{t('log.whitelist_suppressed', count=summary_data.total_ignored)}[/dim]")
725
+
726
+ # Tabelle final aktualisieren
727
+ table = self.query_one("#results-table", ResultsTable)
728
+ table.load_results(self._results)
729
+
730
+ # Summary aktualisieren
731
+ summary = self.query_one("#summary", SummaryHeader)
732
+ summary.update_from_results(self._results, duration_text=duration_text)
733
+
734
+ self.sub_title = t("subtitle.scan_complete", count=len(self._urls))
735
+
736
+ # Site-Score berechnen, im Header anzeigen und ins Log schreiben.
737
+ from .services.site_score import compute_site_score
738
+
739
+ score = compute_site_score(self._results, error_weight=self._settings.score_error_weight)
740
+ with contextlib.suppress(Exception):
741
+ summary.set_score(score.score, score.grade)
742
+ self._write_log(t("log.site_score", score=score.score, grade=score.grade))
743
+
744
+ # Zusammenfassung (Score + Findings + Speicherfresser) automatisch oeffnen
745
+ # - ausser im reinen CLI-Export-Modus (da laeuft die App nicht interaktiv).
746
+ if not (self.output_json or self.output_html):
747
+ self._open_summary()
748
+
749
+ # Jetzt erst Preview fuer die aktuell markierte Zeile nachladen -
750
+ # vorher (waehrend des Scans) war das Auto-Scroll-Cursor-Springen
751
+ # nicht sinnvoll als Preview-Trigger.
752
+ if self.show_preview:
753
+ self._refresh_preview_for_cursor()
754
+
755
+ if self.output_json or self.output_html:
756
+ self._save_reports_auto(summary_data)
757
+
758
+ # Footer-Binding "z Zusammenfassung" jetzt aktivieren (Scan ist durch).
759
+ self.refresh_bindings()
760
+
761
+ def _open_summary(self) -> None:
762
+ """Berechnet den Site-Score aus den aktuellen Ergebnissen neu und oeffnet
763
+ die Zusammenfassung. Gemeinsamer Pfad fuer Auto-Open (Scan-Ende) und das
764
+ ``z``-Binding."""
765
+ if not self._results:
766
+ return
767
+ from .screens.scan_summary import ScanSummaryScreen
768
+ from .services.site_score import compute_site_score
769
+
770
+ score = compute_site_score(self._results, error_weight=self._settings.score_error_weight)
771
+ self.push_screen(ScanSummaryScreen(score))
772
+
773
+ def action_show_summary(self) -> None:
774
+ """Oeffnet die Scan-Zusammenfassung (Site-Score) erneut - nur wenn ein
775
+ Scan abgeschlossen ist (Ergebnisse vorhanden, kein laufender Scan)."""
776
+ if not self._results or self._scan_running:
777
+ return
778
+ self._open_summary()
779
+
780
+ def _on_scan_result(self, result: ScanResult) -> None:
781
+ """Verarbeitet ein einzelnes Scan-Ergebnis (Live-Update)."""
782
+ if self._whitelist is not None:
783
+ self._whitelist.apply(result)
784
+
785
+ table = self.query_one("#results-table", ResultsTable)
786
+ table.update_result(result)
787
+
788
+ summary = self.query_one("#summary", SummaryHeader)
789
+ summary.update_from_results(self._results)
790
+
791
+ stats = self.query_one("#stats-panel", StatsPanel)
792
+ if stats.selected_result() is result:
793
+ stats.refresh_view()
794
+
795
+ def _tick_scan_progress(self) -> None:
796
+ """Aktualisiert den Fortschrittsbalken im Header."""
797
+ # Nach einem Abbruch bleibt die Abbruch-Meldung stehen. Ohne diese Sperre
798
+ # ueberschreibt der Timer sie eine halbe Sekunde spaeter wieder mit dem
799
+ # Fortschritt - dann sieht es aus, als haette die Taste nichts bewirkt,
800
+ # obwohl der Lauf laengst ausleuft.
801
+ if self._scan_cancelled:
802
+ self.sub_title = t("subtitle.cancelling")
803
+ return
804
+
805
+ current = self._scan_current
806
+ total = self._scan_total
807
+ bar = _format_progress_bar(current, total)
808
+ pct = int(current / total * 100) if total > 0 else 0
809
+
810
+ elapsed = time.monotonic() - self._scan_start_time
811
+ if current > 0:
812
+ avg_per_url = elapsed / current
813
+ remaining_s = avg_per_url * (total - current)
814
+ remaining = _format_duration(int(remaining_s * 1000))
815
+ self.sub_title = t(
816
+ "subtitle.scanning",
817
+ bar=bar,
818
+ pct=pct,
819
+ current=current,
820
+ total=total,
821
+ remaining=remaining,
822
+ avg=f"{avg_per_url:.1f}",
823
+ )
824
+ else:
825
+ self.sub_title = t("subtitle.scanning_start", bar=bar, total=total)
826
+
827
+ # --- Tabellen-Events ----------------------------------------------------
828
+
829
+ def on_results_table_result_highlighted(self, event: ResultsTable.ResultHighlighted) -> None:
830
+ stats = self.query_one("#stats-panel", StatsPanel)
831
+ stats.show_result(event.result)
832
+ if self.show_preview and not self._scan_running:
833
+ self._load_preview(event.result.url)
834
+
835
+ def on_results_table_result_selected(self, event: ResultsTable.ResultSelected) -> None:
836
+ from .screens.error_detail import ErrorDetailScreen
837
+
838
+ self.push_screen(ErrorDetailScreen(event.result))
839
+
840
+ def on_results_table_context_requested(self, event: ResultsTable.ContextRequested) -> None:
841
+ """Rechtsklick auf Tabellenzeile → Kontextmenue oeffnen."""
842
+ table = self.query_one("#results-table", ResultsTable)
843
+ # Filter-Toggle-Label dynamisch: je nach aktuellem Zustand
844
+ # "Nur Fehler anzeigen" oder "Alle anzeigen".
845
+ filter_label = t("ctx.show_all") if table._show_only_errors else t("ctx.show_errors_only")
846
+ items = [
847
+ ContextMenuItem("open", t("ctx.open_url")),
848
+ ContextMenuItem("copy_url", t("ctx.copy_url")),
849
+ ContextMenuItem.separator(),
850
+ ContextMenuItem("details", t("ctx.show_details")),
851
+ ContextMenuItem("diet", t("ctx.diet")),
852
+ ContextMenuItem("copy_details", t("ctx.copy_details")),
853
+ ContextMenuItem.separator(),
854
+ ContextMenuItem("export_jira", t("ctx.export_jira")),
855
+ ContextMenuItem("export_json", t("ctx.export_json")),
856
+ ContextMenuItem("export_text", t("ctx.export_text")),
857
+ ContextMenuItem.separator(),
858
+ ContextMenuItem("rescan", t("ctx.rescan"), enabled=not self._scan_running),
859
+ ContextMenuItem.separator(),
860
+ ContextMenuItem("toggle_errors", filter_label),
861
+ ]
862
+ self._ctx_result = event.result
863
+ self.push_screen(
864
+ ContextMenuScreen(items, at=(event.screen_x, event.screen_y)),
865
+ callback=self._on_results_menu,
866
+ )
867
+
868
+ def _on_results_menu(self, choice: str | None) -> None:
869
+ """Verarbeitet die Auswahl aus dem Tabellen-Kontextmenue."""
870
+ result = getattr(self, "_ctx_result", None)
871
+ self._ctx_result = None
872
+ if choice is None or result is None:
873
+ return
874
+
875
+ if choice == "open":
876
+ import webbrowser
877
+
878
+ with contextlib.suppress(Exception):
879
+ webbrowser.open(result.url)
880
+ elif choice == "copy_url":
881
+ self.copy_to_clipboard(result.url)
882
+ self.notify(t("ctx.url_copied"))
883
+ elif choice == "details":
884
+ from .screens.error_detail import ErrorDetailScreen
885
+
886
+ self.push_screen(ErrorDetailScreen(result))
887
+ elif choice == "diet":
888
+ from .screens.diet_advisor import DietAdvisorScreen
889
+
890
+ self.push_screen(DietAdvisorScreen(result))
891
+ elif choice == "copy_details":
892
+ # Selected-Result kurz setzen, dann action_copy_details rufen
893
+ stats = self.query_one("#stats-panel", StatsPanel)
894
+ stats.show_result(result)
895
+ self.action_copy_details()
896
+ elif choice == "export_jira":
897
+ self._export_visible_jira()
898
+ elif choice == "export_json":
899
+ self._export_visible_json()
900
+ elif choice == "export_text":
901
+ self._export_visible_text()
902
+ elif choice == "rescan":
903
+ self._rescan_single(result)
904
+ elif choice == "toggle_errors":
905
+ self.action_toggle_errors()
906
+
907
+ def _visible_results(self) -> list[ScanResult]:
908
+ """Liefert die aktuell in der Tabelle sichtbaren (gefilterten) Ergebnisse."""
909
+ return self.query_one("#results-table", ResultsTable).visible_results()
910
+
911
+ def _export_visible_jira(self) -> None:
912
+ """Kopiert eine JIRA-Tabelle der sichtbaren Fehler-Seiten in die Zwischenablage."""
913
+ visible = self._visible_results()
914
+ table_text = Reporter.generate_jira_table(visible, fmt=self._settings.jira_format)
915
+ if not table_text:
916
+ self.notify(t("notify.no_errors_jira"), severity="information")
917
+ return
918
+ self.copy_to_clipboard(table_text)
919
+ count = sum(1 for r in visible if r.has_issues)
920
+ self._write_log(t("log.jira_copied", count=count))
921
+ self.notify(t("notify.jira_copied", count=count))
922
+
923
+ def _export_visible_json(self) -> None:
924
+ """Kopiert die sichtbaren Ergebnisse als JSON-String in die Zwischenablage."""
925
+ visible = self._visible_results()
926
+ if not visible:
927
+ self.notify(t("notify.not_scanned"), severity="warning")
928
+ return
929
+ summary = ScanSummary.from_results(self.sitemap_url, visible)
930
+ json_text = Reporter.build_json(visible, summary, error_weight=self._settings.score_error_weight)
931
+ self.copy_to_clipboard(json_text)
932
+ self.notify(t("notify.json_copied", count=len(visible)))
933
+
934
+ def _export_visible_text(self) -> None:
935
+ """Kopiert die sichtbaren Ergebnisse als Plaintext in die Zwischenablage."""
936
+ visible = self._visible_results()
937
+ if not visible:
938
+ self.notify(t("notify.not_scanned"), severity="warning")
939
+ return
940
+ self.copy_to_clipboard(Reporter.build_text(visible))
941
+ self.notify(t("notify.text_copied", count=len(visible)))
942
+
943
+ # --- Whitelist-Kontextmenue (Detail-Panel) ------------------------------
944
+
945
+ @staticmethod
946
+ def _wl_short(message: str) -> str:
947
+ """Kuerzt eine Fehlermeldung fuer das Menue-Label (erste Zeile, max 50)."""
948
+ first = (message or "").split("\n", 1)[0].strip()
949
+ return first if len(first) <= 50 else first[:49] + "…"
950
+
951
+ def on_stats_panel_whitelist_menu_requested(self, event: StatsPanel.WhitelistMenuRequested) -> None:
952
+ """Rechtsklick im Detail-Panel: Menue zum Whitelisten / Entfernen.
953
+
954
+ Eintraege werden nach PATTERN dedupliziert: mehrere Fehler, die dasselbe
955
+ abgeleitete (Add) bzw. dasselbe matchende Whitelist-Pattern (Remove)
956
+ haben, ergeben genau EINEN Menue-Eintrag.
957
+ """
958
+ result = event.result
959
+ wl = self._whitelist
960
+ actions: list[tuple[str, str]] = []
961
+ items: list[ContextMenuItem] = []
962
+
963
+ # Aktive Fehler -> hinzufuegen (dedupe nach abgeleitetem Pattern)
964
+ seen_add: set[str] = set()
965
+ for error in result.errors:
966
+ if error.whitelisted:
967
+ continue
968
+ pattern = Whitelist.pattern_for_message(error.message)
969
+ if not pattern or pattern in seen_add:
970
+ continue
971
+ seen_add.add(pattern)
972
+ items.append(ContextMenuItem(str(len(actions)), t("ctx.wl_add", msg=self._wl_short(error.message))))
973
+ actions.append(("add", pattern))
974
+
975
+ # Ge-whitelistete Fehler -> entfernen (dedupe nach matchendem Pattern;
976
+ # Label zeigt das PATTERN, da ein Pattern viele Meldungen abdeckt)
977
+ if wl is not None:
978
+ seen_rm: set[str] = set()
979
+ for error in result.errors:
980
+ if not error.whitelisted:
981
+ continue
982
+ for pattern in wl.patterns_matching(error.message):
983
+ if pattern in seen_rm:
984
+ continue
985
+ seen_rm.add(pattern)
986
+ items.append(ContextMenuItem(str(len(actions)), t("ctx.wl_remove", msg=self._wl_short(pattern))))
987
+ actions.append(("rm", pattern))
988
+
989
+ if not items:
990
+ return
991
+ self._wl_ctx_actions = actions
992
+ self.push_screen(
993
+ ContextMenuScreen(items, at=(event.screen_x, event.screen_y)),
994
+ callback=self._on_whitelist_menu,
995
+ )
996
+
997
+ def _on_whitelist_menu(self, choice: str | None) -> None:
998
+ """Verarbeitet die Auswahl aus dem Whitelist-Kontextmenue."""
999
+ actions = self._wl_ctx_actions
1000
+ self._wl_ctx_actions = []
1001
+ if choice is None:
1002
+ return
1003
+ try:
1004
+ kind, pattern = actions[int(choice)]
1005
+ except (ValueError, IndexError):
1006
+ return
1007
+
1008
+ wl = self._ensure_whitelist()
1009
+ if wl is None:
1010
+ self.notify(t("ctx.wl_no_file"), severity="error")
1011
+ return
1012
+
1013
+ if kind == "add":
1014
+ if not wl.add_pattern(pattern):
1015
+ self.notify(t("ctx.wl_already"), severity="warning")
1016
+ return
1017
+ if self._persist_and_reapply_whitelist(wl):
1018
+ self.notify(t("ctx.wl_added", pattern=pattern))
1019
+ elif kind == "rm":
1020
+ if not wl.remove_pattern(pattern):
1021
+ self.notify(t("ctx.wl_none_removed"), severity="warning")
1022
+ return
1023
+ if self._persist_and_reapply_whitelist(wl):
1024
+ self.notify(t("ctx.wl_removed", count=1))
1025
+
1026
+ def _ensure_whitelist(self) -> Whitelist | None:
1027
+ """Liefert die aktive Whitelist; legt bei Bedarf eine neue an
1028
+ (whitelist.json im CWD bzw. konfigurierter Pfad)."""
1029
+ if self._whitelist is not None:
1030
+ if not self._whitelist.path:
1031
+ self._whitelist.path = str(Path(self.whitelist_path or "whitelist.json").resolve())
1032
+ return self._whitelist
1033
+ path = self.whitelist_path or str(Path("whitelist.json").resolve())
1034
+ self._whitelist = Whitelist(patterns=[], path=path)
1035
+ self.whitelist_path = path
1036
+ self._settings.whitelist_path = path
1037
+ with contextlib.suppress(Exception):
1038
+ self._settings.save()
1039
+ return self._whitelist
1040
+
1041
+ def _persist_and_reapply_whitelist(self, wl: Whitelist) -> bool:
1042
+ """Speichert die Whitelist und klassifiziert ALLE Ergebnisse neu, dann
1043
+ Tabelle, Detail-Panel und Summary aktualisieren.
1044
+
1045
+ Returns:
1046
+ True bei Erfolg, False wenn das Speichern fehlschlug.
1047
+ """
1048
+ try:
1049
+ wl.save()
1050
+ except Exception as e:
1051
+ self._write_log(f"[red]{t('log.whitelist_error', error=e)}[/red]")
1052
+ self.notify(t("ctx.wl_save_failed"), severity="error")
1053
+ return False
1054
+ for r in self._results:
1055
+ wl.reclassify(r)
1056
+ table = self.query_one("#results-table", ResultsTable)
1057
+ for r in self._results:
1058
+ table.update_result(r)
1059
+ with contextlib.suppress(Exception):
1060
+ self.query_one("#stats-panel", StatsPanel).refresh_view()
1061
+ with contextlib.suppress(Exception):
1062
+ self.query_one("#summary", SummaryHeader).update_from_results(self._results)
1063
+ return True
1064
+
1065
+ @work(exclusive=False, group="rescan")
1066
+ async def _rescan_single(self, result: ScanResult) -> None:
1067
+ """Scannt EINE einzelne URL erneut, ohne den ganzen Lauf neu zu starten.
1068
+
1069
+ Verwendet die aktuellen Settings (Konkurrenz egal, da nur 1 URL).
1070
+ Wenn gerade ein voller Scan laeuft, wird der Rescan abgelehnt — der
1071
+ gemeinsame Browser-Sidecar wuerde sonst zwei parallele Welten haben.
1072
+ """
1073
+ if self._scan_running:
1074
+ self.notify(t("ctx.rescan_busy"), severity="warning")
1075
+ return
1076
+
1077
+ self._write_log(f"[dim]{t('ctx.rescanning', url=self.link_markup(result.url, result.url))}[/dim]")
1078
+
1079
+ # Result zuruecksetzen, damit die Tabelle "pending"-Spinner zeigt
1080
+ result.status = PageStatus.PENDING
1081
+ result.http_status_code = 0
1082
+ result.load_time_ms = 0
1083
+ result.page_size_bytes = 0
1084
+ result.errors.clear()
1085
+ result.retry_count = 0
1086
+ result.response_headers = {}
1087
+ result.content_type = ""
1088
+ result.last_modified = ""
1089
+
1090
+ table = self.query_one("#results-table", ResultsTable)
1091
+ table.update_result(result)
1092
+
1093
+ # Single-Result-Scan ueber einen frischen Scanner mit concurrency=1.
1094
+ scanner = Scanner(
1095
+ concurrency=1,
1096
+ timeout=self.timeout,
1097
+ headless=self.headless,
1098
+ console_level=self.console_level,
1099
+ user_agent=self.user_agent,
1100
+ cookies=self.cookies,
1101
+ accept_consent=self.accept_consent,
1102
+ trigger_lazy_load=self.trigger_lazy_load,
1103
+ proxy=self.proxy_url,
1104
+ )
1105
+
1106
+ def on_result(updated: ScanResult) -> None:
1107
+ # Whitelist nachziehen, dann Tabelle + Detail + Summary updaten
1108
+ if self._whitelist is not None:
1109
+ self._whitelist.apply(updated)
1110
+ table.update_result(updated)
1111
+ stats = self.query_one("#stats-panel", StatsPanel)
1112
+ if stats.selected_result() is updated:
1113
+ stats.refresh_view()
1114
+ summary = self.query_one("#summary", SummaryHeader)
1115
+ summary.update_from_results(self._results)
1116
+
1117
+ try:
1118
+ await scanner.scan_urls(
1119
+ [result],
1120
+ on_result=on_result,
1121
+ on_log=self._write_log,
1122
+ )
1123
+ except Exception as exc:
1124
+ self._write_log(f"[red]{t('notify.scan_error', error=exc)}[/red]")
1125
+ self.notify(t("notify.scan_error", error=exc), severity="error")
1126
+ finally:
1127
+ with contextlib.suppress(Exception):
1128
+ await scanner._cleanup()
1129
+
1130
+ # --- Preview-Sidecar ----------------------------------------------------
1131
+
1132
+ def _refresh_preview_for_cursor(self) -> None:
1133
+ """Loest fuer die aktuell markierte Zeile das Vorschau-Bild aus."""
1134
+ if not self.show_preview:
1135
+ return
1136
+ with contextlib.suppress(Exception):
1137
+ table = self.query_one("#results-table", ResultsTable)
1138
+ result = table.get_selected_result()
1139
+ if result is not None:
1140
+ self._load_preview(result.url)
1141
+
1142
+ @work(exclusive=True, group="preview")
1143
+ async def _load_preview(self, url: str) -> None:
1144
+ """Laedt im Hintergrund einen Screenshot und zeigt ihn im Panel."""
1145
+ from .services.preview_service import PreviewService
1146
+
1147
+ panel = self.query_one("#preview-panel", PreviewPanel)
1148
+ panel.show_loading(url)
1149
+ if self._preview_service is None:
1150
+ self._preview_service = PreviewService(proxy=self.proxy_url)
1151
+ # on_phase aktualisiert die Live-Phase im Panel. Der Worker laeuft im
1152
+ # Textual-Event-Loop, daher ist der direkte Widget-Zugriff sicher.
1153
+ data = await self._preview_service.capture(url, on_phase=panel.set_phase)
1154
+ # Cursor kann zwischenzeitlich weitergewandert sein - nur anzeigen,
1155
+ # wenn dieser Worker noch zur aktuellen URL gehoert.
1156
+ if panel.current_url() == url or panel.current_url() == "":
1157
+ panel.show_preview(data, url=url)
1158
+
1159
+ def action_toggle_stats_headers(self) -> None:
1160
+ """Klick auf den HTTP-Header-Panel-Titel im StatsPanel → ein-/ausklappen."""
1161
+ with contextlib.suppress(Exception):
1162
+ self.query_one("#stats-panel", StatsPanel).toggle_headers()
1163
+
1164
+ def on_preview_panel_copy_requested(self, event: PreviewPanel.CopyRequested) -> None:
1165
+ """Rechtsklick auf das Vorschau-Bild → direkt in Zwischenablage kopieren."""
1166
+ self._copy_preview_to_clipboard()
1167
+
1168
+ def on_preview_panel_save_requested(self, event: PreviewPanel.SaveRequested) -> None:
1169
+ """Shift + Rechtsklick → Bild als PNG-Datei speichern."""
1170
+ self._save_preview_to_disk()
1171
+
1172
+ def _copy_preview_to_clipboard(self) -> None:
1173
+ """Kopiert das aktuelle Vorschau-Bild in die OS-Zwischenablage."""
1174
+ from .services.image_clipboard import copy_png_to_clipboard
1175
+
1176
+ panel = self.query_one("#preview-panel", PreviewPanel)
1177
+ png = panel.current_png()
1178
+ if png is None:
1179
+ self.notify(t("preview.no_image"), severity="warning")
1180
+ return
1181
+ try:
1182
+ copy_png_to_clipboard(png)
1183
+ except Exception as exc:
1184
+ self.notify(t("preview.copy_failed", error=exc), severity="error")
1185
+ return
1186
+ self.notify(t("preview.copied"))
1187
+
1188
+ def _save_preview_to_disk(self) -> None:
1189
+ """Speichert das aktuelle Vorschau-Bild als PNG neben den Reports."""
1190
+ panel = self.query_one("#preview-panel", PreviewPanel)
1191
+ png = panel.current_png()
1192
+ url = panel.current_url()
1193
+ if png is None or not url:
1194
+ self.notify(t("preview.no_image"), severity="warning")
1195
+ return
1196
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1197
+ host = _sanitize_filename(urlparse(url).hostname or "page")
1198
+ path = Path(f"preview_{host}_{timestamp}.png")
1199
+ try:
1200
+ path.write_bytes(png)
1201
+ except Exception as exc:
1202
+ self.notify(t("preview.copy_failed", error=exc), severity="error")
1203
+ return
1204
+ self._write_log(f"[green]{t('preview.saved', path=self.link_markup(str(path), str(path)))}[/green]")
1205
+ self.notify(t("preview.saved", path=str(path)))
1206
+
1207
+ async def on_unmount(self) -> None:
1208
+ """Beendet den Preview-Sidecar sauber.
1209
+
1210
+ Reihenfolge: erst laufende Preview-Worker abbrechen, dann
1211
+ Browser/Playwright schliessen. Sonst feuert ein in-flight
1212
+ page.goto noch nach dem Browser-Close ein net::ERR_ABORTED
1213
+ als unbehandelte Future-Exception.
1214
+ """
1215
+ cancelled_preview = False
1216
+ for worker in list(self.workers):
1217
+ if getattr(worker, "group", None) == "preview":
1218
+ worker.cancel()
1219
+ cancelled_preview = True
1220
+ if cancelled_preview:
1221
+ await asyncio.sleep(0.2)
1222
+ if self._preview_service is not None:
1223
+ await self._preview_service.close()
1224
+
1225
+ # --- Reports / Top 10 / Whitelist Viewer -------------------------------
1226
+
1227
+ def action_save_reports(self) -> None:
1228
+ """Speichert HTML- und JSON-Reports."""
1229
+ if not self._results:
1230
+ self.notify(t("notify.no_results"), severity="warning")
1231
+ return
1232
+
1233
+ scanned = [r for r in self._results if r.status not in (PageStatus.PENDING, PageStatus.SCANNING)]
1234
+ if not scanned:
1235
+ self.notify(t("notify.not_scanned"), severity="warning")
1236
+ return
1237
+
1238
+ duration_ms = int((time.monotonic() - self._scan_start_time) * 1000) if self._scan_start_time > 0 else 0
1239
+ summary = ScanSummary.from_results(self.sitemap_url, self._results, duration_ms)
1240
+
1241
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1242
+ site_name = _sanitize_filename(urlparse(self.sitemap_url).hostname or "unknown")
1243
+ base_name = f"console-error-report_{site_name}_{timestamp}"
1244
+
1245
+ json_path = f"{base_name}.json"
1246
+ saved_json = Reporter.save_json(
1247
+ self._results, summary, json_path, error_weight=self._settings.score_error_weight
1248
+ )
1249
+ self._write_log(f"[green]{t('log.json_report', path=self.link_markup(saved_json, saved_json))}[/green]")
1250
+
1251
+ html_path = f"{base_name}.html"
1252
+ saved_html = Reporter.save_html(
1253
+ self._results, summary, html_path, error_weight=self._settings.score_error_weight
1254
+ )
1255
+ self._write_log(f"[green]{t('log.html_report', path=self.link_markup(saved_html, saved_html))}[/green]")
1256
+
1257
+ self.notify(t("notify.reports_saved", json_path=json_path, html_path=html_path))
1258
+
1259
+ def _save_reports_auto(self, summary: ScanSummary) -> None:
1260
+ """Speichert Reports automatisch (CLI-Parameter)."""
1261
+ if self.output_json:
1262
+ path = Reporter.save_json(
1263
+ self._results, summary, self.output_json, error_weight=self._settings.score_error_weight
1264
+ )
1265
+ self._write_log(f"[green]{t('log.json_report', path=self.link_markup(path, path))}[/green]")
1266
+ if self.output_html:
1267
+ path = Reporter.save_html(
1268
+ self._results, summary, self.output_html, error_weight=self._settings.score_error_weight
1269
+ )
1270
+ self._write_log(f"[green]{t('log.html_report', path=self.link_markup(path, path))}[/green]")
1271
+
1272
+ def action_export_jira(self) -> None:
1273
+ """Kopiert eine JIRA-Tabelle der Fehler-Seiten in die Zwischenablage.
1274
+
1275
+ Format (Markdown fuer Jira Cloud oder Wiki Markup) kommt aus den
1276
+ Einstellungen (jira_format).
1277
+ """
1278
+ if not self._results:
1279
+ self.notify(t("notify.no_url_selected"), severity="warning")
1280
+ return
1281
+
1282
+ table_text = Reporter.generate_jira_table(self._results, fmt=self._settings.jira_format)
1283
+ if not table_text:
1284
+ self._write_log(t("log.no_errors_jira"))
1285
+ self.notify(t("notify.no_errors_jira"), severity="information")
1286
+ return
1287
+
1288
+ self.copy_to_clipboard(table_text)
1289
+ error_count = sum(1 for r in self._results if r.has_issues)
1290
+ self._write_log(t("log.jira_copied", count=error_count))
1291
+ self.notify(t("notify.jira_copied", count=error_count))
1292
+
1293
+ def action_copy_details(self) -> None:
1294
+ """Kopiert die Detail-Ansicht (rechter Bereich) in die Zwischenablage."""
1295
+ stats = self.query_one("#stats-panel", StatsPanel)
1296
+ result = stats.selected_result()
1297
+ if result is None:
1298
+ self.notify(t("notify.no_url_selected"), severity="warning")
1299
+ return
1300
+
1301
+ # Plain-Text-Variante der wichtigsten Felder fuer die Zwischenablage.
1302
+ lines = [
1303
+ f"URL: {result.url}",
1304
+ f"Status: {result.status_icon} {result.status.value}",
1305
+ f"HTTP: {result.http_status_code if result.http_status_code else '-'}",
1306
+ f"Ladezeit: {result.load_time_ms}ms",
1307
+ f"Groesse: {result.page_size_bytes} bytes",
1308
+ ]
1309
+ if result.content_type:
1310
+ lines.append(f"Content-Type: {result.content_type}")
1311
+ if result.last_modified:
1312
+ lines.append(f"Last-Modified: {result.last_modified}")
1313
+ if result.retry_count > 0:
1314
+ lines.append(f"Retries: {result.retry_count}")
1315
+
1316
+ active_errors = [e for e in result.errors if not e.whitelisted]
1317
+ if active_errors:
1318
+ lines.append("")
1319
+ lines.append(f"Fehler/Warnungen ({len(active_errors)}):")
1320
+ for idx, err in enumerate(active_errors, 1):
1321
+ tag = err.error_type.value.upper()
1322
+ lines.append(f" {idx}. [{tag}] {err.message}")
1323
+ if err.source:
1324
+ src = err.source + (f":{err.line_number}" if err.line_number else "")
1325
+ lines.append(f" Quelle: {src}")
1326
+
1327
+ ignored = [e for e in result.errors if e.whitelisted]
1328
+ if ignored:
1329
+ lines.append("")
1330
+ lines.append(f"Whitelist ({len(ignored)}):")
1331
+ for idx, err in enumerate(ignored, 1):
1332
+ lines.append(f" {idx}. [{err.error_type.value.upper()}] {err.message}")
1333
+
1334
+ self.copy_to_clipboard("\n".join(lines))
1335
+ self.notify(t("notify.details_copied"))
1336
+
1337
+ def action_show_top_errors(self) -> None:
1338
+ """Zeigt den Top-10-Fehler Dialog."""
1339
+ if not self._results:
1340
+ self.notify(t("notify.no_results_for_top"), severity="warning")
1341
+ return
1342
+ from .screens.top_errors import TopErrorsScreen
1343
+
1344
+ self.push_screen(TopErrorsScreen(self._results))
1345
+
1346
+ def action_show_whitelist(self) -> None:
1347
+ """Zeigt die geladenen Whitelist-Patterns."""
1348
+ from .screens.whitelist import WhitelistScreen
1349
+
1350
+ if self._whitelist is None:
1351
+ self.notify(t("notify.no_whitelist"), severity="warning")
1352
+ self.push_screen(WhitelistScreen(self._whitelist, self._results))
1353
+
1354
+ # --- Log / Filter -------------------------------------------------------
1355
+
1356
+ def action_toggle_log(self) -> None:
1357
+ """Blendet das LogPanel (samt Splitter) ein/aus."""
1358
+ log_panel = self.query_one("#scan-log", LogPanel)
1359
+ log_panel.toggle()
1360
+ self.query_one("#log-splitter", HorizontalSplitter).set_class(log_panel.has_class("-log-hidden"), "hidden")
1361
+
1362
+ def on_log_panel_hidden(self, event: LogPanel.Hidden) -> None:
1363
+ """Log per Kontextmenue ausgeblendet — Splitter mit ausblenden."""
1364
+ self.query_one("#log-splitter", HorizontalSplitter).add_class("hidden")
1365
+
1366
+ def action_toggle_errors(self) -> None:
1367
+ """Wechselt zwischen alle/nur Fehler in der Tabelle."""
1368
+ if not self._results:
1369
+ self.notify(t("notify.no_results"), severity="warning")
1370
+ return
1371
+ table = self.query_one("#results-table", ResultsTable)
1372
+ active = table.toggle_error_filter()
1373
+
1374
+ new_label = t("binding.errors_show_all") if active else t("binding.errors_only")
1375
+ for i, b in enumerate(self._bindings.key_to_bindings.get("e", [])):
1376
+ if b.action == "toggle_errors":
1377
+ self._bindings.key_to_bindings["e"][i] = dataclasses.replace(b, description=new_label)
1378
+ for i, b in enumerate(self._bindings.key_to_bindings.get("E", [])):
1379
+ if b.action == "toggle_errors":
1380
+ self._bindings.key_to_bindings["E"][i] = dataclasses.replace(b, description=new_label)
1381
+ self.refresh_bindings()
1382
+
1383
+ def action_focus_filter(self) -> None:
1384
+ """Fokussiert das Filter-Eingabefeld."""
1385
+ with contextlib.suppress(Exception):
1386
+ from textual.widgets import Input
1387
+
1388
+ filter_input = self.query_one("#filter-bar", Input)
1389
+ filter_input.focus()
1390
+
1391
+ def action_unfocus_filter(self) -> None:
1392
+ """Leert den Filter und gibt Focus zurueck an die Tabelle."""
1393
+ with contextlib.suppress(Exception):
1394
+ from textual.widgets import DataTable, Input
1395
+
1396
+ filter_input = self.query_one("#filter-bar", Input)
1397
+ filter_input.value = ""
1398
+ table = self.query_one("#results-data", DataTable)
1399
+ table.focus()
1400
+
1401
+ # --- About / Settings / History / Theme --------------------------------
1402
+
1403
+ def action_show_http_codes(self) -> None:
1404
+ """Zeigt die HTTP-Statuscode-Referenz aus textual-widgets an."""
1405
+ from textual_widgets import HttpStatusScreen
1406
+
1407
+ self.push_screen(HttpStatusScreen(lang=current_language()))
1408
+
1409
+ def action_show_about(self) -> None:
1410
+ """Zeigt den standardisierten About-Dialog aus textual-widgets an."""
1411
+ from textual_widgets import AboutScreen
1412
+
1413
+ from . import __author__, __year__
1414
+
1415
+ self.push_screen(
1416
+ AboutScreen(
1417
+ app_name="Console Error Scanner",
1418
+ version=__version__,
1419
+ author=__author__,
1420
+ release=__year__,
1421
+ description=t("about.description"),
1422
+ lang=current_language(),
1423
+ license="Apache-2.0",
1424
+ url="https://github.com/michaelblaess/console-error-scanner",
1425
+ )
1426
+ )
1427
+
1428
+ def action_show_settings(self) -> None:
1429
+ """Oeffnet den Einstellungs-Dialog (BaseSettingsScreen)."""
1430
+ from .screens.settings import ScannerSettingsScreen
1431
+
1432
+ current: dict[str, object] = {
1433
+ "language": self._settings.language,
1434
+ "accept_consent": self._settings.accept_consent,
1435
+ "trigger_lazy_load": self._settings.trigger_lazy_load,
1436
+ "respect_robots": self._settings.respect_robots,
1437
+ "concurrency": self._settings.concurrency,
1438
+ "rate_limit_enabled": self._settings.rate_limit_enabled,
1439
+ "rate_per_minute": self._settings.rate_per_minute,
1440
+ "timeout": self._settings.timeout,
1441
+ "console_level": self._settings.console_level,
1442
+ "show_preview": self._settings.show_preview,
1443
+ "no_headless": self._settings.no_headless,
1444
+ "size_warn_mb": self._settings.size_warn_mb,
1445
+ "score_error_weight": self._settings.score_error_weight,
1446
+ "user_agent": self._settings.user_agent,
1447
+ "cookies": self._settings.cookies,
1448
+ "whitelist_path": self._settings.whitelist_path,
1449
+ "proxy_url": self._settings.proxy_url,
1450
+ "jira_format": self._settings.jira_format,
1451
+ }
1452
+ self.push_screen(
1453
+ ScannerSettingsScreen(current, lang=current_language()),
1454
+ callback=self._on_settings_closed,
1455
+ )
1456
+
1457
+ def _on_settings_closed(self, result: dict[str, object] | None) -> None:
1458
+ """Uebernimmt die geaenderten Settings und persistiert sie.
1459
+
1460
+ Schreibt fuer jedes tatsaechlich geaenderte Feld eine
1461
+ ``Label: alt → neu``-Zeile ins Log. Wenn nichts geaendert wurde,
1462
+ steht NUR die Hinweiszeile ``Einstellungen unveraendert`` da.
1463
+ """
1464
+ if result is None:
1465
+ return
1466
+
1467
+ # Old-Snapshot fuer das Diff-Logging
1468
+ old_snapshot = {
1469
+ "language": self._settings.language,
1470
+ "accept_consent": self._settings.accept_consent,
1471
+ "trigger_lazy_load": self._settings.trigger_lazy_load,
1472
+ "respect_robots": self._settings.respect_robots,
1473
+ "concurrency": self._settings.concurrency,
1474
+ "rate_limit_enabled": self._settings.rate_limit_enabled,
1475
+ "timeout": self._settings.timeout,
1476
+ "console_level": self._settings.console_level,
1477
+ "show_preview": self._settings.show_preview,
1478
+ "no_headless": self._settings.no_headless,
1479
+ "user_agent": self._settings.user_agent,
1480
+ "cookies": self._settings.cookies,
1481
+ "whitelist_path": self._settings.whitelist_path,
1482
+ "proxy_url": self._settings.proxy_url,
1483
+ }
1484
+
1485
+ old_whitelist_path = self._settings.whitelist_path
1486
+
1487
+ self._settings.language = str(result.get("language", self._settings.language))
1488
+ self._settings.accept_consent = bool(result.get("accept_consent", self._settings.accept_consent))
1489
+ self._settings.trigger_lazy_load = bool(result.get("trigger_lazy_load", self._settings.trigger_lazy_load))
1490
+ self._settings.respect_robots = bool(result.get("respect_robots", self._settings.respect_robots))
1491
+ self._settings.concurrency = int(result.get("concurrency", self._settings.concurrency)) # type: ignore[arg-type]
1492
+ self._settings.rate_limit_enabled = bool(result.get("rate_limit_enabled", self._settings.rate_limit_enabled))
1493
+ self._settings.rate_per_minute = int(result.get("rate_per_minute", self._settings.rate_per_minute)) # type: ignore[arg-type]
1494
+ self._settings.timeout = int(result.get("timeout", self._settings.timeout)) # type: ignore[arg-type]
1495
+ self._settings.console_level = str(result.get("console_level", self._settings.console_level))
1496
+ self._settings.show_preview = bool(result.get("show_preview", self._settings.show_preview))
1497
+ self._settings.no_headless = bool(result.get("no_headless", self._settings.no_headless))
1498
+ self._settings.user_agent = str(result.get("user_agent", self._settings.user_agent))
1499
+ self._settings.cookies = str(result.get("cookies", self._settings.cookies))
1500
+ self._settings.whitelist_path = str(result.get("whitelist_path", self._settings.whitelist_path))
1501
+ self._settings.size_warn_mb = int(str(result.get("size_warn_mb", self._settings.size_warn_mb)))
1502
+ self._settings.score_error_weight = int(
1503
+ str(result.get("score_error_weight", self._settings.score_error_weight))
1504
+ )
1505
+ self._settings.proxy_url = str(result.get("proxy_url", self._settings.proxy_url))
1506
+ self._settings.jira_format = str(result.get("jira_format", self._settings.jira_format))
1507
+ self._settings.save()
1508
+
1509
+ # Runtime-Werte fuer den naechsten Scan aktualisieren
1510
+ self.accept_consent = self._settings.accept_consent
1511
+ self.trigger_lazy_load = self._settings.trigger_lazy_load
1512
+ self.respect_robots = self._settings.respect_robots
1513
+ self.concurrency = self._settings.concurrency
1514
+ self.rate_per_minute = self._settings.rate_per_minute if self._settings.rate_limit_enabled else 0
1515
+ self.timeout = self._settings.timeout
1516
+ self.console_level = self._settings.console_level
1517
+ self.show_preview = self._settings.show_preview
1518
+ self.headless = not self._settings.no_headless
1519
+ self.user_agent = self._settings.user_agent
1520
+ self.cookies = parse_cookies(self._settings.cookies)
1521
+ self.size_warn_mb = self._settings.size_warn_mb
1522
+ self.proxy_url = self._settings.proxy_url
1523
+ # Tabelle sofort neu einfaerben (kein Neu-Scan noetig).
1524
+ with contextlib.suppress(Exception):
1525
+ self.query_one("#results-table", ResultsTable).set_size_warn_mb(self.size_warn_mb)
1526
+
1527
+ # Preview-Panel zur Laufzeit ein-/ausblenden
1528
+ with contextlib.suppress(Exception):
1529
+ self.query_one("#preview-panel", PreviewPanel).display = self.show_preview
1530
+ self.query_one("#preview-splitter", HorizontalSplitter).display = self.show_preview
1531
+ if self.show_preview:
1532
+ self._refresh_preview_for_cursor()
1533
+
1534
+ # Diff-Log: pro geaendertem Feld eine Zeile
1535
+ diff_fields = [
1536
+ ("language", t("binding.settings") + " (Lang)"),
1537
+ ("accept_consent", t("settings.consent_label")),
1538
+ ("trigger_lazy_load", t("settings.scroll_label")),
1539
+ ("respect_robots", t("settings.robots_label")),
1540
+ ("concurrency", t("settings.concurrency_label")),
1541
+ ("rate_limit_enabled", t("settings.rate_label")),
1542
+ ("timeout", t("settings.timeout_label")),
1543
+ ("console_level", t("settings.console_level_label")),
1544
+ ("show_preview", t("settings.preview_label")),
1545
+ ("no_headless", t("settings.headless_label")),
1546
+ ("user_agent", t("settings.user_agent_label")),
1547
+ ("cookies", t("settings.cookies_label")),
1548
+ ("whitelist_path", t("settings.whitelist_label")),
1549
+ ]
1550
+ changes_logged = 0
1551
+ for key, label in diff_fields:
1552
+ old_val = old_snapshot[key]
1553
+ new_val = getattr(self._settings, key)
1554
+ if old_val == new_val:
1555
+ continue
1556
+ self._write_log(
1557
+ f"[green]{t('log.setting_changed', label=label, old=_fmt_setting(old_val), new=_fmt_setting(new_val))}[/green]"
1558
+ )
1559
+ changes_logged += 1
1560
+ if changes_logged == 0:
1561
+ self._write_log(f"[dim]{t('log.no_settings_changed')}[/dim]")
1562
+
1563
+ # Whitelist-Pfad geaendert?
1564
+ if self._settings.whitelist_path != old_whitelist_path:
1565
+ self.whitelist_path = self._settings.whitelist_path
1566
+ if self.whitelist_path:
1567
+ self._load_whitelist_silent()
1568
+ if self._whitelist is not None and self._results:
1569
+ for result_item in self._results:
1570
+ self._whitelist.apply(result_item)
1571
+ self._write_log(f"[green]{t('log.whitelist_reloaded', count=len(self._whitelist))}[/green]")
1572
+ else:
1573
+ self._whitelist = None
1574
+ for result_item in self._results:
1575
+ for error in result_item.errors:
1576
+ error.whitelisted = False
1577
+ self._write_log(f"[yellow]{t('log.whitelist_cleared')}[/yellow]")
1578
+
1579
+ # Tabelle + Detail + Summary neu zeichnen
1580
+ with contextlib.suppress(Exception):
1581
+ table = self.query_one("#results-table", ResultsTable)
1582
+ table.load_results(self._results)
1583
+ with contextlib.suppress(Exception):
1584
+ self.query_one("#stats-panel", StatsPanel).refresh_view()
1585
+
1586
+ # Header mit aktuellen Konfig-Werten aktualisieren
1587
+ with contextlib.suppress(Exception):
1588
+ header = self.query_one("#summary", SummaryHeader)
1589
+ header.update_config(
1590
+ self.concurrency,
1591
+ self.timeout,
1592
+ self.console_level,
1593
+ self.accept_consent,
1594
+ self.trigger_lazy_load,
1595
+ )
1596
+ header.update_from_results(self._results)
1597
+
1598
+ def action_show_history(self) -> None:
1599
+ """Zeigt die Scan-History und uebernimmt bei Auswahl die URL."""
1600
+ from .screens.history import HistoryScreen
1601
+
1602
+ self.push_screen(HistoryScreen(), callback=self._on_history_selected)
1603
+
1604
+ def _on_history_selected(self, entry: HistoryEntry | None) -> None:
1605
+ """Uebernimmt nur die URL aus dem History-Eintrag — Scan-Parameter
1606
+ kommen weiterhin aus den aktuellen Einstellungen (Settings).
1607
+
1608
+ Analog zum Sitemap-Tracker: History ist eine URL-Liste, kein
1609
+ Settings-Override.
1610
+ """
1611
+ if entry is None:
1612
+ return
1613
+
1614
+ self.sitemap_url = entry.sitemap_url
1615
+ self.sub_title = self.sitemap_url
1616
+ self._write_log(
1617
+ t(
1618
+ "log.history_params",
1619
+ )
1620
+ )
1621
+ self._write_log(t("log.sitemap_label", url=self.link_markup(self.sitemap_url, self.sitemap_url)))
1622
+
1623
+ # Sitemap laden (Scan startet User manuell mit "s")
1624
+ self._load_sitemap()
1625
+
1626
+ def action_cycle_theme(self) -> None:
1627
+ """Wechselt zum naechsten Retro-Theme (alphabetisch sortiert)."""
1628
+ names = sorted(self.available_themes.keys())
1629
+ if not names:
1630
+ return
1631
+ try:
1632
+ idx = names.index(self.theme)
1633
+ except ValueError:
1634
+ idx = -1
1635
+ next_theme = names[(idx + 1) % len(names)]
1636
+ self.theme = next_theme
1637
+ display = THEME_DISPLAY_NAMES.get(next_theme, next_theme)
1638
+ self.notify(t("notify.theme", name=display))
1639
+
1640
+ def watch_theme(self, theme_name: str) -> None:
1641
+ """Persistiert jede Theme-Aenderung (auch via Ctrl+P)."""
1642
+ if not hasattr(self, "_settings"):
1643
+ return
1644
+ if self._settings.theme == theme_name:
1645
+ return
1646
+ self._settings.theme = theme_name
1647
+ self._settings.save()
1648
+ self._log_theme()
1649
+
1650
+ def _log_theme(self) -> None:
1651
+ """Schreibt das aktive Theme ins Log.
1652
+
1653
+ Textual zeigt nirgends an, welches Theme gerade laeuft - nach einem
1654
+ Neustart weiss man also nicht, was man vor sich hat. Der technische
1655
+ Name steht mit dabei, weil genau der in den Einstellungen und in der
1656
+ Befehlspalette auftaucht.
1657
+ """
1658
+ with contextlib.suppress(Exception):
1659
+ from textual_themes import THEME_DISPLAY_NAMES
1660
+
1661
+ name = self.theme or ""
1662
+ anzeige = THEME_DISPLAY_NAMES.get(name, name)
1663
+ beschriftung = f"{anzeige} ({name})" if anzeige != name else name
1664
+ self._write_log(t("log.theme_active", name=beschriftung))
1665
+ if self._discarded_theme:
1666
+ self._write_log(t("log.theme_unknown", name=self._discarded_theme))
1667
+ self._discarded_theme = ""
1668
+
1669
+ # --- check_action -------------------------------------------------------
1670
+
1671
+ def check_action(self, action: str, parameters: tuple) -> bool | None:
1672
+ """Steuert Sichtbarkeit von Bindings."""
1673
+ if action == "cancel_scan":
1674
+ # Abbrechen gibt es nur, solange etwas laeuft.
1675
+ return True if self._scan_running else None
1676
+ if action == "start_scan":
1677
+ return None if (self._sitemap_loading or self._scan_running) else True
1678
+ if action == "load_sitemap_file":
1679
+ return None if self._scan_running or self._sitemap_loading else True
1680
+ if action == "show_history":
1681
+ return None if self._scan_running or self._sitemap_loading else True
1682
+ if action in ("save_reports", "show_top_errors", "toggle_errors", "copy_details"):
1683
+ return True if self._results else None
1684
+ if action == "show_summary":
1685
+ # Nur wenn ein Scan abgeschlossen ist (Ergebnisse da, kein Lauf aktiv).
1686
+ return True if (self._results and not self._scan_running) else None
1687
+ if action == "show_whitelist":
1688
+ return True if self._whitelist is not None else None
1689
+ return True
1690
+
1691
+ # --- Quit ---------------------------------------------------------------
1692
+
1693
+ async def action_quit(self) -> None:
1694
+ """Beendet die App und raeumt den Scanner sauber auf."""
1695
+ if self._scanner:
1696
+ self._scanner.cancel()
1697
+
1698
+ # Playwright TargetClosedError beim Shutdown unterdruecken
1699
+ loop = asyncio.get_running_loop()
1700
+ original_handler = loop.get_exception_handler()
1701
+
1702
+ def _suppress_target_closed(the_loop, context):
1703
+ exception = context.get("exception")
1704
+ if exception is not None and type(exception).__name__ == "TargetClosedError":
1705
+ return
1706
+ if original_handler:
1707
+ original_handler(the_loop, context)
1708
+ else:
1709
+ the_loop.default_exception_handler(context)
1710
+
1711
+ loop.set_exception_handler(_suppress_target_closed)
1712
+
1713
+ with contextlib.suppress(Exception):
1714
+ await self._scanner._cleanup()
1715
+ self._scanner = None
1716
+ self._scan_running = False
1717
+ self.exit()
1718
+
1719
+ # --- Logging-Bridge -----------------------------------------------------
1720
+
1721
+ def _write_log(self, line: str) -> None:
1722
+ """Schreibt eine Zeile ins LogPanel mit auto-verlinkten URLs.
1723
+
1724
+ Nach einem Abbruch werden die eingerueckten Detailzeilen der auslaufenden
1725
+ Seiten (Konsolen-Meldungen, CDP-Befunde, Scroll-Schritte) verworfen. Sie
1726
+ gehoeren zu Seiten, die ohnehin nicht mehr gewertet werden, rauschen aber
1727
+ sekundenlang weiter - und lassen den Abbruch wirkungslos aussehen. Die
1728
+ Zeilen auf der ersten Ebene (Ergebnis je Seite, Abschluss) bleiben.
1729
+ """
1730
+ if self._scan_cancelled and line.startswith(" "):
1731
+ return
1732
+ with contextlib.suppress(Exception):
1733
+ self.query_one("#scan-log", LogPanel).write_log(self.linkify_urls(line))
1734
+
1735
+
1736
+ _BAR_WIDTH = 20
1737
+
1738
+
1739
+ def _format_progress_bar(current: int, total: int) -> str:
1740
+ """Erzeugt einen Unicode-Fortschrittsbalken."""
1741
+ if total <= 0:
1742
+ return "░" * _BAR_WIDTH
1743
+ filled = int(_BAR_WIDTH * current / total)
1744
+ return "█" * filled + "░" * (_BAR_WIDTH - filled)
1745
+
1746
+
1747
+ def _format_duration(duration_ms: int) -> str:
1748
+ """Formatiert eine Dauer in lesbarer Form."""
1749
+ total_s = duration_ms / 1000
1750
+ if total_s < 60:
1751
+ return f"{total_s:.1f}s"
1752
+ minutes = int(total_s // 60)
1753
+ seconds = int(total_s % 60)
1754
+ if minutes < 60:
1755
+ return f"{minutes}m {seconds}s"
1756
+ hours = minutes // 60
1757
+ minutes = minutes % 60
1758
+ return f"{hours}h {minutes}m {seconds}s"
1759
+
1760
+
1761
+ def _fmt_setting(value: object) -> str:
1762
+ """Formatiert einen Settings-Wert kompakt fuers Log-Diff."""
1763
+ if isinstance(value, bool):
1764
+ return "AN" if value else "AUS"
1765
+ if value is None or value == "":
1766
+ return "—"
1767
+ text = str(value)
1768
+ if len(text) > 40:
1769
+ return text[:37] + "…"
1770
+ return text
1771
+
1772
+
1773
+ def _sanitize_filename(name: str) -> str:
1774
+ """Bereinigt einen String fuer Dateinamen."""
1775
+ return re.sub(r'[/:*?"<>|\\]', "_", name).strip("_.")
1776
+
1777
+
1778
+ class _SitemapErrorScreen(ModalScreen):
1779
+ """Modal-Dialog fuer Sitemap-Fehler."""
1780
+
1781
+ DEFAULT_CSS = """
1782
+ _SitemapErrorScreen {
1783
+ align: center middle;
1784
+ }
1785
+
1786
+ _SitemapErrorScreen > Vertical {
1787
+ width: 80%;
1788
+ max-width: 90;
1789
+ height: auto;
1790
+ max-height: 90%;
1791
+ background: $surface;
1792
+ border: thick $error;
1793
+ padding: 1 2;
1794
+ }
1795
+
1796
+ _SitemapErrorScreen #error-title {
1797
+ height: 3;
1798
+ content-align: center middle;
1799
+ text-style: bold;
1800
+ background: $error;
1801
+ color: auto;
1802
+ margin-bottom: 1;
1803
+ }
1804
+
1805
+ _SitemapErrorScreen #error-message {
1806
+ height: auto;
1807
+ padding: 1;
1808
+ }
1809
+
1810
+ _SitemapErrorScreen #error-buttons {
1811
+ height: 3;
1812
+ align: center middle;
1813
+ margin-top: 1;
1814
+ }
1815
+
1816
+ _SitemapErrorScreen #error-buttons Button {
1817
+ margin: 0 1;
1818
+ }
1819
+ """
1820
+
1821
+ BINDINGS = [
1822
+ Binding("escape", "close", "Close"),
1823
+ Binding("q,Q", "close", "Close"),
1824
+ ]
1825
+
1826
+ def __init__(self, message: str, **kwargs) -> None:
1827
+ super().__init__(**kwargs)
1828
+ self._message = message
1829
+
1830
+ def compose(self) -> ComposeResult:
1831
+ from textual.widgets import Static
1832
+
1833
+ # URLs in der Fehlermeldung Hover-klickbar machen. Markup-Sonderzeichen
1834
+ # in der Meldung zuerst escapen, dann linkify - sonst frisst Rich
1835
+ # eckige Klammern als Tag und der Link wird unsichtbar.
1836
+ linkify = getattr(self.app, "linkify_urls", None)
1837
+ body = escape_markup(self._message)
1838
+ if callable(linkify):
1839
+ body = linkify(body)
1840
+
1841
+ with Vertical():
1842
+ yield Static(t("sitemap_error.title"), id="error-title")
1843
+ yield Static(body, id="error-message", markup=True)
1844
+ with Horizontal(id="error-buttons"):
1845
+ yield Button(t("binding.close"), variant="primary", id="error-close")
1846
+
1847
+ @on(Button.Pressed, "#error-close")
1848
+ def _on_close_button(self) -> None:
1849
+ self.dismiss()
1850
+
1851
+ def action_close(self) -> None:
1852
+ self.dismiss()