web-scanner 2.1.2__tar.gz → 2.1.3__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.1.2 → web_scanner-2.1.3}/PKG-INFO +1 -1
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/__init__.py +1 -1
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/colors.py +3 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/links.py +7 -2
- web_scanner-2.1.3/webscanner/ui/activity.py +326 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/app.py +101 -8
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/app.tcss +33 -1
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/export.py +1 -19
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/tables.py +10 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/widgets.py +116 -2
- {web_scanner-2.1.2 → web_scanner-2.1.3}/.gitignore +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/LICENSE +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/README.md +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/app.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/pyproject.toml +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/requirements.txt +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/__main__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/cli.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/context.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/models.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/module.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/helpers.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/security.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/sitemap.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/http.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/psl.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/worldmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-scanner
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.3
|
|
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
|
|
@@ -5,3 +5,6 @@ RED = "#FF6C64"
|
|
|
5
5
|
#: muted value/secondary text — matches the unselected-tab colour
|
|
6
6
|
#: ($text-muted = white @ 60% over the dark theme bg).
|
|
7
7
|
MUTED = "#9D9D9D"
|
|
8
|
+
#: activity-log body text — one step down the white → MUTED ladder, so a log line
|
|
9
|
+
#: reads softer than a heading but stays well clear of the dim MUTED timestamp.
|
|
10
|
+
BODY = "#C6C6C6"
|
|
@@ -18,6 +18,11 @@ from ..core.models import Section, Sections
|
|
|
18
18
|
|
|
19
19
|
_SKIP_PREFIXES = ("#", "javascript:", "mailto:", "tel:", "data:")
|
|
20
20
|
|
|
21
|
+
# Placeholder row shown when a section found nothing. Named so callers can tell
|
|
22
|
+
# "no links" from "one link" — the section is never empty, so len() can't.
|
|
23
|
+
EMPTY_INTERNAL = ("—", "no internal links found")
|
|
24
|
+
EMPTY_EXTERNAL = ("—", "no external links found")
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class LinksModule(ScanModule):
|
|
23
28
|
name = "links"
|
|
@@ -31,8 +36,8 @@ class LinksModule(ScanModule):
|
|
|
31
36
|
internal, external = await asyncio.to_thread(self._parse, ctx)
|
|
32
37
|
|
|
33
38
|
return Sections([
|
|
34
|
-
Section("Internal", internal or [
|
|
35
|
-
Section("External", external or [
|
|
39
|
+
Section("Internal", internal or [EMPTY_INTERNAL], ("Link text", "URL")),
|
|
40
|
+
Section("External", external or [EMPTY_EXTERNAL], ("Link text", "URL")),
|
|
36
41
|
])
|
|
37
42
|
|
|
38
43
|
@staticmethod
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Activity-log lines — one short, informative sentence per scan event.
|
|
2
|
+
|
|
3
|
+
Pure ``ScanEvent -> str``: no Textual imports, so the whole line catalogue can be
|
|
4
|
+
exercised without an app, an event loop or the network. That matters because a
|
|
5
|
+
healthy live scan never reaches the branches most likely to be wrong (FAILED, EMPTY,
|
|
6
|
+
and the ``{"note": ...}`` "nothing to report" shape).
|
|
7
|
+
|
|
8
|
+
Grammar is fixed at ``<Label>: Complete. <facts>`` so the coloured verb always lands
|
|
9
|
+
in the same column. Only the verb and the odd warning word carry markup; the caller
|
|
10
|
+
(``ActivityLog.add``) supplies the timestamp and the body colour.
|
|
11
|
+
|
|
12
|
+
Lines are composed **at full length** — nothing here truncates to a character budget.
|
|
13
|
+
``ActivityLog`` renders each line ``no_wrap`` with ``overflow="ellipsis"``, so a line
|
|
14
|
+
is cut at the panel edge and a wider terminal simply shows more of it. Keep the facts
|
|
15
|
+
front-loaded (the tail is what gets dropped), but don't pre-crop a value to make it
|
|
16
|
+
fit some assumed width.
|
|
17
|
+
|
|
18
|
+
The facts are the point — "DNS: Complete." tells you nothing the tab colour didn't.
|
|
19
|
+
Each summarizer digs the headline number out of its module's own return shape, which
|
|
20
|
+
is different for every module and booby-trapped in several (see the notes below).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
|
|
28
|
+
from rich.markup import escape
|
|
29
|
+
|
|
30
|
+
from ..colors import GREEN, RED
|
|
31
|
+
from ..core.context import ScanContext
|
|
32
|
+
from ..core.models import ModuleResult, ModuleStatus, ScanEvent
|
|
33
|
+
from ..core.scanner import PREFETCH
|
|
34
|
+
from ..modules import all_modules
|
|
35
|
+
from ..modules.dns import RECORD_TYPES
|
|
36
|
+
from ..modules.links import EMPTY_EXTERNAL, EMPTY_INTERNAL
|
|
37
|
+
from ..modules.sitemap import MAX_URLS
|
|
38
|
+
from .tables import _plain
|
|
39
|
+
|
|
40
|
+
#: module name -> tab label ("dns" -> "DNS"), so a line's prefix matches its tab.
|
|
41
|
+
_LABELS: dict[str, str] = {m.name: m.label for m in all_modules()}
|
|
42
|
+
|
|
43
|
+
_DONE = f"[{GREEN}]Complete[/]"
|
|
44
|
+
_ERROR = f"[{RED}]Error[/]"
|
|
45
|
+
|
|
46
|
+
#: repr(exc) -> ("TimeoutError", "'timed out'")
|
|
47
|
+
_ERROR_REPR = re.compile(r"^(\w+)\((.*)\)$", re.S)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---- helpers --------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _esc(value: Any) -> str:
|
|
54
|
+
"""Collapse whitespace and escape rich markup.
|
|
55
|
+
|
|
56
|
+
Every externally-sourced value in a log line goes through here. Escaping is not
|
|
57
|
+
cosmetic: an unescaped ``[/]`` (from a registrar name, a Server header, a
|
|
58
|
+
repr(exc)) raises MarkupError inside ``RichLog.write``, which propagates out of
|
|
59
|
+
``on_scan_progress`` and takes the handler down mid-scan.
|
|
60
|
+
|
|
61
|
+
Deliberately does **not** truncate: values are written in full and the widget
|
|
62
|
+
ellipsises at the panel edge, so a wide terminal shows the whole thing. A hard
|
|
63
|
+
cap here would crop "Web Address Registration Pty Ltd" on a 200-column display.
|
|
64
|
+
"""
|
|
65
|
+
return escape(" ".join(str(value).split()))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _plural(n: int, singular: str, plural: str | None = None) -> str:
|
|
69
|
+
return f"{n} {singular if n == 1 else (plural or singular + 's')}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sentence(text: str) -> str:
|
|
73
|
+
"""Upper-case the first letter and full-stop it, leaving the rest alone —
|
|
74
|
+
``str.capitalize`` would flatten the modules' own wording (WHOIS -> Whois)."""
|
|
75
|
+
text = " ".join(str(text).split()).rstrip(".")
|
|
76
|
+
return f"{text[:1].upper()}{text[1:]}." if text else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _note(data: Any) -> str | None:
|
|
80
|
+
"""The module's own wording when it returned a lone ``{"note": ...}``.
|
|
81
|
+
|
|
82
|
+
whois, security's port table and seo all use this shape to say "nothing to
|
|
83
|
+
report". Echoing it verbatim keeps the wording with the module that owns it.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(data, dict) and set(data) == {"note"}:
|
|
86
|
+
return str(data["note"])
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sections(result: ModuleResult) -> dict[str, Any]:
|
|
91
|
+
"""A Sections result as {title: data}."""
|
|
92
|
+
return {section.title: section.data for section in result.data or []}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _short_error(error: str | None) -> str:
|
|
96
|
+
"""``repr(exc)`` boiled down to ``Type: message``.
|
|
97
|
+
|
|
98
|
+
Unwrapping the repr is formatting, not trimming — the message is kept whole and
|
|
99
|
+
the panel ellipsises whatever doesn't fit.
|
|
100
|
+
"""
|
|
101
|
+
text = (error or "unknown error").strip()
|
|
102
|
+
if match := _ERROR_REPR.match(text):
|
|
103
|
+
name, args = match.group(1), match.group(2).strip().strip("'\"")
|
|
104
|
+
text = f"{name}: {args}" if args else name
|
|
105
|
+
return _esc(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _date_only(value: Any) -> str:
|
|
109
|
+
"""The date half of a whois timestamp — they arrive both as bare dates and as
|
|
110
|
+
ISO datetimes, so slicing [:10] is not safe."""
|
|
111
|
+
text = str(value or "").replace("T", " ").strip()
|
|
112
|
+
return text.split()[0] if text else ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- per-module summaries -------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _dns(result: ModuleResult) -> str:
|
|
119
|
+
# Keys are only present when non-empty, and DMARC/DKIM are folded in alongside
|
|
120
|
+
# the record types — count only the latter, then name the email auth found.
|
|
121
|
+
data = result.data or {}
|
|
122
|
+
if not data:
|
|
123
|
+
return f"{_DONE}. No records found."
|
|
124
|
+
types = [key for key in data if key in RECORD_TYPES]
|
|
125
|
+
records = sum(len(data[key]) for key in types)
|
|
126
|
+
line = f"{_DONE}. {_plural(records, 'record')} across {_plural(len(types), 'type')}"
|
|
127
|
+
auth = [key for key in ("DMARC", "DKIM") if key in data]
|
|
128
|
+
return f"{line} · {', '.join(auth)}." if auth else f"{line}."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _whois(result: ModuleResult) -> str:
|
|
132
|
+
data = result.data or {}
|
|
133
|
+
if not data:
|
|
134
|
+
return f"{_DONE}. No WHOIS data returned."
|
|
135
|
+
if (note := _note(data)) is not None:
|
|
136
|
+
return f"{_DONE}. {_sentence(note)}"
|
|
137
|
+
bits = []
|
|
138
|
+
if registrar := data.get("registrar"):
|
|
139
|
+
# Unlabelled: the Whois prefix and the "expires" half make it obvious enough,
|
|
140
|
+
# and the 10 chars "Registrar " costs are better spent on the name itself.
|
|
141
|
+
bits.append(_esc(registrar))
|
|
142
|
+
if expiry := _date_only(data.get("expiry_date")):
|
|
143
|
+
bits.append(f"expires {_esc(expiry)}")
|
|
144
|
+
# Sparse ccTLD output can carry neither field; say what did come back.
|
|
145
|
+
return f"{_DONE}. {' · '.join(bits) or _plural(len(data), 'field')}."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _subdomains(result: ModuleResult) -> str:
|
|
149
|
+
found = result.data or []
|
|
150
|
+
if not found:
|
|
151
|
+
return f"{_DONE}. None found."
|
|
152
|
+
return f"{_DONE}. Found {_plural(len(found), 'subdomain')}."
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ssl(result: ModuleResult) -> str:
|
|
156
|
+
data = result.data or {}
|
|
157
|
+
if not data:
|
|
158
|
+
return "No certificate found (TLS handshake failed)."
|
|
159
|
+
issuer = _esc(data.get("issuer_org") or data.get("issuer_cn") or "unknown issuer")
|
|
160
|
+
# san_count, not len(san) — the san list is capped at 20.
|
|
161
|
+
sans = _plural(data.get("san_count") or 0, "SAN")
|
|
162
|
+
if data.get("expired"):
|
|
163
|
+
return f"Certificate [{RED}]expired[/] — {issuer} · {sans}."
|
|
164
|
+
bits = [issuer]
|
|
165
|
+
# days_until_expiry is absent when the notAfter date failed to parse.
|
|
166
|
+
if (days := data.get("days_until_expiry")) is not None:
|
|
167
|
+
bits.append(f"{_plural(days, 'day')} left")
|
|
168
|
+
bits.append(sans)
|
|
169
|
+
return f"{_DONE}. {' · '.join(bits)}."
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _security(result: ModuleResult) -> str:
|
|
173
|
+
# All three tables are fixed-length (every port/header/blocklist gets a row), so
|
|
174
|
+
# the counts have to come from the values, not len().
|
|
175
|
+
sections = _sections(result)
|
|
176
|
+
ports = sections.get("Open Ports", {})
|
|
177
|
+
headers = sections.get("HTTP Security", {})
|
|
178
|
+
blocklists = sections.get("Blocklists", {})
|
|
179
|
+
|
|
180
|
+
if (note := _note(ports)) is not None:
|
|
181
|
+
port_bit = _sentence(note).rstrip(".") # the module's wording, leading the list
|
|
182
|
+
else:
|
|
183
|
+
port_bit = _plural(sum(1 for v in ports.values() if _plain(v) == "open"), "open port")
|
|
184
|
+
present = sum(1 for v in headers.values() if _plain(v) == "Yes")
|
|
185
|
+
# Exact match: "Not Blocked" contains "Blocked".
|
|
186
|
+
blocked = sum(1 for v in blocklists.values() if _plain(v) == "Blocked")
|
|
187
|
+
return f"{_DONE}. {port_bit}, {present}/{len(headers)} headers, {blocked} blocked."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _headers(result: ModuleResult) -> str:
|
|
191
|
+
data = result.data or {}
|
|
192
|
+
if not data:
|
|
193
|
+
return f"{_DONE}. No headers (fetch failed)."
|
|
194
|
+
# dict(ctx.headers) drops requests' CaseInsensitiveDict, so keys keep whatever
|
|
195
|
+
# casing the server sent.
|
|
196
|
+
server = next((v for k, v in data.items() if k.lower() == "server"), None)
|
|
197
|
+
line = f"{_DONE}. {_plural(len(data), 'header')}"
|
|
198
|
+
# Whole value, incl. "Apache/2.4.41 (Ubuntu) mod_wsgi/4.6.8" — it's the last
|
|
199
|
+
# thing on the line, so a narrow panel ellipsises only its tail.
|
|
200
|
+
return f"{line} · Server {_esc(server)}." if server else f"{line}."
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _tech(result: ModuleResult) -> str:
|
|
204
|
+
# A tech declaring three groups appears in three Grids — dedupe on name.
|
|
205
|
+
sections = result.data or []
|
|
206
|
+
names = {name for section in sections for name in section.data.names}
|
|
207
|
+
if not names:
|
|
208
|
+
return f"{_DONE}. No technologies detected."
|
|
209
|
+
return (
|
|
210
|
+
f"{_DONE}. {_plural(len(names), 'technology', 'technologies')} "
|
|
211
|
+
f"across {_plural(len(sections), 'group')}."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _seo(result: ModuleResult) -> str:
|
|
216
|
+
sections = _sections(result)
|
|
217
|
+
content = sections.get("Content", {})
|
|
218
|
+
if (note := _note(content)) is not None:
|
|
219
|
+
return f"{_DONE}. {_sentence(note)}"
|
|
220
|
+
bits = []
|
|
221
|
+
if content.get("Title", "-") != "-":
|
|
222
|
+
bits.append("title")
|
|
223
|
+
if content.get("Description", "-") != "-":
|
|
224
|
+
bits.append("desc")
|
|
225
|
+
bits.append(_plural(len(content.get("H1") or []), "H1", "H1s"))
|
|
226
|
+
if _plain(sections.get("Schema", {}).get("Has Schema", "")) == "Yes":
|
|
227
|
+
bits.append("schema")
|
|
228
|
+
if _plain(sections.get("Robots", {}).get("Found", "")) == "Yes":
|
|
229
|
+
bits.append("robots")
|
|
230
|
+
return f"{_DONE}. {_sentence(', '.join(bits))}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sitemap(result: ModuleResult) -> str:
|
|
234
|
+
root = result.data
|
|
235
|
+
if root is None:
|
|
236
|
+
return f"{_DONE}. No sitemap found."
|
|
237
|
+
total = root.total or 0
|
|
238
|
+
suffix = " (truncated)" if total >= MAX_URLS else ""
|
|
239
|
+
return f"{_DONE}. {_plural(total, 'URL')} found{suffix}."
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _links(result: ModuleResult) -> str:
|
|
243
|
+
# An empty section holds one placeholder row, so len() reads 1 for "none".
|
|
244
|
+
sections = _sections(result)
|
|
245
|
+
internal = sections.get("Internal", [])
|
|
246
|
+
external = sections.get("External", [])
|
|
247
|
+
n_internal = 0 if internal == [EMPTY_INTERNAL] else len(internal)
|
|
248
|
+
n_external = 0 if external == [EMPTY_EXTERNAL] else len(external)
|
|
249
|
+
if not n_internal and not n_external:
|
|
250
|
+
return f"{_DONE}. No links found."
|
|
251
|
+
return f"{_DONE}. {n_internal} internal, {n_external} external links."
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_SUMMARIZERS: dict[str, Callable[[ModuleResult], str]] = {
|
|
255
|
+
"dns": _dns,
|
|
256
|
+
"whois": _whois,
|
|
257
|
+
"subdomains": _subdomains,
|
|
258
|
+
"ssl": _ssl,
|
|
259
|
+
"security": _security,
|
|
260
|
+
"headers": _headers,
|
|
261
|
+
"tech": _tech,
|
|
262
|
+
"seo": _seo,
|
|
263
|
+
"sitemap": _sitemap,
|
|
264
|
+
"links": _links,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---- public API -----------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def started(target: str, count: int) -> str:
|
|
272
|
+
"""The opening line."""
|
|
273
|
+
return f"Scan: Started {_esc(target)} · {_plural(count, 'module')}."
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def overall(completed: int, failed: int, total: int, seconds: float) -> str:
|
|
277
|
+
"""The closing line. ``seconds`` is wall-clock for the whole scan — modules run
|
|
278
|
+
concurrently, so summing their durations would overstate it several-fold."""
|
|
279
|
+
ok = completed - failed
|
|
280
|
+
took = f"in {round(seconds)}s"
|
|
281
|
+
if failed:
|
|
282
|
+
return (
|
|
283
|
+
f"Overall Scan Status: {ok}/{total} modules [{GREEN}]completed[/] {took}, "
|
|
284
|
+
f"[{RED}]{_plural(failed, 'error')}[/]."
|
|
285
|
+
)
|
|
286
|
+
return f"Overall Scan Status: {total}/{total} modules [{GREEN}]completed[/] {took}."
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _prefetch(ctx: ScanContext | None) -> str:
|
|
290
|
+
if ctx is None:
|
|
291
|
+
return f"Prefetch: {_ERROR}. No scan context."
|
|
292
|
+
if ctx.fetch_error:
|
|
293
|
+
return f"Prefetch: [{RED}]error[/] — {_esc(ctx.fetch_error)}."
|
|
294
|
+
bits = [_esc(ctx.ip)] if ctx.ip else []
|
|
295
|
+
if ctx.status_code is not None:
|
|
296
|
+
bits.append(f"HTTP {ctx.status_code}")
|
|
297
|
+
if ctx.response_time_ms is not None:
|
|
298
|
+
bits.append(f"{round(ctx.response_time_ms)}ms")
|
|
299
|
+
return f"Prefetch: {' · '.join(bits) or 'no response'}."
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def summarize(event: ScanEvent, ctx: ScanContext | None) -> str | None:
|
|
303
|
+
"""One log line for a ScanEvent, or ``None`` if it shouldn't be logged.
|
|
304
|
+
|
|
305
|
+
Module RUNNING events are dropped on purpose: all ten fire in a single
|
|
306
|
+
``asyncio.gather`` burst, so logging them would push ten lines through a
|
|
307
|
+
four-line panel in one tick and none of them would be read.
|
|
308
|
+
"""
|
|
309
|
+
if event.name == PREFETCH:
|
|
310
|
+
# Prefetch events never carry a result — the numbers live on the context.
|
|
311
|
+
return _prefetch(ctx) if event.status is ModuleStatus.DONE else None
|
|
312
|
+
if event.status is ModuleStatus.RUNNING:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
label = _LABELS.get(event.name, event.name)
|
|
316
|
+
result = event.result
|
|
317
|
+
if result is None or event.status is ModuleStatus.FAILED:
|
|
318
|
+
error = result.error if result is not None else None
|
|
319
|
+
return f"{label}: {_ERROR}. {_short_error(error)}"
|
|
320
|
+
try:
|
|
321
|
+
body = _SUMMARIZERS[event.name](result)
|
|
322
|
+
except Exception: # noqa: BLE001
|
|
323
|
+
# Modules are failure-isolated; the handler that logs them isn't. A summary
|
|
324
|
+
# that trips over an unexpected shape must not take the scan down with it.
|
|
325
|
+
body = f"{_DONE}."
|
|
326
|
+
return f"{label}: {body}"
|
|
@@ -12,6 +12,10 @@ live as each module completes.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from bs4 import BeautifulSoup
|
|
15
19
|
from textual.app import App, ComposeResult
|
|
16
20
|
from textual.binding import Binding
|
|
17
21
|
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
@@ -21,9 +25,10 @@ from textual.widgets import Input, LoadingIndicator, Static
|
|
|
21
25
|
from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
|
|
22
26
|
from ..core.scanner import PREFETCH
|
|
23
27
|
from ..modules import all_modules
|
|
28
|
+
from . import activity
|
|
24
29
|
from .export import export_csvs
|
|
25
30
|
from .tables import render_result
|
|
26
|
-
from .widgets import MapPanel, SitemapTree, StatusPanel, TabBar, Tab
|
|
31
|
+
from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Tab
|
|
27
32
|
|
|
28
33
|
#: width (cells) of the footer progress bar and its dim (incomplete) colour
|
|
29
34
|
_BAR_WIDTH = 22
|
|
@@ -46,6 +51,73 @@ def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
|
|
|
46
51
|
return None
|
|
47
52
|
|
|
48
53
|
|
|
54
|
+
#: matches a ``<meta name="generator">`` name attribute — real pages ship
|
|
55
|
+
#: ``name="Generator"`` as often as lowercase, so the value is matched case-insensitively
|
|
56
|
+
_GENERATOR_ATTR = re.compile(r"^\s*generator\s*$", re.I)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _split_generator(content: str) -> tuple[str, str | None]:
|
|
60
|
+
"""Split a ``<meta name="generator">`` content value into (name, version|None).
|
|
61
|
+
|
|
62
|
+
The version is the first token that contains a digit, plus everything after it
|
|
63
|
+
("Sitefinity 14.4.8152.0 DX" → ``("Sitefinity", "14.4.8152.0 DX")``); a value with
|
|
64
|
+
no such token is all name ("Webflow" → ``("Webflow", None)``). A leading digit-ish
|
|
65
|
+
token is part of the name, not a version ("1C-Bitrix", "TYPO3 CMS"). Trailing
|
|
66
|
+
parentheticals are dropped ("Drupal 10 (https://www.drupal.org)" → "Drupal", "10").
|
|
67
|
+
"""
|
|
68
|
+
content = re.sub(r"\s*\([^)]*\)", "", content).strip()
|
|
69
|
+
tokens = content.split()
|
|
70
|
+
for i, token in enumerate(tokens):
|
|
71
|
+
if i and any(char.isdigit() for char in token):
|
|
72
|
+
return " ".join(tokens[:i]), " ".join(tokens[i:])
|
|
73
|
+
return content, None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _generators(html: str | None) -> list[tuple[str, str | None]]:
|
|
77
|
+
"""Every ``<meta name="generator">`` in the page, as (name, version|None) pairs."""
|
|
78
|
+
if not html:
|
|
79
|
+
return []
|
|
80
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
81
|
+
found = []
|
|
82
|
+
for tag in soup.find_all("meta", attrs={"name": _GENERATOR_ATTR}):
|
|
83
|
+
content = (tag.get("content") or "").strip()
|
|
84
|
+
if content:
|
|
85
|
+
found.append(_split_generator(content))
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _same_cms(left: str, right: str) -> bool:
|
|
90
|
+
"""Do two CMS names refer to the same product? Compared on letters/digits only,
|
|
91
|
+
either containing the other, so "Wix" matches "Wix.com Website Builder"."""
|
|
92
|
+
left, right = (re.sub(r"[^a-z0-9]", "", name.lower()) for name in (left, right))
|
|
93
|
+
return bool(left and right) and (left in right or right in left)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] | None:
|
|
97
|
+
"""The CMS (name, version) for the Server panel, or ``None`` if nothing detected.
|
|
98
|
+
|
|
99
|
+
Wappalyzer's ``CMS`` category is the primary signal — it's curated, so it won't
|
|
100
|
+
mistake a page builder or analytics tag for the CMS. The ``<meta name="generator">``
|
|
101
|
+
tag covers the two cases it misses: a CMS Wappalyzer has no fingerprint for (e.g.
|
|
102
|
+
Sitefinity, Webflow) → the generator is used outright; and a CMS it detects but
|
|
103
|
+
can't version → the generator supplies the version, but only when it names the
|
|
104
|
+
*same* product, so an "Elementor 3.x" generator can't hijack a "WordPress" hit.
|
|
105
|
+
"""
|
|
106
|
+
tech = _cms_from_tech(tech_data)
|
|
107
|
+
generators = _generators(html)
|
|
108
|
+
if tech is None:
|
|
109
|
+
# Nothing in the tech stack — fall back to the generator, preferring a
|
|
110
|
+
# versioned one when the page carries several.
|
|
111
|
+
versioned = next((gen for gen in generators if gen[1]), None)
|
|
112
|
+
return versioned or (generators[0] if generators else None)
|
|
113
|
+
name, version = tech
|
|
114
|
+
if version is None:
|
|
115
|
+
for gen_name, gen_version in generators:
|
|
116
|
+
if gen_version and _same_cms(name, gen_name):
|
|
117
|
+
return name, gen_version
|
|
118
|
+
return tech
|
|
119
|
+
|
|
120
|
+
|
|
49
121
|
class ScanProgress(Message):
|
|
50
122
|
"""A ScanEvent surfaced onto the Textual message pump."""
|
|
51
123
|
|
|
@@ -98,6 +170,9 @@ class WebScannerApp(App):
|
|
|
98
170
|
self.completed = 0
|
|
99
171
|
self.failed = 0
|
|
100
172
|
self._scanning = False
|
|
173
|
+
# monotonic (not wall clock — immune to the system clock moving) start of the
|
|
174
|
+
# current scan, for the log's closing "completed in Ns".
|
|
175
|
+
self._scan_start = 0.0
|
|
101
176
|
# last result rendered into the sitemap Tree, so switching tabs doesn't
|
|
102
177
|
# rebuild (and re-collapse) it every visit.
|
|
103
178
|
self._tree_result = None
|
|
@@ -109,10 +184,12 @@ class WebScannerApp(App):
|
|
|
109
184
|
yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
|
|
110
185
|
yield TabBar(self.modules, id="tabs")
|
|
111
186
|
with Grid(id="grid"):
|
|
112
|
-
with
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
with Vertical(id="left"):
|
|
188
|
+
with VerticalScroll(id="main"):
|
|
189
|
+
yield LoadingIndicator(id="main-loading")
|
|
190
|
+
yield Static("", id="main-content")
|
|
191
|
+
yield SitemapTree(id="main-tree")
|
|
192
|
+
yield ActivityLog(id="activity")
|
|
116
193
|
yield MapPanel(id="map")
|
|
117
194
|
with VerticalScroll(id="status"):
|
|
118
195
|
yield StatusPanel(id="status-content")
|
|
@@ -124,6 +201,7 @@ class WebScannerApp(App):
|
|
|
124
201
|
self.query_one("#topbar").border_title = "🌐 WebScanner"
|
|
125
202
|
self.query_one("#map").border_title = "server location"
|
|
126
203
|
self.query_one("#status", VerticalScroll).border_title = "Server"
|
|
204
|
+
self.query_one("#activity", ActivityLog).border_title = "Activity Log"
|
|
127
205
|
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
128
206
|
self._update_main_title()
|
|
129
207
|
self._set_keybar(editing=False)
|
|
@@ -141,6 +219,7 @@ class WebScannerApp(App):
|
|
|
141
219
|
self.completed = 0
|
|
142
220
|
self.failed = 0
|
|
143
221
|
self._scanning = True
|
|
222
|
+
self._scan_start = time.monotonic()
|
|
144
223
|
self._tree_result = None
|
|
145
224
|
|
|
146
225
|
tabs = self.query_one("#tabs", TabBar)
|
|
@@ -150,6 +229,9 @@ class WebScannerApp(App):
|
|
|
150
229
|
self._set_main_loading(True)
|
|
151
230
|
self.query_one("#map", MapPanel).show_loading()
|
|
152
231
|
self.query_one("#status-content", StatusPanel).show_loading(self.ctx)
|
|
232
|
+
log = self.query_one("#activity", ActivityLog)
|
|
233
|
+
log.clear()
|
|
234
|
+
log.add(activity.started(self.ctx.domain, len(self.modules)))
|
|
153
235
|
self._update_progress()
|
|
154
236
|
|
|
155
237
|
# Blur the input so single-key nav (←/→, q, r) reaches the app rather
|
|
@@ -174,6 +256,9 @@ class WebScannerApp(App):
|
|
|
174
256
|
|
|
175
257
|
def on_scan_progress(self, message: ScanProgress) -> None:
|
|
176
258
|
event = message.event
|
|
259
|
+
# Above the early returns below — the log wants prefetch events too.
|
|
260
|
+
if (line := activity.summarize(event, self.ctx)) is not None:
|
|
261
|
+
self.query_one("#activity", ActivityLog).add(line)
|
|
177
262
|
if event.name == PREFETCH:
|
|
178
263
|
if event.status is ModuleStatus.DONE and self.ctx is not None:
|
|
179
264
|
self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
|
|
@@ -193,16 +278,24 @@ class WebScannerApp(App):
|
|
|
193
278
|
self.results[event.name] = event.result
|
|
194
279
|
if event.name == self.selected:
|
|
195
280
|
self._refresh_main()
|
|
196
|
-
if event.name == "tech" and self.ctx is not None
|
|
281
|
+
if event.name == "tech" and self.ctx is not None:
|
|
282
|
+
# No result guard: a failed Tech scan can still surface a CMS via the
|
|
283
|
+
# page's <meta name="generator">.
|
|
284
|
+
data = event.result.data if event.result is not None else None
|
|
197
285
|
self.query_one("#status-content", StatusPanel).set_ctx(
|
|
198
|
-
self.ctx, cms=
|
|
286
|
+
self.ctx, cms=_detect_cms(data, self.ctx.html)
|
|
199
287
|
)
|
|
200
288
|
self._update_progress()
|
|
201
289
|
|
|
202
290
|
def on_scan_finished(self, message: ScanFinished) -> None:
|
|
203
291
|
self._scanning = False
|
|
204
292
|
total = len(self.modules)
|
|
205
|
-
self.query_one("#
|
|
293
|
+
self.query_one("#activity", ActivityLog).add(
|
|
294
|
+
activity.overall(
|
|
295
|
+
self.completed, self.failed, total, time.monotonic() - self._scan_start
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
self.query_one("#progress", Static).update("")
|
|
206
299
|
|
|
207
300
|
# ---- progress line ----------------------------------------------------
|
|
208
301
|
|
|
@@ -73,9 +73,18 @@ Tab.-selected {
|
|
|
73
73
|
height: 1fr; /* fill remaining screen so 1fr rows split equally */
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/* Left column: the data table over the fixed activity log. A bare wrapper (no
|
|
77
|
+
border/padding of its own) so the grid still sees one cell here. */
|
|
78
|
+
#left {
|
|
77
79
|
row-span: 2;
|
|
78
80
|
height: 100%;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#main {
|
|
84
|
+
height: 4fr; /* 80% of the left column (activity takes the other 20%). Must
|
|
85
|
+
stay fr, NOT 100%: that claims the whole column and silently
|
|
86
|
+
clips #activity (Vertical is overflow: hidden, so no
|
|
87
|
+
scrollbar, no error — the panel just never appears). */
|
|
79
88
|
border: round $primary;
|
|
80
89
|
padding: 0 1;
|
|
81
90
|
scrollbar-size: 0 0; /* keep wheel/key scroll, hide the bar */
|
|
@@ -112,6 +121,13 @@ Tab.-selected {
|
|
|
112
121
|
color: $primary;
|
|
113
122
|
text-style: bold;
|
|
114
123
|
}
|
|
124
|
+
/* The expand/collapse chevron. Not a stock Textual component class — SitemapTree
|
|
125
|
+
adds it and paints the icon span itself, because Textual renders the chevron in
|
|
126
|
+
the widget's base style, which no CSS can reach without dragging the labels
|
|
127
|
+
along. White lifts it clear of the labels (ANSI default fg, ~#c5c8c6). */
|
|
128
|
+
#main-tree > .tree--toggle {
|
|
129
|
+
color: white;
|
|
130
|
+
}
|
|
115
131
|
/* Leaf pages are @click links (click to open the URL). Neutralise Textual's default
|
|
116
132
|
link styling so they render EXACTLY like plain leaf text — no underline, no hover
|
|
117
133
|
colour/background change. */
|
|
@@ -124,6 +140,22 @@ Tab.-selected {
|
|
|
124
140
|
link-background-hover: transparent;
|
|
125
141
|
}
|
|
126
142
|
|
|
143
|
+
/* Activity log: 20% of the left column, so it grows with the terminal (#main is
|
|
144
|
+
the other 80%). 4 rows of that go to the border + padding (box-sizing is
|
|
145
|
+
border-box), so visible lines = height - 4: ~3 at 44 rows, ~6 at 60.
|
|
146
|
+
min-height floors it at one readable line on a short terminal, where 20% would
|
|
147
|
+
otherwise round down to nothing but chrome.
|
|
148
|
+
background: transparent overrides RichLog's $surface, which would punch an
|
|
149
|
+
opaque block through the ansi_default bg. */
|
|
150
|
+
#activity {
|
|
151
|
+
height: 1fr;
|
|
152
|
+
min-height: 5;
|
|
153
|
+
border: round $primary;
|
|
154
|
+
padding: 1 1;
|
|
155
|
+
background: transparent;
|
|
156
|
+
scrollbar-size: 0 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
#map {
|
|
128
160
|
height: 100%;
|
|
129
161
|
border: round $primary;
|
|
@@ -13,10 +13,8 @@ from datetime import datetime
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any, Iterator
|
|
15
15
|
|
|
16
|
-
from rich.text import Text
|
|
17
|
-
|
|
18
16
|
from ..core.models import Grid, ModuleStatus, Sections, TreeNode
|
|
19
|
-
from .tables import TAB_HEADERS, _SMART_LABEL_TABS, _label
|
|
17
|
+
from .tables import TAB_HEADERS, _SMART_LABEL_TABS, _label, _plain
|
|
20
18
|
|
|
21
19
|
def _output_base() -> Path:
|
|
22
20
|
"""Parent directory for the ``<domain>_<ts>/`` scan folder.
|
|
@@ -34,22 +32,6 @@ def _output_base() -> Path:
|
|
|
34
32
|
return cwd
|
|
35
33
|
|
|
36
34
|
|
|
37
|
-
def _stringify(value: Any) -> str:
|
|
38
|
-
if isinstance(value, (list, tuple, set)):
|
|
39
|
-
return "\n".join(str(v) for v in value) if value else "-"
|
|
40
|
-
if isinstance(value, bool):
|
|
41
|
-
return "yes" if value else "no"
|
|
42
|
-
if value is None:
|
|
43
|
-
return "-"
|
|
44
|
-
return str(value)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _plain(value: Any) -> str:
|
|
48
|
-
"""Stringify and strip rich markup (e.g. ``[green]Yes[/]`` -> ``Yes``)."""
|
|
49
|
-
s = _stringify(value)
|
|
50
|
-
return Text.from_markup(s).plain if "[/]" in s else s
|
|
51
|
-
|
|
52
|
-
|
|
53
35
|
def _is_pairs(data: Any) -> bool:
|
|
54
36
|
return (
|
|
55
37
|
isinstance(data, (list, tuple))
|
|
@@ -139,6 +139,16 @@ def _stringify(value: Any) -> str:
|
|
|
139
139
|
return str(value)
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def _plain(value: Any) -> str:
|
|
143
|
+
"""Stringify and strip rich markup (e.g. ``[green]Yes[/]`` -> ``Yes``).
|
|
144
|
+
|
|
145
|
+
Shared with export (CSV cells) and activity (log counts) — both need the plain
|
|
146
|
+
text behind a module's colour markup.
|
|
147
|
+
"""
|
|
148
|
+
s = _stringify(value)
|
|
149
|
+
return Text.from_markup(s).plain if "[/]" in s else s
|
|
150
|
+
|
|
151
|
+
|
|
142
152
|
def _label(key: Any, mode: str = "upper") -> str:
|
|
143
153
|
"""Format a dict key as a column label. ``mode``: ``"upper"`` (ALL CAPS),
|
|
144
154
|
``"smart"`` (Title Case, acronyms kept upper), or ``"raw"`` (separators
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
"""Custom Textual widgets: tab bar, world-map panel, status panel."""
|
|
1
|
+
"""Custom Textual widgets: tab bar, world-map panel, status panel, activity log."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections import deque
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
5
8
|
from rich.markup import escape
|
|
9
|
+
from rich.text import Text
|
|
6
10
|
from textual.binding import Binding
|
|
7
11
|
from textual.containers import Horizontal
|
|
8
12
|
from textual.message import Message
|
|
9
|
-
from textual.widgets import Static, Tree
|
|
13
|
+
from textual.widgets import RichLog, Static, Tree
|
|
10
14
|
|
|
15
|
+
from ..colors import BODY, MUTED
|
|
11
16
|
from ..core.context import ScanContext
|
|
12
17
|
from ..core.models import ModuleStatus, TreeNode
|
|
13
18
|
from .tables import UNSET, render_status
|
|
@@ -103,6 +108,85 @@ class StatusPanel(Static):
|
|
|
103
108
|
self.update(render_status(ctx, cms))
|
|
104
109
|
|
|
105
110
|
|
|
111
|
+
class ActivityLog(RichLog):
|
|
112
|
+
"""Fixed scan log under the main panel; the newest line is always in view.
|
|
113
|
+
|
|
114
|
+
Lines are composed by ``activity.py`` — this only stamps the time, paints the
|
|
115
|
+
body, and decides how a too-long line is cut, so the wording stays testable
|
|
116
|
+
without an app.
|
|
117
|
+
|
|
118
|
+
Each line is written as a ``no_wrap`` / ``overflow="ellipsis"`` Text at the
|
|
119
|
+
panel's exact width, so it's truncated with "…" at the panel edge and a wider
|
|
120
|
+
terminal reveals more of it. Nothing is pre-cropped to an assumed width.
|
|
121
|
+
|
|
122
|
+
``can_focus=False`` is load-bearing, not tidiness: RichLog inherits
|
|
123
|
+
ScrollableContainer's ungated up/down bindings, so a focused log would swallow
|
|
124
|
+
↑/↓ from the Sitemap tree once it overflows. It also stops a click on the panel
|
|
125
|
+
from taking focus off the app, which is what keeps single-key nav (q/r/s/←/→)
|
|
126
|
+
working.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
can_focus = False
|
|
130
|
+
|
|
131
|
+
#: scrollback kept for re-rendering on resize; matches RichLog's max_lines
|
|
132
|
+
_MAX_LINES = 200
|
|
133
|
+
|
|
134
|
+
def __init__(self, **kwargs) -> None:
|
|
135
|
+
# min_width=0: the 78 default renders every write at >=78 cells, overflowing
|
|
136
|
+
# a narrower panel. wrap=True: with wrap=False, RichLog forces
|
|
137
|
+
# overflow="ignore" on Text and the tail vanishes with no ellipsis.
|
|
138
|
+
super().__init__(markup=True, wrap=True, min_width=0, max_lines=self._MAX_LINES, **kwargs)
|
|
139
|
+
# 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)
|
|
142
|
+
self._rendered_width: int | None = None
|
|
143
|
+
|
|
144
|
+
def add(self, body: str) -> None:
|
|
145
|
+
"""Write one line, stamped with a muted [HH:MM:SS]."""
|
|
146
|
+
stamp = escape(f"[{datetime.now():%H:%M:%S}]")
|
|
147
|
+
markup = f"[{MUTED}]{stamp}[/] [{BODY}]{body}[/]"
|
|
148
|
+
self._entries.append(markup)
|
|
149
|
+
self._write(markup)
|
|
150
|
+
|
|
151
|
+
def _write(self, markup: str) -> None:
|
|
152
|
+
text = Text.from_markup(markup)
|
|
153
|
+
text.no_wrap = True
|
|
154
|
+
text.overflow = "ellipsis"
|
|
155
|
+
# Explicit width, because RichLog's own sizing measures against
|
|
156
|
+
# `app.console`, which is a plain 80 columns regardless of how wide the app
|
|
157
|
+
# actually is — every line would ellipsise at 80 on a wider terminal. Passing
|
|
158
|
+
# width bypasses measure/shrink/min_width entirely. Width 0 means we aren't
|
|
159
|
+
# laid out yet: let RichLog defer the write, and on_resize replays it.
|
|
160
|
+
width = self.scrollable_content_region.width
|
|
161
|
+
if width:
|
|
162
|
+
self.write(text, width=width)
|
|
163
|
+
else:
|
|
164
|
+
self.write(text)
|
|
165
|
+
|
|
166
|
+
def clear(self) -> "ActivityLog":
|
|
167
|
+
self._entries.clear()
|
|
168
|
+
super().clear()
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def on_resize(self, event) -> None:
|
|
172
|
+
# Flushes any deferred writes, once, when a size is first known.
|
|
173
|
+
super().on_resize(event)
|
|
174
|
+
width = self.scrollable_content_region.width
|
|
175
|
+
if not width or width == self._rendered_width:
|
|
176
|
+
return
|
|
177
|
+
self._rendered_width = width
|
|
178
|
+
if not self._entries:
|
|
179
|
+
return
|
|
180
|
+
# RichLog renders to strips at write time and never re-renders, so replay at
|
|
181
|
+
# the new width: a wider terminal reveals more of each line, a narrower one
|
|
182
|
+
# re-ellipsises. Also fixes the first sizing, where deferred lines were
|
|
183
|
+
# rendered by RichLog without our explicit width.
|
|
184
|
+
entries = list(self._entries)
|
|
185
|
+
super().clear()
|
|
186
|
+
for markup in entries:
|
|
187
|
+
self._write(markup)
|
|
188
|
+
|
|
189
|
+
|
|
106
190
|
class SitemapTree(Tree):
|
|
107
191
|
"""Sitemap tab: the site's URL-path hierarchy as a collapsed tree.
|
|
108
192
|
|
|
@@ -124,6 +208,12 @@ class SitemapTree(Tree):
|
|
|
124
208
|
Binding("space", "toggle_all", "Expand/collapse all", show=False),
|
|
125
209
|
]
|
|
126
210
|
|
|
211
|
+
#: Textual gives the expand/collapse chevron no component class of its own — it
|
|
212
|
+
#: renders with the widget's base style, so plain CSS can't reach it without
|
|
213
|
+
#: recolouring every label too. Adding one here (Textual unions COMPONENT_CLASSES
|
|
214
|
+
#: across the MRO) lets `#main-tree > .tree--toggle` in app.tcss style it alone.
|
|
215
|
+
COMPONENT_CLASSES = {"tree--toggle"}
|
|
216
|
+
|
|
127
217
|
def __init__(self, **kwargs) -> None:
|
|
128
218
|
super().__init__("/", **kwargs)
|
|
129
219
|
self.show_root = True
|
|
@@ -152,6 +242,30 @@ class SitemapTree(Tree):
|
|
|
152
242
|
else:
|
|
153
243
|
parent.add_leaf(data.label) # e.g. the "… (truncated)" note — not a link
|
|
154
244
|
|
|
245
|
+
def render_label(self, node, base_style, style) -> Text:
|
|
246
|
+
"""Paint the chevron with ``tree--toggle``, leaving the label untouched.
|
|
247
|
+
|
|
248
|
+
Textual builds the line as ``ICON + label``, styling the icon with the
|
|
249
|
+
widget's own base style; re-styling that span is the only way to colour the
|
|
250
|
+
chevron independently. The icon is a fixed-width prefix (``"▼ "``/``"▶ "``)
|
|
251
|
+
present only on expandable nodes, so the span is the first ``len(ICON_NODE)``
|
|
252
|
+
cells. A partial style layers over the base, so only the colour changes.
|
|
253
|
+
|
|
254
|
+
The ``_component_styles`` guard is required, not defensive: this also runs
|
|
255
|
+
pre-mount via ``get_label_width`` (a reactive in ``__init__`` rebuilds the
|
|
256
|
+
tree lines), and component styles don't exist until the stylesheet is applied
|
|
257
|
+
on mount — asking for one before that raises ``KeyError``. That early call
|
|
258
|
+
only measures, and a colour never changes cell width, so skipping is safe.
|
|
259
|
+
"""
|
|
260
|
+
label = super().render_label(node, base_style, style)
|
|
261
|
+
if node.allow_expand and "tree--toggle" in self._component_styles:
|
|
262
|
+
label.stylize(
|
|
263
|
+
self.get_component_rich_style("tree--toggle", partial=True),
|
|
264
|
+
0,
|
|
265
|
+
len(self.ICON_NODE),
|
|
266
|
+
)
|
|
267
|
+
return label
|
|
268
|
+
|
|
155
269
|
def action_open_leaf(self, index: int) -> None:
|
|
156
270
|
if 0 <= index < len(self._leaf_urls):
|
|
157
271
|
self.app.open_url(self._leaf_urls[index])
|
|
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
|