chore(dev): vendor lp-shot, excluded from release tarballs
The WebUI screenshot helper CLAUDE.md already tells agents to use only ever existed on the maintainer's box. Vendoring it means it survives a machine rebuild and the setup steps are written down. It does NOT ship: make_release.sh builds with `git archive`, which honours export-ignore, so scripts/dev joins scripts/release and docs on that list. Verified — the staged tarball has 1666 files and none under scripts/dev. Keeping it out of releases is deliberate, not incidental. lp-shot signs itself a session from the jwtSecret in frontend/.auth.json, which is fine on a box where you already own that file, and has no business sitting in a user's install where it would read as a backdoor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c7372b8c2
commit
84b027feed
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -3,6 +3,7 @@
|
|||||||
# trees never ship in libreportal-<ver>.tar.gz.
|
# trees never ship in libreportal-<ver>.tar.gz.
|
||||||
scripts/unused export-ignore
|
scripts/unused export-ignore
|
||||||
scripts/release export-ignore
|
scripts/release export-ignore
|
||||||
|
scripts/dev export-ignore
|
||||||
site export-ignore
|
site export-ignore
|
||||||
docs export-ignore
|
docs export-ignore
|
||||||
.claude export-ignore
|
.claude export-ignore
|
||||||
|
|||||||
486
scripts/dev/lp-shot
Executable file
486
scripts/dev/lp-shot
Executable file
@ -0,0 +1,486 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""lp-shot — headless screenshot of a LibrePortal WebUI route.
|
||||||
|
|
||||||
|
lp-shot /admin/system # whole route -> /tmp/webui-shot.png
|
||||||
|
lp-shot /admin/system /tmp/x.png 12 ".sys-strip" # one element, 12px padding, crisp
|
||||||
|
|
||||||
|
Arguments (all optional after the route):
|
||||||
|
route WebUI path, e.g. /apps/overview (a full http:// URL also works)
|
||||||
|
out output PNG (default /tmp/webui-shot.png)
|
||||||
|
pad padding in CSS px around the element clip (default 0)
|
||||||
|
selector CSS selector — capture just that element (default: full page)
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
|
||||||
|
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
|
||||||
|
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
|
||||||
|
LP_SHOT_SETTLE extra seconds after load (default 1.5)
|
||||||
|
LP_SHOT_CHROME chromium binary to use (default: first found on PATH)
|
||||||
|
|
||||||
|
The WebUI is behind a login, so every route except / needs a session. On the host
|
||||||
|
that needs no setup: lp-shot signs one itself from the jwtSecret the backend keeps
|
||||||
|
in frontend/.auth.json, exactly as /api/auth/login would, and it expires in an hour.
|
||||||
|
No password is involved — the stored one is a bcrypt hash and is never touched.
|
||||||
|
|
||||||
|
Override that when running off-host, or against another instance:
|
||||||
|
|
||||||
|
LP_SHOT_TOKEN an existing `libreportal_token` cookie value
|
||||||
|
LP_SHOT_USER username, and
|
||||||
|
LP_SHOT_PASS password — posted once to /api/auth/login for a cookie
|
||||||
|
LP_SHOT_AUTH_FILE path to a .auth.json to sign from
|
||||||
|
LP_SHOT_VERBOSE say which .auth.json the session was signed from
|
||||||
|
|
||||||
|
Drives a headless Chromium over the DevTools protocol: navigate, wait for the SPA
|
||||||
|
to paint, then capture. Element captures are clipped by the element's real box, so
|
||||||
|
they stay 1:1 with the page rather than being cropped out of a scaled screenshot.
|
||||||
|
Page console errors are echoed to stderr — a blank shot is usually a JS error.
|
||||||
|
|
||||||
|
DEV TOOL — not part of a release. scripts/dev is `export-ignore`d in
|
||||||
|
.gitattributes, so this never lands in a user's tarball. It reads the host's
|
||||||
|
.auth.json to sign itself a session, which is fine on a maintainer's box (you
|
||||||
|
already own that file) and has no business in a shipped install.
|
||||||
|
|
||||||
|
Requires: a chromium/chrome binary, and python3-websockets. On Ubuntu:
|
||||||
|
sudo snap install chromium && sudo apt install python3-websockets
|
||||||
|
Install with:
|
||||||
|
sudo install -m 755 scripts/dev/lp-shot /usr/local/bin/lp-shot
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from websockets.sync.client import connect
|
||||||
|
|
||||||
|
DEFAULT_OUT = "/tmp/webui-shot.png"
|
||||||
|
COMPOSE = "/libreportal-containers/libreportal/docker-compose.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg, code=1):
|
||||||
|
print(f"lp-shot: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def base_url():
|
||||||
|
"""Where the WebUI lives: env wins, else the live compose's published port."""
|
||||||
|
if os.environ.get("LP_SHOT_URL"):
|
||||||
|
return os.environ["LP_SHOT_URL"].rstrip("/")
|
||||||
|
try:
|
||||||
|
with open(COMPOSE) as fh:
|
||||||
|
m = re.search(r'^\s*-\s*"(\d+):\d+"', fh.read(), re.M)
|
||||||
|
if m:
|
||||||
|
return f"http://localhost:{m.group(1)}"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "http://localhost:3179"
|
||||||
|
|
||||||
|
|
||||||
|
COOKIE = "libreportal_token"
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_FILES = [
|
||||||
|
os.environ.get("LP_SHOT_AUTH_FILE"),
|
||||||
|
"/libreportal-containers/libreportal/frontend/.auth.json",
|
||||||
|
os.path.expanduser(
|
||||||
|
"~/Documents/LibrePortal/LibrePortal/containers/libreportal/frontend/.auth.json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_hs256(payload, secret):
|
||||||
|
"""Sign a JWT the way the backend's jsonwebtoken does (HS256, compact form)."""
|
||||||
|
def seg(raw):
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b"=")
|
||||||
|
head = seg(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||||
|
body = seg(json.dumps(payload, separators=(",", ":")).encode())
|
||||||
|
signed = head + b"." + body
|
||||||
|
sig = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
|
||||||
|
return (signed + b"." + seg(sig)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def mint_token():
|
||||||
|
"""Sign our own session from the host's own JWT secret — no password needed.
|
||||||
|
|
||||||
|
The backend keeps {username, passwordHash, jwtSecret} in frontend/.auth.json
|
||||||
|
and mints session cookies as jwt.sign({sub: username}, jwtSecret). The
|
||||||
|
password is bcrypt-hashed and unrecoverable, but the secret is right there in
|
||||||
|
plaintext, so a tool running on the host can issue itself the same cookie the
|
||||||
|
login endpoint would hand out. That's what makes lp-shot zero-config here.
|
||||||
|
|
||||||
|
Deliberately short-lived: this token exists for one screenshot run, not as a
|
||||||
|
standing credential.
|
||||||
|
"""
|
||||||
|
for path in filter(None, AUTH_FILES):
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
secret, user = data.get("jwtSecret"), data.get("username")
|
||||||
|
if not (secret and user):
|
||||||
|
continue
|
||||||
|
now = int(time.time())
|
||||||
|
return jwt_hs256({"sub": user, "iat": now, "exp": now + 3600}, secret), path
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def session_token(base):
|
||||||
|
"""A `libreportal_token` value, or None if we could not get one.
|
||||||
|
|
||||||
|
Three ways, in order: handed to us (LP_SHOT_TOKEN), exchanged for one at the
|
||||||
|
login endpoint using credentials in the environment, or — the usual case on
|
||||||
|
the host itself — signed locally from .auth.json's jwtSecret. Nothing is
|
||||||
|
cached: the cookie is injected for this run and the profile is thrown away.
|
||||||
|
"""
|
||||||
|
tok = os.environ.get("LP_SHOT_TOKEN")
|
||||||
|
if tok:
|
||||||
|
return tok.strip()
|
||||||
|
user, pw = os.environ.get("LP_SHOT_USER"), os.environ.get("LP_SHOT_PASS")
|
||||||
|
if not (user and pw):
|
||||||
|
tok, src = mint_token()
|
||||||
|
if tok:
|
||||||
|
if os.environ.get("LP_SHOT_VERBOSE"):
|
||||||
|
print(f"lp-shot: signed a session from {src}", file=sys.stderr)
|
||||||
|
return tok
|
||||||
|
return None
|
||||||
|
body = json.dumps({"username": user, "password": pw}).encode()
|
||||||
|
req = urllib.request.Request(f"{base}/api/auth/login", data=body,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
for header, value in resp.getheaders():
|
||||||
|
if header.lower() == "set-cookie" and value.startswith(COOKIE + "="):
|
||||||
|
return value.split("=", 1)[1].split(";")[0]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = "invalid credentials" if e.code == 401 else \
|
||||||
|
"rate-limited, wait it out" if e.code == 429 else f"HTTP {e.code}"
|
||||||
|
die(f"login as {user!r} failed: {detail}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
die(f"cannot reach {base}: {e.reason}")
|
||||||
|
die("login succeeded but returned no session cookie")
|
||||||
|
|
||||||
|
|
||||||
|
def check_token(base, token):
|
||||||
|
req = urllib.request.Request(f"{base}/api/auth/status",
|
||||||
|
headers={"Cookie": f"{COOKIE}={token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return bool(json.loads(resp.read()).get("authenticated"))
|
||||||
|
except (urllib.error.URLError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_PROBE = "!!document.getElementById('login-form')"
|
||||||
|
|
||||||
|
AUTH_HELP = (
|
||||||
|
"the WebUI needs a session, and none could be signed locally.\n"
|
||||||
|
" - on the host: check .auth.json is readable (tried: %s)\n"
|
||||||
|
" - elsewhere: export LP_SHOT_TOKEN=... (the libreportal_token cookie)\n"
|
||||||
|
" or LP_SHOT_USER=admin LP_SHOT_PASS=...\n"
|
||||||
|
"See `lp-shot --help`." % ", ".join(p for p in AUTH_FILES if p)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_chrome():
|
||||||
|
env = os.environ.get("LP_SHOT_CHROME")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"):
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
die("no chromium found — install one (`sudo snap install chromium`) or set LP_SHOT_CHROME")
|
||||||
|
|
||||||
|
|
||||||
|
def profile_dir(chrome):
|
||||||
|
"""A throwaway user-data-dir the browser can actually write to.
|
||||||
|
|
||||||
|
The snap build is confined: it cannot see /tmp or dot-directories in $HOME,
|
||||||
|
so park the profile under its own SNAP_USER_COMMON. Everything else gets a
|
||||||
|
normal temp dir.
|
||||||
|
|
||||||
|
One profile per run, never a shared path: chromium refuses to start on a
|
||||||
|
directory another instance still holds, and a snap-confined browser can't be
|
||||||
|
killed from outside (AppArmor drops the signal even for root), so a single
|
||||||
|
wedged run would otherwise brick the tool until someone rebooted.
|
||||||
|
"""
|
||||||
|
if "/snap/" in os.path.realpath(chrome) or chrome.startswith("/snap/"):
|
||||||
|
home = os.path.expanduser("~/snap/chromium/common")
|
||||||
|
os.makedirs(home, exist_ok=True)
|
||||||
|
sweep_stale(home)
|
||||||
|
return tempfile.mkdtemp(prefix="lp-shot-", dir=home)
|
||||||
|
return tempfile.mkdtemp(prefix="lp-shot-")
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_stale(home):
|
||||||
|
"""Drop lp-shot profiles left behind by runs that died mid-flight."""
|
||||||
|
cutoff = time.time() - 3600
|
||||||
|
try:
|
||||||
|
for name in os.listdir(home):
|
||||||
|
path = os.path.join(home, name)
|
||||||
|
if name.startswith("lp-shot-") and os.path.isdir(path) \
|
||||||
|
and os.path.getmtime(path) < cutoff:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def launch(chrome, width, height):
|
||||||
|
prof = profile_dir(chrome)
|
||||||
|
port_file = os.path.join(prof, "DevToolsActivePort")
|
||||||
|
try:
|
||||||
|
os.remove(port_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
chrome,
|
||||||
|
"--headless=new",
|
||||||
|
"--remote-debugging-port=0", # port 0 -> the real one lands in DevToolsActivePort
|
||||||
|
f"--user-data-dir={prof}",
|
||||||
|
f"--window-size={width},{height}",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-features=Translate,MediaRouter",
|
||||||
|
"about:blank",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
for _ in range(300): # up to 30s — a cold snap start is slow
|
||||||
|
if proc.poll() is not None:
|
||||||
|
die("chromium exited before it opened a debugging port")
|
||||||
|
try:
|
||||||
|
with open(port_file) as fh:
|
||||||
|
lines = fh.read().split("\n")
|
||||||
|
if lines and lines[0].strip():
|
||||||
|
return proc, prof, int(lines[0].strip())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
proc.kill()
|
||||||
|
die("timed out waiting for chromium's debugging port")
|
||||||
|
|
||||||
|
|
||||||
|
def page_socket(port):
|
||||||
|
for _ in range(100):
|
||||||
|
try:
|
||||||
|
raw = urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2).read()
|
||||||
|
for t in json.loads(raw):
|
||||||
|
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
|
||||||
|
return t["webSocketDebuggerUrl"]
|
||||||
|
except (urllib.error.URLError, socket.timeout, ValueError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
die("chromium never exposed a page target")
|
||||||
|
|
||||||
|
|
||||||
|
class CDP:
|
||||||
|
def __init__(self, ws_url):
|
||||||
|
# max_size=None: a full-page screenshot is far past the 1MB default frame cap.
|
||||||
|
self.ws = connect(ws_url, max_size=None, open_timeout=20)
|
||||||
|
self.n = 0
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
def send(self, method, timeout=60, **params):
|
||||||
|
self.n += 1
|
||||||
|
self.ws.send(json.dumps({"id": self.n, "method": method, "params": params}))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
msg = json.loads(self.ws.recv(timeout=max(1, deadline - time.time())))
|
||||||
|
if msg.get("id") == self.n:
|
||||||
|
if "error" in msg:
|
||||||
|
die(f"{method}: {msg['error'].get('message')}")
|
||||||
|
return msg.get("result", {})
|
||||||
|
if "method" in msg:
|
||||||
|
self.events.append(msg)
|
||||||
|
die(f"{method}: timed out")
|
||||||
|
|
||||||
|
def drain(self):
|
||||||
|
"""Collect any events that arrived while we weren't listening."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = json.loads(self.ws.recv(timeout=0.05))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if "method" in msg:
|
||||||
|
self.events.append(msg)
|
||||||
|
|
||||||
|
def eval(self, expr):
|
||||||
|
r = self.send("Runtime.evaluate", expression=expr, returnByValue=True, awaitPromise=True)
|
||||||
|
if r.get("exceptionDetails"):
|
||||||
|
return None
|
||||||
|
return r.get("result", {}).get("value")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
||||||
|
print(__doc__.strip())
|
||||||
|
sys.exit(0 if len(sys.argv) > 1 else 2)
|
||||||
|
|
||||||
|
route = sys.argv[1]
|
||||||
|
out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_OUT
|
||||||
|
pad = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
|
||||||
|
selector = sys.argv[4] if len(sys.argv) > 4 else None
|
||||||
|
|
||||||
|
base = base_url()
|
||||||
|
url = route if re.match(r"^https?://", route) else base + "/" + route.lstrip("/")
|
||||||
|
vp = os.environ.get("LP_SHOT_VIEWPORT", "1440x900")
|
||||||
|
try:
|
||||||
|
width, height = (int(x) for x in vp.lower().split("x"))
|
||||||
|
except ValueError:
|
||||||
|
die(f"bad LP_SHOT_VIEWPORT {vp!r} — want WIDTHxHEIGHT")
|
||||||
|
scale = float(os.environ.get("LP_SHOT_SCALE", "2"))
|
||||||
|
settle = float(os.environ.get("LP_SHOT_SETTLE", "1.5"))
|
||||||
|
|
||||||
|
token = session_token(base)
|
||||||
|
if token and not check_token(base, token):
|
||||||
|
die("that session is not valid (expired token, or wrong LP_SHOT_URL)")
|
||||||
|
|
||||||
|
chrome = find_chrome()
|
||||||
|
proc, prof, port = launch(chrome, width, height)
|
||||||
|
cdp = None
|
||||||
|
try:
|
||||||
|
cdp = CDP(page_socket(port))
|
||||||
|
cdp.send("Page.enable")
|
||||||
|
cdp.send("Runtime.enable")
|
||||||
|
cdp.send("Log.enable")
|
||||||
|
cdp.send("Network.enable")
|
||||||
|
if token:
|
||||||
|
host = re.sub(r"^https?://", "", base).split(":")[0].split("/")[0]
|
||||||
|
cdp.send("Network.setCookie", name=COOKIE, value=token, domain=host,
|
||||||
|
path="/", httpOnly=True, sameSite="Strict")
|
||||||
|
# Pin the metrics rather than trusting --window-size: headless sizes the
|
||||||
|
# window including chrome-less padding, and we want an exact CSS viewport.
|
||||||
|
cdp.send("Emulation.setDeviceMetricsOverride",
|
||||||
|
width=width, height=height, deviceScaleFactor=scale, mobile=False)
|
||||||
|
|
||||||
|
cdp.send("Page.navigate", url=url, timeout=60)
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while time.time() < deadline:
|
||||||
|
if any(e["method"] == "Page.loadEventFired" for e in cdp.events):
|
||||||
|
break
|
||||||
|
cdp.drain()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# The WebUI is an SPA: the document loads long before the route paints.
|
||||||
|
# Wait for the thing we're actually capturing (or for a non-empty body),
|
||||||
|
# then settle for late renders/fonts.
|
||||||
|
# The SPA shows a full-screen boot loader (#libreportal-loading-screen,
|
||||||
|
# removed from the DOM once it finishes) over an otherwise-ready page.
|
||||||
|
# Waiting only for "body has text" happily captures that splash at 9%,
|
||||||
|
# so every probe is gated on the loader being gone first.
|
||||||
|
want = f"!!document.querySelector({json.dumps(selector)})" if selector else \
|
||||||
|
"!!document.body && document.body.innerText.trim().length > 0"
|
||||||
|
probe = f"(!document.getElementById('libreportal-loading-screen')) && ({want})"
|
||||||
|
# An unauthenticated run lands on the sign-in box: without a check it
|
||||||
|
# would either quietly screenshot that, or sit here until the selector
|
||||||
|
# times out — and neither says what's actually wrong. But "login form on
|
||||||
|
# screen" isn't itself the failure (you may be shooting the login page,
|
||||||
|
# or an element inside it), so it only counts once what was ASKED for
|
||||||
|
# has failed to show: an unsatisfied probe on a route that isn't the
|
||||||
|
# login route, or a selector that never turned up.
|
||||||
|
root_route = urllib.parse.urlparse(url).path.rstrip("/") in ("", "/")
|
||||||
|
deadline = time.time() + 45 # cold SPA boot is slow
|
||||||
|
while time.time() < deadline:
|
||||||
|
if cdp.eval(probe):
|
||||||
|
break
|
||||||
|
if not selector and not root_route and cdp.eval(LOGIN_PROBE):
|
||||||
|
die(AUTH_HELP)
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
if selector and cdp.eval(LOGIN_PROBE):
|
||||||
|
die(AUTH_HELP)
|
||||||
|
if selector:
|
||||||
|
die(f"selector {selector!r} never appeared on {url}")
|
||||||
|
if not selector and not root_route and cdp.eval(LOGIN_PROBE):
|
||||||
|
die(AUTH_HELP)
|
||||||
|
time.sleep(settle)
|
||||||
|
|
||||||
|
for e in cdp.events:
|
||||||
|
if e["method"] == "Log.entryAdded" and e["params"]["entry"].get("level") == "error":
|
||||||
|
print(f" page error: {e['params']['entry'].get('text')}", file=sys.stderr)
|
||||||
|
|
||||||
|
if selector:
|
||||||
|
box = cdp.eval(
|
||||||
|
"(() => { const el = document.querySelector(%s); if (!el) return null;"
|
||||||
|
" el.scrollIntoView({block:'center', inline:'center', behavior:'instant'});"
|
||||||
|
" const r = el.getBoundingClientRect();"
|
||||||
|
" return {x: r.left + window.scrollX, y: r.top + window.scrollY,"
|
||||||
|
" w: r.width, h: r.height}; })()" % json.dumps(selector)
|
||||||
|
)
|
||||||
|
if not box:
|
||||||
|
die(f"selector {selector!r} matched nothing on {url}")
|
||||||
|
if box["w"] < 1 or box["h"] < 1:
|
||||||
|
die(f"selector {selector!r} matched a zero-size element (hidden?)")
|
||||||
|
clip = {
|
||||||
|
"x": max(0.0, box["x"] - pad),
|
||||||
|
"y": max(0.0, box["y"] - pad),
|
||||||
|
"width": box["w"] + pad * 2,
|
||||||
|
"height": box["h"] + pad * 2,
|
||||||
|
"scale": scale,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
m = cdp.send("Page.getLayoutMetrics")
|
||||||
|
size = m.get("cssContentSize") or m.get("contentSize")
|
||||||
|
clip = {
|
||||||
|
"x": 0.0, "y": 0.0,
|
||||||
|
"width": float(size["width"]),
|
||||||
|
"height": min(float(size["height"]), 20000.0),
|
||||||
|
"scale": scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
shot = cdp.send("Page.captureScreenshot", format="png", clip=clip,
|
||||||
|
captureBeyondViewport=True, fromSurface=True, timeout=90)
|
||||||
|
data = base64.b64decode(shot["data"])
|
||||||
|
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
||||||
|
with open(out, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
print(f"{out} ({int(clip['width'])}x{int(clip['height'])} css @{scale:g}x, "
|
||||||
|
f"{len(data) // 1024} KB) {url}")
|
||||||
|
finally:
|
||||||
|
# Shut the browser down through the protocol, not with a signal: the snap
|
||||||
|
# launcher is setuid-root, so the PID we hold is root-owned and os.kill
|
||||||
|
# comes back EPERM. Browser.close is the only teardown that actually
|
||||||
|
# works there; the signals are just a backstop for an unconfined chrome.
|
||||||
|
if cdp:
|
||||||
|
try:
|
||||||
|
cdp.send("Browser.close", timeout=5)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
cdp.close()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
for stop in (proc.terminate, proc.kill):
|
||||||
|
try:
|
||||||
|
stop()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
break
|
||||||
|
except (PermissionError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
shutil.rmtree(prof, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user