PagePixels Alternative: Scheduled Screenshots in Code

A PagePixels alternative for developers: run scheduled screenshots from cron or GitHub Actions, flag changed pages, and store captures in your own bucket.

By Simon Chiu · Updated

PagePixels is built around scheduled captures: you set up pages and a schedule in a dashboard, and screenshots arrive without code. RenderScreenshot has no scheduler. It is a capture API that you call from a schedule you already own (cron, GitHub Actions, a job queue), with the images going to your disk, our CDN or your own S3-compatible bucket. If you want scheduling without writing or hosting anything, PagePixels does that better. If you already run jobs and want captures in your own pipeline, this page shows the pattern in four forms.

Disclosure: I build RenderScreenshot. PagePixels is described here at the level of its core model (hosted, scheduled captures); see PagePixels for its current features and plans.

This page is part of our screenshot API comparison series. For the general cron pattern with more scheduling options, see scheduled screenshots with cron.

Verdict

Choose PagePixels if Choose RenderScreenshot if
You want schedules, history and alerts in a dashboard, with no code You already run cron, CI or a job queue and want captures inside it
Non-developers set up and review the captures The captures feed your own app, archive or data pipeline
You want change alerts without building them You want to choose how "changed" is defined (hash, pixel threshold, selector)
You do not want to store images yourself You want images in your own bucket under your own retention rules

That first row is a real advantage. A hosted scheduler means no runner to maintain, no secrets to rotate in CI and no failed job you forget to look at. Our approach gives you control and portability, and in exchange you own the schedule.

Where scheduling lives in each approach

Piece PagePixels RenderScreenshot
Schedule Configured in their dashboard Your cron, GitHub Actions, Solid Queue, Sidekiq, Celery or Cloud Scheduler
List of pages In their dashboard A file or database table you control (urls.txt below)
Rendering Their browsers Our browsers (POST /v1/screenshot or POST /v1/batch)
Storage Their storage Your disk, our CDN (X-Cache-URL), or your bucket via the storage parameter
History Their dashboard Dated folders or bucket paths, for example {domain}/{date}/{hash}.{ext}
Change alerts Built in Your job compares runs (examples below)
Failure alerts Their notifications Your CI or job runner's alerts, driven by a non-zero exit code

Option 1: GitHub Actions, no server at all

If you have a GitHub repository, a scheduled workflow is the smallest setup. It installs the rs CLI, captures every URL in urls.txt and keeps the images as a build artifact:

# .github/workflows/scheduled-screenshots.yml
name: Scheduled screenshots
on:
  schedule:
    - cron: '0 6 * * *'      # every day at 06:00 UTC
  workflow_dispatch: {}       # run by hand from the Actions tab

jobs:
  capture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install the RenderScreenshot CLI
        run: |
          curl -fsSL https://github.com/Render-Screenshot/rs-cli/releases/latest/download/rs-linux-amd64 -o rs
          chmod +x rs && sudo mv rs /usr/local/bin/

      - name: Capture every URL in urls.txt
        env:
          RS_API_KEY: ${{ secrets.RS_API_KEY }}
        run: |
          mkdir -p "archive/$(date -u +%F)"
          rs batch -f urls.txt --full-page --block-cookies -d "archive/$(date -u +%F)"

      - name: Keep the images for 90 days
        uses: actions/upload-artifact@v4
        with:
          name: screenshots-${{ github.run_id }}
          path: archive/
          retention-days: 90

Add RS_API_KEY under the repository's Actions secrets. Two things to know about GitHub's scheduler: times are UTC, and scheduled runs can start late when GitHub is busy, so do not use it for captures that must happen at an exact minute. The YAML was checked for syntax; we did not run it in a GitHub repository for this page.

Option 2: Node.js script with change flags

Use this when you run your own cron or job runner. It writes each capture to archive/<date>/, compares it with the previous run, and prints new, unchanged or changed for each URL. A failed capture makes the script exit with code 1, so cron or your runner can alert you.

