ScreenshotNeo

BlogHow-to

How to Build a Bulk Webpage Screenshot Workflow with Google Sheets and Apps Script

Capture screenshots for URLs in Google Sheets with Apps Script, save results to Drive, and resume batches safely within Google’s quotas.

By the ScreenshotNeo team4 October 20269 min read

To capture screenshots for a list of URLs in Google Sheets, use Apps Script to read rows in bounded batches, send each URL to a browser-based screenshot renderer, save the returned image to Google Drive, and write a status and file link back to the sheet. UrlFetchApp handles HTTP requests; it does not render webpages. The renderer is a separate service.

This guide builds a resumable workflow with per-row results and error reporting. It uses a renderer endpoint that returns image bytes; adapt the request and response handling to the provider you choose. Check that provider’s authentication, output format, limits, retention, pricing, and terms before processing your URLs.

1. Set up the sheet and Apps Script

Create a sheet named Screenshots with these headers in row 1:

Column Purpose
A: URL Page to capture; use one URL per row.
B: Status pending, complete, or error.
C: Screenshot URL Drive link to the saved image.
D: Error Failure detail for diagnosis.

In the spreadsheet, open Extensions → Apps Script and add the code below. Set RENDERER_ENDPOINT and adapt buildRendererUrl to your screenshot provider’s documented API. This example expects a GET request whose response body is an image. It is runnable after you supply a compatible endpoint and, if needed, its authentication headers or query parameters.

const SHEET_NAME = 'Screenshots';
const FIRST_DATA_ROW = 2;
const BATCH_SIZE = 10;
const MAX_MILLIS = 4.5 * 60 * 1000;
const RENDERER_ENDPOINT = 'https://your-renderer.example/capture';
const DRIVE_FOLDER_ID = ''; // Optional: set to a folder ID.

function captureNextBatch() {
  const started = Date.now();
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  if (!sheet) throw new Error(`Sheet not found: ${SHEET_NAME}`);

  const lastRow = sheet.getLastRow();
  if (lastRow < FIRST_DATA_ROW) return;

  const rowCount = lastRow - FIRST_DATA_ROW + 1;
  const rows = sheet.getRange(FIRST_DATA_ROW, 1, rowCount, 4).getValues();
  const folder = DRIVE_FOLDER_ID
    ? DriveApp.getFolderById(DRIVE_FOLDER_ID)
    : DriveApp.getRootFolder();
  let processed = 0;

  for (let i = 0; i < rows.length; i++) {
    if (processed >= BATCH_SIZE || Date.now() - started > MAX_MILLIS) break;
    const [rawUrl, status] = rows[i];
    const rowNumber = FIRST_DATA_ROW + i;
    const url = String(rawUrl || '').trim();

    if (!url || String(status).toLowerCase() === 'complete') continue;
    if (!isHttpUrl(url)) {
      writeResult(sheet, rowNumber, 'error', '', 'URL must start with http:// or https://');
      processed++;
      continue;
    }

    // Mark first so an interrupted run leaves a visible state.
    sheet.getRange(rowNumber, 2, 1, 3).setValues([['processing', '', '']]);
    SpreadsheetApp.flush();

    try {
      const response = UrlFetchApp.fetch(buildRendererUrl(url), {
        method: 'get',
        muteHttpExceptions: true,
        followRedirects: true,
        validateHttpsCertificates: true
      });
      const code = response.getResponseCode();
      if (code < 200 || code >= 300) {
        throw new Error(`Renderer returned HTTP ${code}: ${response.getContentText().slice(0, 300)}`);
      }

      const blob = response.getBlob();
      const contentType = String(blob.getContentType() || '').toLowerCase();
      if (!contentType.startsWith('image/')) {
        throw new Error(`Expected image bytes; received content type ${contentType || 'unknown'}`);
      }
      const extension = extensionFor(contentType);
      blob.setName(`screenshot-row-${rowNumber}.${extension}`);
      const file = folder.createFile(blob);
      writeResult(sheet, rowNumber, 'complete', file.getUrl(), '');
    } catch (err) {
      writeResult(sheet, rowNumber, 'error', '', String(err && err.message ? err.message : err).slice(0, 1000));
    }
    processed++;
  }
}

