agentsclimarketplace

Webpage screenshot

Skill Malikasadjaved/skills/webpage-screenshot

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.From its SKILL.md

Install
npx -y skills add Malikasadjaved/skills --skill webpage-screenshot

Assembled 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.

SKILL.md

23.1 KB, ~5.5k tokens by cl100k_base, 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:3000 and 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:

ScenarioUse
I need to capture a full webpage as PNG from a URLThis skill
I want a screenshot of my local dev server (localhost:3000, etc.)This skill
I need a FastAPI endpoint that returns screenshotsThis skill
I want to write Playwright testsOfficial Playwright docs
I need a general-purpose browser agentagent-browser skill
I need to scrape structured datafirecrawl 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_apidevices 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 contextsnew_context() is cheap (~5ms), launch() is expensive (~500ms)
  • Each context is isolated — cookies, localStorage, and session data don't leak between requests
  • --no-sandbox required when running as root (Docker containers)
  • --disable-dev-shm-usage prevents crashes in Docker where /dev/shm is 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

  1. Launching a browser per request. A Chromium launch takes ~400–700ms and allocates ~200MB RAM. Pool one browser and create isolated contexts per request.

  2. No timeout on page.goto(). Slow or dead pages will hang your endpoint indefinitely. Always set timeout= (in milliseconds). Default is 30000 — tune it for your use case.

  3. Capturing before content loads. wait_until="load" only waits for the initial HTML — images, fonts, and async JS are still loading. Use wait_until="networkidle" for full rendering. For SPAs, add an explicit wait_for_selector on the root component.

  4. Forgetting --no-sandbox in Docker. Chromium requires sandboxing unless running as root (Docker default). The flag is required in containers.

  5. 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.

  6. 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")
    
  7. 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_())
    
  8. 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

What ships with it: 3 files

11.8 KB alongside SKILL.md, 2 of them executable

references/

scripts/

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.