// snapshot.mjs: capture every URL in urls.txt into archive/<date>/ and flag pages that changed since the last run.
// Run it from cron, a CI schedule or any job runner.
import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const today = new Date().toISOString().slice(0, 10);
const slug = (url) => url.replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/gi, '-').replace(/-+$/, '');
const sha = (buf) => createHash('sha256').update(buf).digest('hex');

const urls = (await readFile('urls.txt', 'utf8')).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
const days = (await readdir('archive').catch(() => [])).filter((d) => d < today).sort();
const previous = days.at(-1);
await mkdir(`archive/${today}`, { recursive: true });

let failures = 0;
for (const url of urls) {
  const file = `${slug(url)}.png`;
  try {
    const png = await client.take(
      TakeOptions.url(url).width(1280).height(800).fullPage().blockCookieBanners().blockChatWidgets(),
    );
    await writeFile(`archive/${today}/${file}`, png);
    const before = previous ? await readFile(`archive/${previous}/${file}`).catch(() => null) : null;
    const status = !before ? 'new' : sha(before) === sha(png) ? 'unchanged' : 'changed';
    console.log(`${status.padEnd(9)} ${url}`);
  } catch (err) {
    failures++;
    console.error(`failed    ${url}: ${err.code ?? err.message}`);
  }
}
if (failures) process.exitCode = 1;

A crontab line to run it every morning:

0 6 * * * cd /srv/snapshots && RS_API_KEY=rs_live_xxxxx /usr/bin/node snapshot.mjs >> snapshot.log 2>&1

Option 3: Python, same pattern

"""snapshot.py: the same scheduled capture in Python, with plain HTTP."""
import datetime
import hashlib
import os
import pathlib
import re
import sys

import requests

API = "https://api.renderscreenshot.com/v1/screenshot"
today = datetime.date.today().isoformat()
archive = pathlib.Path("archive")
previous = max((d.name for d in archive.glob("*") if d.name < today), default=None)
(archive / today).mkdir(parents=True, exist_ok=True)

failures = 0
for url in [l.strip() for l in open("urls.txt") if l.strip() and not l.startswith("#")]:
    name = re.sub(r"[^a-z0-9]+", "-", re.sub(r"^https?://", "", url), flags=re.I).strip("-") + ".png"
    resp = requests.post(
        API,
        headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"},
        json={
            "url": url,
            "viewport": {"width": 1280, "height": 800},
            "capture": {"mode": "full_page", "scroll": {"simulate": True}},
            "block": {"cookie_banners": True, "chat_widgets": True},
        },
        timeout=90,
    )
    if resp.status_code != 200:
        failures += 1
        print(f"failed    {url}: {resp.json().get('error', {}).get('code')}", file=sys.stderr)
        continue
    (archive / today / name).write_bytes(resp.content)
    old = archive / previous / name if previous else None
    if not old or not old.exists():
        status = "new"
    elif hashlib.sha256(old.read_bytes()).digest() == hashlib.sha256(resp.content).digest():
        status = "unchanged"
    else:
        status = "changed"
    print(f"{status:<9} {url}")

sys.exit(1 if failures else 0)

The Python version also turns on capture.scroll.simulate, which scrolls the page before a full-page capture so lazy-loaded images are present. See capture options for the scroll step, delay and the 16,000 px default maximum height.

Option 4: Ruby, straight to your bucket

If the captures should end up in S3, R2 or another S3-compatible bucket, let the API upload them. You set the bucket credentials once in the dashboard, and each request names the path. Nothing is written to the machine that runs the job:

# snapshot.rb: scheduled captures that upload straight to your own S3-compatible bucket.
# Configure the bucket once in the RenderScreenshot dashboard; nothing is written to this machine.
require 'renderscreenshot'

client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
urls = File.readlines('urls.txt', chomp: true).reject { |l| l.strip.empty? || l.start_with?('#') }

