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.
- {web_scanner-2.1.0 → web_scanner-2.1.2}/PKG-INFO +18 -13
- {web_scanner-2.1.0 → web_scanner-2.1.2}/README.md +17 -12
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/__init__.py +1 -1
- web_scanner-2.1.2/webscanner/colors.py +7 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/models.py +26 -3
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/scanner.py +1 -1
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/__init__.py +5 -2
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/dns.py +28 -21
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/security.py +2 -2
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/seo.py +50 -4
- web_scanner-2.1.2/webscanner/modules/sitemap.py +202 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/subdomains.py +25 -4
- web_scanner-2.1.2/webscanner/modules/tech.py +52 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/app.py +98 -23
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/app.tcss +42 -3
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/export.py +41 -15
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/tables.py +83 -31
- web_scanner-2.1.2/webscanner/ui/widgets.py +180 -0
- web_scanner-2.1.0/webscanner/colors.py +0 -4
- web_scanner-2.1.0/webscanner/modules/tech.py +0 -43
- web_scanner-2.1.0/webscanner/ui/widgets.py +0 -101
- {web_scanner-2.1.0 → web_scanner-2.1.2}/.gitignore +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/LICENSE +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/app.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/pyproject.toml +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/requirements.txt +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/__main__.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/cli.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/context.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/core/module.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/helpers.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/links.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/http.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/net/psl.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.1.0 → web_scanner-2.1.2}/webscanner/ui/data/countries.json +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
146
|
-
|
|
|
147
|
-
| `←` / `→` `Tab`
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
98
|
-
|
|
|
99
|
-
| `←` / `→` `Tab`
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
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 |
|
|
@@ -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__(
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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"
|
|
54
|
-
"
|
|
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"] =
|
|
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 "[
|
|
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"[
|
|
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"] =
|
|
92
|
-
blocks[0] if len(blocks) == 1 else blocks
|
|
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()))
|