web-scanner 2.1.0__tar.gz → 2.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {web_scanner-2.1.0 → web_scanner-2.1.2}/PKG-INFO +18 -13
  2. {web_scanner-2.1.0 → web_scanner-2.1.2}/README.md +17 -12
  3. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/__init__.py +1 -1
  4. web_scanner-2.1.2/webscanner/colors.py +7 -0
  5. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/models.py +26 -3
  6. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/scanner.py +1 -1
  7. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/__init__.py +5 -2
  8. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/dns.py +28 -21
  9. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/security.py +2 -2
  10. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/seo.py +50 -4
  11. web_scanner-2.1.2/webscanner/modules/sitemap.py +202 -0
  12. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/subdomains.py +25 -4
  13. web_scanner-2.1.2/webscanner/modules/tech.py +52 -0
  14. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/app.py +98 -23
  15. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/app.tcss +42 -3
  16. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/export.py +41 -15
  17. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/tables.py +83 -31
  18. web_scanner-2.1.2/webscanner/ui/widgets.py +180 -0
  19. web_scanner-2.1.0/webscanner/colors.py +0 -4
  20. web_scanner-2.1.0/webscanner/modules/tech.py +0 -43
  21. web_scanner-2.1.0/webscanner/ui/widgets.py +0 -101
  22. {web_scanner-2.1.0 → web_scanner-2.1.2}/.gitignore +0 -0
  23. {web_scanner-2.1.0 → web_scanner-2.1.2}/LICENSE +0 -0
  24. {web_scanner-2.1.0 → web_scanner-2.1.2}/app.py +0 -0
  25. {web_scanner-2.1.0 → web_scanner-2.1.2}/pyproject.toml +0 -0
  26. {web_scanner-2.1.0 → web_scanner-2.1.2}/requirements.txt +0 -0
  27. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/__main__.py +0 -0
  28. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/cli.py +0 -0
  29. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/__init__.py +0 -0
  30. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/context.py +0 -0
  31. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/module.py +0 -0
  32. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/helpers.py +0 -0
  33. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/headers.py +0 -0
  34. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/links.py +0 -0
  35. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/ssl.py +0 -0
  36. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/whois.py +0 -0
  37. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/__init__.py +0 -0
  38. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/data/public_suffix_list.dat +0 -0
  39. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/http.py +0 -0
  40. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/psl.py +0 -0
  41. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/__init__.py +0 -0
  42. {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/data/countries.json +0 -0
  43. {web_scanner-2.1.0 → web_scanner-2.1.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.1.0
3
+ Version: 2.1.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
@@ -62,10 +62,11 @@ borders are embedded (Natural Earth), so even the map needs no tile service.
62
62
 
63
63
  ## Features
64
64
 
65
- Nine tabs, scanned concurrently and rendered live as each module finishes:
65
+ Ten tabs, scanned concurrently and rendered live as each module finishes:
66
66
 
67
67
  - **DNS** — A / AAAA / NS / CNAME / SOA / MX / TXT / CAA / DS / DNSKEY records, plus email
68
- authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors).
68
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors, each
69
+ labelled with its email provider).
69
70
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
70
71
  dates, nameservers, per-contact details).
71
72
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -76,10 +77,12 @@ Nine tabs, scanned concurrently and rendered live as each module finishes:
76
77
  X-Frame-Options, …), and blocklist status across public filtering resolvers
77
78
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
78
79
  - **Headers** — the full set of HTTP response headers.
79
- - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer):
80
- name, category, confidence, groups and version.
80
+ - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer),
81
+ split into one table per technology group, each showing name, category, confidence and version.
81
82
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
82
83
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
84
+ - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
85
+ sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
83
86
  - **Links** — internal and external links, with their anchor text.
84
87
 
85
88
  Alongside the tabs, two fixed panels:
@@ -87,7 +90,7 @@ Alongside the tabs, two fixed panels:
87
90
  - **Country map** — real country outlines auto-framed around the server's location, drawn
88
91
  with braille characters (`+` / `-` to zoom).
89
92
  - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
90
- and detected tech.
93
+ and detected CMS (name and version, from the Tech scan).
91
94
 
92
95
  ## Installation
93
96
 
