#!/usr/bin/env php '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 ]; } function sb_coerce(string $key, string $raw, string $type): mixed { if ($type === 'bool') { $v = strtolower(trim($raw)); if (in_array($v, ['1', 'true', 'yes', 'on'], true)) return true; if (in_array($v, ['0', 'false', 'no', 'off'], true)) return false; sb_err("invalid boolean for $key: '$raw' (use true|false)"); exit(2); } if ($type === 'int') { if (!preg_match('/^-?\d+$/', trim($raw))) { sb_err("invalid integer for $key: '$raw'"); exit(2); } return (int)$raw; } if ($type === 'float') { if (!is_numeric(trim($raw))) { sb_err("invalid number for $key: '$raw'"); exit(2); } return (float)$raw; } // Enum keys: validate against the canonical set defined in capture_options.php. static $enums = [ 'color-scheme' => ['dark', 'light'], 'dismiss-cookies' => ['accept', 'reject'], 'format' => ['jpg', 'png', 'webp', 'avif', 'pdf'], 'pdf-page-size' => ['A4', 'A3', 'A5', 'Letter', 'Legal', 'Tabloid'], ]; if (isset($enums[$key]) && !in_array($raw, $enums[$key], true)) { sb_err("invalid value for $key: '$raw' (allowed: " . implode('|', $enums[$key]) . ')'); exit(2); } return $raw; } function sb_apply_defaults(array $opts): array { $cfg = sb_config_load(); $defaults = is_array($cfg['defaults'] ?? null) ? $cfg['defaults'] : []; if (!$defaults) return $opts; $allowed = sb_default_keys(); foreach ($defaults as $k => $v) { if (!isset($allowed[$k])) continue; // unknown key: ignore silently if (array_key_exists($k, $opts)) continue; // CLI flag wins $opts[$k] = $v; } return $opts; } function sb_api_base(): string { $b = getenv('SHOTBOT_API_BASE'); return is_string($b) && trim($b) !== '' ? rtrim(trim($b), '/') : SHOTBOT_API_BASE_DEFAULT; } function sb_prompt_for_key(): string { sb_note(); sb_note(sb_b('No Shotbot API key configured.')); sb_note('Get one at ' . sb_clr('https://www.shotbot.net/api-key/', '36')); sb_note(); fwrite(STDOUT, 'API key: '); $key = trim((string)fgets(STDIN)); if ($key === '') { sb_err('empty key, aborting'); exit(2); } if (!preg_match('/^[0-9A-Za-z]{12}$/', $key)) { sb_warn('key does not match expected format (12 alphanumeric chars), saving anyway'); } $cfg = sb_config_load(); $cfg['api_key'] = $key; sb_config_save($cfg); sb_ok('Saved to ' . sb_config_path() . ' (chmod 600)'); sb_note(); return $key; } // ── HTTP ───────────────────────────────────────────────────────────────────── function sb_http(string $method, string $path, ?array $body = null): array { $url = sb_api_base() . $path; $ch = curl_init($url); $headers = [ 'Accept: application/json', 'User-Agent: shotbot-php-cli/' . SHOTBOT_VERSION, ]; $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, CURLOPT_FOLLOWLOCATION => false, ]; if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = json_encode($body ?? new stdClass); $headers[] = 'Content-Type: application/json'; } elseif ($method !== 'GET') { $opts[CURLOPT_CUSTOMREQUEST] = $method; } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $raw = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $cerr = curl_error($ch); curl_close($ch); if ($raw === false) { sb_err("network error: $cerr"); exit(3); } $j = json_decode((string)$raw, true); return ['code' => $code, 'json' => is_array($j) ? $j : null, 'raw' => (string)$raw]; } function sb_download(string $url, string $dest): int { $fp = @fopen($dest, 'wb'); if (!$fp) { sb_err("cannot open $dest for writing"); exit(2); } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 120, CURLOPT_USERAGENT => 'shotbot-php-cli/' . SHOTBOT_VERSION, ]); $ok = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); fclose($fp); if (!$ok || $code !== 200) { @unlink($dest); sb_err("download failed (HTTP $code)"); exit(3); } return (int)@filesize($dest); } // ── Args parser ────────────────────────────────────────────────────────────── /** * Parse argv into [cmd, opts(assoc), pos(list)]. * Recognises --flag, --key=value, --key value, and bare positional args. */ function sb_parse_args(array $argv): array { array_shift($argv); // script $cmd = ''; if (!empty($argv) && !str_starts_with($argv[0], '-')) { $cmd = (string)array_shift($argv); } $opts = []; $pos = []; $n = count($argv); for ($i = 0; $i < $n; $i++) { $a = $argv[$i]; if (str_starts_with($a, '--')) { $tail = substr($a, 2); if (str_contains($tail, '=')) { [$k, $v] = explode('=', $tail, 2); $opts[$k] = $v; } else { $next = $argv[$i + 1] ?? null; if ($next !== null && !str_starts_with($next, '-')) { $opts[$tail] = $next; $i++; } else { $opts[$tail] = true; } } } elseif (str_starts_with($a, '-') && strlen($a) > 1) { $opts[substr($a, 1)] = true; } else { $pos[] = $a; } } return ['cmd' => $cmd, 'opts' => $opts, 'pos' => $pos]; } // ── Commands ───────────────────────────────────────────────────────────────── function sb_cmd_help(): void { $v = SHOTBOT_VERSION; $cfg = sb_config_path(); echo << [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