function buildRendererUrl(pageUrl) {
  // Replace this query format with the provider's documented request format.
  return RENDERER_ENDPOINT + '?url=' + encodeURIComponent(pageUrl);
}

function isHttpUrl(value) {
  return /^https?:\/\//i.test(value);
}

function extensionFor(contentType) {
  if (contentType.includes('png')) return 'png';
  if (contentType.includes('webp')) return 'webp';
  if (contentType.includes('jpeg') || contentType.includes('jpg')) return 'jpg';
  return 'img';
}

function writeResult(sheet, row, status, screenshotUrl, error) {
  sheet.getRange(row, 2, 1, 3).setValues([[status, screenshotUrl, error]]);
}

Apps Script may ask you to authorize spreadsheet, Drive, and external request access on the first run. If you maintain an explicit oauthScopes list in the project manifest, include https://www.googleapis.com/auth/script.external_request for URL Fetch, plus the Sheets and Drive scopes needed by your code. See Google’s UrlFetchApp reference.

2. Configure the renderer request and output

The sample isolates provider-specific work in buildRendererUrl. Change it to match the service’s documented request: API key placement, output format, viewport, full-page setting, and any wait or authentication options. Avoid placing a secret key in a cell or in a URL users can view. Store credentials in Apps Script Properties and read them inside the script if your provider supports key-based authentication.

This version expects raw image bytes. Some APIs return JSON containing an image URL, a job ID, or a base64 value instead. For a URL response, parse JSON and fetch the returned image URL before creating the Drive file; for an asynchronous job, poll the documented job endpoint with a bounded timeout. Do not treat a JSON response as an image. The content-type check catches many such mismatches.

To display a saved image in the grid, add another column with a Sheets image formula referencing a URL that Sheets can access, or use the renderer’s supported image URL output. A Drive file link is useful for opening and sharing the asset, but it is not automatically equivalent to an image URL usable by every spreadsheet formula. Confirm sharing and access settings before exposing images to collaborators.

3. Run in resumable batches

  1. Paste page URLs into column A starting at row 2.
  2. Run captureNextBatch from Apps Script and approve its requested access.
  3. Review columns B–D. Correct rows marked error; rerun the function to retry them.
  4. Run it again until all eligible rows show complete.

The code skips completed rows and processes at most ten others per run or stops after about 4.5 minutes. The time buffer leaves room for script cleanup and spreadsheet writes. A row left as processing after an interrupted execution can be retried: the function skips only complete rows. For a large recurring workflow, add a time-driven trigger that calls this function and monitor the sheet for errors.

Google documents a six-minute runtime per execution and daily URL Fetch quotas of 20,000 calls for consumer accounts and 100,000 for Workspace accounts. Quotas are per user, reset 24 hours after the first request, and can change. These are platform limits, not a recommended batch size: rendering time, provider rate limits, sheet write time, and other Apps Script work can become constraints first. See Google’s Apps Script quotas.

4. Use fetchAll for grouped requests

UrlFetchApp.fetchAll() accepts an array of request objects and returns an array of HTTP responses. It can reduce the overhead of issuing requests one at a time, but it does not remove provider limits, execution limits, or the need to handle each response independently. Use it only after the serial version works and your renderer permits concurrent requests.

function fetchBatch(urls) {
  const requests = urls.map(pageUrl => ({
    url: buildRendererUrl(pageUrl),
    method: 'get',
    muteHttpExceptions: true,
    followRedirects: true
  }));
  return UrlFetchApp.fetchAll(requests).map((response, index) => ({
    pageUrl: urls[index],
    statusCode: response.getResponseCode(),
    contentType: response.getBlob().getContentType(),
    blob: response.getBlob()
  }));
}

Integrate this in small groups and map each response back to its source row. A request can fail while others succeed; write one result per row rather than treating the entire group as successful or failed. Do not increase concurrency blindly: the renderer may rate-limit bursts, and a large response group can increase memory use.

5. Choose where screenshots live

Output Useful when Considerations
Drive file and link You need a durable file reference and want to open the screenshot separately. Choose a folder, set sharing intentionally, and account for Drive storage and cleanup.
Image URL in a cell The renderer returns a stable, accessible image URL and you want an in-cell preview. Check URL expiration, access requirements, and whether the sheet can fetch it.
Image inserted over cells The sheet should visually show each capture near its source row. Large images can make the sheet unwieldy; verify the chosen insertion method at your expected scale.

