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.
- console_error_scanner/__init__.py +5 -0
- console_error_scanner/__main__.py +370 -0
- console_error_scanner/app.py +1870 -0
- console_error_scanner/app.tcss +91 -0
- console_error_scanner/i18n.py +74 -0
- console_error_scanner/locale/de.json +417 -0
- console_error_scanner/locale/en.json +417 -0
- console_error_scanner/models/__init__.py +1 -0
- console_error_scanner/models/history.py +220 -0
- console_error_scanner/models/robots.py +154 -0
- console_error_scanner/models/scan_result.py +265 -0
- console_error_scanner/models/settings.py +242 -0
- console_error_scanner/models/sitemap.py +347 -0
- console_error_scanner/models/whitelist.py +226 -0
- console_error_scanner/screens/__init__.py +1 -0
- console_error_scanner/screens/diet_advisor.py +128 -0
- console_error_scanner/screens/error_detail.py +164 -0
- console_error_scanner/screens/history.py +187 -0
- console_error_scanner/screens/scan_summary.py +234 -0
- console_error_scanner/screens/settings.py +292 -0
- console_error_scanner/screens/top_errors.py +304 -0
- console_error_scanner/screens/whitelist.py +154 -0
- console_error_scanner/services/__init__.py +1 -0
- console_error_scanner/services/image_clipboard.py +104 -0
- console_error_scanner/services/preview_service.py +291 -0
- console_error_scanner/services/rate_limit.py +56 -0
- console_error_scanner/services/reporter.py +561 -0
- console_error_scanner/services/scanner.py +965 -0
- console_error_scanner/services/site_score.py +151 -0
- console_error_scanner/widgets/__init__.py +1 -0
- console_error_scanner/widgets/bar_chart.py +60 -0
- console_error_scanner/widgets/preview_panel.py +356 -0
- console_error_scanner/widgets/results_table.py +594 -0
- console_error_scanner/widgets/stats_panel.py +387 -0
- console_error_scanner/widgets/summary_panel.py +176 -0
- console_error_scanner-1.16.3.dist-info/METADATA +509 -0
- console_error_scanner-1.16.3.dist-info/RECORD +41 -0
- console_error_scanner-1.16.3.dist-info/WHEEL +5 -0
- console_error_scanner-1.16.3.dist-info/entry_points.txt +2 -0
- console_error_scanner-1.16.3.dist-info/licenses/LICENSE +202 -0
- 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()
|