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