@@ -142,10 +145,12 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
142
145
 
143
146
  ### Keys
144
147
 
145
- | Key | Action |
146
- | --------------- | ------------------------------------------------------------------- |
147
- | `←` / `→` `Tab` | Switch tabs |
148
- | `r` | Rescan |
149
- | `s` | Save export every tab to CSV under `output/<domain>_<timestamp>/` |
150
- | `esc` | Edit the domain and scan a new one |
151
- | `q` | Quit |
148
+ | Key | Action |
149
+ | ---------------- | ------------------------------------------------------------------- |
150
+ | `←` / `→` `Tab` | Switch tabs |
151
+ | `+` / `-` | Zoom the country map in / out |
152
+ | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
153
+ | `r` | Rescan |
154
+ | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
155
+ | `esc` | Edit the domain and scan a new one |
156
+ | `q` | Quit |
@@ -14,10 +14,11 @@ borders are embedded (Natural Earth), so even the map needs no tile service.
14
14
 
15
15
  ## Features
16
16
 
17
- Nine tabs, scanned concurrently and rendered live as each module finishes:
17
+ Ten tabs, scanned concurrently and rendered live as each module finishes:
18
18
 
19
19
  - **DNS** — A / AAAA / NS / CNAME / SOA / MX / TXT / CAA / DS / DNSKEY records, plus email
20
- authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors).
20
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors, each
21
+ labelled with its email provider).
21
22
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
22
23
  dates, nameservers, per-contact details).
23
24
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -28,10 +29,12 @@ Nine tabs, scanned concurrently and rendered live as each module finishes:
28
29
  X-Frame-Options, …), and blocklist status across public filtering resolvers
29
30
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
30
31
  - **Headers** — the full set of HTTP response headers.
31
- - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer):
32
- name, category, confidence, groups and version.
32
+ - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer),
33
+ split into one table per technology group, each showing name, category, confidence and version.
33
34
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
34
35
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
36
+ - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
37
+ sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
35
38
  - **Links** — internal and external links, with their anchor text.
36
39
 
37
40
  Alongside the tabs, two fixed panels:
@@ -39,7 +42,7 @@ Alongside the tabs, two fixed panels:
39
42
  - **Country map** — real country outlines auto-framed around the server's location, drawn
40
43
  with braille characters (`+` / `-` to zoom).
41
44
  - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
42
- and detected tech.
45
+ and detected CMS (name and version, from the Tech scan).
43
46
 
44
47
  ## Installation
45
48
 
@@ -94,10 +97,12 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
94
97
 
95
98
  ### Keys
96
99
 
97
- | Key | Action |
98
- | --------------- | ------------------------------------------------------------------- |
99
- | `←` / `→` `Tab` | Switch tabs |
100
- | `r` | Rescan |
101
- | `s` | Save export every tab to CSV under `output/<domain>_<timestamp>/` |
102
- | `esc` | Edit the domain and scan a new one |
103
- | `q` | Quit |
100
+ | Key | Action |
101
+ | ---------------- | ------------------------------------------------------------------- |
102
+ | `←` / `→` `Tab` | Switch tabs |
103
+ | `+` / `-` | Zoom the country map in / out |
104
+ | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
105
+ | `r` | Rescan |
106
+ | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
107
+ | `esc` | Edit the domain and scan a new one |
108
+ | `q` | Quit |
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.1.2"
@@ -0,0 +1,7 @@
1
+ """Shared semantic colours used in cell markup across modules and the UI."""
2
+
3
+ GREEN = "green"
4
+ RED = "#FF6C64"
5
+ #: muted value/secondary text — matches the unselected-tab colour
6
+ #: ($text-muted = white @ 60% over the dark theme bg).
7
+ MUTED = "#9D9D9D"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from enum import Enum
7
7
  from typing import Any
8
8
 
