ScreenshotNeo

BlogHow-to

How to Schedule Website Screenshots at a Fixed Scroll Position

Capture the same part of a website on a recurring schedule with Playwright and GitHub Actions, including setup, scroll handling, and common failure fixes.

By the ScreenshotNeo team4 October 202610 min read

To schedule a screenshot at a fixed scroll position, run a browser script on a recurring schedule. With Playwright, navigate to the page, wait for the content you care about, set the document’s vertical scroll offset, and capture the current viewport. A normal page screenshot captures the visible viewport; fullPage: true captures the full scrollable page instead. [Playwright screenshots]

This guide uses Node.js Playwright and GitHub Actions. The same sequence works in other schedulers: install the browser runtime, run the capture script, and save or upload the output. A fixed offset is measured in CSS pixels from the top of the document, so keep the viewport, scale, page state, and timing consistent if you compare runs.

1. Create a Playwright capture script

In an empty project directory, install Playwright and its Chromium browser:

npm init -y
npm install playwright
npx playwright install chromium

Create capture.mjs. Change the target URL and SCROLL_Y for your page. The script waits for a page-specific element when configured, scrolls to the requested offset, then saves a viewport screenshot.

import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';

const targetUrl = process.env.TARGET_URL ?? 'https://example.com';
const outputPath = process.env.OUTPUT_PATH ?? 'artifacts/shot.png';
const scrollY = Number(process.env.SCROLL_Y ?? 1200);
const readySelector = process.env.READY_SELECTOR;

if (!Number.isFinite(scrollY) || scrollY < 0) {
  throw new Error('SCROLL_Y must be a non-negative number');
}

await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
  const context = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1,
    colorScheme: 'light',
  });
  const page = await context.newPage();
  page.setDefaultTimeout(15000);

  await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
  if (readySelector) {
    await page.locator(readySelector).waitFor({ state: 'visible' });
  }

  // Let layout settle after navigation or the selector becoming visible.
  await page.evaluate(() => new Promise(requestAnimationFrame));
  await page.evaluate((y) => window.scrollTo(0, y), scrollY);
  await page.evaluate(() => new Promise(requestAnimationFrame));

  await page.screenshot({
    path: outputPath,
    type: 'png',
    fullPage: false,
    animations: 'disabled',
    caret: 'hide',
  });
  console.log(`Saved ${outputPath} at scrollY=${await page.evaluate(() => window.scrollY)}`);
} finally {
  await browser.close();
}

The requestAnimationFrame waits allow the browser to render after the scroll, but they do not guarantee that a site’s asynchronous content has finished loading. Prefer a selector, application state, or another explicit readiness condition for pages with dynamic content. Playwright documents screenshot options including full-page capture and element capture in its screenshot guide and Page screenshot API.

2. Schedule it with GitHub Actions

Commit capture.mjs, package.json, and the lockfile to the repository’s default branch. Add .github/workflows/website-screenshot.yml:

name: Scheduled website screenshot

on:
  workflow_dispatch:
  schedule:
    - cron: '17 */6 * * *'

jobs:
  capture:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Capture page
        run: node capture.mjs
        env:
          TARGET_URL: https://example.com
          SCROLL_Y: '1200'
          READY_SELECTOR: 'main'
          OUTPUT_PATH: artifacts/shot.png
      - name: Keep screenshot as workflow artifact
        uses: actions/upload-artifact@v4
        with:
          name: website-screenshot-${{ github.run_id }}
          path: artifacts/shot.png
          if-no-files-found: error
          retention-days: 14

The example schedule runs every six hours at minute 17. GitHub Actions accepts POSIX cron schedules, defaults schedules to UTC, supports IANA time zones, and has a shortest supported interval of five minutes. Scheduled workflows run on the default branch. GitHub warns that heavy load can delay scheduled runs and may cause queued jobs to be dropped; avoid scheduling at the start of an hour when timing matters. Public-repository schedules are disabled after 60 days without repository activity. Review the current GitHub schedule event documentation before depending on a cadence or timezone.

