web-scanner 2.2.1__tar.gz → 2.2.2__tar.gz

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 (44) hide show
  1. {web_scanner-2.2.1 → web_scanner-2.2.2}/PKG-INFO +1 -1
  2. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/__init__.py +1 -1
  3. web_scanner-2.2.2/webscanner/cli.py +36 -0
  4. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/colors.py +3 -0
  5. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/models.py +6 -2
  6. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/sitemap.py +39 -0
  7. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/version_check.py +24 -6
  8. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/activity.py +29 -9
  9. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/app.py +48 -31
  10. web_scanner-2.2.1/webscanner/cli.py +0 -24
  11. {web_scanner-2.2.1 → web_scanner-2.2.2}/.gitignore +0 -0
  12. {web_scanner-2.2.1 → web_scanner-2.2.2}/LICENSE +0 -0
  13. {web_scanner-2.2.1 → web_scanner-2.2.2}/README.md +0 -0
  14. {web_scanner-2.2.1 → web_scanner-2.2.2}/app.py +0 -0
  15. {web_scanner-2.2.1 → web_scanner-2.2.2}/pyproject.toml +0 -0
  16. {web_scanner-2.2.1 → web_scanner-2.2.2}/requirements.txt +0 -0
  17. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/__main__.py +0 -0
  18. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/__init__.py +0 -0
  19. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/context.py +0 -0
  20. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/module.py +0 -0
  21. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/scanner.py +0 -0
  22. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/helpers.py +0 -0
  23. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/__init__.py +0 -0
  24. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/dns.py +0 -0
  25. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/headers.py +0 -0
  26. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/links.py +0 -0
  27. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/security.py +0 -0
  28. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/seo.py +0 -0
  29. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/ssl.py +0 -0
  30. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/subdomains.py +0 -0
  31. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/tech.py +0 -0
  32. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/whois.py +0 -0
  33. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/__init__.py +0 -0
  34. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/agents.py +0 -0
  35. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/data/public_suffix_list.dat +0 -0
  36. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/http.py +0 -0
  37. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/psl.py +0 -0
  38. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/__init__.py +0 -0
  39. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/app.tcss +0 -0
  40. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/data/countries.json +0 -0
  41. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/export.py +0 -0
  42. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/tables.py +0 -0
  43. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/widgets.py +0 -0
  44. {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/worldmap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-scanner
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
5
5
  Project-URL: Homepage, https://github.com/iamramizk/web-scanner
6
6
  Project-URL: Repository, https://github.com/iamramizk/web-scanner
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.2.1"
3
+ __version__ = "2.2.2"
@@ -0,0 +1,36 @@
1
+ """Console entry point for the ``webscan`` command.
2
+
3
+ webscan example.com
4
+ python -m webscanner example.com
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ from webscanner.helpers import is_valid_url
13
+ from webscanner.ui.app import WebScannerApp
14
+
15
+
16
+ def main() -> None:
17
+ target = sys.argv[1].strip() if len(sys.argv) > 1 else None
18
+ if target and not is_valid_url(target):
19
+ print(f"[!] Not a valid target: {target}")
20
+ raise SystemExit(1)
21
+ WebScannerApp(target).run()
22
+ # Textual has already restored the terminal by the time run() returns.
23
+ # Quitting mid-scan cancels the scan worker's coroutine, but the blocking
24
+ # work it dispatched via asyncio.to_thread (requests/socket/pydig/Wappalyzer,
25
+ # each up to ~12–30s of timeout) keeps running in asyncio's default thread
26
+ # pool. Those are non-daemon threads, so concurrent.futures' atexit handler
27
+ # joins them on interpreter shutdown and the process hangs after the UI is
28
+ # gone. os._exit bypasses that join and terminates now — safe here: the
29
+ # terminal is restored and there is no post-scan state left to flush.
30
+ sys.stdout.flush()
31
+ sys.stderr.flush()
32
+ os._exit(0)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -5,6 +5,9 @@ RED = "#FF6C64"
5
5
  #: matches the app's primary theme blue ($primary, textual-dark) — progress bar,
6
6
  #: selected-tab chip, keybar hints.
7
7
  BLUE = "#0178D4"
8
+ #: update-available signal — the status-bar version dot when a newer PyPI release
9
+ #: exists (green = up to date, blue = not yet checked / couldn't check).
10
+ ORANGE = "#F5A623"
8
11
  #: muted value/secondary text — matches the unselected-tab colour
9
12
  #: ($text-muted = white @ 60% over the dark theme bg).
10
13
  MUTED = "#9D9D9D"
@@ -70,13 +70,17 @@ class TreeNode:
70
70
  site's URL-path hierarchy (``/blog`` → ``/blog/post-1`` …). A node is a branch
71
71
  (expandable folder) iff it has ``children``; otherwise it's a leaf (a page). The
72
72
  UI turns the root into a Textual ``Tree`` widget. ``total`` (set on the root only)
73
- is the count of page URLs behind the tree, shown in the panel subtitle. ``url`` is
74
- the full page URL a node stands for (set on leaves), so the UI can make it clickable.
73
+ is the count of URLs behind the tree, split into ``pages`` + ``assets`` (also
74
+ root-only), a purely parsed classification by file extension see
75
+ ``sitemap._is_asset``; shown in the panel subtitle. ``url`` is the full page URL a
76
+ node stands for (set on leaves), so the UI can make it clickable.
75
77
  """
76
78
 
77
79
  label: str
78
80
  children: list["TreeNode"] = field(default_factory=list)
79
81
  total: int | None = None
82
+ pages: int | None = None
83
+ assets: int | None = None
80
84
  url: str | None = None
81
85
 
82
86
 
@@ -37,6 +37,32 @@ MAX_URLS = 10000
37
37
  #: how deep a chain of nested <sitemapindex> files we follow
38
38
  MAX_DEPTH = 6
39
39
 
40
+ #: File extensions that mark a sitemap ``<loc>`` as a downloadable **asset**
41
+ #: (image / media / document / archive / static file) rather than a page.
42
+ #: Classification is *pure parsing* — read off the URL path, never fetched.
43
+ #: Anything not listed here (no extension, ``.html``/``.php``, a dynamic route,
44
+ #: a trailing slash) counts as a page, so the bias is toward "page" and odd
45
+ #: dynamic URLs aren't mislabelled.
46
+ ASSET_EXTENSIONS = frozenset({
47
+ # images
48
+ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico",
49
+ "tif", "tiff", "avif", "heic", "heif",
50
+ # audio / video
51
+ "mp3", "mp4", "wav", "ogg", "oga", "webm", "mov", "avi", "mkv",
52
+ "flv", "m4a", "m4v", "wmv", "aac", "flac",
53
+ # documents
54
+ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
55
+ "odp", "rtf", "csv", "tsv", "epub",
56
+ # archives
57
+ "zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz",
58
+ # web / static assets
59
+ "css", "js", "mjs", "map", "wasm", "rss", "atom",
60
+ # fonts
61
+ "woff", "woff2", "ttf", "otf", "eot",
62
+ # binaries / packages
63
+ "dmg", "exe", "apk", "pkg", "iso", "bin", "deb", "rpm", "msi",
64
+ })
65
+
40
66
 
41
67
  class SitemapModule(ScanModule):
42
68
  name = "sitemap"
@@ -158,6 +184,17 @@ def _localname(tag: str) -> str:
158
184
  return tag.rsplit("}", 1)[-1].lower()
159
185
 
160
186
 
187
+ def _is_asset(url: str) -> bool:
188
+ """True if `url`'s path names a downloadable asset (by file extension) rather
189
+ than a page. Pure parsing — no request is ever made. The extension is taken
190
+ from the last path segment only (query/fragment ignored); a missing or
191
+ unknown extension, or a trailing slash, counts as a page."""
192
+ last = urlparse(url).path.rsplit("/", 1)[-1]
193
+ if "." not in last:
194
+ return False
195
+ return last.rsplit(".", 1)[-1].lower() in ASSET_EXTENSIONS
196
+
197
+
161
198
  def _segments(url: str, include_host: bool) -> list[str]:
162
199
  """Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
163
200
  (the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
@@ -196,6 +233,8 @@ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
196
233
  if truncated:
197
234
  root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
198
235
  root.total = len(urls)
236
+ root.assets = sum(1 for u in urls if _is_asset(u))
237
+ root.pages = root.total - root.assets
199
238
  _sort_tree(root)
200
239
  return root
201
240
 
@@ -62,11 +62,17 @@ def _write_cache(latest: str) -> None:
62
62
  path.write_text(json.dumps({"latest": latest, "checked_at": time.time()}))
63
63
 
64
64
 
65
- def check_for_update(current: str) -> str | None:
66
- """The latest PyPI version if newer than ``current``, else ``None``.
65
+ def update_status(current: str) -> tuple[str, str | None]:
66
+ """The update state as ``(status, latest)``:
67
67
 
68
- Silent on any failure network, parse, or filesystem — since a broken update
69
- check must never be visible as a scan problem.
68
+ - ``("outdated", latest)``a newer PyPI release exists;
69
+ - ``("latest", None)`` confirmed up to date;
70
+ - ``("unknown", None)`` — couldn't check (network, parse, or filesystem failure).
71
+
72
+ The ``unknown`` case is kept distinct from ``latest`` on purpose: the status-bar
73
+ dot shows green only for a *confirmed* up-to-date check, and stays neutral (blue)
74
+ when the check couldn't complete — a broken update check must never look like a
75
+ scan problem, nor falsely claim "up to date".
70
76
  """
71
77
  try:
72
78
  latest = _cached_latest()
@@ -76,6 +82,18 @@ def check_for_update(current: str) -> str | None:
76
82
  if latest is None:
77
83
  latest = _fetch_latest()
78
84
  _write_cache(latest)
79
- return latest if _parse(latest) > _parse(current) else None
80
85
  except Exception: # noqa: BLE001
81
- return None
86
+ return "unknown", None
87
+ if _parse(latest) > _parse(current):
88
+ return "outdated", latest
89
+ return "latest", None
90
+
91
+
92
+ def check_for_update(current: str) -> str | None:
93
+ """The latest PyPI version if newer than ``current``, else ``None``.
94
+
95
+ Silent on any failure — network, parse, or filesystem — since a broken update
96
+ check must never be visible as a scan problem.
97
+ """
98
+ status, latest = update_status(current)
99
+ return latest if status == "outdated" else None
@@ -25,9 +25,12 @@ from __future__ import annotations
25
25
  import re
26
26
  from typing import Any, Callable
27
27
 
28
+ from urllib.parse import urlparse
29
+
28
30
  from rich.markup import escape
29
31
 
30
- from ..colors import BLUE, GREEN, RED
32
+ from ..colors import GREEN, RED
33
+ from ..net.psl import registrable_domain
31
34
  from ..core.context import ScanContext
32
35
  from ..core.models import ModuleResult, ModuleStatus, ScanEvent
33
36
  from ..core.scanner import PREFETCH, SHARED_IP
@@ -129,6 +132,20 @@ def _dns(result: ModuleResult) -> str:
129
132
  return f"{line} · {', '.join(auth)}." if auth else f"{line}."
130
133
 
131
134
 
135
+ def _registrar_root(url: str | None) -> str:
136
+ """Registrar's registrable domain from its WHOIS ``registrar_url``.
137
+
138
+ ``http://www.hostinger.com`` → ``hostinger.com``. Tolerates a scheme-less
139
+ value (``www.hostinger.com``) — ``urlparse`` puts that in ``path``, not
140
+ ``netloc``, so fall back to the whole string as the host. Returns "" when
141
+ nothing usable (no URL, or no registrable domain resolves)."""
142
+ if not url:
143
+ return ""
144
+ parsed = urlparse(url.strip())
145
+ host = parsed.netloc or parsed.path
146
+ return registrable_domain(host.split("/")[0].split("@")[-1])
147
+
148
+
132
149
  def _whois(result: ModuleResult) -> str:
133
150
  data = result.data or {}
134
151
  if not data:
@@ -136,7 +153,11 @@ def _whois(result: ModuleResult) -> str:
136
153
  if (note := _note(data)) is not None:
137
154
  return f"{_DONE}. {_sentence(note)}"
138
155
  bits = []
139
- if registrar := data.get("registrar"):
156
+ # Prefer the registrar's root domain (registrable eTLD+1 of its URL) over its
157
+ # legal name — "hostinger.com" reads truer than "HOSTINGER operations, UAB".
158
+ # Fall back to the name when there's no URL (sparse ccTLD output).
159
+ registrar = _registrar_root(data.get("registrar_url")) or data.get("registrar")
160
+ if registrar:
140
161
  # Unlabelled: the Whois prefix and the "expires" half make it obvious enough,
141
162
  # and the 10 chars "Registrar " costs are better spent on the name itself.
142
163
  bits.append(_esc(registrar))
@@ -237,7 +258,12 @@ def _sitemap(result: ModuleResult) -> str:
237
258
  return f"{_DONE}. No sitemap found."
238
259
  total = root.total or 0
239
260
  suffix = " (truncated)" if total >= MAX_URLS else ""
240
- return f"{_DONE}. {_plural(total, 'URL')} found{suffix}."
261
+ assets = root.assets or 0
262
+ pages = root.pages if root.pages is not None else total
263
+ tally = _plural(pages, "page")
264
+ if assets:
265
+ tally += f", {_plural(assets, 'asset')}"
266
+ return f"{_DONE}. {tally} found{suffix}."
241
267
 
242
268
 
243
269
  def _links(result: ModuleResult) -> str:
@@ -338,12 +364,6 @@ def waf(result: ModuleResult) -> str | None:
338
364
  return f"{line} · actively blocking." if blocked else f"{line}."
339
365
 
340
366
 
341
- def update_available(latest: str) -> str:
342
- """A newer release exists on PyPI. Emitted once, after the scan's own closing
343
- line, only when a newer version was actually found — see net/version_check.py."""
344
- return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
345
-
346
-
347
367
  def overall(completed: int, failed: int, total: int, seconds: float) -> str:
348
368
  """The closing line. ``seconds`` is wall-clock for the whole scan — modules run
349
369
  concurrently, so summing their durations would overstate it several-fold."""
@@ -25,10 +25,11 @@ from textual.message import Message
25
25
  from textual.widgets import Input, LoadingIndicator, Static
26
26
 
27
27
  from .. import __version__
28
+ from ..colors import BLUE, GREEN, ORANGE
28
29
  from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
29
30
  from ..core.scanner import PREFETCH, SHARED_IP
30
31
  from ..modules import all_modules
31
- from ..net.version_check import check_for_update
32
+ from ..net.version_check import update_status
32
33
  from . import activity
33
34
  from .export import export_csvs
34
35
  from .tables import UNSET, render_result, render_status
@@ -38,6 +39,11 @@ from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Ta
38
39
  _BAR_WIDTH = 22
39
40
  _BAR_DIM = "grey30"
40
41
 
42
+ #: colour of the status-bar version dot per update state (see net/version_check.py):
43
+ #: blue until the background check answers / if it couldn't, green = up to date,
44
+ #: orange = a newer PyPI release is available.
45
+ _DOT_COLOURS = {"unknown": BLUE, "latest": GREEN, "outdated": ORANGE}
46
+
41
47
  #: below this terminal width the layout collapses to a single column (the fixed map
42
48
  #: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
43
49
  _NARROW_MAX = 90
@@ -133,6 +139,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
133
139
  return tech
134
140
 
135
141
 
142
+ def _sitemap_subtitle(root: object) -> str:
143
+ """The Sitemap tab's ``#main`` subtitle: ``"12 pages • 3 assets"`` (pages vs.
144
+ assets split purely by URL extension — see ``sitemap._is_asset``). Assets are
145
+ only shown when present; falls back to the raw total if the split is missing."""
146
+ pages = getattr(root, "pages", None)
147
+ assets = getattr(root, "assets", None) or 0
148
+ if pages is None: # older/other tree without the split — show the plain count
149
+ return f"{getattr(root, 'total', 0) or 0} Total"
150
+ parts = [f"{pages} {'page' if pages == 1 else 'pages'}"]
151
+ if assets:
152
+ parts.append(f"{assets} {'asset' if assets == 1 else 'assets'}")
153
+ return " • ".join(parts)
154
+
155
+
136
156
  class ScanProgress(Message):
137
157
  """A ScanEvent surfaced onto the Textual message pump."""
138
158
 
@@ -146,10 +166,14 @@ class ScanFinished(Message):
146
166
 
147
167
 
148
168
  class VersionChecked(Message):
149
- """Result of the background PyPI update check (see net/version_check.py)."""
169
+ """Result of the background PyPI update check (see net/version_check.py).
170
+
171
+ ``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` — it drives
172
+ the colour of the status-bar version dot.
173
+ """
150
174
 
151
- def __init__(self, latest: str | None) -> None:
152
- self.latest = latest
175
+ def __init__(self, status: str) -> None:
176
+ self.status = status
153
177
  super().__init__()
154
178
 
155
179
 
@@ -212,11 +236,9 @@ class WebScannerApp(App):
212
236
  # last result rendered into the sitemap Tree, so switching tabs doesn't
213
237
  # rebuild (and re-collapse) it every visit.
214
238
  self._tree_result = None
215
- # background PyPI update check (see _check_version) — announced once, after
216
- # whichever finishes last: the scan or the check itself.
217
- self._update_available: str | None = None
218
- self._version_checked = False
219
- self._update_announced = False
239
+ # background PyPI update check (see _check_version) — drives the colour of the
240
+ # status-bar version dot. "unknown" (blue) until the check answers.
241
+ self._version_status = "unknown"
220
242
 
221
243
  # ---- layout -----------------------------------------------------------
222
244
 
@@ -246,6 +268,9 @@ class WebScannerApp(App):
246
268
  self.query_one("#tabs", TabBar).set_selected(self.selected)
247
269
  # Establish the layout (wide vs narrow) before the first paint / scan.
248
270
  self._apply_narrow(self.size.width < _NARROW_MAX)
271
+ # Show the version + (blue) update dot straight away; the dot recolours once
272
+ # the background check answers, and the progress bar reclaims the slot mid-scan.
273
+ self._refresh_version()
249
274
  # own group: the scan worker below is exclusive=True, which cancels every
250
275
  # other worker in its group — a shared/default group would kill this one.
251
276
  self.run_worker(
@@ -288,26 +313,20 @@ class WebScannerApp(App):
288
313
  self._set_keybar(editing=editing)
289
314
 
290
315
  async def _check_version(self) -> None:
291
- latest = await asyncio.to_thread(check_for_update, __version__)
292
- self.post_message(VersionChecked(latest))
316
+ status, _latest = await asyncio.to_thread(update_status, __version__)
317
+ self.post_message(VersionChecked(status))
293
318
 
294
319
  def on_version_checked(self, message: VersionChecked) -> None:
295
- self._update_available = message.latest
296
- self._version_checked = True
297
- self._maybe_announce_update()
298
-
299
- def _maybe_announce_update(self) -> None:
300
- if (
301
- self._update_announced
302
- or self._scanning
303
- or not self._version_checked
304
- or self._update_available is None
305
- ):
320
+ self._version_status = message.status
321
+ self._refresh_version()
322
+
323
+ def _refresh_version(self) -> None:
324
+ """Draw the version + update dot at the right of the footer, unless a scan is
325
+ in progress (then the progress bar owns that slot — restored on finish)."""
326
+ if self._scanning:
306
327
  return
307
- self._update_announced = True
308
- self.query_one("#activity", ActivityLog).add(
309
- activity.update_available(self._update_available)
310
- )
328
+ dot = _DOT_COLOURS.get(self._version_status, BLUE)
329
+ self.query_one("#progress", Static).update(f"v{__version__} [{dot}]•[/]")
311
330
 
312
331
  # ---- scanning ---------------------------------------------------------
313
332
 
@@ -415,8 +434,7 @@ class WebScannerApp(App):
415
434
  self.completed, self.failed, total, time.monotonic() - self._scan_start
416
435
  )
417
436
  )