@@ -62,16 +62,39 @@ class Sections(list):
62
62
  """
63
63
 
64
64
 
65
+ @dataclass(slots=True)
66
+ class TreeNode:
67
+ """One node in a hierarchical result (e.g. the Sitemap tab's URL tree).
68
+
69
+ The module returns a synthetic root ``TreeNode`` whose descendants mirror the
70
+ site's URL-path hierarchy (``/blog`` → ``/blog/post-1`` …). A node is a branch
71
+ (expandable folder) iff it has ``children``; otherwise it's a leaf (a page). The
72
+ UI turns the root into a Textual ``Tree`` widget. ``total`` (set on the root only)
73
+ is the count of page URLs behind the tree, shown in the panel subtitle. ``url`` is
74
+ the full page URL a node stands for (set on leaves), so the UI can make it clickable.
75
+ """
76
+
77
+ label: str
78
+ children: list["TreeNode"] = field(default_factory=list)
79
+ total: int | None = None
80
+ url: str | None = None
81
+
82
+
65
83
  class Grid(list):
66
84
  """A multi-column table result: rows (list of value-lists) + column headers.
67
85
 
68
86
  A ``list`` of rows, so an empty ``Grid`` reports EMPTY via ``_is_empty``. The
69
- first column is treated as each row's primary name.
87
+ first column is treated as each row's primary name. ``widths`` optionally pins
88
+ each column to a fixed character width (else content-fit) — used when several
89
+ Grids share a tab (e.g. Tech's per-group tables) so they all line up.
70
90
  """
71
91
 
72
- def __init__(self, columns: list[str], rows: Any) -> None:
92
+ def __init__(
93
+ self, columns: list[str], rows: Any, widths: list[int] | None = None
94
+ ) -> None:
73
95
  super().__init__(rows)
74
96
  self.columns = list(columns)
97
+ self.widths = list(widths) if widths else None
75
98
 
76
99
  @property
77
100
  def names(self) -> list[str]:
@@ -31,7 +31,7 @@ def _is_empty(data: object) -> bool:
31
31
  return True
32
32
  if isinstance(data, (dict, list, str, tuple, set)):
33
33
  return len(data) == 0
34
- return False
34
+ return False # a returned TreeNode is DONE; modules signal empty by returning None
35
35
 
36
36
 
37
37
  class AsyncScanner:
@@ -1,9 +1,10 @@
1
1
  """Scan module registry.
2
2
 
3
3
  ``all_modules()`` order defines the tab order in the UI:
4
- DNS · Whois · Subdomains · SSL · Security · Headers · Tech · SEO · Links
4
+ DNS · Whois · Subdomains · SSL · Security · Headers · Tech · SEO · Sitemap · Links
5
5
  (Email auth — DMARC/DKIM — is folded into DNS. Security = ports + HTTP headers + blocklists.
6
- SEO = schema + content + keywords. Links = internal + external page links.)
6
+ SEO = schema + content + keywords. Sitemap = the site's published sitemap(s) as a tree.
7
+ Links = internal + external page links.)
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -14,6 +15,7 @@ from .headers import HeadersModule
14
15
  from .links import LinksModule
15
16
  from .security import SecurityModule
16
17
  from .seo import SeoModule
18
+ from .sitemap import SitemapModule
17
19
  from .ssl import SslModule
18
20
  from .subdomains import SubdomainsModule
19
21
  from .tech import TechModule
@@ -31,6 +33,7 @@ def all_modules() -> list[ScanModule]:
31
33
  HeadersModule(),
32
34
  TechModule(),
33
35
  SeoModule(),
36
+ SitemapModule(),
34
37
  LinksModule(),
35
38
  ]
36
39
 
@@ -19,40 +19,44 @@ from ..core.context import ScanContext
19
19
  RECORD_TYPES = ("A", "AAAA", "NS", "CNAME", "SOA", "MX", "TXT", "CAA", "DS", "DNSKEY")
20
20
 
21
21
  # Known DKIM selectors across the major providers/ESPs. DKIM can't be enumerated