Sheets has multiple image mechanisms, and their behavior depends on the source and desired layout. Test a small sample with the actual renderer output before processing the full list. Keep the original URL and a stable status or timestamp alongside any image so the result remains traceable.

6. Reliability, performance, and cost

  • Bound each run. Keep batch size modest and stop before the execution ceiling. Tune from observed completion time, not a promised fixed number of pages.
  • Make retries safe. Store row status and avoid duplicating completed work. If each attempt creates a Drive file, a retry after a partially completed write can leave an orphan; consider recording the file ID or searching for an existing row-specific file before creating another.
  • Record useful failures. Keep HTTP status and a short response excerpt, but avoid writing secrets or sensitive page content into the sheet.
  • Watch both quotas. Each page may consume a URL Fetch call plus separate provider usage. The provider’s own rate, request, retention, and billing rules vary.
  • Limit stored output. Choose image dimensions and format based on the review task. Delete old Drive captures under a retention policy that fits your needs.
  • Protect credentials and URLs. Sheets may contain private or tokenized URLs. Restrict sheet and Drive access and avoid logging sensitive query strings.

Apps Script coordinates requests; the renderer controls browser rendering behavior and output. For browser-dependent pages, choose a service that documents how it handles page load, delayed content, authentication, redirects, and failures. No single batch size or cost estimate applies across providers.

7. Troubleshooting

Symptom Likely cause Fix
Authorization is required or permission error The script has not been authorized, or required scopes are missing. Run it manually and approve access. If scopes are explicit, add the external request, Sheets, and Drive scopes the script uses.
HTTP 401 or 403 Missing/invalid renderer credentials, or the target page/service denies access. Check the provider’s authentication format and key status; distinguish provider response errors from target-page access controls.
HTTP 429 Provider rate limit or quota reached. Reduce batch size or concurrency, wait before retrying, and follow the provider’s retry guidance.
HTTP 5xx or timeout Temporary renderer or network failure, or a slow page. Leave the row retryable, rerun later, and use provider-specific timeout or async-job options where available.
Expected image bytes; received JSON The endpoint returns metadata, a job response, or an error document. Inspect the documented response schema and fetch the image URL or poll the job endpoint as required.
Screenshot is blank or incomplete The renderer captured before the page finished, content is lazy-loaded, or the target blocks automated access. Configure documented wait conditions or full-page behavior; check the renderer’s page verdict and failure diagnostics if provided.
Drive permission or file not found Invalid folder ID or the executing account lacks access. Verify the folder ID and run the script as an account with permission to create files there.
Execution stopped at processing The runtime ended or execution was interrupted before writing the result. Rerun: only completed rows are skipped. For diagnosis, inspect execution logs and reduce the batch size.
Image does not render in a cell The Drive link is not a directly accessible image URL, or access is restricted. Use a compatible public or authorized image URL, or keep a Drive link for opening the file instead of embedding it.

8. Or skip the browser setup

ScreenshotNeo provides a screenshot API and MCP server. Its clean-capture steps accept cookie and consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Only clean shots are billed: bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. AI agents can use its MCP server tools, including take_screenshot, get_page_info, and capture_pdf.

For a single call, replace the URL with your target and use your API key. See the ScreenshotNeo API documentation for parameters and response behavior.

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}`);

To use it in this sheet workflow, adapt buildRendererUrl to the documented API parameters and handle the returned image bytes. The plans include 1,000 screenshots a month free with no card; paid plans start at $5 for 3,000. Every feature is available on every plan. Sign up for 1,000 free screenshots a month, with no card required.

9. FAQ

Can Apps Script take a screenshot by itself?

No. It can make HTTP requests and coordinate results, but a browser renderer must capture the rendered page.

Can I put the screenshot directly in a cell?

Yes, if you use a compatible image URL or an appropriate Sheets image mechanism. A Drive file link alone may open the file without rendering it in the cell.

Will one execution handle every row?

It might for a short list, but the workflow should assume it will not. Process bounded batches and rerun or schedule subsequent executions.

Does fetchAll make the workflow unlimited?

No. It groups HTTP requests into one method call, while runtime, daily quotas, renderer rate limits, and response handling still apply.

Sources