feat(screenshots): cropped shots, sizes for the site, a site check

- shots.py crops to a region (clip) or to what one or more elements cover
  (element, pad), per-shot viewport; capture.py writes the published sizes to
  src/data/screenshots.json so the page reserves the right space.
- anonymize.py no longer empties secrets that netOrk compares with each other
  (Wi-Fi keys on an SSID against the key read from the access point). Emptying
  them invented passphrase "drift" that never existed; a keyed hash keeps equal
  equal, reverses nothing, and its key lives for one run.
- scripts/check/site.py checks the built site in both languages at four widths:
  sideways overflow, one h1, images with alt and size, console errors, requests
  to other origins, links to unknown routes, old-URL redirects, language
  detection, and word counts against the budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-09-29 23:24:49 +02:00
co-authored by Claude Opus 5.5
parent d585f9272f
commit 369f66afdc
18 changed files with 259 additions and 762 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Check the built site the way a visitor meets it.
npm run build && npx vite preview --port 4173 &
scripts/check/site.py [--base http://127.0.0.1:4173] [--out .check]
Every route in both languages at 360, 390, 768 and 1440 px:
- no sideways scrolling, exactly one h1, every image loaded with alt and size
- no console errors, no request to any other origin
- internal links only to routes that exist
Plus: the old URLs redirect, the language follows the browser until someone
chooses, and the word count of each page (Home EN fails above its budget).
Full-page PNGs land in --out for looking at.
"""
import argparse
import sys
from pathlib import Path
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
ROUTES = ["/", "/pricing", "/nis2", "/plugins", "/glossary", "/docs/getting-started",
"/for/it-department", "/for/it-support", "/for/msp", "/impressum", "/datenschutz"]
REDIRECTS = {"/features": "/#included", "/drivers": "/#hardware", "/roadmap": "/", "/nope": "/"}
WIDTHS = [360, 390, 768, 1440]
# EN word budgets from docs/PAGES.md; only Home is a hard failure.
BUDGETS = {"/": 400, "/pricing": 300, "/nis2": 550, "/plugins": 450, "/docs/getting-started": 80,
"/for/it-department": 320, "/for/it-support": 320, "/for/msp": 320}
EXTERNAL_OK = ("https://license.netork.io/", "mailto:")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--base", default="http://127.0.0.1:4173")
ap.add_argument("--out", type=Path, default=Path(".check"))
args = ap.parse_args()
base = args.base.rstrip("/")
origin = urlparse(base).netloc
args.out.mkdir(parents=True, exist_ok=True)
problems: list[str] = []
words: dict[tuple[str, str], int] = {}
with sync_playwright() as p:
browser = p.chromium.launch()
for lang in ("en", "de"):
for width in WIDTHS:
ctx = browser.new_context(viewport={"width": width, "height": 900})
ctx.add_init_script(f"localStorage.setItem('lang', '{lang}')")
page = ctx.new_page()
errors: list[str] = []
foreign: set[str] = set()
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
page.on("request", lambda r: foreign.add(r.url) if urlparse(r.url).netloc not in (origin, "") and not r.url.startswith("data:") else None)
for route in ROUTES:
where = f"{route} [{lang} {width}px]"
errors.clear()
page.goto(base + route)
page.wait_for_load_state("networkidle")
# Scroll through so lazy images load.
height = page.evaluate("document.documentElement.scrollHeight")
for y in range(0, height, 600):
page.evaluate(f"window.scrollTo(0, {y})")
page.wait_for_timeout(40)
page.wait_for_timeout(300)
page.evaluate("window.scrollTo(0, 0)")
overflow = page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth")
if overflow > 0:
problems.append(f"{where}: {overflow}px sideways overflow")
h1 = page.locator("h1").count()
if h1 != 1:
problems.append(f"{where}: {h1} h1 elements")
bad_imgs = page.evaluate("""[...document.images].filter(i =>
!i.alt || !i.getAttribute('width') || !i.getAttribute('height') || !i.complete || i.naturalWidth === 0
).map(i => i.currentSrc || i.src)""")
for src in bad_imgs:
problems.append(f"{where}: image missing alt/size or not loaded: {src}")
for e in errors:
problems.append(f"{where}: console: {e[:160]}")
hrefs = page.evaluate("[...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href'))")
for href in hrefs:
if href.startswith(EXTERNAL_OK):
continue
if href.startswith(("http:", "https:")):
problems.append(f"{where}: unexpected external link {href}")
continue
path = href.split("#")[0] or "/"
if path.startswith("/") and path not in ROUTES:
problems.append(f"{where}: link to unknown route {href}")
if width == 1440:
words[(route, lang)] = page.evaluate("document.querySelector('main').innerText.split(/\\s+/).filter(Boolean).length")
name = route.strip("/").replace("/", "-") or "home"
page.screenshot(path=str(args.out / f"{name}-{lang}.png"), full_page=True)
if width == 390:
name = route.strip("/").replace("/", "-") or "home"
page.screenshot(path=str(args.out / f"{name}-{lang}-390.png"), full_page=True)
for url in sorted(foreign):
problems.append(f"[{lang} {width}px]: request to another origin: {url}")
ctx.close()
# Old URLs.
ctx = browser.new_context()
page = ctx.new_page()
for old, target in REDIRECTS.items():
page.goto(base + old)
page.wait_for_load_state("networkidle")
got = urlparse(page.url)
landed = got.path + (f"#{got.fragment}" if got.fragment else "")
if landed != target:
problems.append(f"redirect {old}: landed on {landed}, expected {target}")
ctx.close()
# Language: the browser decides until someone chooses.
for locale, stored, expected in [("de-DE", None, "de"), ("en-US", None, "en"), ("fr-FR", None, "en"),
("de-DE", "en", "en")]:
ctx = browser.new_context(locale=locale)
if stored:
ctx.add_init_script(f"localStorage.setItem('lang', '{stored}')")
page = ctx.new_page()
page.goto(base + "/")
page.wait_for_load_state("networkidle")
got = page.evaluate("document.documentElement.lang")
stored_after = page.evaluate("localStorage.getItem('lang')")
if got != expected:
problems.append(f"language: locale {locale}, stored {stored}: got {got}, expected {expected}")
if not stored and stored_after is not None:
problems.append(f"language: locale {locale}: a choice was stored without anyone choosing")
ctx.close()
browser.close()
print("words in <main> (EN / DE, budget):")
for route in ROUTES:
budget = BUDGETS.get(route)
en, de = words.get((route, "en"), 0), words.get((route, "de"), 0)
flag = " OVER" if budget and en > budget else ""
print(f" {route:24} {en:5} / {de:5} {budget or '-'}{flag}")
if words.get(("/", "en"), 0) > BUDGETS["/"]:
problems.append(f"Home EN has {words[('/', 'en')]} words, budget {BUDGETS['/']}")
if problems:
print(f"\n{len(problems)} problems:")
for pr in problems:
print(f" {pr}")
return 1
print("\nno problems")
return 0
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -16,6 +16,10 @@ Log in as `netork` / `netork-demo`.
- Only the API and the UI run. There is no worker, no beat and no Redis, so
nothing polls or reaches a device. Stored credentials are emptied, and the
encryption key is random per start.
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
keys on an SSID against the key read from the access point). Those become a
keyed hash, so equal stays equal and the drift view shows the real state
instead of invented deviations. The key exists only for one run.
- The mapping from real to demo names lives outside the repo in
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
Domains become `example.demo`.
+38 -7
View File
@@ -33,6 +33,7 @@ import ipaddress
import json
import os
import re
import secrets
import sys
from pathlib import Path
@@ -183,9 +184,19 @@ class Mapper:
return ".".join(mapped[::-1]) + ".in-addr.arpa"
# -- whole strings -------------------------------------------------------
@staticmethod
def _secret(m: re.Match) -> str:
name, sep = m.group(1), m.group(2)
if name in SECRET_JSON_KEEP:
return m.group(0)
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
if not raw or not COMPARED_SECRET.match(name):
return f'"{name}"{sep}""'
value = json.loads(f'"{raw}"') # the value as the column would hold it
return f'"{name}"{sep}"{secret_token(value)}"'
def text(self, s: str) -> str:
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
else f'"{m.group(1)}"{m.group(2)}""', s)
s = SECRET_JSON.sub(self._secret, s)
s = REVERSE.sub(self.reverse, s)
s = EMAIL.sub(self.email, s)
if self.domain_re:
@@ -219,6 +230,18 @@ SECRET_JSON = re.compile(
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
# the key read from the access point). Emptying them would invent drift that never
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
# reversed, and the key lives only for this run.
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
RUN_KEY = secrets.token_hex(32)
def secret_token(value: str) -> str:
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
# Whole tables that only hold secrets or personal delivery data.
@@ -284,14 +307,22 @@ async def scrub_secrets(con, dry: bool) -> None:
continue
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
continue # flags like require_password are booleans
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
if not n:
continue
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
print(f" {t}.{c}: {n} replaced by keyed hash")
if not dry:
await con.execute(
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
continue
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
if dt == "bytea" and nullable != "YES":
value = "''::bytea"
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
if n:
print(f" {t}.{c}: {n} emptied")
if not dry:
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
print(f" {t}.{c}: {n} emptied")
if not dry:
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
# Settings flagged secret keep their key, lose their value.
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
+33 -6
View File
@@ -5,7 +5,7 @@ Normally that instance is the local demo copy from scripts/demo (anonymized
production data), which this script logs into on its own:
capture.py --list-devices # prints IDs to pick for --var
capture.py --var ap=<id> --var server=<id> [--only name ...]
capture.py --var ap=<id> [--only name ...]
Against a real instance, log in by hand and cover what must not be seen:
@@ -42,7 +42,8 @@ STATE = Path(os.environ.get(
# One term per line: site names, customer names, domains ... never committed.
MASK_FILE = Path(os.environ.get(
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
OUT = Path(__file__).resolve().parents[2] / "public" / "screenshots"
SITE = Path(__file__).resolve().parents[2]
OUT = SITE / "public" / "screenshots"
VIEWPORT = {"width": 1600, "height": 1000}
@@ -123,13 +124,14 @@ def settle(page: Page) -> None:
page.wait_for_timeout(800)
def publish(png: bytes, path: Path, width: int) -> None:
def publish(png: bytes, path: Path, width: int) -> dict[str, int]:
"""Scale the 2x capture down to its published width and store it as WebP."""
img = Image.open(io.BytesIO(png)).convert("RGB")
if img.width > width:
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
img.save(path, "WEBP", quality=85, method=6)
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
return {"width": img.width, "height": img.height}
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
@@ -137,6 +139,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
terms = mask_terms()
tok = token() if LOCAL else None
blocked: list[str] = []
sizes_file = SITE / "src" / "data" / "screenshots.json"
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
def guard(route):
if route.request.method in ("GET", "HEAD", "OPTIONS"):
@@ -163,6 +167,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
continue
print(f"{shot.name}: {path}")
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
page.set_viewport_size({"width": vw, "height": vh})
page.goto(f"{BASE}{path}")
if page.url.rstrip("/").endswith("/login"):
sys.exit("Session expired; run --login again.")
@@ -182,12 +188,33 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
masks += [page.get_by_text(t) for t in terms]
clip = None
if shot.height:
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
if shot.clip:
x, y, w, h = shot.clip
clip = {"x": x, "y": y, "width": w, "height": h}
elif shot.element:
boxes = []
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
el = page.locator(sel).first
el.scroll_into_view_if_needed()
b = el.bounding_box()
if not b:
sys.exit(f"{shot.name}: element not found: {sel}")
boxes.append(b)
left = min(b["x"] for b in boxes)
top = min(b["y"] for b in boxes)
right = max(b["x"] + b["width"] for b in boxes)
bottom = max(b["y"] + b["height"] for b in boxes)
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
clip = {"x": x0, "y": y0,
"width": min(vw - x0, box["width"] + 2 * shot.pad),
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
mask_color="#334155", animations="disabled")
publish(png, OUT / f"{shot.name}.webp", shot.width)
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
browser.close()
# The site reads these to reserve the right space for each image.
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
if blocked:
print("Blocked non-GET requests (nothing was sent):")
for b in sorted(set(blocked)):
+25 -11
View File
@@ -21,9 +21,15 @@ class Shot:
wait_for: str = "main"
mask: list[str] = field(default_factory=list)
full_page: bool = False
# Crop height in CSS pixels; None keeps the viewport height.
height: int | None = None
# Width of the published WebP in pixels (the capture is 3200 wide).
# Crop to a region (x, y, width, height in CSS px of the viewport) ...
clip: tuple[int, int, int, int] | None = None
# ... or to one element, plus `pad` px around it. Cropping shows less of a
# real screen; it never changes what is on it.
element: str | list[str] | None = None # several: crop to what they cover together
pad: int = 16
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
viewport: tuple[int, int] | None = None
# Width of the published WebP in pixels (captures are taken at 2x).
width: int = 1600
# Selectors clicked in order before the shot, first match each. Only for
# controls that change the view (filters, tabs); the API guard in
@@ -31,18 +37,26 @@ class Shot:
clicks: list[str] = field(default_factory=list)
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
# the end of the table. The card around it stretches to the window height.
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
SHOTS: list[Shot] = [
Shot("devices", "/devices", width=2400),
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
Shot("vlans", "/vlans"),
Shot("device-security", "/devices/{server}#security/assessment"),
Shot("vulnerabilities", "/vulnerabilities"),
Shot("dashboard", "/"),
# Home hero: an access point checked against its profile, wide and short.
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
pad=28, viewport=(1440, 900), width=2400),
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
"xpath=//tbody//span[contains(., 'Incomplete')]",
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
pad=16, viewport=(1180, 900), width=1200),
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
# Background polls drown out what people did: filter the scheduler out,
# the way a reader would (click a source badge, then flip it to exclude).
Shot("audit-log", "/audit-log", clicks=[
Shot("audit-log", "/audit-log", clip=(256, 40, 1344, 560), width=2400, clicks=[
"tbody td >> text=scheduler",
"button[title='Click to toggle include/exclude']",
]),
Shot("service-checks", "/monitoring/checks"),
]