22
- # from DNS alone, so we probe this list of well-known selectors.
23
- DKIM_SELECTORS = (
24
- # generic
25
- "default", "dkim", "mail", "selector", "s", "s1", "s2", "key1", "dk",
22
+ # from DNS alone, so we probe this list of well-known selectors. The value is the
23
+ # provider a hit points to (shown next to the selector); an empty string means the
24
+ # selector is generic and doesn't identify a provider on its own.
25
+ DKIM_SELECTORS: dict[str, str] = {
26
+ # generic — the selector name alone doesn't identify a provider
27
+ "default": "", "dkim": "", "mail": "", "selector": "", "s": "",
28
+ "s1": "", "s2": "", "key1": "", "dk": "",
26
29
  # Microsoft 365 / Outlook
27
- "selector1", "selector2",
30
+ "selector1": "Microsoft 365", "selector2": "Microsoft 365",
28
31
  # Google Workspace
29
- "google",
32
+ "google": "Google Workspace",
30
33
  # Amazon SES
31
- "amazonses",
34
+ "amazonses": "Amazon SES",
32
35
  # Zoho
33
- "zoho", "zmail",
36
+ "zoho": "Zoho", "zmail": "Zoho",
34
37
  # ProtonMail
35
- "protonmail", "protonmail2", "protonmail3",
38
+ "protonmail": "Proton Mail", "protonmail2": "Proton Mail", "protonmail3": "Proton Mail",
36
39
  # Fastmail
37
- "fm1", "fm2", "fm3",
40
+ "fm1": "Fastmail", "fm2": "Fastmail", "fm3": "Fastmail",
38
41
  # Mailchimp / Mandrill
39
- "k1", "k2", "k3", "mandrill",
42
+ "k1": "Mailchimp", "k2": "Mailchimp", "k3": "Mailchimp", "mandrill": "Mandrill",
40
43
  # SendGrid
41
- "smtpapi",
44
+ "smtpapi": "SendGrid",
42
45
  # Postmark
43
- "pm",
46
+ "pm": "Postmark",
44
47
  # Mailgun / generic smtp
45
- "smtp", "mg", "mailo",
48
+ "smtp": "", "mg": "Mailgun", "mailo": "",
46
49
  # Campaign Monitor
47
- "cm",
50
+ "cm": "Campaign Monitor",
48
51
  # Apple iCloud
49
- "sig1",
52
+ "sig1": "Apple iCloud",
50
53
  # HubSpot
51
- "hs1", "hs2",
54
+ "hs1": "HubSpot", "hs2": "HubSpot",
52
55
  # misc common
53
- "mxvault", "everlytickey1", "everlytickey2", "titan1", "titan2",
54
- "turbo-smtp", "mailjet", "sendinblue", "klaviyo",
55
- )
56
+ "mxvault": "MXVault", "everlytickey1": "Everlytic", "everlytickey2": "Everlytic",
57
+ "titan1": "Titan", "titan2": "Titan", "turbo-smtp": "TurboSMTP",
58
+ "mailjet": "Mailjet", "sendinblue": "Brevo (Sendinblue)", "klaviyo": "Klaviyo",
59
+ }
56
60
 
57
61
 
58
62
  def _is_dkim(records: list[str]) -> bool:
@@ -92,5 +96,8 @@ class DnsModule(ScanModule):
92
96
  out["DMARC"] = dmarc_records
93
97
  found = [s for s in dkim_hits if s]
94
98
  if found:
95
- out["DKIM"] = found
99
+ out["DKIM"] = [
100
+ f"{s} ({DKIM_SELECTORS[s]})" if DKIM_SELECTORS[s] else s
101
+ for s in found
102
+ ]
96
103
  return out
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
 
14
- from ..colors import GREEN, RED
14
+ from ..colors import GREEN, RED, MUTED
15
15
  from ..core.module import ScanModule
16
16
  from ..core.context import ScanContext
17
17
  from ..core.models import Section, Sections
@@ -71,7 +71,7 @@ async def _port_open(ip: str, port: int) -> bool:
71
71
  async def _scan_ports(ip: str) -> dict[str, str]:
72
72
  opens = await asyncio.gather(*(_port_open(ip, p) for p in COMMON_PORTS))
73
73
  return {
74
- f"{port} ({svc})": f"[{GREEN}]open[/]" if is_open else "[dim]closed[/]"
74
+ f"{port} ({svc})": f"[{GREEN}]open[/]" if is_open else f"[{MUTED}]closed[/]"
75
75
  for (port, svc), is_open in zip(COMMON_PORTS.items(), opens)
76
76
  }
77
77
 
@@ -22,7 +22,7 @@ import requests
22
22
  from bs4 import BeautifulSoup
23
23
  from rich.markup import escape
24
24
 
