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:
co-authored by
Claude Opus 5.5
parent
d585f9272f
commit
369f66afdc
@@ -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)):
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user