#!/usr/bin/env python3 """ shotbot | Python CLI for the Shotbot screenshot API. Single-file standalone script. Requires Python 3.8+ and stdlib only. Run `./shotbot help` for usage. Copyright (c) 2026 Valentin Beck SPDX-License-Identifier: MIT """ from __future__ import annotations import json import os import re import socket import sys import time from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import quote, urlparse from urllib.request import Request, urlopen VERSION = "0.3.0" API_BASE_DEFAULT = "https://api.shotbot.net" POLL_INITIAL_S = 2 POLL_INTERVAL_S = 2 POLL_TIMEOUT_S = 180 # ── Output helpers ─────────────────────────────────────────────────────────── _color_cache = None def use_color() -> bool: global _color_cache if _color_cache is not None: return _color_cache if os.environ.get("NO_COLOR") is not None: _color_cache = False return False try: _color_cache = bool(sys.stdout.isatty()) except Exception: _color_cache = False return _color_cache def clr(s: str, code: str) -> str: return f"\033[{code}m{s}\033[0m" if use_color() else s def note(s: str = "") -> None: sys.stdout.write(s + "\n") def ok(s: str) -> None: sys.stdout.write(clr("✓", "32") + " " + s + "\n") def warn(s: str) -> None: sys.stderr.write(clr("!", "33") + " " + s + "\n") def err(s: str) -> None: sys.stderr.write(clr("✗", "31") + " " + s + "\n") def dim(s: str) -> str: return clr(s, "2") def b(s: str) -> str: return clr(s, "1") # ── Config ─────────────────────────────────────────────────────────────────── def home() -> str: try: h = str(Path.home()) except (RuntimeError, OSError): h = "" if not h: h = os.environ.get("HOME") or os.environ.get("USERPROFILE") or "" if not h: err("cannot determine home directory") sys.exit(2) return h.rstrip("/\\") def config_dir() -> str: return os.path.join(home(), ".shotbot-python-cli") def config_path() -> str: return os.path.join(config_dir(), "config.json") def config_load() -> dict: p = config_path() if not os.path.isfile(p): return {} try: with open(p, "rb") as fp: data = fp.read() j = json.loads(data.decode("utf-8")) return j if isinstance(j, dict) else {} except (OSError, ValueError): return {} def config_save(c: dict) -> None: d = config_dir() try: os.makedirs(d, mode=0o700, exist_ok=True) except OSError: err(f"cannot create config directory: {d}") sys.exit(2) try: os.chmod(d, 0o700) except OSError: pass p = config_path() try: with open(p, "w", encoding="utf-8") as fp: json.dump(c, fp, indent=2, ensure_ascii=False) fp.write("\n") except OSError: err(f"cannot write config file: {p}") sys.exit(2) try: os.chmod(p, 0o600) except OSError: pass def api_key_get() -> str: env = os.environ.get("SHOTBOT_API_KEY", "") if isinstance(env, str) and env.strip(): return env.strip() cfg = config_load() if cfg.get("api_key"): return str(cfg["api_key"]) return prompt_for_key() # ── Persistent capture defaults ────────────────────────────────────────────── # Keys allowed in config.json `defaults`. Type drives coercion in `set` and # validation when merging into the parsed CLI options at capture time. def default_keys() -> dict: return { # All tiers "preset": "string", # og|mobile|youtube_thumbnail|square|reel|pinterest|tablet|desktop|desktop_hd|twitter_header|linkedin_banner|hero_banner "frame": "string", # rounded|shadow|browser_chrome|browser_chrome_dark|mobile|mobile_light|tablet|tablet_light|laptop|polaroid|gradient|shotbot_brand "format": "string", # jpg|png|webp|avif|pdf "viewport": "int", # 280..3840 (Pro custom; common 360..1920) "output-size": "int", # 120..1920 "wait": "int", # 0..120 "ratio": "string", # 16:9, 4:3, 1:1, ... "color-scheme": "string", # dark|light (sets prefers-color-scheme) "full-page": "bool", "nojs": "bool", "hidpi": "bool", "reduce-motion": "bool", # emulate prefers-reduced-motion: reduce # Pro "dismiss-cookies": "string", # accept|reject "scroll": "bool", "scroll-offset": "int", # 0..30000, scroll N px before capture "scroll-to": "string", # CSS selector, scrollIntoView before capture "block-ads": "bool", "crop-height": "int", "selector": "string", "autoplay-videos": "bool", "omit-background": "bool", "emulate-print-media": "bool", "http-auth-user": "string", "http-auth-pass": "string", # PDF (only meaningful when format=pdf) "pdf-page-size": "string", # A4|A3|A5|Letter|Legal|Tabloid "pdf-margin": "int", # 0..50 mm "pdf-scale": "float", # 0.10..2.00 "pdf-landscape": "bool", # CLI ergonomics "output": "string", # path or "true" for cwd-auto-name "cdn": "bool", # upload to the public CDN (default: private) "timeout": "int", # poll timeout, seconds } ENUMS = { "color-scheme": ["dark", "light"], "dismiss-cookies": ["accept", "reject"], "format": ["jpg", "png", "webp", "avif", "pdf"], "pdf-page-size": ["A4", "A3", "A5", "Letter", "Legal", "Tabloid"], } def coerce(key: str, raw: str, kind: str): if kind == "bool": v = raw.strip().lower() if v in ("1", "true", "yes", "on"): return True if v in ("0", "false", "no", "off"): return False err(f"invalid boolean for {key}: '{raw}' (use true|false)") sys.exit(2) if kind == "int": if not re.match(r"^-?\d+$", raw.strip()): err(f"invalid integer for {key}: '{raw}'") sys.exit(2) return int(raw) if kind == "float": try: return float(raw.strip()) except ValueError: err(f"invalid number for {key}: '{raw}'") sys.exit(2) # Enum keys: validate against the canonical set defined in capture_options.php. if key in ENUMS and raw not in ENUMS[key]: err(f"invalid value for {key}: '{raw}' (allowed: " + "|".join(ENUMS[key]) + ")") sys.exit(2) return raw def apply_defaults(opts: dict) -> dict: cfg = config_load() defaults = cfg.get("defaults") or {} if not isinstance(defaults, dict) or not defaults: return opts allowed = default_keys() for k, v in defaults.items(): if k not in allowed: continue # unknown key: ignore silently if k in opts: continue # CLI flag wins opts[k] = v return opts def api_base() -> str: base = os.environ.get("SHOTBOT_API_BASE", "") if isinstance(base, str) and base.strip(): return base.strip().rstrip("/") return API_BASE_DEFAULT def prompt_for_key() -> str: note() note(b("No Shotbot API key configured.")) note("Get one at " + clr("https://www.shotbot.net/api-key/", "36")) note() sys.stdout.write("API key: ") sys.stdout.flush() key = sys.stdin.readline().strip() if not key: err("empty key, aborting") sys.exit(2) if not re.match(r"^[0-9A-Za-z]{12}$", key): warn("key does not match expected format (12 alphanumeric chars), saving anyway") cfg = config_load() cfg["api_key"] = key config_save(cfg) ok(f"Saved to {config_path()} (chmod 600)") note() return key # ── HTTP ───────────────────────────────────────────────────────────────────── def truthy(v) -> bool: """Mimic PHP `!empty()` for the values we ever encounter.""" if v is None or v is False: return False if v == "" or v == 0 or v == "0": return False return True def http(method: str, path: str, body: dict | None = None) -> dict: url = api_base() + path headers = { "Accept": "application/json", "User-Agent": f"shotbot-python-cli/{VERSION}", } data = None if method == "POST": data = json.dumps(body if body is not None else {}, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" req = Request(url, data=data, headers=headers, method=method) raw = "" code = 0 try: with urlopen(req, timeout=60) as resp: code = resp.getcode() raw = resp.read().decode("utf-8", errors="replace") except HTTPError as e: code = e.code try: raw = e.read().decode("utf-8", errors="replace") except Exception: raw = "" except (URLError, socket.timeout, ConnectionError, OSError) as e: err(f"network error: {e}") sys.exit(3) j = None if raw: try: parsed = json.loads(raw) if isinstance(parsed, dict): j = parsed except ValueError: j = None return {"code": code, "json": j, "raw": raw} def download(url: str, dest: str) -> int: headers = {"User-Agent": f"shotbot-python-cli/{VERSION}"} req = Request(url, headers=headers) try: with urlopen(req, timeout=120) as resp: code = resp.getcode() if code != 200: err(f"download failed (HTTP {code})") sys.exit(3) try: fp = open(dest, "wb") except OSError: err(f"cannot open {dest} for writing") sys.exit(2) try: while True: chunk = resp.read(65536) if not chunk: break fp.write(chunk) finally: fp.close() except (HTTPError, URLError, socket.timeout, ConnectionError, OSError) as e: try: os.unlink(dest) except OSError: pass err(f"download failed: {e}") sys.exit(3) try: return os.path.getsize(dest) except OSError: return 0 # ── Args parser ────────────────────────────────────────────────────────────── def parse_args(argv: list) -> dict: """Parse argv into {cmd, opts, pos}. Recognises --flag, --key=value, --key value, and bare positional args. """ rest = list(argv[1:]) cmd = "" if rest and not rest[0].startswith("-"): cmd = rest.pop(0) opts: dict = {} pos: list = [] i = 0 n = len(rest) while i < n: a = rest[i] if a.startswith("--"): tail = a[2:] if "=" in tail: k, _, v = tail.partition("=") opts[k] = v else: nxt = rest[i + 1] if i + 1 < n else None if nxt is not None and not nxt.startswith("-"): opts[tail] = nxt i += 1 else: opts[tail] = True elif a.startswith("-") and len(a) > 1: opts[a[1:]] = True else: pos.append(a) i += 1 return {"cmd": cmd, "opts": opts, "pos": pos} # ── Commands ───────────────────────────────────────────────────────────────── def cmd_help() -> None: cfg = config_path() sys.stdout.write(f""" shotbot v{VERSION} Python CLI for the Shotbot screenshot API USAGE shotbot [options] COMMANDS capture Capture a single URL (see CAPTURE OPTIONS). status Show account plan, credits, quota and in-flight captures. set [value] Set a persistent default for `capture` (see DEFAULTS). Booleans accept true/false/yes/no/1/0; bare `set ` on a boolean is shorthand for `set true`. unset Remove a persistent default. defaults List currently-set persistent defaults. defaults --keys List every key you can `set` and its type. config Print the path to the config file. reset Re-prompt for the API key (overwrites the stored one). version Print the version and exit. help Print this help. CAPTURE OPTIONS (free tier) --url=URL Required. The page to capture (http/https). --preset=NAME Named output bundle: og, mobile, youtube_thumbnail, square, reel, pinterest, tablet (free); desktop, desktop_hd, twitter_header, linkedin_banner, hero_banner (Pro). Fills viewport/output-size/crop-height in one go. --frame=NAME Decorative frame around the image: rounded, shadow, browser_chrome, browser_chrome_dark, mobile, mobile_light, tablet, tablet_light, laptop, polaroid, gradient, shotbot_brand. Free accounts get a "shotbot.fr" mark | clean with Pro. --format=FMT jpg | png | webp | avif | pdf (default: jpg) --viewport=N Viewport width, common 360..1920; Pro 280..3840 (default: 1280) --output-size=N Resize the result. 120..1920 px (default: same as viewport) --wait=N Seconds to wait after page load 0..120 (default: 5) --ratio=R 16:9, 4:3, 1:1, 9:16, etc. (default: 16:9) --full-page Capture the full scrollable page (max 30000 px). --nojs Disable JavaScript before navigation. --color-scheme=dark|light Force prefers-color-scheme (matches API field). --hidpi Render at 2x device pixel ratio. --reduce-motion Emulate prefers-reduced-motion: reduce (well-built sites skip animations | cleaner captures). CAPTURE OPTIONS (Pro) --dismiss-cookies=accept|reject Auto-handle cookie consent banners. --scroll Scroll the page before capture (triggers lazy-loaded images). --scroll-offset=N Scroll down N px before capture (0..30000). --scroll-to=CSS Scroll the matched element into view before capture (mutually exclusive with --scroll-offset). --block-ads Block ad-network requests. --crop-height=N Crop the result to N px tall (10..20000), overrides --ratio, implies full-page. --selector=CSS Capture only the matched DOM element. --autoplay-videos Allow unmuted