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
+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"),
]