418
- self.query_one("#progress", Static).update(f"v{__version__}")
419
- self._maybe_announce_update()
437
+ self._refresh_version()
420
438
 
421
439
  # ---- progress line ----------------------------------------------------
422
440
 
@@ -542,9 +560,8 @@ class WebScannerApp(App):
542
560
  if self._tree_result is not result:
543
561
  tree.populate(result.data)
544
562
  self._tree_result = result
545
- total = result.data.total
546
- if total is not None:
547
- self.query_one("#main").border_subtitle = f"{total} Total"
563
+ if result.data.total is not None:
564
+ self.query_one("#main").border_subtitle = _sitemap_subtitle(result.data)
548
565
  elif result.status is ModuleStatus.FAILED:
549
566
  self._set_main(f"[red]failed:[/] {result.error}")
550
567
  elif result.status is ModuleStatus.EMPTY:
@@ -1,24 +0,0 @@
1
- """Console entry point for the ``webscan`` command.
2
-
3
- webscan example.com
4
- python -m webscanner example.com
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sys
10
-
11
- from webscanner.helpers import is_valid_url
12
- from webscanner.ui.app import WebScannerApp
13
-
14
-
15
- def main() -> None:
16
- target = sys.argv[1].strip() if len(sys.argv) > 1 else None
17
- if target and not is_valid_url(target):
18
- print(f"[!] Not a valid target: {target}")
19
- raise SystemExit(1)
20
- WebScannerApp(target).run()
21
-
22
-
23
- if __name__ == "__main__":
24
- main()
File without changes
File without changes
File without changes
File without changes
File without changes