25
- from ..colors import GREEN, RED
25
+ from ..colors import GREEN, RED, MUTED
26
26
  from ..core.module import ScanModule
27
27
  from ..core.context import ScanContext
28
28
  from ..core.models import Section, Sections
@@ -46,10 +46,56 @@ _STOPWORDS = frozenset(
46
46
  )
47
47
 
48
48
 
49
+ # JSON syntax-highlight colours (dark-friendly, aligned with the app palette).
50
+ _J_KEY = "#5FAFFF" # object keys — blue
51
+ _J_STR = GREEN # string values
52
+ _J_NUM = "#D7AF87" # numbers — amber
53
+ _J_KW = RED # true / false / null
54
+ _J_PUNCT = MUTED # braces, brackets, commas, colons
55
+
56
+ _JSON_TOKEN = re.compile(
57
+ r'(?P<space>\s+)'
58
+ r'|(?P<str>"(?:[^"\\]|\\.)*")'
59
+ r'|(?P<num>-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)'
60
+ r'|(?P<kw>true|false|null)'
61
+ r'|(?P<punct>[{}\[\],:])'
62
+ )
63
+
64
+
65
+ def _highlight_json(obj: object) -> str:
66
+ """Pretty-print `obj` as indented JSON with Rich colour markup. Brackets in
67
+ the payload are escaped so Rich doesn't parse them; `_value_cell` renders the
68
+ markup and CSV export strips it back to plain JSON."""
69
+ raw = json.dumps(obj, indent=2, ensure_ascii=False)
70
+ tokens = list(_JSON_TOKEN.finditer(raw))
71
+ out: list[str] = []
72
+ pos = 0
73
+ for i, m in enumerate(tokens):
74
+ if m.start() > pos: # any char the tokenizer skipped, kept verbatim
75
+ out.append(escape(raw[pos:m.start()]))
76
+ pos = m.end()
77
+ kind, text = m.lastgroup, m.group()
78
+ if kind == "space":
79
+ out.append(text)
80
+ elif kind == "str":
81
+ # a string is a key iff the next non-space token is a colon
82
+ nxt = next((t for t in tokens[i + 1:] if t.lastgroup != "space"), None)
83
+ colour = _J_KEY if nxt and nxt.group() == ":" else _J_STR
84
+ out.append(f"[{colour}]{escape(text)}[/]")
85
+ elif kind == "num":
86
+ out.append(f"[{_J_NUM}]{text}[/]")
87
+ elif kind == "kw":
88
+ out.append(f"[{_J_KW}]{text}[/]")
89
+ else: # punct
90
+ out.append(f"[{_J_PUNCT}]{escape(text)}[/]")
91
+ out.append(escape(raw[pos:]))
92
+ return "".join(out)
93
+
94
+
49
95
  def _len_line(text: str, lo: int, hi: int) -> str:
50
96
  n = len(text)
51
97
  colour = GREEN if lo <= n <= hi else RED
52
- return f"[dim]{escape(text)}[/]\n[{colour}]{n} chars · rec. {lo}-{hi}[/]"
98
+ return f"[{MUTED}]{escape(text)}[/]\n[{colour}]{n} chars · rec. {lo}-{hi}[/]"
53
99
 
54
100
 
55
101
  class SeoModule(ScanModule):
@@ -88,8 +134,8 @@ class SeoModule(ScanModule):
88
134
  pass
89
135
  schema: dict[str, str] = {"Has Schema": f"[{GREEN}]Yes[/]" if blocks else f"[{RED}]No[/]"}
90
136
  if blocks:
