web-scanner 2.2.0__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.
- {web_scanner-2.2.0 → web_scanner-2.2.2}/PKG-INFO +1 -1
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/__init__.py +1 -1
- web_scanner-2.2.2/webscanner/cli.py +36 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/colors.py +3 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/models.py +6 -2
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/sitemap.py +39 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/version_check.py +24 -6
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/activity.py +29 -9
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/app.py +205 -46
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/app.tcss +36 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/tables.py +62 -13
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/widgets.py +88 -33
- web_scanner-2.2.0/webscanner/cli.py +0 -24
- {web_scanner-2.2.0 → web_scanner-2.2.2}/.gitignore +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/LICENSE +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/README.md +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/app.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/pyproject.toml +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/requirements.txt +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/__main__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/context.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/module.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/helpers.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/links.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/security.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/agents.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/http.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/psl.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/export.py +0 -0
- {web_scanner-2.2.0 → 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.
|
|
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
|
|
@@ -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
|
|
74
|
-
|
|
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
|
|
66
|
-
"""The
|
|
65
|
+
def update_status(current: str) -> tuple[str, str | None]:
|
|
66
|
+
"""The update state as ``(status, latest)``:
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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."""
|
|
@@ -17,6 +17,7 @@ import re
|
|
|
17
17
|
import time
|
|
18
18
|
|
|
19
19
|
from bs4 import BeautifulSoup
|
|
20
|
+
from rich.padding import Padding
|
|
20
21
|
from textual.app import App, ComposeResult
|
|
21
22
|
from textual.binding import Binding
|
|
22
23
|
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
@@ -24,19 +25,36 @@ from textual.message import Message
|
|
|
24
25
|
from textual.widgets import Input, LoadingIndicator, Static
|
|
25
26
|
|
|
26
27
|
from .. import __version__
|
|
28
|
+
from ..colors import BLUE, GREEN, ORANGE
|
|
27
29
|
from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
|
|
28
30
|
from ..core.scanner import PREFETCH, SHARED_IP
|
|
29
31
|
from ..modules import all_modules
|
|
30
|
-
from ..net.version_check import
|
|
32
|
+
from ..net.version_check import update_status
|
|
31
33
|
from . import activity
|
|
32
34
|
from .export import export_csvs
|
|
33
|
-
from .tables import UNSET, render_result
|
|
35
|
+
from .tables import UNSET, render_result, render_status
|
|
34
36
|
from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Tab
|
|
35
37
|
|
|
36
38
|
#: width (cells) of the footer progress bar and its dim (incomplete) colour
|
|
37
39
|
_BAR_WIDTH = 22
|
|
38
40
|
_BAR_DIM = "grey30"
|
|
39
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
|
+
|
|
47
|
+
#: below this terminal width the layout collapses to a single column (the fixed map
|
|
48
|
+
#: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
|
|
49
|
+
_NARROW_MAX = 90
|
|
50
|
+
|
|
51
|
+
#: pseudo-tabs shown only in the narrow layout, before the real module tabs: the
|
|
52
|
+
#: Activity log and the Server panel, which are fixed side/bottom panels on wide
|
|
53
|
+
#: terminals. (name, label) — names must not collide with any module name.
|
|
54
|
+
_PSEUDO_TABS: tuple[tuple[str, str], ...] = (("activity", "Activity"), ("server", "Server"))
|
|
55
|
+
_PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
|
|
56
|
+
_PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
|
|
57
|
+
|
|
40
58
|
|
|
41
59
|
def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
|
|
42
60
|
"""The CMS (name, version) from the Tech result, or ``None`` if none detected.
|
|
@@ -121,6 +139,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
|
|
|
121
139
|
return tech
|
|
122
140
|
|
|
123
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
|
+
|
|
124
156
|
class ScanProgress(Message):
|
|
125
157
|
"""A ScanEvent surfaced onto the Textual message pump."""
|
|
126
158
|
|
|
@@ -134,10 +166,14 @@ class ScanFinished(Message):
|
|
|
134
166
|
|
|
135
167
|
|
|
136
168
|
class VersionChecked(Message):
|
|
137
|
-
"""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).
|
|
138
170
|
|
|
139
|
-
|
|
140
|
-
|
|
171
|
+
``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` — it drives
|
|
172
|
+
the colour of the status-bar version dot.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, status: str) -> None:
|
|
176
|
+
self.status = status
|
|
141
177
|
super().__init__()
|
|
142
178
|
|
|
143
179
|
|
|
@@ -166,6 +202,10 @@ class WebScannerApp(App):
|
|
|
166
202
|
Binding("minus,underscore", "zoom_out", "Zoom map out", show=False),
|
|
167
203
|
Binding("pageup", "scroll_main_up", "Scroll up", show=False),
|
|
168
204
|
Binding("pagedown", "scroll_main_down", "Scroll down", show=False),
|
|
205
|
+
# Number keys jump to the Nth visible tab: 1 = first tab (DNS when wide,
|
|
206
|
+
# Activity when narrow) … 9 = ninth, 0 = tenth. Not priority, so a digit typed
|
|
207
|
+
# into the focused domain input is entered as text rather than switching tabs.
|
|
208
|
+
*[Binding(str(d), f"select_tab({d})", show=False) for d in range(10)],
|
|
169
209
|
]
|
|
170
210
|
|
|
171
211
|
def __init__(self, target: str | None = None) -> None:
|
|
@@ -184,6 +224,9 @@ class WebScannerApp(App):
|
|
|
184
224
|
# value handed to StatusPanel so the export matches what's on screen.
|
|
185
225
|
self._cms: object = UNSET
|
|
186
226
|
self.selected = self.modules[0].name
|
|
227
|
+
# Narrow (phone-width) layout: right column + activity strip become tabs.
|
|
228
|
+
# Applied for real in on_mount/on_resize once the terminal size is known.
|
|
229
|
+
self._narrow = False
|
|
187
230
|
self.completed = 0
|
|
188
231
|
self.failed = 0
|
|
189
232
|
self._scanning = False
|
|
@@ -193,18 +236,16 @@ class WebScannerApp(App):
|
|
|
193
236
|
# last result rendered into the sitemap Tree, so switching tabs doesn't
|
|
194
237
|
# rebuild (and re-collapse) it every visit.
|
|
195
238
|
self._tree_result = None
|
|
196
|
-
# background PyPI update check (see _check_version) —
|
|
197
|
-
#
|
|
198
|
-
self.
|
|
199
|
-
self._version_checked = False
|
|
200
|
-
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"
|
|
201
242
|
|
|
202
243
|
# ---- layout -----------------------------------------------------------
|
|
203
244
|
|
|
204
245
|
def compose(self) -> ComposeResult:
|
|
205
246
|
with Vertical(id="topbar"):
|
|
206
247
|
yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
|
|
207
|
-
yield TabBar(self.modules, id="tabs")
|
|
248
|
+
yield TabBar(self.modules, pseudo=_PSEUDO_TABS, id="tabs")
|
|
208
249
|
with Grid(id="grid"):
|
|
209
250
|
with Vertical(id="left"):
|
|
210
251
|
with VerticalScroll(id="main"):
|
|
@@ -225,8 +266,11 @@ class WebScannerApp(App):
|
|
|
225
266
|
self.query_one("#status", VerticalScroll).border_title = "Server"
|
|
226
267
|
self.query_one("#activity", ActivityLog).border_title = "Activity Log"
|
|
227
268
|
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
228
|
-
|
|
229
|
-
self.
|
|
269
|
+
# Establish the layout (wide vs narrow) before the first paint / scan.
|
|
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()
|
|
230
274
|
# own group: the scan worker below is exclusive=True, which cancels every
|
|
231
275
|
# other worker in its group — a shared/default group would kill this one.
|
|
232
276
|
self.run_worker(
|
|
@@ -237,27 +281,52 @@ class WebScannerApp(App):
|
|
|
237
281
|
else:
|
|
238
282
|
self.action_toggle_edit()
|
|
239
283
|
|
|
284
|
+
def on_resize(self, event) -> None:
|
|
285
|
+
"""Switch between the wide and narrow layouts as the terminal is resized."""
|
|
286
|
+
narrow = event.size.width < _NARROW_MAX
|
|
287
|
+
if narrow != self._narrow:
|
|
288
|
+
self._apply_narrow(narrow)
|
|
289
|
+
|
|
290
|
+
def _apply_narrow(self, narrow: bool) -> None:
|
|
291
|
+
"""Toggle the single-column phone layout.
|
|
292
|
+
|
|
293
|
+
Adds/removes the ``-narrow`` class on the Screen (which drives the CSS: the
|
|
294
|
+
map + Server panels hide, the grid collapses to one column) and re-derives the
|
|
295
|
+
Python-side state that CSS can't: the visible tab set (Activity/Server become
|
|
296
|
+
real tabs), the selected tab (a pseudo tab can't stay selected once it vanishes
|
|
297
|
+
on the way back to wide), and which of #main / #activity is shown.
|
|
298
|
+
"""
|
|
299
|
+
self._narrow = narrow
|
|
300
|
+
if self.screen is not None:
|
|
301
|
+
self.screen.set_class(narrow, "-narrow")
|
|
302
|
+
if narrow:
|
|
303
|
+
# Entering narrow: select the first tab (Activity), mirroring how DNS is
|
|
304
|
+
# the default first tab on wide terminals.
|
|
305
|
+
self.selected = _PSEUDO_TABS[0][0]
|
|
306
|
+
elif self.selected in _PSEUDO_NAMES:
|
|
307
|
+
# The pseudo tabs don't exist on wide terminals — fall back to the first
|
|
308
|
+
# real module tab so the selection stays valid.
|
|
309
|
+
self.selected = self.modules[0].name
|
|
310
|
+
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
311
|
+
self._refresh_main()
|
|
312
|
+
editing = self.focused is not None and self.focused.id == "domain"
|
|
313
|
+
self._set_keybar(editing=editing)
|
|
314
|
+
|
|
240
315
|
async def _check_version(self) -> None:
|
|
241
|
-
|
|
242
|
-
self.post_message(VersionChecked(
|
|
316
|
+
status, _latest = await asyncio.to_thread(update_status, __version__)
|
|
317
|
+
self.post_message(VersionChecked(status))
|
|
243
318
|
|
|
244
319
|
def on_version_checked(self, message: VersionChecked) -> None:
|
|
245
|
-
self.
|
|
246
|
-
self.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
or self._scanning
|
|
253
|
-
or not self._version_checked
|
|
254
|
-
or self._update_available is None
|
|
255
|
-
):
|
|
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:
|
|
256
327
|
return
|
|
257
|
-
self.
|
|
258
|
-
self.query_one("#
|
|
259
|
-
activity.update_available(self._update_available)
|
|
260
|
-
)
|
|
328
|
+
dot = _DOT_COLOURS.get(self._version_status, BLUE)
|
|
329
|
+
self.query_one("#progress", Static).update(f"v{__version__} [{dot}]•[/]")
|
|
261
330
|
|
|
262
331
|
# ---- scanning ---------------------------------------------------------
|
|
263
332
|
|
|
@@ -314,12 +383,14 @@ class WebScannerApp(App):
|
|
|
314
383
|
if event.status is ModuleStatus.DONE and self.ctx is not None:
|
|
315
384
|
self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
|
|
316
385
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx)
|
|
386
|
+
self._refresh_server_tab()
|
|
317
387
|
return
|
|
318
388
|
if event.name == SHARED_IP:
|
|
319
389
|
# Not a module — a late panel-only refresh once the shared-IP lookup lands.
|
|
320
390
|
# Re-pass the tracked CMS so a completed Tech row isn't dropped back to UNSET.
|
|
321
391
|
if self.ctx is not None:
|
|
322
392
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=self._cms)
|
|
393
|
+
self._refresh_server_tab()
|
|
323
394
|
return
|
|
324
395
|
|
|
325
396
|
self.query_one("#tabs", TabBar).set_status(event.name, event.status)
|
|
@@ -351,6 +422,7 @@ class WebScannerApp(App):
|
|
|
351
422
|
detected = _detect_cms(data, self.ctx.html)
|
|
352
423
|
self._cms = detected
|
|
353
424
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=detected)
|
|
425
|
+
self._refresh_server_tab()
|
|
354
426
|
self.query_one("#activity", ActivityLog).add(activity.cms(detected))
|
|
355
427
|
self._update_progress()
|
|
356
428
|
|
|
@@ -362,8 +434,7 @@ class WebScannerApp(App):
|
|
|
362
434
|
self.completed, self.failed, total, time.monotonic() - self._scan_start
|
|
363
435
|
)
|
|
364
436
|
)
|
|
365
|
-
self.
|
|
366
|
-
self._maybe_announce_update()
|
|
437
|
+
self._refresh_version()
|
|
367
438
|
|
|
368
439
|
# ---- progress line ----------------------------------------------------
|
|
369
440
|
|
|
@@ -386,17 +457,30 @@ class WebScannerApp(App):
|
|
|
386
457
|
def _module_names(self) -> list[str]:
|
|
387
458
|
return [m.name for m in self.modules]
|
|
388
459
|
|
|
460
|
+
def _nav_names(self) -> list[str]:
|
|
461
|
+
"""Tab names in cycle order — the pseudo tabs (Activity/Server) lead the list
|
|
462
|
+
in the narrow layout, and are absent (fixed panels) when wide."""
|
|
463
|
+
names = self._module_names()
|
|
464
|
+
if self._narrow:
|
|
465
|
+
return [name for name, _ in _PSEUDO_TABS] + names
|
|
466
|
+
return names
|
|
467
|
+
|
|
389
468
|
def _select(self, name: str) -> None:
|
|
390
469
|
self.selected = name
|
|
391
470
|
self.query_one("#tabs", TabBar).set_selected(name)
|
|
392
471
|
self._refresh_main()
|
|
472
|
+
# Reset the shared main-panel scroll so a tab left scrolled-down doesn't carry
|
|
473
|
+
# its offset onto the next tab (they all share the one #main VerticalScroll).
|
|
474
|
+
self.query_one("#main", VerticalScroll).scroll_home(animate=False)
|
|
393
475
|
# keybar depends on the selected tab (Sitemap shows tree-nav hints); keep
|
|
394
476
|
# the editing state if the domain input still has focus.
|
|
395
477
|
editing = self.focused is not None and self.focused.id == "domain"
|
|
396
478
|
self._set_keybar(editing=editing)
|
|
397
479
|
|
|
398
480
|
def _update_main_title(self) -> None:
|
|
399
|
-
label =
|
|
481
|
+
label = _PSEUDO_LABELS.get(self.selected) or next(
|
|
482
|
+
m.label for m in self.modules if m.name == self.selected
|
|
483
|
+
)
|
|
400
484
|
main = self.query_one("#main")
|
|
401
485
|
main.border_title = label
|
|
402
486
|
main.border_subtitle = "" # only the Sitemap tab sets one (URL total)
|
|
@@ -412,15 +496,54 @@ class WebScannerApp(App):
|
|
|
412
496
|
self.query_one("#main-loading", LoadingIndicator).display = loading
|
|
413
497
|
self.query_one("#main-content", Static).display = not loading
|
|
414
498
|
|
|
499
|
+
def _main_avail(self) -> int | None:
|
|
500
|
+
"""Character width available to the main-panel table (inside border + padding),
|
|
501
|
+
or None before the panel is laid out — the table renderer then skips the ⅓ cap."""
|
|
502
|
+
width = self.query_one("#main", VerticalScroll).content_size.width
|
|
503
|
+
return width or None
|
|
504
|
+
|
|
415
505
|
def _refresh_main(self) -> None:
|
|
416
506
|
self._update_main_title()
|
|
417
|
-
|
|
507
|
+
main = self.query_one("#main", VerticalScroll)
|
|
508
|
+
activity = self.query_one("#activity", ActivityLog)
|
|
509
|
+
|
|
510
|
+
# Activity pseudo-tab (narrow only): show the live log widget full-height in
|
|
511
|
+
# place of the main panel. In the wide layout it's the fixed bottom strip.
|
|
512
|
+
if self._narrow and self.selected == "activity":
|
|
513
|
+
main.display = False
|
|
514
|
+
activity.display = True
|
|
515
|
+
# Drop tree visibility so _sync_focus doesn't park focus on a widget
|
|
516
|
+
# whose parent (#main) is now hidden.
|
|
517
|
+
self.query_one("#main-tree", SitemapTree).display = False
|
|
518
|
+
self._sync_focus()
|
|
519
|
+
return
|
|
520
|
+
main.display = True
|
|
521
|
+
activity.display = not self._narrow
|
|
522
|
+
|
|
418
523
|
tree = self.query_one("#main-tree", SitemapTree)
|
|
419
524
|
content = self.query_one("#main-content", Static)
|
|
525
|
+
loading_widget = self.query_one("#main-loading", LoadingIndicator)
|
|
526
|
+
|
|
527
|
+
# Server pseudo-tab (narrow only): render the same status table the fixed
|
|
528
|
+
# Server panel shows, into the main content area.
|
|
529
|
+
if self.selected == "server":
|
|
530
|
+
main.border_subtitle = ""
|
|
531
|
+
tree.display = False
|
|
532
|
+
has_ctx = self.ctx is not None
|
|
533
|
+
loading_widget.display = not has_ctx
|
|
534
|
+
content.display = has_ctx
|
|
535
|
+
if has_ctx:
|
|
536
|
+
# 1ch top + left pad so the status table sits clear of the border,
|
|
537
|
+
# consistent with where the other tabs' content begins.
|
|
538
|
+
content.update(Padding(render_status(self.ctx, self._cms), (1, 0, 0, 1)))
|
|
539
|
+
self._sync_focus()
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
result = self.results.get(self.selected)
|
|
420
543
|
loading = result is None
|
|
421
544
|
# Spinner inside the panel until this tab's module completes; swap to the
|
|
422
545
|
# Tree (Sitemap tab) or the Static content once there's a result to render.
|
|
423
|
-
|
|
546
|
+
loading_widget.display = loading
|
|
424
547
|
|
|
425
548
|
want_tree = (
|
|
426
549
|
self.selected == "sitemap"
|
|
@@ -437,18 +560,36 @@ class WebScannerApp(App):
|
|
|
437
560
|
if self._tree_result is not result:
|
|
438
561
|
tree.populate(result.data)
|
|
439
562
|
self._tree_result = result
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
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)
|
|
443
565
|
elif result.status is ModuleStatus.FAILED:
|
|
444
566
|
self._set_main(f"[red]failed:[/] {result.error}")
|
|
445
567
|
elif result.status is ModuleStatus.EMPTY:
|
|
446
568
|
msg = "no sitemap found" if self.selected == "sitemap" else "no data found"
|
|
447
569
|
self._set_main(f"[dim]{msg}[/]")
|
|
448
570
|
else:
|
|
449
|
-
|
|
571
|
+
avail = self._main_avail()
|
|
572
|
+
content.update(
|
|
573
|
+
render_result(self.selected, result.data, narrow=self._narrow, avail=avail)
|
|
574
|
+
)
|
|
575
|
+
# When switching from a tab that had #main hidden (Activity), the panel's
|
|
576
|
+
# content_size isn't known this tick, so the ⅓ column cap was skipped.
|
|
577
|
+
# Re-render once layout settles so the cap applies.
|
|
578
|
+
if avail is None:
|
|
579
|
+
self.call_after_refresh(self._reflow)
|
|
450
580
|
self._sync_focus()
|
|
451
581
|
|
|
582
|
+
def _reflow(self) -> None:
|
|
583
|
+
"""Re-render the current tab once the main panel has a known width (used after
|
|
584
|
+
it was un-hidden, when the first render ran before layout gave it a size)."""
|
|
585
|
+
if self._main_avail() is not None:
|
|
586
|
+
self._refresh_main()
|
|
587
|
+
|
|
588
|
+
def _refresh_server_tab(self) -> None:
|
|
589
|
+
"""Re-render the Server pseudo-tab as its status data lands (narrow only)."""
|
|
590
|
+
if self._narrow and self.selected == "server":
|
|
591
|
+
self._refresh_main()
|
|
592
|
+
|
|
452
593
|
def _sync_focus(self) -> None:
|
|
453
594
|
"""Focus the Sitemap Tree while that tab is up (so ↑/↓/space reach it); drop
|
|
454
595
|
focus otherwise. Never steals focus from the domain input while editing."""
|
|
@@ -469,17 +610,28 @@ class WebScannerApp(App):
|
|
|
469
610
|
def action_prev_tab(self) -> None:
|
|
470
611
|
if self.focused and self.focused.id == "domain":
|
|
471
612
|
return
|
|
472
|
-
names = self.
|
|
613
|
+
names = self._nav_names()
|
|
473
614
|
idx = (names.index(self.selected) - 1) % len(names)
|
|
474
615
|
self._select(names[idx])
|
|
475
616
|
|
|
476
617
|
def action_next_tab(self) -> None:
|
|
477
618
|
if self.focused and self.focused.id == "domain":
|
|
478
619
|
return
|
|
479
|
-
names = self.
|
|
620
|
+
names = self._nav_names()
|
|
480
621
|
idx = (names.index(self.selected) + 1) % len(names)
|
|
481
622
|
self._select(names[idx])
|
|
482
623
|
|
|
624
|
+
def action_select_tab(self, digit: int) -> None:
|
|
625
|
+
"""Jump to the Nth visible tab. ``digit`` is the key pressed: 1..9 select the
|
|
626
|
+
1st..9th tab, 0 selects the 10th. Ignored while editing the domain input (the
|
|
627
|
+
digit is typed there instead) or when there's no such tab."""
|
|
628
|
+
if self.focused and self.focused.id == "domain":
|
|
629
|
+
return
|
|
630
|
+
position = 10 if digit == 0 else digit
|
|
631
|
+
names = self._nav_names()
|
|
632
|
+
if position <= len(names):
|
|
633
|
+
self._select(names[position - 1])
|
|
634
|
+
|
|
483
635
|
def action_rescan(self) -> None:
|
|
484
636
|
if self.ctx is not None:
|
|
485
637
|
self.start_scan(self.ctx.domain)
|
|
@@ -537,11 +689,18 @@ class WebScannerApp(App):
|
|
|
537
689
|
if editing:
|
|
538
690
|
pairs = [("enter", "Scan"), ("esc", "Cancel")]
|
|
539
691
|
elif self.selected == "sitemap":
|
|
540
|
-
# tree-navigation hints, shown only while the Sitemap tab is up
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
("
|
|
544
|
-
|
|
692
|
+
# tree-navigation hints, shown only while the Sitemap tab is up. The
|
|
693
|
+
# narrow layout drops Rescan/Edit to fit the phone-width footer.
|
|
694
|
+
if self._narrow:
|
|
695
|
+
pairs = [("↑/↓", "Move"), ("enter", "Toggle"), ("space", "All"), ("←/→", "Tab")]
|
|
696
|
+
else:
|
|
697
|
+
pairs = [
|
|
698
|
+
("←/→", "Tab"), ("↑/↓", "Move"), ("enter", "Toggle"),
|
|
699
|
+
("space", "All"), ("r", "Rescan"), ("esc", "Edit"),
|
|
700
|
+
]
|
|
701
|
+
elif self._narrow:
|
|
702
|
+
# Compact set — the full labels overflow a phone-width footer.
|
|
703
|
+
pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
|
|
545
704
|
else:
|
|
546
705
|
pairs = [("q", "Quit"), ("←/→", "Tab"), ("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain")]
|
|
547
706
|
text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
|
|
@@ -20,9 +20,14 @@ Screen {
|
|
|
20
20
|
color: white;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* TabBar is a HorizontalScroll so the selected tab can be scrolled into view when
|
|
24
|
+
the row overflows a narrow terminal; the scrollbar itself is hidden. */
|
|
23
25
|
#tabs {
|
|
24
26
|
height: 1;
|
|
25
27
|
margin-top: 1;
|
|
28
|
+
overflow-x: auto;
|
|
29
|
+
overflow-y: hidden;
|
|
30
|
+
scrollbar-size: 0 0;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/* Tabs stay dim regardless of scan status; only the selected tab is highlighted. */
|
|
@@ -38,6 +43,14 @@ Tab.-selected {
|
|
|
38
43
|
color: $text;
|
|
39
44
|
text-style: bold;
|
|
40
45
|
}
|
|
46
|
+
/* Activity/Server pseudo tabs: hidden on wide terminals (fixed panels there),
|
|
47
|
+
shown only in the narrow single-column layout. */
|
|
48
|
+
Tab.-pseudo {
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
.-narrow Tab.-pseudo {
|
|
52
|
+
display: block;
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
/* ---- bottom footer row: keybar (left) + progress (right) ---- */
|
|
43
56
|
#footer {
|
|
@@ -172,3 +185,26 @@ Tab.-selected {
|
|
|
172
185
|
padding: 1 1 0 2; /* space above the table and to the left */
|
|
173
186
|
scrollbar-size: 0 0; /* keep the bar hidden even if content overflows */
|
|
174
187
|
}
|
|
188
|
+
|
|
189
|
+
/* ---- narrow (phone-width) layout ----
|
|
190
|
+
Applied below _NARROW_MAX cols via the `-narrow` class on the Screen (see
|
|
191
|
+
_apply_narrow). The fixed map + Server panels and the activity strip become tabs,
|
|
192
|
+
so the grid collapses to a single full-width column and padding is trimmed to buy
|
|
193
|
+
back horizontal room. */
|
|
194
|
+
.-narrow #topbar {
|
|
195
|
+
padding: 1 1 0 1; /* keep the gap above the domain input; trim only below the tabs */
|
|
196
|
+
}
|
|
197
|
+
.-narrow #grid {
|
|
198
|
+
grid-size: 1 1;
|
|
199
|
+
grid-columns: 1fr;
|
|
200
|
+
grid-rows: 1fr;
|
|
201
|
+
}
|
|
202
|
+
/* One visible cell now — drop the row-span so the single column fills the height. */
|
|
203
|
+
.-narrow #left {
|
|
204
|
+
row-span: 1;
|
|
205
|
+
}
|
|
206
|
+
/* Map + Server become tabs; the activity strip is toggled by tab in Python. */
|
|
207
|
+
.-narrow #map,
|
|
208
|
+
.-narrow #status {
|
|
209
|
+
display: none;
|
|
210
|
+
}
|
|
@@ -59,27 +59,55 @@ def _smart_token(tok: str) -> str:
|
|
|
59
59
|
return tok.upper() if tok.lower() in _ACRONYMS else tok.capitalize()
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def
|
|
62
|
+
def _cap_key_width(width: int | None, avail: int | None) -> int | None:
|
|
63
|
+
"""Cap a first-column width at ~⅓ of the available table width so a long key
|
|
64
|
+
(a header name, a whois field) can't dominate the row; the column then wraps
|
|
65
|
+
within that cap. ``avail`` unknown (pre-layout) → the width is left uncapped.
|
|
66
|
+
``width`` None means "no content-fit width yet" → take the cap outright."""
|
|
67
|
+
if avail and avail > 0:
|
|
68
|
+
cap = max(6, avail // 3)
|
|
69
|
+
return min(width, cap) if width else cap
|
|
70
|
+
return width
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_result(name: str, data: Any, narrow: bool = False, avail: int | None = None) -> RenderableType:
|
|
63
74
|
"""Render a module's result: multi-column Grid, stacked sub-tables (Sections),
|
|
64
|
-
or one key/value table.
|
|
75
|
+
or one key/value table.
|
|
76
|
+
|
|
77
|
+
``narrow`` collapses wide (>2 column) Grids to their first + last column so a
|
|
78
|
+
multi-column tab (Tech: Name/…/Version) still reads on a phone-width terminal.
|
|
79
|
+
``avail`` is the table's available character width; when known, the first column
|
|
80
|
+
is capped at ~⅓ of it and wraps rather than running the row wide.
|
|
81
|
+
"""
|
|
65
82
|
if isinstance(data, Grid):
|
|
66
|
-
return render_grid(data)
|
|
83
|
+
return render_grid(data, narrow=narrow, avail=avail)
|
|
67
84
|
if isinstance(data, Sections):
|
|
68
85
|
# SEO pins all sub-tables to one fixed first-column width so its four
|
|
69
86
|
# tables (Content/Keywords/Robots/Schema) line up exactly.
|
|
70
|
-
return render_sections(data, key_width=12 if name == "seo" else None)
|
|
87
|
+
return render_sections(data, key_width=12 if name == "seo" else None, narrow=narrow, avail=avail)
|
|
71
88
|
mode = "smart" if name in _SMART_LABEL_TABS else "upper"
|
|
72
|
-
return render_table(data, TAB_HEADERS.get(name), mode=mode)
|
|
89
|
+
return render_table(data, TAB_HEADERS.get(name), mode=mode, avail=avail)
|
|
73
90
|
|
|
74
91
|
|
|
75
|
-
def render_grid(grid: Grid) -> Table:
|
|
92
|
+
def render_grid(grid: Grid, narrow: bool = False, avail: int | None = None) -> Table:
|
|
76
93
|
"""Render a multi-column table (e.g. Tech: Name/Category/Confidence/…).
|
|
77
94
|
|
|
78
95
|
First column is the primary name (bold); the rest are dim, matching the
|
|
79
96
|
key/value tables. Long list columns fold rather than truncate. When the Grid
|
|
80
97
|
carries ``widths``, every column is pinned to that fixed width (and the table
|
|
81
98
|
stops expanding) so sibling Grids on one tab line up identically.
|
|
99
|
+
|
|
100
|
+
``narrow`` (phone-width terminals) keeps only the first + last column — for
|
|
101
|
+
Tech that's Name + Version, the two that matter — and pins both to a fixed 50/50
|
|
102
|
+
``ratio`` (not content-fit) so every sibling sub-table on the tab lines up
|
|
103
|
+
identically instead of each fitting its own name lengths.
|
|
82
104
|
"""
|
|
105
|
+
collapsed = narrow and len(grid.columns) > 2
|
|
106
|
+
if collapsed:
|
|
107
|
+
keep = [0, len(grid.columns) - 1]
|
|
108
|
+
columns = [grid.columns[i] for i in keep]
|
|
109
|
+
rows = [[row[i] for i in keep] for row in grid]
|
|
110
|
+
grid = Grid(columns, rows) # widths dropped → columns sized by the ratio below
|
|
83
111
|
widths = grid.widths
|
|
84
112
|
table = Table(
|
|
85
113
|
show_header=True,
|
|
@@ -93,7 +121,20 @@ def render_grid(grid: Grid) -> Table:
|
|
|
93
121
|
)
|
|
94
122
|
for i, col in enumerate(grid.columns):
|
|
95
123
|
w = widths[i] if widths else None
|
|
96
|
-
if
|
|
124
|
+
if collapsed:
|
|
125
|
+
# Fixed 50/50 across every sub-table, so the Name/Version columns align
|
|
126
|
+
# regardless of each group's name lengths.
|
|
127
|
+
style = KEY_STYLE if i == 0 else MUTED
|
|
128
|
+
table.add_column(col, style=style, ratio=1, no_wrap=False, overflow="fold")
|
|
129
|
+
elif i == 0 and w is None:
|
|
130
|
+
# No fixed widths (e.g. the narrow 2-column Tech): content-fit the Name
|
|
131
|
+
# column but cap it at ~⅓ and let it wrap, like the key/value tables.
|
|
132
|
+
name_fit = min(
|
|
133
|
+
MAX_KEY_WIDTH,
|
|
134
|
+
max((len(str(row[0])) for row in grid), default=len(col)),
|
|
135
|
+
)
|
|
136
|
+
table.add_column(col, style=KEY_STYLE, width=_cap_key_width(name_fit, avail), no_wrap=False, overflow="fold")
|
|
137
|
+
elif i == 0:
|
|
97
138
|
table.add_column(col, style=KEY_STYLE, width=w, no_wrap=True, overflow="ellipsis")
|
|
98
139
|
else:
|
|
99
140
|
table.add_column(col, style=MUTED, width=w, no_wrap=False, overflow="fold")
|
|
@@ -105,13 +146,17 @@ def render_grid(grid: Grid) -> Table:
|
|
|
105
146
|
return table
|
|
106
147
|
|
|
107
148
|
|
|
108
|
-
def render_sections(sections: Sections, key_width: int | None = None) -> Group:
|
|
149
|
+
def render_sections(sections: Sections, key_width: int | None = None, narrow: bool = False, avail: int | None = None) -> Group:
|
|
109
150
|
"""Render several titled sub-tables stacked; content-fit sections share one
|
|
110
151
|
first-column width, ratio sections use their fixed proportions. A caller may
|
|
111
|
-
pass ``key_width`` to pin that shared first-column width explicitly.
|
|
152
|
+
pass ``key_width`` to pin that shared first-column width explicitly. ``narrow``
|
|
153
|
+
is forwarded to any Grid sub-tables (Tech) so they collapse to two columns.
|
|
154
|
+
``avail`` caps the shared first-column width at ~⅓ of the table width."""
|
|
112
155
|
if key_width is None:
|
|
113
156
|
widths = [_col1_width(s.data, s.headers, mode="raw") for s in sections if not s.ratio]
|
|
114
157
|
key_width = max([w for w in widths if w], default=None)
|
|
158
|
+
# Cap the shared key column at ~⅓ so a long field can't widen every sub-table.
|
|
159
|
+
key_width = _cap_key_width(key_width, avail)
|
|
115
160
|
parts: list[RenderableType] = [Text("")] # space above the first section title
|
|
116
161
|
for i, sec in enumerate(sections):
|
|
117
162
|
if i:
|
|
@@ -121,7 +166,7 @@ def render_sections(sections: Sections, key_width: int | None = None) -> Group:
|
|
|
121
166
|
parts.append(Text(f" {sec.title.upper()} ", style=SECTION_STYLE))
|
|
122
167
|
if isinstance(sec.data, Grid):
|
|
123
168
|
# e.g. Tech's per-group tables — multi-column, not key/value
|
|
124
|
-
parts.append(render_grid(sec.data))
|
|
169
|
+
parts.append(render_grid(sec.data, narrow=narrow, avail=avail))
|
|
125
170
|
else:
|
|
126
171
|
parts.append(
|
|
127
172
|
render_table(sec.data, sec.headers, mode="raw", spaced=sec.spaced, key_width=key_width, ratio=sec.ratio)
|
|
@@ -197,6 +242,7 @@ def render_table(
|
|
|
197
242
|
spaced: bool = True,
|
|
198
243
|
key_width: int | None = None,
|
|
199
244
|
ratio: tuple[int, int] | None = None,
|
|
245
|
+
avail: int | None = None,
|
|
200
246
|
) -> Table:
|
|
201
247
|
"""Generic key/value (dict), pair-list, or indexed (list) table.
|
|
202
248
|
|
|
@@ -204,7 +250,8 @@ def render_table(
|
|
|
204
250
|
``"smart"`` (Title Case, acronyms upper), or ``"raw"``. ``spaced`` inserts a
|
|
205
251
|
blank line between rows. ``key_width`` fixes the first-column width (content-fit
|
|
206
252
|
by default). ``ratio`` (col1, col2) forces proportional columns instead of
|
|
207
|
-
content-fit (e.g. (3, 2) for 60/40).
|
|
253
|
+
content-fit (e.g. (3, 2) for 60/40). ``avail`` caps the content-fit first column
|
|
254
|
+
at ~⅓ of the table width (it then wraps instead of running the row wide).
|
|
208
255
|
"""
|
|
209
256
|
table = Table(
|
|
210
257
|
show_header=headers is not None,
|
|
@@ -218,6 +265,7 @@ def render_table(
|
|
|
218
265
|
)
|
|
219
266
|
if key_width is None:
|
|
220
267
|
key_width = _col1_width(data, headers, mode)
|
|
268
|
+
key_width = _cap_key_width(key_width, avail)
|
|
221
269
|
|
|
222
270
|
if isinstance(data, dict) or _is_pairs(data):
|
|
223
271
|
if isinstance(data, dict):
|
|
@@ -234,8 +282,9 @@ def render_table(
|
|
|
234
282
|
# Table(expand=True) routes all surplus width there and the fixed-width
|
|
235
283
|
# key column stays put — otherwise Rich spreads the surplus across both
|
|
236
284
|
# columns proportionally and the key column widens when values are short
|
|
237
|
-
# (e.g. an empty Robots/Schema section in the SEO tab).
|
|
238
|
-
|
|
285
|
+
# (e.g. an empty Robots/Schema section in the SEO tab). The key column
|
|
286
|
+
# folds (not ellipsis) so a field wider than its ~⅓ cap wraps in place.
|
|
287
|
+
table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=False, overflow="fold")
|
|
239
288
|
table.add_column(c2, ratio=1, no_wrap=False, overflow="fold")
|
|
240
289
|
_add_spaced(table, rows, spaced)
|
|
241
290
|
elif isinstance(data, (list, tuple)):
|
|
@@ -6,9 +6,10 @@ from collections import deque
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
8
|
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
9
10
|
from rich.text import Text
|
|
10
11
|
from textual.binding import Binding
|
|
11
|
-
from textual.containers import Horizontal
|
|
12
|
+
from textual.containers import Horizontal, HorizontalScroll
|
|
12
13
|
from textual.message import Message
|
|
13
14
|
from textual.widgets import RichLog, Static, Tree
|
|
14
15
|
|
|
@@ -29,10 +30,14 @@ class Tab(Static):
|
|
|
29
30
|
self.tab_name = tab_name
|
|
30
31
|
super().__init__()
|
|
31
32
|
|
|
32
|
-
def __init__(self, name: str, label: str) -> None:
|
|
33
|
+
def __init__(self, name: str, label: str, pseudo: bool = False) -> None:
|
|
33
34
|
super().__init__(label, id=f"tab-{name}")
|
|
34
35
|
self.tab_name = name
|
|
35
36
|
self.add_class("-pending")
|
|
37
|
+
# Pseudo tabs (Activity/Server) exist only in the narrow layout; CSS hides
|
|
38
|
+
# them on wide terminals where those are fixed panels instead.
|
|
39
|
+
if pseudo:
|
|
40
|
+
self.add_class("-pseudo")
|
|
36
41
|
|
|
37
42
|
def set_status(self, status: ModuleStatus) -> None:
|
|
38
43
|
self.remove_class(*_STATUS_CLASSES)
|
|
@@ -45,12 +50,28 @@ class Tab(Static):
|
|
|
45
50
|
self.post_message(self.Clicked(self.tab_name))
|
|
46
51
|
|
|
47
52
|
|
|
48
|
-
class TabBar(
|
|
49
|
-
|
|
53
|
+
class TabBar(HorizontalScroll):
|
|
54
|
+
"""The row of tabs. Scrolls horizontally so the selected tab is always brought
|
|
55
|
+
into view when the row is wider than the terminal (narrow screens) — Textual's
|
|
56
|
+
plain ``Horizontal`` would just clip the overflow with no way to reach it.
|
|
57
|
+
|
|
58
|
+
``pseudo`` tabs (Activity/Server) are rendered first but hidden by CSS on wide
|
|
59
|
+
terminals; they surface only in the narrow layout, where those panels become tabs.
|
|
60
|
+
|
|
61
|
+
``can_focus=False`` keeps single-key nav (↑/↓ for the Sitemap tree, etc.) from
|
|
62
|
+
being swallowed by the scroll container's own bindings.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
can_focus = False
|
|
66
|
+
|
|
67
|
+
def __init__(self, modules, pseudo: tuple[tuple[str, str], ...] = (), **kwargs) -> None:
|
|
50
68
|
super().__init__(**kwargs)
|
|
51
69
|
self._modules = list(modules)
|
|
70
|
+
self._pseudo = list(pseudo)
|
|
52
71
|
|
|
53
72
|
def compose(self):
|
|
73
|
+
for name, label in self._pseudo:
|
|
74
|
+
yield Tab(name, label, pseudo=True)
|
|
54
75
|
for module in self._modules:
|
|
55
76
|
yield Tab(module.name, module.label)
|
|
56
77
|
|
|
@@ -58,8 +79,14 @@ class TabBar(Horizontal):
|
|
|
58
79
|
self.query_one(f"#tab-{name}", Tab).set_status(status)
|
|
59
80
|
|
|
60
81
|
def set_selected(self, name: str) -> None:
|
|
82
|
+
selected = None
|
|
61
83
|
for tab in self.query(Tab):
|
|
62
84
|
tab.set_selected(tab.tab_name == name)
|
|
85
|
+
if tab.tab_name == name:
|
|
86
|
+
selected = tab
|
|
87
|
+
# Keep the active tab visible when the row overflows the terminal width.
|
|
88
|
+
if selected is not None and selected.display:
|
|
89
|
+
self.scroll_to_widget(selected, animate=False)
|
|
63
90
|
|
|
64
91
|
|
|
65
92
|
class MapPanel(Static):
|
|
@@ -111,13 +138,15 @@ class StatusPanel(Static):
|
|
|
111
138
|
class ActivityLog(RichLog):
|
|
112
139
|
"""Fixed scan log under the main panel; the newest line is always in view.
|
|
113
140
|
|
|
114
|
-
Lines are composed by ``activity.py`` — this only stamps the time
|
|
115
|
-
body,
|
|
116
|
-
without an app.
|
|
141
|
+
Lines are composed by ``activity.py`` — this only stamps the time and paints the
|
|
142
|
+
body, so the wording stays testable without an app.
|
|
117
143
|
|
|
118
|
-
Each line is
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
Each line is a two-column ``Table.grid``: a fixed-width timestamp column and a
|
|
145
|
+
wrapping body column. When the body is wider than the panel it **wraps** (word
|
|
146
|
+
boundaries), and the grid's second column keeps every continuation line indented
|
|
147
|
+
past the timestamp — a hanging indent aligned under the first body character. The
|
|
148
|
+
grid is rendered at the panel's exact width, so a wider terminal simply fits more
|
|
149
|
+
on each line before wrapping.
|
|
121
150
|
|
|
122
151
|
``can_focus=False`` is load-bearing, not tidiness: RichLog inherits
|
|
123
152
|
ScrollableContainer's ungated up/down bindings, so a focused log would swallow
|
|
@@ -133,35 +162,38 @@ class ActivityLog(RichLog):
|
|
|
133
162
|
|
|
134
163
|
def __init__(self, **kwargs) -> None:
|
|
135
164
|
# min_width=0: the 78 default renders every write at >=78 cells, overflowing
|
|
136
|
-
# a narrower panel.
|
|
137
|
-
# overflow="ignore" on Text and the tail vanishes with no ellipsis.
|
|
165
|
+
# a narrower panel.
|
|
138
166
|
super().__init__(markup=True, wrap=True, min_width=0, max_lines=self._MAX_LINES, **kwargs)
|
|
139
167
|
# RichLog renders to strips at write time and never re-renders, so keep the
|
|
140
|
-
# markup to replay when the width changes.
|
|
141
|
-
self._entries: deque[str] = deque(maxlen=self._MAX_LINES)
|
|
168
|
+
# (stamp, body) markup pairs to replay when the width changes.
|
|
169
|
+
self._entries: deque[tuple[str, str]] = deque(maxlen=self._MAX_LINES)
|
|
142
170
|
self._rendered_width: int | None = None
|
|
143
171
|
|
|
144
172
|
def add(self, body: str) -> None:
|
|
145
173
|
"""Write one line, stamped with a muted [HH:MM:SS]."""
|
|
146
|
-
stamp = escape(f
|
|
147
|
-
|
|
148
|
-
self._entries.append(
|
|
149
|
-
self._write(
|
|
150
|
-
|
|
151
|
-
def _write(self,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
stamp = f"[{MUTED}]{escape(f'[{datetime.now():%H:%M:%S}]')}[/]"
|
|
175
|
+
body_markup = f"[{BODY}]{body}[/]"
|
|
176
|
+
self._entries.append((stamp, body_markup))
|
|
177
|
+
self._write(stamp, body_markup)
|
|
178
|
+
|
|
179
|
+
def _write(self, stamp: str, body: str) -> None:
|
|
180
|
+
# Two columns: the timestamp at a fixed width (its 10 chars + a 1-cell gap),
|
|
181
|
+
# then the body in a flexible column that wraps. The grid keeps the body
|
|
182
|
+
# column left edge constant, so wrapped continuation lines hang-indent under
|
|
183
|
+
# the first body character rather than sliding back under the timestamp.
|
|
184
|
+
grid = Table.grid(expand=True)
|
|
185
|
+
grid.add_column(width=11, no_wrap=True)
|
|
186
|
+
grid.add_column(ratio=1)
|
|
187
|
+
grid.add_row(Text.from_markup(stamp), Text.from_markup(body))
|
|
155
188
|
# Explicit width, because RichLog's own sizing measures against
|
|
156
189
|
# `app.console`, which is a plain 80 columns regardless of how wide the app
|
|
157
|
-
# actually is —
|
|
158
|
-
#
|
|
159
|
-
# laid out yet: let RichLog defer the write, and on_resize replays it.
|
|
190
|
+
# actually is — the body would wrap at 80 on a wider terminal. Width 0 means
|
|
191
|
+
# we aren't laid out yet: let RichLog defer the write, and on_resize replays it.
|
|
160
192
|
width = self.scrollable_content_region.width
|
|
161
193
|
if width:
|
|
162
|
-
self.write(
|
|
194
|
+
self.write(grid, width=width)
|
|
163
195
|
else:
|
|
164
|
-
self.write(
|
|
196
|
+
self.write(grid)
|
|
165
197
|
|
|
166
198
|
def clear(self) -> "ActivityLog":
|
|
167
199
|
self._entries.clear()
|
|
@@ -178,13 +210,13 @@ class ActivityLog(RichLog):
|
|
|
178
210
|
if not self._entries:
|
|
179
211
|
return
|
|
180
212
|
# RichLog renders to strips at write time and never re-renders, so replay at
|
|
181
|
-
# the new width: a wider terminal
|
|
182
|
-
# re-
|
|
183
|
-
# rendered by RichLog without our explicit width.
|
|
213
|
+
# the new width: a wider terminal fits more before wrapping, a narrower one
|
|
214
|
+
# re-wraps to more lines. Also fixes the first sizing, where deferred lines
|
|
215
|
+
# were rendered by RichLog without our explicit width.
|
|
184
216
|
entries = list(self._entries)
|
|
185
217
|
super().clear()
|
|
186
|
-
for
|
|
187
|
-
self._write(
|
|
218
|
+
for stamp, body in entries:
|
|
219
|
+
self._write(stamp, body)
|
|
188
220
|
|
|
189
221
|
|
|
190
222
|
class SitemapTree(Tree):
|
|
@@ -206,6 +238,17 @@ class SitemapTree(Tree):
|
|
|
206
238
|
BINDINGS = [
|
|
207
239
|
Binding("enter", "toggle_node", "Toggle", show=False),
|
|
208
240
|
Binding("space", "toggle_all", "Expand/collapse all", show=False),
|
|
241
|
+
# Tree's base (ScrollView) binds left/right to horizontal scroll, which would
|
|
242
|
+
# swallow the app's ←/→ tab navigation whenever a long URL overflows the panel.
|
|
243
|
+
# Redirect them to the app so ←/→ always changes tab from the Sitemap too.
|
|
244
|
+
Binding("left", "prev_tab", show=False),
|
|
245
|
+
Binding("right", "next_tab", show=False),
|
|
246
|
+
# The tree is height:auto inside the #main VerticalScroll, so it has nothing
|
|
247
|
+
# of its own to scroll — its inherited page up/down bindings would just no-op
|
|
248
|
+
# and swallow the keys. Redirect them to #main so paging works like every
|
|
249
|
+
# other tab.
|
|
250
|
+
Binding("pageup", "page_main_up", show=False),
|
|
251
|
+
Binding("pagedown", "page_main_down", show=False),
|
|
209
252
|
]
|
|
210
253
|
|
|
211
254
|
#: Textual gives the expand/collapse chevron no component class of its own — it
|
|
@@ -270,6 +313,18 @@ class SitemapTree(Tree):
|
|
|
270
313
|
if 0 <= index < len(self._leaf_urls):
|
|
271
314
|
self.app.open_url(self._leaf_urls[index])
|
|
272
315
|
|
|
316
|
+
def action_prev_tab(self) -> None:
|
|
317
|
+
self.app.action_prev_tab()
|
|
318
|
+
|
|
319
|
+
def action_next_tab(self) -> None:
|
|
320
|
+
self.app.action_next_tab()
|
|
321
|
+
|
|
322
|
+
def action_page_main_up(self) -> None:
|
|
323
|
+
self.app.action_scroll_main_up()
|
|
324
|
+
|
|
325
|
+
def action_page_main_down(self) -> None:
|
|
326
|
+
self.app.action_scroll_main_down()
|
|
327
|
+
|
|
273
328
|
def action_toggle_all(self) -> None:
|
|
274
329
|
branches = self._branches()
|
|
275
330
|
if not branches:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|