screenshot
Headless full-page screenshot of any URL to PNG via Playwright/Chromium โ automatic full page height, optional lazy-load scrolling.
Downloads: 8 ยท ID: 1fbc06f90bc17afb5f000000
Headless full-page screenshot of any URL to PNG via Playwright/Chromium โ automatic full page height, optional lazy-load scrolling.
Downloads: 8 ยท ID: 1fbc06f90bc17afb5f000000
<!-- FILE: screenshot_skill.md -->
# Screenshot Skill
Headless full-page screenshot of a URL โ PNG using Playwright/Chromium. Captures the **entire page** (everything below the fold) in one PNG via full-page mode, without scrolling or stitching.
```
uv run screenshot.py <url> [options]
```
| Arg | Default | Desc |
|-----|---------|------|
| `url` | required | URL to capture (scheme auto-added if missing) |
| `-o, --output` | `~/Pictures/shot_<ts>.png` | Output PNG path |
| `-w, --width` | 1280 | Viewport width (px) |
| `--height` | 900 | Viewport height (px) |
| `--dpr` | 1.0 | Device scale factor (2 = retina โ sharper but larger PNG) |
| `-V, --viewport` | off | Capture only the viewport, not the full page |
| `-s, --scroll` | off | Scroll to bottom first to trigger lazy-loaded content |
| `-u, --wait-until` | `networkidle` | `load`, `domcontentloaded`, `networkidle`, `commit` |
| `-t, --timeout` | 45000 | Load timeout in ms |
| `--no-upload` | off | Skip the automatic PengyShare upload |
## Output
Saves the PNG, prints the absolute path, an inline `<img>` tag, **and auto-uploads to PengyShare** (public by default) printing the shareable URL:
```
~/Pictures/shot_1712345678.png
<img src="file://~/Pictures/shot_1712345678.png" alt="Screenshot of https://...">
โ
https://YOUR-IMAGESHARE-HOST/a3f9c
```
Pass `--no-upload` to skip the PengyShare upload (local PNG is still saved).
## Examples
```bash
uv run screenshot.py https://news.ycombinator.com
uv run screenshot.py example.com -o /tmp/page.png # scheme auto-added
uv run screenshot.py https://example.com -w 1920 --dpr 2 # wide + retina
uv run screenshot.py https://example.com -s # force-load lazy images first
uv run screenshot.py https://example.com -V # just the viewport
uv run screenshot.py https://example.com --no-upload # local copy only, no share link
```
## Notes
- **Full page is the default** โ no need to guess scroll height (Playwright auto-detects content height).
- First run downloads the bundled Chromium (~150MB, one-time) to Playwright's global cache; later runs are fast.
- Pass `-s` **only** if the page lazy-loads images on scroll (the scroll forces them in before capture). Most static pages don't need it.
- `dependencies = ["playwright"]` auto-installed by `uv` on first run.
- **PengyShare upload is automatic** (public by default). It needs `PENGYSHARE_API_KEY` in env or `~/.secrets`; missing key โ upload skipped, local PNG still saved. Pass `--no-upload` to opt out.
## Troubleshooting
- **Launch fails with "missing shared libraries"** (headless Linux deps): e.g.
`error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file`.
Install the system libs with `sudo uv run --with playwright python -m playwright install-deps chromium`.
This is a **one-time per machine** step โ and the Playwright Python package + Chromium
browser binaries being present does *not* mean it has been run.
- **Safer/portable variant** (no sudo for the query, works when `install-deps` doesn't
recognise a brand-new Ubuntu release and refuses to map package names):
`uv run --with playwright python -m playwright install-deps chromium --dry-run`
prints the exact package names as this distro spells them (e.g. Ubuntu 26.04 uses
`libatk1.0-0t64`, not `libatk1.0-0`), then feed them to apt:
`sudo apt-get install -y --no-install-recommends <that list>`.
- **the server (Ubuntu 26.04) done 2026-09-14** โ 16 packages were missing:
`at-spi2-common fonts-freefont-ttf fonts-ipafont-gothic fonts-liberation
fonts-noto-color-emoji fonts-tlwg-loma-otf fonts-unifont fonts-wqy-zenhei
libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libxdamage1 libxres1
xfonts-cyrillic xfonts-scalable xvfb`.
- Quick check without launching: `ldd <chrome-headless-shell>|grep 'not found'` should be
empty, or `playwright install-deps chromium --dry-run` should say
"All system dependencies are installed."
- **Empty/blank shot**: the page may render after JS โ raise `-t`, or retry with `-u load`.
<!-- FILE: screenshot.py -->
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
"""Headless full-page screenshot of a URL -> PNG (Playwright/Chromium).
Captures the entire page (including content below the fold) in one shot via
full_page=True. For sites with lazy-loaded images, pass --scroll to scroll the
page to the bottom first so those load before capture.
Usage:
uv run screenshot.py <url> [options]
"""
import argparse, json, os, re, subprocess, sys, time
from pathlib import Path
def auto_upload(_path, enabled=True, timeout=60):
"""Upload a PNG to PengyShare (public by default). Skip when enabled=False."""
if not enabled:
return
script = Path.home() / "skills" / "pengyshare" / "upload.py"
if not script.exists():
print(f"โ ๏ธ PengyShare upload script not found at {script}", file=sys.stderr)
return
try:
r = subprocess.run([sys.executable, str(script), "-j", str(_path)],
capture_output=True, text=True, timeout=timeout)
if r.returncode == 0:
print(f"โ
{json.loads(r.stdout)['url']}")
else:
print(f"โ ๏ธ Upload failed: {r.stderr.strip()}", file=sys.stderr)
except subprocess.TimeoutExpired:
print("โ ๏ธ Upload timed out", file=sys.stderr)
except Exception as e:
print(f"โ ๏ธ Upload error: {e}", file=sys.stderr)
def _ensure_uv():
try:
subprocess.run(["uv", "--version"], capture_output=True, check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
print("ERROR: 'uv' (https://docs.astral.sh/uv/) is required.", file=sys.stderr)
print("Install: curl -LsSf https://astral.sh/uv/install.sh | sh", file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
pass
_ensure_uv()
# Playwright's sync API spins an internal asyncio loop; on normal process exit it
# emits benign "Task was destroyed"/"Future exception was never retrieved" noise.
# Suppress just those two at the loop level so output stays clean.
import asyncio
class _QuietLoop(asyncio.SelectorEventLoop):
def call_exception_handler(self, context):
msg = context.get("message", "")
if ("Task was destroyed" in msg
or "Future exception was never retrieved" in msg
or "Task exception was never retrieved" in msg):
return
super().call_exception_handler(context)
class _QuietPolicy(asyncio.DefaultEventLoopPolicy):
def new_event_loop(self):
return _QuietLoop()
asyncio.set_event_loop_policy(_QuietPolicy())
from playwright.sync_api import sync_playwright
OUT_DIR = Path.home() / "Pictures"
def _normalize_url(url):
if not url.startswith(("http://", "https://", "file://")):
return "https://" + url
return url
def ensure_browser():
"""Install the Chromium build if not already present (one-time ~150MB)."""
with sync_playwright() as p:
exe = Path(p.chromium.executable_path)
if exe.exists():
return
print("โฌ๏ธ Chromium not installed for Playwright; downloading (one-time ~150MB)...",
file=sys.stderr)
try:
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"],
check=True, timeout=900)
except subprocess.CalledProcessError:
print("Browser download failed.", file=sys.stderr)
print("Try: uv run --with playwright python -m playwright install chromium",
file=sys.stderr)
sys.exit(1)
def scroll_through(page):
"""Scroll to the bottom in steps to trigger lazy-loaded content."""
page.evaluate("window.scrollTo(0, 0)")
prev = -1
while True:
page.evaluate("window.scrollBy(0, window.innerHeight)")
page.wait_for_timeout(400)
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(700)
cur = page.evaluate("window.scrollY")
if cur == prev or cur >= page.evaluate("document.body.scrollHeight"):
break
prev = cur
page.evaluate("window.scrollTo(0, 0)")
page.wait_for_timeout(400)
def capture(url, out, width, height, dpr, full, scroll, wait_until, timeout_ms):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(
viewport={"width": width, "height": height},
device_scale_factor=dpr,
)
page.goto(url, wait_until=wait_until, timeout=timeout_ms)
page.wait_for_timeout(600)
if scroll:
scroll_through(page)
kwargs = {"path": str(out)}
if full:
kwargs["full_page"] = True
page.screenshot(**kwargs)
browser.close()
def main():
ap = argparse.ArgumentParser(description="Headless full-page screenshot to PNG")
ap.add_argument("url", help="URL to capture")
ap.add_argument("-o", "--output", default=None, help="Output filename (default: ~/Pictures/shot_<ts>.png)")
ap.add_argument("-w", "--width", type=int, default=1280, help="Viewport width (default 1280)")
ap.add_argument("--height", type=int, default=900, help="Viewport height (default 900)")
ap.add_argument("--dpr", type=float, default=1.0, help="Device scale factor (2 = retina). Higher = larger PNG")
ap.add_argument("-V", "--viewport", action="store_true",
help="Capture only the viewport instead of the full page")
ap.add_argument("-s", "--scroll", action="store_true",
help="Scroll to bottom first to trigger lazy-loaded content")
ap.add_argument("-u", "--wait-until", default="networkidle",
choices=["load", "domcontentloaded", "networkidle", "commit"],
help="When to consider the page loaded")
ap.add_argument("-t", "--timeout", type=int, default=45000, help="Load timeout in ms (default 45000)")
ap.add_argument("--no-upload", action="store_true",
help="Skip auto-upload to PengyShare (a public URL is printed by default)")
a = ap.parse_args()
url = _normalize_url(a.url)
out = (Path(a.output) if a.output else OUT_DIR / f"shot_{int(time.time())}.png").expanduser()
out = out if out.suffix else out.with_suffix(".png")
out.parent.mkdir(parents=True, exist_ok=True)
ensure_browser()
capture(url, out, a.width, a.height, a.dpr,
full=not a.viewport, scroll=a.scroll,
wait_until=a.wait_until, timeout_ms=a.timeout)
print(out)
print(f'<img src="file://{out}" alt="Screenshot of {url}">')
auto_upload(out, enabled=not a.no_upload)
if __name__ == "__main__":
main()