Webpage screenshot
Production-grade agent skills for FastAPI, Qdrant, and WhatsApp Cloud API
npx -y skills add Malikasadjaved/skills --skill webpage-screenshotAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use this skill when capturing full-page screenshots of web pages — Playwright headless browser, full-page PNG/JPEG output, lazy-load handling, element-specific captures, PDF generation, device emulation (mobile/tablet), and FastAPI integration for agent backends. Covers Docker deployment with Chromium dependencies.
SKILL.md
23.1 KB, as published. Nobody here has run it
Overview
Capture full-page, viewport, and element-specific screenshots of any webpage using Playwright's headless Chromium. This skill covers the full application-layer: from one-off scripts through FastAPI endpoints serving AI agents.
What this skill covers (that generic Playwright docs don't):
- Full-page screenshots with lazy-load and infinite-scroll handling
- Local dev server capture — point at
http://localhost:3000and get a screenshot - Production FastAPI endpoint — accept a URL, return a PNG
- Docker Compose with Chromium and all system dependencies
- Browser pooling so you don't launch a new browser per request
- Auth handling (cookies, headers, login-before-capture)
- PDF generation alongside PNG output
- Mobile and tablet device emulation
- Error handling for timeouts, oversized pages, auth walls, and dead URLs
When to use this skill vs. other approaches:
| Scenario | Use |
|---|---|
| I need to capture a full webpage as PNG from a URL | This skill |
| I want a screenshot of my local dev server (localhost:3000, etc.) | This skill |
| I need a FastAPI endpoint that returns screenshots | This skill |
| I want to write Playwright tests | Official Playwright docs |
| I need a general-purpose browser agent | agent-browser skill |
| I need to scrape structured data | firecrawl skill |
Quick Start
One-shot script (no server)
import asyncio
from playwright.async_api import async_playwright
async def capture(url: str, output: str = "screenshot.png"):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={"width": 1920, "height": 1080})
await page.goto(url, wait_until="networkidle")
await page.screenshot(path=output, full_page=True)
await browser.close()
asyncio.run(capture("https://example.com"))
This works but is NOT suitable for production — it launches a browser per request (~500ms overhead), doesn't handle errors, and has no concurrency control. The patterns below fix all of that.
Local Development
The most common use case: you're building a web app and want a quick screenshot
without deploying it. Playwright runs on your machine and can capture
http://localhost:* just like any remote URL.
Capture your local dev server
# Vite / React (default port 5173)
asyncio.run(capture("http://localhost:5173", "my-app.png"))
# Next.js (default port 3000)
asyncio.run(capture("http://localhost:3000", "next-app.png"))
# FastAPI / Flask (common port 8000 or 5000)
asyncio.run(capture("http://localhost:8000/docs", "api-docs.png"))
Wait for dev server to be ready
If your dev server hasn't started yet, the capture will fail with
net::ERR_CONNECTION_REFUSED. Poll until it's up:
import time
import httpx
def wait_for_dev_server(url: str, timeout: int = 30):
"""Block until the dev server responds."""
start = time.time()
while time.time() - start < timeout:
try:
resp = httpx.get(url, timeout=2)
if resp.status_code < 500:
print(f"[wait] Server ready at {url}")
return
except Exception:
pass
time.sleep(1)
raise TimeoutError(f"Dev server at {url} not ready after {timeout}s")
wait_for_dev_server("http://localhost:5173")
asyncio.run(capture("http://localhost:5173", "app.png"))
Capture with hot-reload (Vite, Next.js, etc.)
Dev servers with HMR hold WebSocket connections open, which can prevent
networkidle from ever firing. Use domcontentloaded + a fixed wait instead:
page = await browser.new_page()
await page.goto("http://localhost:5173", wait_until="domcontentloaded")
await asyncio.sleep(2) # let React/Vue/Svelte hydrate
await page.screenshot(path="app.png", full_page=True)
Capture multiple pages of your local app
pages = [
("http://localhost:3000", "home.png"),
("http://localhost:3000/about", "about.png"),
("http://localhost:3000/pricing", "pricing.png"),
("http://localhost:3000/login", "login.png"),
]
browser = await p.chromium.launch(headless=True)
for url, filename in pages:
page = await browser.new_page()
await page.goto(url, wait_until="networkidle")
await page.screenshot(path=filename, full_page=True)
await page.close()
print(f"[capture] {url} → {filename}")
await browser.close()
Docker caveat
If you're running the capture script inside Docker but your dev server runs on
the host, localhost won't work — it points to the container. Use
host.docker.internal instead:
# Inside Docker, reaching the host machine
asyncio.run(capture("http://host.docker.internal:3000", "app.png"))
Full-Page Screenshot
Basic full-page
page = await browser.new_page()
await page.goto(url, wait_until="networkidle")
await page.screenshot(path="full.png", full_page=True)
full_page=True captures the entire scrollable page, not just the viewport.
The page must have finished loading and layout — wait_until="networkidle"
ensures lazy images and async content have loaded before capture.
Viewport-only (no full_page)
await page.set_viewport_size({"width": 1920, "height": 1080})
await page.screenshot(path="viewport.png") # full_page defaults to False
Custom viewport for specific breakpoints
await page.set_viewport_size({"width": 390, "height": 844}) # iPhone 14
await page.screenshot(path="mobile.png", full_page=True)
await page.set_viewport_size({"width": 1024, "height": 768}) # iPad
await page.screenshot(path="tablet.png", full_page=True)
JPEG for smaller file size
await page.screenshot(
path="page.jpg",
full_page=True,
type="jpeg",
quality=80, # 0-100, lower = smaller file
)
JPEG with quality 70–85 is 5-10x smaller than PNG for photo-heavy pages. Use PNG when you need pixel-perfect text or transparency.
Handling Lazy-Loaded Content
Many modern sites lazy-load images, videos, and content as the user scrolls.
full_page=True only captures what's rendered — if content hasn't loaded yet,
it captures empty placeholders.
Scroll-to-trigger pattern
import asyncio
async def scroll_to_load(page, max_scrolls: int = 20):
"""Scroll through the page to trigger lazy loading."""
prev_height = await page.evaluate("document.body.scrollHeight")
for _ in range(max_scrolls):
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(0.5) # wait for lazy content to load
await page.wait_for_load_state("networkidle")
new_height = await page.evaluate("document.body.scrollHeight")
if new_height == prev_height:
break # no more content loaded
prev_height = new_height
await page.evaluate("window.scrollTo(0, 0)") # scroll back to top
Call this BEFORE page.screenshot(full_page=True). The page height stops
growing when all lazy content has been triggered.
Infinite scroll pages
For true infinite-scroll feeds (Twitter, Reddit), set a reasonable limit:
async def scroll_for_infinite_feed(page, max_items: int = 50):
items = []
while len(items) < max_items:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(1)
await page.wait_for_load_state("networkidle")
items = await page.query_selector_all("[data-item]") # adjust selector
await page.evaluate("window.scrollTo(0, 0)")
Infinite scroll pages can produce enormous screenshots (50,000+ px tall). Set a hard limit on scroll iterations or total screenshot height.
Wait for specific element
# Wait for a lazy-loaded hero image or component
await page.wait_for_selector("img.hero", state="visible", timeout=10000)
await page.screenshot(path="page.png", full_page=True)
More reliable than networkidle alone when you know exactly what must be loaded.
Element-Specific Screenshots
Capture a single element
element = await page.query_selector("div.chart-container")
await element.screenshot(path="chart.png")
Captures only that element's bounding box — useful for charts, tables, modals.
Capture with padding
element = await page.query_selector("section.pricing")
box = await element.bounding_box()
await page.screenshot(
path="pricing.png",
clip={
"x": box["x"] - 10,
"y": box["y"] - 10,
"width": box["width"] + 20,
"height": box["height"] + 20,
},
)
Hide a fixed/floating element before capture
# Remove cookie banners, chat widgets, sticky headers
await page.evaluate("""
const elements = document.querySelectorAll('.cookie-banner, #chat-widget');
elements.forEach(el => el.remove());
""")
await page.screenshot(path="clean.png", full_page=True)
Common selectors to remove: .cookie-banner, #intercom-container,
.fixed-header, [data-testid="floating-action"].
PDF Generation
Full page to PDF
await page.pdf(
path="page.pdf",
format="A4",
print_background=True, # include CSS backgrounds
margin={"top": "20px", "right": "20px", "bottom": "20px", "left": "20px"},
)
Custom page size
await page.pdf(
path="page.pdf",
width="1920px",
height="1080px",
print_background=True,
)
Landscape PDF
await page.pdf(
path="landscape.pdf",
format="A4",
landscape=True,
print_background=True,
)
PDF output uses the print CSS of the page (@media print rules). Results differ from screenshots — use PDF for documents and reports, PNG for visual parity.
Device Emulation
Playwright ships with device descriptors for iPhone, iPad, Pixel, and more.
from playwright.async_api import async_playwright, devices
iPhone = devices["iPhone 14 Pro"]
Pixel = devices["Pixel 7"]
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
# Mobile screenshot
ctx = await browser.new_context(**iPhone)
page = await ctx.new_page()
await page.goto(url)
await page.screenshot(path="iphone.png", full_page=True)
await ctx.close()
# Desktop screenshot
ctx = await browser.new_context(viewport={"width": 1920, "height": 1080})
page = await ctx.new_page()
await page.goto(url)
await page.screenshot(path="desktop.png", full_page=True)
await ctx.close()
Available devices: iPhone 14 Pro, iPhone 14 Pro Max, iPhone SE,
Pixel 7, iPad Pro, iPad Mini, Galaxy Tab S4, and ~50 more.
Import from playwright.async_api → devices dict.
Browser Pooling (Production)
Launching a browser per request adds ~500ms and leaks memory under load. Pool a single browser instance and reuse contexts.
import asyncio
from playwright.async_api import async_playwright, Browser
_browser: Browser | None = None
_lock = asyncio.Lock()
async def get_browser() -> Browser:
global _browser
if _browser is None or not _browser.is_connected():
async with _lock:
if _browser is None or not _browser.is_connected():
p = await async_playwright().start()
_browser = await p.chromium.launch(
headless=True,
args=[
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-sandbox", # required in Docker
"--disable-setuid-sandbox",
],
)
return _browser
async def capture_screenshot(url: str) -> bytes:
browser = await get_browser()
ctx = await browser.new_context(viewport={"width": 1920, "height": 1080})
page = await ctx.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
screenshot = await page.screenshot(full_page=True, type="png")
return screenshot
finally:
await ctx.close() # clean up this request's context, not the browser
Key points:
- One browser, many contexts —
new_context()is cheap (~5ms),launch()is expensive (~500ms) - Each context is isolated — cookies, localStorage, and session data don't leak between requests
--no-sandboxrequired when running as root (Docker containers)--disable-dev-shm-usageprevents crashes in Docker where/dev/shmis small
FastAPI Integration
Full endpoint (accept URL, return screenshot)
import io
from contextlib import asynccontextmanager
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import Response
from playwright.async_api import async_playwright, Browser
app = FastAPI(title="Screenshot API")
_browser: Browser | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _browser
p = await async_playwright().start()
_browser = await p.chromium.launch(
headless=True,
args=["--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox"],
)
yield
await _browser.close()
await p.stop()
app = FastAPI(lifespan=lifespan)
@app.get("/screenshot")
async def capture(
url: str = Query(..., description="URL to capture"),
full_page: bool = Query(True, description="Capture full scrollable page"),
width: int = Query(1920, ge=320, le=3840),
height: int = Query(1080, ge=240, le=2160),
format: str = Query("png", pattern=r"^(png|jpeg)$"),
quality: int = Query(80, ge=10, le=100),
):
ctx = await _browser.new_context(viewport={"width": width, "height": height})
page = await ctx.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
screenshot = await page.screenshot(
full_page=full_page,
type=format,
quality=quality if format == "jpeg" else None,
)
media_type = "image/png" if format == "png" else "image/jpeg"
return Response(content=screenshot, media_type=media_type)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Screenshot failed: {e}")
finally:
await ctx.close()
@app.get("/screenshot/element")
async def capture_element(
url: str = Query(...),
selector: str = Query(..., description="CSS selector of the element"),
width: int = Query(1920),
height: int = Query(1080),
):
ctx = await _browser.new_context(viewport={"width": width, "height": height})
page = await ctx.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
element = await page.wait_for_selector(selector, state="visible", timeout=10000)
screenshot = await element.screenshot(type="png")
return Response(content=screenshot, media_type="image/png")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Element capture failed: {e}")
finally:
await ctx.close()
@app.get("/screenshot/pdf")
async def capture_pdf(url: str = Query(...)):
ctx = await _browser.new_context()
page = await ctx.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
pdf = await page.pdf(format="A4", print_background=True)
return Response(content=pdf, media_type="application/pdf")
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF generation failed: {e}")
finally:
await ctx.close()
@app.get("/health")
async def health():
return {"status": "ok" if _browser and _browser.is_connected() else "degraded"}
Rate-limit this endpoint. A single browser can handle ~5 concurrent screenshots before memory becomes an issue. For higher throughput, launch multiple browser instances behind a semaphore.
Concurrency guard
import asyncio
_semaphore = asyncio.Semaphore(5) # max 5 concurrent captures
@app.get("/screenshot")
async def capture(url: str = Query(...)):
async with _semaphore:
ctx = await _browser.new_context()
try:
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle", timeout=30000)
screenshot = await page.screenshot(full_page=True)
return Response(content=screenshot, media_type="image/png")
finally:
await ctx.close()
Auth Handling
Set cookies before capture
ctx = await browser.new_context()
await ctx.add_cookies([
{
"name": "session",
"value": "abc123",
"domain": ".example.com",
"path": "/",
"httpOnly": True,
"secure": True,
},
])
page = await ctx.new_page()
await page.goto("https://app.example.com/dashboard")
await page.screenshot(path="dashboard.png", full_page=True)
Set auth header
ctx = await browser.new_context(
extra_http_headers={"Authorization": "Bearer your-token-here"},
)
page = await ctx.new_page()
await page.goto(url)
Login-then-capture
page = await ctx.new_page()
# Log in
await page.goto("https://example.com/login")
await page.fill("input[name='email']", "[email protected]")
await page.fill("input[name='password']", "password")
await page.click("button[type='submit']")
await page.wait_for_url("**/dashboard") # confirm login succeeded
# Now capture the authenticated page
await page.goto("https://example.com/admin/reports")
await page.screenshot(path="report.png", full_page=True)
For production, store credentials in env vars and never log them.
Error Handling
Timeout
from playwright.async_api import TimeoutError as PlaywrightTimeout
try:
await page.goto(url, wait_until="networkidle", timeout=15000)
except PlaywrightTimeout:
# Page is too slow — capture what we have
await page.screenshot(path="partial.png", full_page=True)
Navigation failure
try:
response = await page.goto(url, wait_until="networkidle", timeout=20000)
if response and response.status >= 400:
raise HTTPException(
status_code=502,
detail=f"Target returned {response.status}",
)
except Exception as e:
if "net::ERR_NAME_NOT_RESOLVED" in str(e):
raise HTTPException(status_code=400, detail="Invalid URL or domain not found")
if "net::ERR_CONNECTION_REFUSED" in str(e):
raise HTTPException(status_code=502, detail="Target server refused connection")
raise
Oversized page guard
MAX_HEIGHT = 40000 # ~40,000px — anything more will likely OOM
page = await ctx.new_page()
await page.goto(url)
height = await page.evaluate("document.body.scrollHeight")
if height > MAX_HEIGHT:
raise HTTPException(
status_code=413,
detail=f"Page too tall ({height}px). Max is {MAX_HEIGHT}px.",
)
await page.screenshot(path="page.png", full_page=True)
Infinite-scroll feeds can produce 100,000+ px pages — always guard against this.
Docker Deployment
Dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
chromium \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-6 \
libxcomposite1 \
libxdamage1 \
libxkbcommon0 \
libxrandr2 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/lib/chromium
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
docker-compose.yml
services:
screenshot:
build: .
ports:
- "8080:8080"
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G # Chromium needs ~500MB minimum
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
requirements.txt
fastapi>=0.111.0
playwright>=1.40.0
uvicorn[standard]
Environment Variables
BROWSER_HEADLESS=true # Run headless (default: true)
SCREENSHOT_TIMEOUT=30000 # Page load timeout in ms (default: 30000)
SCREENSHOT_MAX_HEIGHT=40000 # Max page height in px (default: 40000)
CONCURRENCY_LIMIT=5 # Max concurrent captures (default: 5)
DEFAULT_VIEWPORT_WIDTH=1920 # Default viewport width (default: 1920)
DEFAULT_VIEWPORT_HEIGHT=1080 # Default viewport height (default: 1080)
Load with pydantic-settings:
from pydantic_settings import BaseSettings
class ScreenshotSettings(BaseSettings):
browser_headless: bool = True
screenshot_timeout: int = 30000
screenshot_max_height: int = 40000
concurrency_limit: int = 5
default_viewport_width: int = 1920
default_viewport_height: int = 1080
model_config = {"env_file": ".env"}
Common Pitfalls
-
Launching a browser per request. A Chromium launch takes ~400–700ms and allocates ~200MB RAM. Pool one browser and create isolated contexts per request.
-
No timeout on
page.goto(). Slow or dead pages will hang your endpoint indefinitely. Always settimeout=(in milliseconds). Default is 30000 — tune it for your use case. -
Capturing before content loads.
wait_until="load"only waits for the initial HTML — images, fonts, and async JS are still loading. Usewait_until="networkidle"for full rendering. For SPAs, add an explicitwait_for_selectoron the root component. -
Forgetting
--no-sandboxin Docker. Chromium requires sandboxing unless running as root (Docker default). The flag is required in containers. -
No height limit on infinite-scroll pages. Twitter, Reddit, and feed-based pages can generate 100,000+ px screenshots that OOM your server. Always cap.
-
Web fonts not rendered. If the page uses custom fonts, they may not load on first capture. Wait for
document.fonts.ready:await page.evaluate("document.fonts.ready") -
3rd-party embeds blocking load. YouTube iframes, analytics scripts, and ad networks can prevent
networkidle. Block them:await page.route("**/*", lambda route: route.abort() if route.request.resource_type in ["media", "ping", "font"] else route.continue_()) -
Not cleaning up browser contexts. After each request, close the context (NOT the browser). Leaked contexts accumulate memory until the process dies.
Install
pip install playwright>=1.40.0 fastapi uvicorn[standard]
playwright install chromium