-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck.py
More file actions
154 lines (139 loc) · 6.85 KB
/
Copy pathcheck.py
File metadata and controls
154 lines (139 loc) · 6.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# TestnetHub integrity gate. Run `python3 build.py && python3 check.py` before
# deploying. Validates the built site so a future edit can't silently regress:
# broken links/anchors, heading skips, CSP leaks, oversized meta
# descriptions, sitemap drift, undefined CSS classes, unbalanced tags, and (the
# important one) donation-address consistency + QR payload. Stdlib only; the QR
# check additionally uses `segno` if it is installed. Exits non-zero on failure.
import glob
import json
import os
import re
from html.parser import HTMLParser
ROOT = os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT)
errors, warnings = [], []
def err(m): errors.append(m)
def warn(m): warnings.append(m)
pages = sorted(glob.glob("*.html"))
frags = sorted(glob.glob("content/*.html"))
built_clean = {("/" if p == "index.html" else "/" + p[:-5]) for p in pages} | {"/"}
assets = set(glob.glob("assets/**/*", recursive=True)) | {"favicon.svg", "manifest.webmanifest"}
# 1. Internal links resolve; 2. anchors resolve.
for f in pages:
h = open(f, encoding="utf-8").read()
for href in re.findall(r'href="(/[^"#]*)(#[^"]*)?"', h):
base = href[0].split("?", 1)[0] # drop cache-busting query (?v=2)
if base.startswith("/assets/") or base in ("/favicon.svg", "/manifest.webmanifest"):
if not os.path.exists(base.lstrip("/")):
err(f"{f}: missing asset {base}")
elif base not in built_clean:
err(f"{f}: broken internal link {base}")
for aid in re.findall(r'href="#([^"]+)"', h):
if aid and f'id="{aid}"' not in h:
err(f"{f}: broken anchor #{aid}")
# 3. Exactly one h1 per page, no skipped heading levels.
for f in pages:
hs = [int(x) for x in re.findall(r"<h([1-6])", open(f, encoding="utf-8").read())]
if hs.count(1) != 1:
err(f"{f}: expected one <h1>, found {hs.count(1)}")
for i in range(1, len(hs)):
if hs[i] > hs[i - 1] + 1:
err(f"{f}: heading level skip {hs[i-1]}->{hs[i]}")
# 4. Only expected scripts; 5. no external resource loads.
ALLOWED_SCRIPT = ("theme-init.js", "copy.js", "status.js", 'type="application/ld+json"')
for f in pages:
h = open(f, encoding="utf-8").read()
for s in re.findall(r"<script([^>]*)>", h):
if not any(a in s for a in ALLOWED_SCRIPT):
err(f"{f}: unexpected <script{s}>")
# copy.js loads only where there is a copy button; status.js only on status.
has_copy_trigger = bool(re.search(r'<button[^>]*\bdata-copy', h))
if "/copy.js" in h and not has_copy_trigger:
err(f"{f}: loads copy.js but has no copy trigger")
if has_copy_trigger and "/copy.js" not in h:
err(f"{f}: has a copy trigger but does not load copy.js")
if "/status.js" in h and f != "status.html":
err(f"{f}: status.js should load only on status.html")
# resource-loading attrs (exclude rel=canonical / navigation <a>)
for tag in re.findall(r"<(?:script|img)\b[^>]*?(?:src)=\"([^\"]+)\"", h):
if tag.startswith(("http", "//")):
err(f"{f}: external resource {tag}")
for m in re.finditer(r"<link\b([^>]*)>", h):
attrs = m.group(1)
rel = (re.search(r'rel="([^"]+)"', attrs) or [None, ""])[1]
href = (re.search(r'href="([^"]+)"', attrs) or [None, ""])[1]
if rel in ("stylesheet", "icon", "manifest", "apple-touch-icon") and href.startswith(("http", "//")):
err(f"{f}: external {rel} {href}")
# 6. Meta descriptions within a sane length.
for f in pages:
m = re.search(r'name="description" content="(.*?)"', open(f, encoding="utf-8").read())
if m and len(m.group(1)) > 160:
warn(f"{f}: meta description {len(m.group(1))} chars (>160, may truncate)")
# 7. Sitemap matches built pages (404 excluded).
sm = set(re.findall(r"<loc>https://testnethub\.com(.*?)</loc>", open("sitemap.xml").read()))
want = {p for p in built_clean if p != "/404"}
if sm != want:
err(f"sitemap mismatch: missing {want - sm or '{}'}, extra {sm - want or '{}'}")
# 8. manifest valid JSON.
try:
json.load(open("manifest.webmanifest"))
except Exception as e:
err(f"manifest.webmanifest invalid JSON: {e}")
# 9. Undefined CSS classes.
css = open("assets/style.css").read()
defined = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
for f in frags:
for attr in re.findall(r'class="([^"]+)"', open(f, encoding="utf-8").read()):
for c in attr.split():
if c not in defined:
err(f"{f}: undefined CSS class .{c}")
# 10. Tag balance on built pages.
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}
class Bal(HTMLParser):
def __init__(s): super().__init__(); s.st = []; s.bad = False
def handle_starttag(s, t, a):
if t not in VOID: s.st.append(t)
def handle_endtag(s, t):
if t in VOID: return
if s.st and s.st[-1] == t: s.st.pop()
elif t in s.st:
while s.st and s.st[-1] != t: s.st.pop(); s.bad = True
if s.st: s.st.pop()
else: s.bad = True
for f in pages:
p = Bal(); p.feed(open(f, encoding="utf-8").read())
if p.bad or [x for x in p.st if x not in ("html", "body")]:
err(f"{f}: unbalanced tags")
# 11. Donation addresses: one address per coin, identical across all uses.
donate = open("donate.html", encoding="utf-8").read()
for coin, pat in {"BTC": r"bc1q[a-z0-9]{20,}", "LTC": r"ltc1q[a-z0-9]{20,}", "XMR": r"\b[48][0-9A-Za-z]{94,105}\b"}.items():
uniq = set(re.findall(pat, donate))
if len(uniq) != 1:
err(f"donate.html: {coin} has {len(uniq)} distinct addresses (expected 1): {uniq}")
elif donate.count(next(iter(uniq))) < 3:
warn(f"donate.html: {coin} address appears < 3 times (QR/text/copy/wallet expected)")
# 12. QR codes encode the correct payment URIs (needs segno).
try:
import io, segno
schemes = {"btc": "bitcoin", "ltc": "litecoin", "xmr": "monero"}
addr = {c: re.search(p, donate).group(0) for c, p in
{"btc": r"bc1q[a-z0-9]{20,}", "ltc": r"ltc1q[a-z0-9]{20,}", "xmr": r"[48][0-9A-Za-z]{94,105}"}.items()}
for c, scheme in schemes.items():
buf = io.BytesIO()
segno.make(f"{scheme}:{addr[c]}", error="m").save(buf, kind="svg", scale=8, border=4, dark="#111111", light="#ffffff")
if buf.getvalue() != open(f"assets/donate/{c}.svg", "rb").read():
err(f"assets/donate/{c}.svg does not encode {scheme}:{addr[c][:16]}... (regenerate)")
except ImportError:
warn("segno not installed: skipped QR payload verification (pip install segno to enable)")
# ---- Report ----
print(f"Checked {len(pages)} pages / {len(frags)} fragments.")
for w in warnings:
print(f" WARN {w}")
for e in errors:
print(f" FAIL {e}")
if errors:
print(f"\n{len(errors)} error(s), {len(warnings)} warning(s). FAILED.")
raise SystemExit(1)
print(f"\nAll checks passed ({len(warnings)} warning(s)).")