sitemap-tracker 2.13.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. sitemap_tracker/__init__.py +5 -0
  2. sitemap_tracker/__main__.py +389 -0
  3. sitemap_tracker/app.py +1614 -0
  4. sitemap_tracker/app.tcss +72 -0
  5. sitemap_tracker/i18n.py +100 -0
  6. sitemap_tracker/locale/de.json +404 -0
  7. sitemap_tracker/locale/en.json +404 -0
  8. sitemap_tracker/models/__init__.py +0 -0
  9. sitemap_tracker/models/crawl_result.py +188 -0
  10. sitemap_tracker/models/history.py +267 -0
  11. sitemap_tracker/models/robots.py +175 -0
  12. sitemap_tracker/models/settings.py +203 -0
  13. sitemap_tracker/models/sitemap_reader.py +284 -0
  14. sitemap_tracker/models/sitemap_writer.py +249 -0
  15. sitemap_tracker/models/url_utils.py +135 -0
  16. sitemap_tracker/screens/__init__.py +0 -0
  17. sitemap_tracker/screens/file_check.py +349 -0
  18. sitemap_tracker/screens/history.py +193 -0
  19. sitemap_tracker/screens/proxy_warning.py +107 -0
  20. sitemap_tracker/screens/scan_summary.py +194 -0
  21. sitemap_tracker/screens/settings.py +236 -0
  22. sitemap_tracker/screens/source_view.py +284 -0
  23. sitemap_tracker/services/__init__.py +0 -0
  24. sitemap_tracker/services/crawler.py +908 -0
  25. sitemap_tracker/services/file_checker.py +271 -0
  26. sitemap_tracker/services/page_analysis.py +225 -0
  27. sitemap_tracker/services/preview_service.py +318 -0
  28. sitemap_tracker/services/proxy_detect.py +119 -0
  29. sitemap_tracker/services/rate_limit.py +56 -0
  30. sitemap_tracker/services/reporter.py +192 -0
  31. sitemap_tracker/services/site_score.py +99 -0
  32. sitemap_tracker/services/source_fetcher.py +180 -0
  33. sitemap_tracker/services/zwischenablage.py +66 -0
  34. sitemap_tracker/widgets/__init__.py +0 -0
  35. sitemap_tracker/widgets/crawl_header.py +148 -0
  36. sitemap_tracker/widgets/page_tree.py +357 -0
  37. sitemap_tracker/widgets/preview_panel.py +335 -0
  38. sitemap_tracker/widgets/stats_panel.py +322 -0
  39. sitemap_tracker/widgets/url_table.py +797 -0
  40. sitemap_tracker-2.13.1.dist-info/METADATA +328 -0
  41. sitemap_tracker-2.13.1.dist-info/RECORD +45 -0
  42. sitemap_tracker-2.13.1.dist-info/WHEEL +5 -0
  43. sitemap_tracker-2.13.1.dist-info/entry_points.txt +2 -0
  44. sitemap_tracker-2.13.1.dist-info/licenses/LICENSE +191 -0
  45. sitemap_tracker-2.13.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Sitemap Tracker - Crawlt Websites, erzeugt sitemap.xml, trackt Dead-Links."""
2
+
3
+ __version__ = "2.13.1"
4
+ __author__ = "Michael Blaess"
5
+ __year__ = "2026"
@@ -0,0 +1,389 @@
1
+ """Entry Point fuer Sitemap Tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import os
8
+ import sys
9
+ from typing import TYPE_CHECKING, TextIO
10
+
11
+ if TYPE_CHECKING:
12
+ from sys import UnraisableHookArgs
13
+
14
+ # Frozen-EXE Erkennung (PyInstaller UND Nuitka):
15
+ # PLAYWRIGHT_BROWSERS_PATH muss gesetzt werden BEVOR playwright importiert wird,
16
+ # damit das gebundelte Chromium im "browsers"-Unterordner gefunden wird.
17
+ # PyInstaller setzt sys.frozen, Nuitka setzt stattdessen __compiled__.
18
+ _is_frozen = getattr(sys, "frozen", False) or "__compiled__" in globals()
19
+ if _is_frozen:
20
+ _exe_dir = os.path.dirname(sys.executable)
21
+ _browsers_dir = os.path.join(_exe_dir, "browsers")
22
+ if os.path.isdir(_browsers_dir):
23
+ os.environ["PLAYWRIGHT_BROWSERS_PATH"] = _browsers_dir
24
+
25
+ from textual_widgets import reset_terminal_title, set_terminal_title
26
+
27
+ # Log-Handle offen halten, solange der Prozess laeuft - faulthandler schreibt
28
+ # beim fatalen Signal direkt hinein. Ohne Referenz wuerde der GC es schliessen.
29
+ _fault_log: TextIO | None = None
30
+
31
+ from sitemap_tracker import __version__
32
+ from sitemap_tracker.i18n import load_locale, t
33
+ from sitemap_tracker.models.settings import Settings
34
+
35
+
36
+ def _migrate_user_data() -> None:
37
+ """Kopiert ``~/.sitemap-generator/`` einmalig nach ``~/.sitemap-tracker/``.
38
+
39
+ Das Tool hiess bis v1.x ``sitemap-generator`` und legte Settings sowie
40
+ History unter ``~/.sitemap-generator/`` ab. Beim ersten Start unter dem
41
+ neuen Namen ``sitemap-tracker`` werden Bestandskunden-Daten kopiert,
42
+ damit History und Einstellungen nicht verloren gehen.
43
+
44
+ Markiert das alte Verzeichnis mit ``.migrated_to_tracker``, damit die
45
+ Kopie nicht erneut ueberschreibt — der User kann das alte Verzeichnis
46
+ danach gefahrlos selbst loeschen.
47
+ """
48
+ import shutil
49
+ from pathlib import Path
50
+
51
+ old_dir = Path.home() / ".sitemap-generator"
52
+ new_dir = Path.home() / ".sitemap-tracker"
53
+ marker = old_dir / ".migrated_to_tracker"
54
+ if not old_dir.is_dir() or new_dir.exists() or marker.exists():
55
+ return
56
+ try:
57
+ shutil.copytree(old_dir, new_dir)
58
+ marker.write_text("Daten kopiert nach ~/.sitemap-tracker/\n", encoding="utf-8")
59
+ except Exception:
60
+ # Im Zweifel still abbrechen — fehlende Migration ist kein Fehlerfall,
61
+ # der User startet einfach mit Default-Settings.
62
+ pass
63
+
64
+
65
+ def _silence_windows_teardown_noise() -> None:
66
+ """Blendet die bekannten asyncio/playwright-Teardown-Meldungen aus.
67
+
68
+ Drei Quellen, drei Filter:
69
+
70
+ 1. ``ResourceWarning: unclosed transport`` aus den ``__del__``-Methoden
71
+ der Proactor-Pipes (Windows asyncio + Playwright-Subprozess) — via
72
+ ``warnings.filterwarnings``.
73
+ 2. ``Exception ignored in: <function _ProactorBasePipeTransport.__del__>``
74
+ mit ``ValueError: I/O operation on closed pipe`` — via
75
+ ``sys.unraisablehook``.
76
+ 3. ``Future exception was never retrieved`` (z.B.
77
+ ``net::ERR_ABORTED; maybe frame was detached?``) aus internen
78
+ Playwright-Tasks, die nach dem Browser-Close noch eine Antwort
79
+ erwarten — via ``loop.set_exception_handler``. Tritt vor allem im
80
+ Nuitka-Compilat auf, wo das Timing leicht anders ist.
81
+ """
82
+ import asyncio
83
+ import warnings
84
+
85
+ warnings.filterwarnings("ignore", category=ResourceWarning, message=".*unclosed transport.*")
86
+ warnings.filterwarnings("ignore", category=ResourceWarning, message=".*subprocess.*")
87
+
88
+ prev = sys.unraisablehook
89
+
90
+ def _hook(unraisable: UnraisableHookArgs) -> None:
91
+ exc = unraisable.exc_value
92
+ if isinstance(exc, ValueError) and "closed pipe" in str(exc):
93
+ return
94
+ if isinstance(exc, ResourceWarning):
95
+ return
96
+ prev(unraisable)
97
+
98
+ sys.unraisablehook = _hook
99
+
100
+ def _loop_exc_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
101
+ msg = str(context.get("message", ""))
102
+ exc = context.get("exception")
103
+ exc_msg = str(exc) if exc is not None else ""
104
+ if "never retrieved" in msg or "net::ERR_ABORTED" in exc_msg or "frame was detached" in exc_msg:
105
+ return # bekannte playwright/cleanup-Reste — ignorieren
106
+ loop.default_exception_handler(context)
107
+
108
+ # Asyncio nutzt den Event-Loop-Exception-Handler fuer
109
+ # "Future exception was never retrieved". Wir installieren ihn pro
110
+ # Loop, sobald ein Loop existiert — beim Programmstart ist meist
111
+ # noch keiner aktiv, deshalb haengen wir uns in den Policy-Loop-
112
+ # Factory-Mechanismus.
113
+ base_policy = asyncio.get_event_loop_policy()
114
+
115
+ class _PatchedPolicy(type(base_policy)): # type: ignore[misc]
116
+ def new_event_loop(self) -> asyncio.AbstractEventLoop:
117
+ loop = super().new_event_loop()
118
+ loop.set_exception_handler(_loop_exc_handler)
119
+ return loop
120
+
121
+ with contextlib.suppress(Exception):
122
+ asyncio.set_event_loop_policy(_PatchedPolicy())
123
+
124
+
125
+ def _preinit_graphics() -> None:
126
+ """Initialisiert textual-image vor dem App-Start.
127
+
128
+ textual-image sendet beim Import Terminal-Queries (DA1, Cell-Size).
129
+ Diese muessen laufen, bevor Textual stdin uebernimmt - sonst landen die
130
+ Antworten als Muell-Input in Textual-Widgets.
131
+ """
132
+ try:
133
+ import textual_image.renderable # noqa: F401
134
+ import textual_image.widget # noqa: F401
135
+ from textual_image._terminal import get_cell_size
136
+
137
+ get_cell_size()
138
+ except Exception:
139
+ pass
140
+
141
+
142
+ def main() -> None:
143
+ """Haupteinstiegspunkt fuer die CLI."""
144
+ _enable_faulthandler()
145
+ # Einmalige Migration der User-Daten vom alten Tool-Namen
146
+ # (sitemap-generator) — muss VOR ``Settings.load()`` laufen.
147
+ _migrate_user_data()
148
+
149
+ # Sprache aus Settings laden (fuer CLI-Hilfe)
150
+ settings = Settings.load()
151
+
152
+ # Vorab --lang aus sys.argv parsen (vor argparse),
153
+ # damit Banner und Hilfetexte uebersetzt sind
154
+ lang = settings.language
155
+ for i, arg in enumerate(sys.argv[1:]):
156
+ if arg == "--lang" and i + 1 < len(sys.argv[1:]):
157
+ lang = sys.argv[i + 2]
158
+ break
159
+ if arg.startswith("--lang="):
160
+ lang = arg.split("=", 1)[1]
161
+ break
162
+
163
+ load_locale(lang)
164
+
165
+ # Sprache in Settings persistieren
166
+ if lang != settings.language:
167
+ settings.language = lang
168
+ settings.save()
169
+
170
+ parser = argparse.ArgumentParser(
171
+ prog="sitemap-tracker",
172
+ description=t("cli.banner", version=__version__),
173
+ epilog=t("cli.examples"),
174
+ formatter_class=argparse.RawDescriptionHelpFormatter,
175
+ )
176
+
177
+ parser.add_argument(
178
+ "url",
179
+ nargs="?",
180
+ default="",
181
+ metavar="URL",
182
+ help=t("cli.url_help"),
183
+ )
184
+ parser.add_argument(
185
+ "--output",
186
+ "-o",
187
+ default="",
188
+ metavar="PATH",
189
+ help=t("cli.output_help"),
190
+ )
191
+ parser.add_argument(
192
+ "--max-depth",
193
+ "-d",
194
+ type=int,
195
+ default=None,
196
+ metavar="N",
197
+ help=t("cli.max_depth_help"),
198
+ )
199
+ parser.add_argument(
200
+ "--concurrency",
201
+ "-c",
202
+ type=int,
203
+ default=None,
204
+ metavar="N",
205
+ help=t("cli.concurrency_help"),
206
+ )
207
+ parser.add_argument(
208
+ "--timeout",
209
+ "-t",
210
+ type=int,
211
+ default=None,
212
+ metavar="SEC",
213
+ help=t("cli.timeout_help"),
214
+ )
215
+ parser.add_argument(
216
+ "--render",
217
+ action="store_true",
218
+ default=False,
219
+ help=t("cli.render_help"),
220
+ )
221
+ parser.add_argument(
222
+ "--no-headless",
223
+ action="store_true",
224
+ default=False,
225
+ help=t("cli.no_headless_help"),
226
+ )
227
+ parser.add_argument(
228
+ "--rate-limit",
229
+ type=int,
230
+ default=None,
231
+ metavar="N",
232
+ help=t("cli.rate_limit_help"),
233
+ )
234
+ parser.add_argument(
235
+ "--ignore-robots",
236
+ action="store_true",
237
+ default=False,
238
+ help=t("cli.ignore_robots_help"),
239
+ )
240
+ parser.add_argument(
241
+ "--user-agent",
242
+ default="",
243
+ metavar="UA",
244
+ help=t("cli.user_agent_help"),
245
+ )
246
+ parser.add_argument(
247
+ "--cookie",
248
+ action="append",
249
+ default=[],
250
+ metavar="NAME=VALUE",
251
+ help=t("cli.cookie_help"),
252
+ )
253
+ parser.add_argument(
254
+ "--lang",
255
+ default=lang,
256
+ choices=["de", "en"],
257
+ help="Sprache / Language (de, en)",
258
+ )
259
+
260
+ args = parser.parse_args()
261
+
262
+ # Cookies parsen: "NAME=VALUE" -> {"name": "NAME", "value": "VALUE"}
263
+ cookies = []
264
+ for cookie_str in args.cookie:
265
+ if "=" not in cookie_str:
266
+ print(t("cli.invalid_cookie", cookie=cookie_str))
267
+ sys.exit(1)
268
+ name, value = cookie_str.split("=", 1)
269
+ cookies.append({"name": name.strip(), "value": value.strip()})
270
+
271
+ # Pruefen ob das Argument eine lokale XML-Datei ist
272
+ sitemap_file = ""
273
+ start_url = args.url
274
+ if start_url and os.path.isfile(start_url) and start_url.lower().endswith(".xml"):
275
+ sitemap_file = os.path.abspath(start_url)
276
+ start_url = "" # Wird aus der XML-Datei ermittelt
277
+
278
+ # Teardown-Rauschen aus playwright/asyncio auf Windows ausblenden.
279
+ _silence_windows_teardown_noise()
280
+
281
+ # textual-image vor App-Start initialisieren — IMMER, nicht nur bei
282
+ # aktivierter Vorschau. Das `PreviewPanel`-Widget wird im DOM auch dann
283
+ # erzeugt, wenn die Vorschau aus ist (nur unsichtbar), und sein
284
+ # __init__ kann textual-image lazy nachladen, sobald der User die
285
+ # Vorschau spaeter einschaltet. Wenn der Import erst nach Textual-
286
+ # Stdin-Hijack passiert, blutet die DA1/Cell-Size-Antwort des Terminals
287
+ # ins Eingabefeld. Einmal vorab importieren, fertig.
288
+ _preinit_graphics()
289
+
290
+ from sitemap_tracker.app import SitemapTrackerApp
291
+
292
+ # Terminal-Tab-Titel setzen - Textual macht das nicht selbst.
293
+ set_terminal_title(f"sitemap-tracker v{__version__}")
294
+ try:
295
+ app = SitemapTrackerApp(
296
+ start_url=start_url,
297
+ sitemap_file=sitemap_file,
298
+ output_path=args.output,
299
+ max_depth=args.max_depth,
300
+ concurrency=args.concurrency,
301
+ rate_per_minute=args.rate_limit,
302
+ timeout=args.timeout,
303
+ render=args.render,
304
+ headless=not args.no_headless,
305
+ respect_robots=not args.ignore_robots,
306
+ user_agent=args.user_agent,
307
+ cookies=cookies,
308
+ )
309
+ app.run()
310
+ finally:
311
+ reset_terminal_title()
312
+ _reset_mouse_tracking()
313
+
314
+
315
+ def _enable_faulthandler() -> None:
316
+ """Faengt HARTE Abstuerze ab, die an Pythons Exception-Handling UND am
317
+ finally-Block vorbeilaufen: native Access Violation, Stack-Overflow, fataler
318
+ Interpreter-Fehler.
319
+
320
+ faulthandler schreibt den Traceback aller Threads in fault.log - separat vom
321
+ Terminal, das der Maus-Tracking-Muell sonst unlesbar macht. CrashGuard und
322
+ _persist_crash greifen nur bei normalen Python-Exceptions, dieser Handler bei
323
+ allem darunter. Die Startzeile ist die zweite Haelfte der Diagnose: steht
324
+ danach nichts weiter in der Datei, wurde der Prozess von aussen abgeraeumt.
325
+ """
326
+ import atexit
327
+ import contextlib
328
+ import faulthandler
329
+ from datetime import datetime
330
+
331
+ from .models.settings import SETTINGS_FILE
332
+
333
+ global _fault_log
334
+ with contextlib.suppress(Exception):
335
+ verzeichnis = SETTINGS_FILE.parent
336
+ verzeichnis.mkdir(parents=True, exist_ok=True)
337
+ # Bewusst offen lassen (Prozess-Lebensdauer) - faulthandler schreibt
338
+ # beim fatalen Signal direkt in dieses Handle.
339
+ _fault_log = open(verzeichnis / "fault.log", "a", encoding="utf-8") # noqa: SIM115
340
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
341
+ _fault_log.write(f"\n===== Start {stamp} - v{__version__} =====\n")
342
+ _fault_log.flush()
343
+ faulthandler.enable(file=_fault_log, all_threads=True)
344
+ # Gegenstueck zur Startzeile - siehe _write_fault_end.
345
+ atexit.register(_write_fault_end)
346
+
347
+
348
+ def _reset_mouse_tracking() -> None:
349
+ """Schaltet alle Maus-Tracking-Modi des Terminals ab (idempotent).
350
+
351
+ Schreibt bewusst nach sys.__stdout__, NICHT sys.stdout: Textual kapert
352
+ sys.stdout zur Laufzeit, ein Reset dorthin landet im Nichts und das Terminal
353
+ bleibt im Maus-Tracking-Modus haengen. Der garantierte Reset passiert im
354
+ Shell-Wrapper run.ps1 (laeuft auch nach einem harten Crash) - das hier ist
355
+ der zusaetzliche In-Prozess-Pfad.
356
+ """
357
+ stream = sys.__stdout__
358
+ if stream is None or not stream.isatty():
359
+ return
360
+ stream.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l")
361
+ stream.flush()
362
+
363
+
364
+ def _write_fault_end() -> None:
365
+ """Schreibt die Endzeile der Sitzungsklammer (ueber atexit registriert).
366
+
367
+ Erst dieses Gegenstueck zur Startzeile macht die Datei aussagekraeftig:
368
+
369
+ Start + Ende -> sauber beendet
370
+ Start + Traceback -> Python-Fehler (der Handler hat ihn gesehen)
371
+ Start und sonst nichts -> Prozess hart abgeraeumt
372
+
373
+ Unter Windows hilft ein Signalhandler dabei nicht: ein Abbruch von aussen
374
+ laeuft dort ueber TerminateProcess und liefert dem Ziel kein abfangbares
375
+ Signal. Die FEHLENDE Endzeile ist der einzige Beleg.
376
+ """
377
+ import contextlib
378
+ from datetime import datetime
379
+
380
+ if _fault_log is None:
381
+ return
382
+ with contextlib.suppress(Exception):
383
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
384
+ _fault_log.write(f"===== Ende {stamp} =====\n")
385
+ _fault_log.flush()
386
+
387
+
388
+ if __name__ == "__main__":
389
+ main()