91
- schema["Schema"] = json.dumps(
92
- blocks[0] if len(blocks) == 1 else blocks, indent=2, ensure_ascii=False
137
+ schema["Schema"] = _highlight_json(
138
+ blocks[0] if len(blocks) == 1 else blocks
93
139
  )
94
140
 
95
141
  # --- Content ---
@@ -0,0 +1,202 @@
1
+ """Sitemap module — the site's URLs as a folder tree built from their paths.
2
+
3
+ This does **not** crawl the site: it discovers the sitemap(s) the site already
4
+ publishes (``Sitemap:`` lines in robots.txt, falling back to ``/sitemap.xml`` and
5
+ ``/sitemap_index.xml``), fetches them (recursing into any nested ``<sitemapindex>``
6
+ files, gunzipping ``.xml.gz``), and collects every page ``<loc>``.
7
+
8
+ Those flat URLs are then rebuilt into a hierarchy keyed by their **path segments**,
9
+ so the tree reads like the site's folder structure — first level is the first slug
10
+ (``/blog``, ``/news``, ``/wp-content`` …), leaves are the final pages — rather than
11
+ mirroring the sitemap *files*. If the sitemaps span more than one host (e.g.
12
+ subdomains), the differing host becomes the top level so URLs aren't merged.
13
+
14
+ Robustness: a ``visited`` set + caps on total sitemap files, collected URLs and
15
+ sitemap-index nesting depth guard against loops and pathologically huge sitemaps.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import gzip
22
+ import xml.etree.ElementTree as ET
23
+ from urllib.parse import urlparse
24
+
25
+ import requests
26
+
27
+ from ..core.module import ScanModule
28
+ from ..core.context import ScanContext
29
+ from ..core.models import TreeNode
30
+ from ..net.http import DEFAULT_HEADERS, TIMEOUT
31
+
32
+ #: total sitemap files fetched across the whole tree (loop / fan-out guard)
33
+ MAX_SITEMAPS = 60
34
+ #: total page URLs collected before we stop (bounds the tree size)
35
+ MAX_URLS = 10000
36
+ #: how deep a chain of nested <sitemapindex> files we follow
37
+ MAX_DEPTH = 6
38
+
39
+
40
+ class SitemapModule(ScanModule):
41
+ name = "sitemap"
42
+ label = "Sitemap"
43
+
44
+ async def run(self, ctx: ScanContext) -> TreeNode | None:
45
+ return await asyncio.to_thread(self._build, ctx)
46
+
47
+ # ---- build (blocking; runs in a thread) -------------------------------
48
+
49
+ def _build(self, ctx: ScanContext) -> TreeNode | None:
50
+ urls = self._collect(ctx.domain)
51
+ if not urls:
52
+ return None # -> EMPTY
53
+ truncated = len(urls) >= MAX_URLS
54
+ return _url_tree(urls, truncated)
55
+
56
+ def _collect(self, domain: str) -> list[str]:
57
+ """Fetch every sitemap (recursing indexes) and return the page URLs found."""
58
+ visited: set[str] = set()
59
+ urls: list[str] = []
60
+ budget = [MAX_SITEMAPS] # mutable counter shared across the recursion
61
+ for sitemap in self._discover(domain):
62
+ self._crawl(sitemap, visited, urls, budget, depth=0)
63
+ if len(urls) >= MAX_URLS:
64
+ break
65
+ seen: set[str] = set() # dedupe, preserve order
66
+ return [u for u in urls if not (u in seen or seen.add(u))]
67
+
68
+ def _crawl(self, url: str, visited: set[str], urls: list[str], budget: list[int], depth: int) -> None:
69
+ if url in visited or budget[0] <= 0 or depth > MAX_DEPTH or len(urls) >= MAX_URLS:
70
+ return
71
+ visited.add(url)
72
+ budget[0] -= 1
73
+
74
+ raw = self._get(url)
75
+ if raw is None:
76
+ return
77
+ kind, locs = _parse_sitemap(raw)
78
+ if kind == "index":
79
+ for loc in locs:
80
+ self._crawl(loc, visited, urls, budget, depth + 1)
81
+ if len(urls) >= MAX_URLS:
82
+ return
83
+ elif kind == "urlset":
84
+ for loc in locs:
85
+ urls.append(loc)
86
+ if len(urls) >= MAX_URLS:
87
+ return
88
+
89
+ # ---- discovery + fetch ------------------------------------------------
90
+
91
+ def _discover(self, domain: str) -> list[str]:
92
+ """Sitemap URLs from robots.txt, else the two conventional defaults."""
93
+ urls = self._robots_sitemaps(domain)
94
+ if not urls:
95
+ base = f"https://{domain}"
96
+ urls = [f"{base}/sitemap.xml", f"{base}/sitemap_index.xml"]
97
+ seen: set[str] = set()
98
+ return [u for u in urls if not (u in seen or seen.add(u))]
99
+
100
+ @staticmethod
101
+ def _robots_sitemaps(domain: str) -> list[str]:
102
+ try:
103
+ resp = requests.get(
104
+ f"https://{domain}/robots.txt",
105
+ headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True,
106
+ )
107
+ if resp.status_code != 200:
108
+ return []
109
+ return [
110
+ ln.split(":", 1)[1].strip()
111
+ for ln in resp.text.splitlines()
112
+ if ln.strip().lower().startswith("sitemap:")
113
+ ]
114
+ except Exception: # noqa: BLE001
115
+ return []
116
+
117
+ @staticmethod
118
+ def _get(url: str) -> bytes | None:
119
+ """Fetch a sitemap; gunzip ``.xml.gz`` / gzip payloads by magic bytes."""
120
+ try:
121
+ resp = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True)
122
+ if resp.status_code != 200:
123
+ return None
124
+ raw = resp.content
125
+ if raw[:2] == b"\x1f\x8b": # gzip magic — a served .gz file (not transport enc.)
126
+ raw = gzip.decompress(raw)
127
+ return raw
128
+ except Exception: # noqa: BLE001
129
+ return None
130
+
131
+
132
+ # ---- parsing + tree building ----------------------------------------------
133
+
134
+
135
+ def _parse_sitemap(raw: bytes) -> tuple[str | None, list[str]]:
136
+ """Return ``("index"|"urlset", [loc, …])`` or ``(None, [])`` if unparseable."""
137
+ try:
138
+ root = ET.fromstring(raw)
139
+ except ET.ParseError:
140
+ return None, []
141
+ locs = [
142
+ el.text.strip()
143
+ for el in root.iter()
144
+ if _localname(el.tag) == "loc" and el.text and el.text.strip()
145
+ ]
146
+ kind = "index" if _localname(root.tag) == "sitemapindex" else "urlset"
147
+ return kind, locs
148
+
149
+
150
+ def _localname(tag: str) -> str:
151
+ """Strip the ``{namespace}`` prefix ElementTree keeps on namespaced tags."""
152
+ return tag.rsplit("}", 1)[-1].lower()
153
+
154
+
155
+ def _segments(url: str, include_host: bool) -> list[str]:
156
+ """Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
157
+ (the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
158
+ `include_host`, a leading host label (no slash) groups multi-host sitemaps. Any
159
+ query is kept on the final leaf."""
160
+ p = urlparse(url)
161
+ parts = [s for s in p.path.split("/") if s]
162
+ segs: list[str] = []
163
+ if include_host and p.netloc:
164
+ segs.append(p.netloc)
165
+ segs += ["/" + s for s in parts]
166
+ if p.query and segs:
167
+ segs[-1] = segs[-1] + "?" + p.query
168
+ return segs
169
+
170
+
171
+ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
172
+ """Build a path-keyed tree under a visible ``/`` root. Within every level,
173
+ branches (toggleable folders) sort first, then leaf pages — both alphabetical."""
174
+ include_host = len({urlparse(u).netloc for u in urls if urlparse(u).netloc}) > 1
175
+ root = TreeNode(label="/") # the site root / homepage; shown + expanded by the UI
176
+ index: dict[tuple[str, ...], TreeNode] = {(): root}
177
+ for url in sorted(urls):
178
+ parent = root
179
+ node = root
180
+ key: tuple[str, ...] = ()
181
+ for seg in _segments(url, include_host):
182
+ key += (seg,)
183
+ node = index.get(key)
184
+ if node is None:
185
+ node = TreeNode(label=seg)
186
+ index[key] = node
187
+ parent.children.append(node)
188
+ parent = node
189
+ node.url = url # deepest node for this URL (a leaf, unless it later gains children)
190
+ if truncated:
191
+ root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
192
+ root.total = len(urls)
193
+ _sort_tree(root)
194
+ return root
195
+
196
+
197
+ def _sort_tree(node: TreeNode) -> None:
198
+ """Recursively order children: branches (have children) before leaves, each
199
+ group alphabetical by label."""
200
+ for child in node.children:
201
+ _sort_tree(child)
202
+ node.children.sort(key=lambda c: (not c.children, c.label.lower()))