workflow_dispatch adds a manual run button, which is useful for checking the first capture and debugging changes. The uploaded artifact is retained for 14 days in this example; adjust retention to match how long you need the snapshots. Uploading an artifact stores each run separately. For a permanent history, send the file to storage you control and use a deterministic name or timestamp in the script.

3. Choose the right scroll target

Document scroll position

window.scrollTo(0, y) moves the main document to a vertical offset in CSS pixels. The actual position can be clamped if the page is shorter than the requested offset. Check window.scrollY after scrolling when it matters, as the script logs the actual position.

Nested scrollable panel

If the content is inside a scrollable panel, scrolling the window will not move that panel. Set the panel’s own scrollTop instead:

const panel = page.locator('.results-panel');
await panel.evaluate((el, y) => { el.scrollTop = y; }, 600);
await panel.screenshot({ path: 'artifacts/panel.png' });

An element screenshot captures the element’s visible box. Playwright’s locator screenshot documentation describes this behavior for scrollable elements; see Playwright’s screenshot guide.

Scroll to an element

If the meaningful target is a section whose location changes between releases, scroll it into view rather than maintaining a pixel offset:

const section = page.locator('#pricing');
await section.scrollIntoViewIfNeeded();
await page.screenshot({ path: 'artifacts/pricing.png' });

This is a different capture contract: the target is a named element, not a fixed distance from the top. It is often less fragile when preceding page content grows or shrinks.

4. Keep captures comparable

  • Use a fixed viewport. Set width and height explicitly. Responsive breakpoints can change layout, causing a fixed offset to show different content.
  • Keep scale fixed. A device scale factor affects output pixel dimensions. Playwright’s screenshot API supports CSS-pixel and device-pixel scale behavior; choose one and keep it stable.
  • Wait for the right state. domcontentloaded means the initial document was parsed; it does not mean every image, API request, or client-side update has completed. Wait for a selector or application-specific condition.
  • Control motion and blinking UI. Screenshot options can disable animations and hide the caret. For other page-specific moving content, use a screenshot stylesheet or handle the content explicitly. See the screenshot API options.
  • Account for lazy loading. A page may load images only as they approach the viewport. Scroll to the target before capturing and allow its content to load; for a distant target, staged scrolling may be needed to trigger intermediate lazy content.
  • Be consistent about consent and authentication state. A new browser context starts without your personal browser session. If the target needs login or a consent choice, establish that state in the script or a controlled storage state file.

5. Screenshot options that matter

Need Playwright approach
Current visible viewport at fixed offset page.screenshot({ fullPage: false }) after scrolling
Entire scrollable document page.screenshot({ fullPage: true }); this is a tall page capture, not a fixed-offset viewport
Only an element page.locator(selector).screenshot()
Specific rectangular region Use the screenshot API’s clip option
PNG output Default or type: 'png'
JPEG output type: 'jpeg' and set quality from 0 to 100
Suppress animation/caret variation animations: 'disabled', caret: 'hide'
Repeatable styling Use style to inject CSS for the screenshot only

For the complete set of arguments and version notes, use the official Playwright screenshot API reference. A viewport screenshot is the right mode when the requirement is “show the page as seen at offset Y”; use full-page mode when the requirement is “capture the whole document.”

6. Python alternative

The same workflow can be written in Python. Install the package and browser with pip install playwright and playwright install chromium. Save as capture.py:

import os
from pathlib import Path
from playwright.sync_api import sync_playwright

target_url = os.getenv("TARGET_URL", "https://example.com")
scroll_y = int(os.getenv("SCROLL_Y", "1200"))
ready_selector = os.getenv("READY_SELECTOR")
output_path = Path(os.getenv("OUTPUT_PATH", "artifacts/shot.png"))
output_path.parent.mkdir(parents=True, exist_ok=True)

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        page = browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
        page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
        if ready_selector:
            page.locator(ready_selector).wait_for(state="visible", timeout=15000)
        page.evaluate("() => new Promise(requestAnimationFrame)")
        page.evaluate("y => window.scrollTo(0, y)", scroll_y)
        page.evaluate("() => new Promise(requestAnimationFrame)")
        page.screenshot(path=str(output_path), full_page=False, animations="disabled", caret="hide")
        print(f"Saved {output_path}; actual scrollY={page.evaluate('() => window.scrollY')}")
    finally:
        browser.close()