failures = 0
urls.each do |url|
  options = RenderScreenshot::TakeOptions
    .url(url)
    .width(1280)
    .height(800)
    .full_page
    .block_cookie_banners
    .storage_enabled
    .storage_path('{domain}/{date}/{hash}.{ext}')

  client.take_json(options)
  puts "stored    #{url}"
rescue RenderScreenshot::Error => e
  failures += 1
  warn "failed    #{url}: #{e.code} (retryable: #{e.retryable?})"
end

exit(failures.zero? ? 0 : 1)

The path template variables ({domain}, {date}, {hash}, {ext}, plus {year}, {month}, {day} and {timestamp}) are listed in the storage parameter docs. In a Rails app, the same loop fits in a recurring job (Solid Queue's config/recurring.yml or a Sidekiq cron entry) instead of a script.

Tested on 2026-10-15 against a local mock of the API: Node.js 22.18.0 with renderscreenshot 1.1.0, Python 3.14 with requests 2.34.2, and Ruby 4.0.7 with the renderscreenshot gem 1.0.0. The Node.js and Python runs reported new on the first run, then unchanged for an identical file and changed for a modified one on the second run. The Ruby run checked the request shape, including the storage block.

Make change detection less noisy

An exact file hash marks a page as changed when anything moves: a rotating banner, a "last updated" timestamp, a carousel frame. Three ways to cut false alarms:

  1. Hide the noisy parts before capture. Send page.hide with the selectors of clocks, carousels and ad slots, so they render as empty space every time.
  2. Capture one element instead of the page. capture.mode: "element" with capture.selector: "#pricing-table" watches only what you care about.
  3. Compare pixels with a threshold. Replace the hash with a pixel comparison and alert only above, say, 1% changed pixels. The pixelmatch approach in our CaptureKit migration guide shows the code; it works the same for day-to-day comparisons of one provider.

For a full monitoring setup, see the website monitoring use case and the website archiving use case.

What a scheduled job costs

Each scheduled capture is one API request. RenderScreenshot plans (checked 2026-10-15, see pricing):

Pages x frequency Captures per month Plan Price
5 pages daily about 150 Free (150 credits) $0
33 pages daily about 1,000 Starter (2,500 credits) $10
330 pages daily, or 14 pages hourly about 10,000 Growth (12,000 credits) $40
3,300 pages daily about 100,000 Above Scale (60,000 credits for $100): custom plan Quote

Two cost notes. Repeat captures inside the cache window (24 hours by default) can be served from cache instead of re-rendered; for monitoring you usually want a fresh render, so set a short cache.ttl or cache.refresh. And the GitHub Actions runner itself is free on public repositories and counts against your included minutes on private ones. Compare PagePixels' plans on its pricing page at the same volumes.

When to stay with PagePixels

  • The people who manage the captures are not developers, and a dashboard is the product they need.
  • You want change alerts and a capture history without building or hosting anything.
  • You do not have, or do not want, a scheduler, a CI system or a server.
  • You rely on PagePixels features that have no row in the tables above. Check its feature list against what your scripts would need.

Set up your first schedule

Create a free key, add five URLs to urls.txt, and run snapshot.mjs once by hand. When the output looks right, add the crontab line or the GitHub Actions workflow. Five pages captured daily fit in the Free plan.

Frequently asked questions

Does RenderScreenshot have built-in scheduled screenshots like PagePixels?
No. RenderScreenshot does not have a scheduler or a dashboard of recurring captures. You trigger captures from your own cron, CI schedule or job queue, and the API does the rendering, caching and optional upload to your bucket.
How do I take a screenshot of a website every day automatically?
Put the URLs in a file and run a capture script on a schedule: a crontab line on a server, or a GitHub Actions workflow with a cron trigger. The workflow and scripts on this page do exactly that.
Can I get notified when a page changes?
The scripts on this page mark each capture as new, unchanged or changed compared with the previous run. Send the changed list to Slack or email from the same job. For pages with dynamic content, use a pixel-difference threshold instead of an exact file hash.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.