Skip to content

Documenting OSS READMEs with shot-scraper

Ema Suriano
Ema Surianoat

4 min read

--views

For a long time, README screenshots for my open source projects were the usual GitHub upload: capture something locally, drag it into an issue, paste the user-attachments URL, forget about it until the UI changed again.

That works for any OSS project with a website. It also gets boring. I recently switched to shot-scraper, Simon Willison’s Playwright CLI, and let Actions refresh the PNGs from the live demo whenever the default branch moves.

shot-scraper Github
shot-scraper Github

Basic setup

Locally:

pip install shot-scraper
shot-scraper install
shot-scraper multi shots.yml

shots.yml is just a list of pages. Each entry can run a bit of JavaScript before the capture (theme toggles, scroll, wait for widgets):

- url: https://example.com/
  output: screenshots/home-light.png
  width: 1440
  height: 900
  wait: 1000
  javascript: |
    localStorage.setItem('theme', 'light');
    document.documentElement.classList.add('light');
    document.documentElement.classList.remove('dark');

- url: https://example.com/
  output: screenshots/home-dark.png
  width: 1440
  height: 900
  wait: 1000
  javascript: |
    localStorage.setItem('theme', 'dark');
    document.documentElement.classList.add('dark');
    document.documentElement.classList.remove('light');

- url: https://example.com/
  output: screenshots/projects.png
  width: 1440
  height: 900
  wait: 1500
  selector: "#projects"
  padding: 16
  javascript: |
    async () => {
      document.querySelector("#projects")?.scrollIntoView({ block: "center" });
      await new Promise((r) => setTimeout(r, 500));
    }

Then a GitHub Action installs shot-scraper, waits for the live site, runs multi, and commits the PNGs when they change:

name: Screenshots

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths-ignore:
      - "screenshots/**"
      - "README.md"

permissions:
  contents: write

jobs:
  shot-scraper:
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'workflow_dispatch' || github.actor != 'github-actions[bot]' }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt
      - run: shot-scraper install
      - name: Wait for deploy
        run: |
          for i in $(seq 1 24); do
            code=$(curl -s -o /dev/null -w '%{http_code}' https://example.com/)
            [ "$code" = "200" ] && sleep 20 && exit 0
            sleep 15
          done
          exit 1
      - run: shot-scraper multi shots.yml
      - name: Commit if changed
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add screenshots/*.png
          git diff --cached --quiet && exit 0
          git commit -m "chore: refresh README screenshots [skip ci]"
          git push

paths-ignore plus [skip ci] on the bot commit keeps the job from looping. Point the URLs at the deployed site (Netlify, GitHub Pages, whatever you ship), not a local build, so demo content matches what visitors see.

Light and dark need a site-specific trick. Class or data-theme toggles go in the YAML javascript block. Sites that only respect CSS prefers-color-scheme need --color-scheme dark on the CLI instead. If light and dark bleed into each other in one browser session, split into shots-light.yml / shots-dark.yml and run them as parallel matrix jobs.

Where I’m using it

I rolled this out across a few of my projects that already have public demos:

RepoDemo
astro-art-portfolioNetlify
astro-frame-shiftGitHub Pages
astro-collaborative-calendar-themeGitHub Pages
astro-resumeNetlify
astro-mateNetlify

Each one has a shots.yml (or light/dark pair), a screenshots/ folder with relative paths in the README, and .github/workflows/screenshots.yml. Same idea would work for any OSS README that links a live site.

Why it’s worth it

  • Screenshots stay current after UI merges, without another manual capture.
  • In-repo PNGs mean visual changes show up as reviewable diffs.
  • Adding a page or a widget is another YAML entry, not a custom script.
  • Selector shots document a section better than a full-page hero alone.
  • Multi-viewport tables (desktop / tablet / phone × light / dark) stop going stale.

The cost

Extra CI time: Python deps, Playwright browsers, waiting for the live deploy to answer 200. That is the main downside. The job is non-blocking and screenshot-only commits use [skip ci], so it has not been a problem in practice.

Other caveats: you document deployed main, not the PR under review (unless you wire preview URLs later), and the theme strategy is different per site. Fine for docs that should match production.

Thanks for reading.

Related articles