Python screenshot behavior and element screenshots are covered in the Playwright Python documentation.

7. Reliability, performance, and cost

Scheduling reliability

A cron expression requests a run; it does not guarantee execution at the exact minute. GitHub documents possible delay and dropped queued runs during high load. If missing a capture is consequential, monitor workflow completion and alert on failures, and choose a scheduler whose documented delivery guarantees meet the requirement. Use the manual trigger during development and verify that the workflow is on the default branch.

Capture reliability

Make the script fail visibly when navigation or the expected selector fails. Keep a useful timeout, log the requested and actual scroll offsets, and upload artifacts only after successful capture. Avoid swallowing errors and uploading an old image as if it were the new run. If sites vary by time or user, explicitly set locale, timezone, authentication, and other browser context state as required by your use case.

Runtime and storage cost

Each browser launch, page load, and image upload consumes workflow time and storage. Capture only as often as the use case needs, choose an output format and viewport that are sufficient, and set artifact retention deliberately. GitHub Actions availability and pricing depend on account and repository context; the research sources do not establish a single cost for this workflow, so check the current GitHub plan and usage details for your account.

8. Troubleshooting

Symptom Likely cause Fix
Screenshot is at the top The scroll ran before navigation completed, or another script reset the scroll Scroll after goto and readiness checks; wait one render frame after scrolling; log window.scrollY.
Wrong content appears at the offset Viewport width, page content, font loading, or layout differs between runs Fix viewport and scale, wait for the relevant content, and prefer an element anchor if content above the target changes.
Target inside panel did not move The panel has its own scrolling context Set that element’s scrollTop and capture the element or viewport that shows it.
Blank image or navigation timeout Slow response, blocked request, invalid URL, or site-specific access check Verify the URL in a browser, inspect workflow logs, increase timeout only when justified, and handle access requirements legitimately.
Selector wait times out Selector does not exist, content is hidden, or a page update never completed Inspect the page structure, use a stable selector, and wait for the actual state needed rather than an unrelated element.
Images are missing below the fold Lazy-loaded content has not been requested yet Scroll to the target before capture and wait for its image/content readiness; staged scrolls can trigger lazy loading.
Different image dimensions Viewport or device scale changed, or full-page mode was enabled Pin viewport and scale and use fullPage: false for fixed viewport shots.
Workflow never runs Workflow is absent from default branch, cron syntax is invalid, or public repository schedule was disabled after inactivity Check the default branch, cron expression, repository activity, and Actions settings.
Runs start late or are missing GitHub Actions schedule load caveat Avoid the top of the hour, inspect run history, and use a monitored scheduler appropriate for stricter timing.
Browser executable missing in CI Playwright package installed without its browser binaries Run npx playwright install --with-deps chromium in the workflow.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. A single GET request can capture a URL, and supports options including viewport dimensions and full-page capture. See the ScreenshotNeo API documentation for request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

The examples above show a one-call capture. For a recurring fixed-scroll capture, pass the screenshot request through your scheduler and use the documented scroll-position option from the API docs where available for your request configuration. ScreenshotNeo removes cookie banners, popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Plans include Starter at $5 for 3,000 shots, Growth at $15 for 15,000, Pro at $39 for 60,000, Scale at $99 for 250,000, and Business at $249 for 1,000,000; yearly billing gives two months free. Sign up for 1,000 free screenshots a month, with no card required.

FAQ

Does fixed scroll position mean a full-page screenshot?

No. It means capture the viewport after scrolling to an offset. Full-page mode produces a tall image of the scrollable document.

Is scroll position measured in pixels?

window.scrollTo uses CSS pixel coordinates. Device scale affects screenshot pixel output, not the meaning of the document scroll offset.

Can I schedule screenshots more often than every five minutes in GitHub Actions?

The cited GitHub schedule documentation specifies a five-minute minimum interval. It also warns that the actual start can be delayed under load.

What if the page changes height between runs?

A numeric offset will still target that distance from the top, but different content may occupy it. For a stable section rather than a stable coordinate, scroll a locator into view.

References