Screenshot a list of URLs in bulk, and watch the queue drain
A bulk screenshot job is a queue: URLs wait, a few render at once, and a rate limit decides how fast new ones may start. Set your numbers and run it to see how long the job takes and which limit is holding it back.
This is a model, not a benchmark. The starting numbers are examples only, every one is an input you can change, and the formulae are printed below. The plan buttons fill in the real limits of each CurlShot plan.
How long does a bulk screenshot job take?
A bulk screenshot job takes the larger of two times: the time its concurrency allows and the time its rate limit allows. Only one of the two is the bottleneck, and raising the other one changes nothing.
The formulae
- requests = URLs x (1 + f + f x f)
- time by concurrency = requests x seconds per render / renders at once
- time by rate limit = requests / requests per minute x 60
- time = the larger of the two
- still failing after 3 tries = URLs x f x f x f
f is the share of attempts that fail: 3% is 0.03. The model tries each URL up to 3 times and treats every attempt as taking the average time.
A worked example
Example only1,000 URLs, 4 seconds per render, 5 at once, 60 requests a minute, 3% of attempts failing.
- requests = 1,000 x (1 + 0.03 + 0.03 x 0.03) = 1,031
- time by concurrency = 1,031 x 4 / 5 = 825 s = 13 min 45 s
- time by rate limit = 1,031 / 60 x 60 = 1,031 s = 17 min 11 s
- time = the larger = 17 min 11 s
- still failing after 3 tries = 1,000 x 0.03 x 0.03 x 0.03 = 0.027, so fewer than 1
Your rate limit, not your concurrency, is what is slowing this job. At 60 requests a minute and 4 s a render, only 4 render slots are busy on average. More than 4 at once changes nothing; a higher rate limit does.
The same formula at other sizes
Each row is the arithmetic above with no failures, so you can check it by hand. The inputs are examples, not measurements of any service.
| URLs | Per render | At once | Per minute | Time by concurrency | Time by rate limit | Job takes | Bottleneck |
|---|---|---|---|---|---|---|---|
| 100 URLs | Per render4 s | At once1 | Per minute10 | Time by concurrency6 min 40 s | Time by rate limit10 min | Job takes10 min | BottleneckRate limit |
| 1,000 URLs | Per render4 s | At once2 | Per minute60 | Time by concurrency33 min 20 s | Time by rate limit16 min 40 s | Job takes33 min 20 s | BottleneckConcurrency |
| 1,000 URLs | Per render4 s | At once5 | Per minute60 | Time by concurrency13 min 20 s | Time by rate limit16 min 40 s | Job takes16 min 40 s | BottleneckRate limit |
| 10,000 URLs | Per render4 s | At once10 | Per minute120 | Time by concurrency1 h 7 min | Time by rate limit1 h 23 min | Job takes1 h 23 min | BottleneckRate limit |
| 10,000 URLs | Per render8 s | At once10 | Per minute120 | Time by concurrency2 h 13 min | Time by rate limit1 h 23 min | Job takes2 h 13 min | BottleneckConcurrency |
| 50,000 URLs | Per render4 s | At once20 | Per minute600 | Time by concurrency2 h 47 min | Time by rate limit1 h 23 min | Job takes2 h 47 min | BottleneckConcurrency |
Measure the one number nobody can give you
Where does the list of URLs come from?
A list of URLs for bulk screenshots comes from one of three places: a spreadsheet you already have, the site's sitemap, or a crawl of the site. All three end as the same file: urls.txt, one address per line.
A spreadsheet or CSV column
You already have the addresses: a client list, a directory, an export from analytics or a CMS.
- Export the sheet as CSV, keep the address column, and write one URL per line to
urls.txt. - The one-liner drops the header row, quotes and Windows line endings, and removes exact duplicates. It assumes no comma inside the address; if yours have one, export that single column on its own.
Shell # pages.csv has a header row and the address in column 1 tail -n +2 pages.csv | cut -d, -f1 | tr -d '"\r' | sort -u > urls.txt- Export the sheet as CSV, keep the address column, and write one URL per line to
A sitemap.xml
You want every page a site lists for search engines, which is usually every page that matters.
- A sitemap is an XML file with a
<urlset>root, one<url>per page and the address in<loc>. One file holds at most 50,000 URLs and 50 MB uncompressed, and may be gzipped.[1] - Larger sites publish a sitemap index: a
<sitemapindex>root whose<sitemap>entries each point at another sitemap in<loc>, up to 50,000 of them. - The address of the sitemap is often in
robots.txt, on a line that starts withSitemap:. Values in the file are entity-escaped, so&has to become&again.
sitemap.mjs // node sitemap.mjs https://example.com/sitemap.xml > urls.txt import { gunzipSync } from 'node:zlib' const decode = (text) => text.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') async function locs(sitemapUrl) { const response = await fetch(sitemapUrl) if (!response.ok) throw new Error(`${sitemapUrl}: HTTP ${response.status}`) let bytes = Buffer.from(await response.arrayBuffer()) if (bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = gunzipSync(bytes) // a .xml.gz file const xml = bytes.toString('utf8') const found = [...xml.matchAll(/<loc>\s*(.*?)\s*<\/loc>/gs)].map((match) => decode(match[1])) if (!/<sitemapindex[\s>]/.test(xml)) return found const all = [] // a sitemap index lists other sitemaps: read each one in turn for (const child of found) all.push(...(await locs(child))) return all } console.log([...new Set(await locs(process.argv[2]))].join('\n'))- A sitemap is an XML file with a
A crawl of your own site
There is no sitemap, or you want the pages people can actually reach by clicking.
- A crawl starts at one page, collects the links that stay on the same origin, and repeats for each new page until it has seen them all or hits a cap.
- Read
/robots.txtfirst and leave out what it disallows. This sketch does not parse it, which is acceptable only on a site you run. It pauses a second between requests and stops at 500 pages.[2] - A crawl finds only pages that are linked. A sitemap finds only pages that are listed. For a full inventory, merge the two lists and deduplicate.
crawl.mjs // node crawl.mjs https://example.com > urls.txt (a site you run) const start = new URL(process.argv[2]) const MAX_PAGES = 500 const PAUSE_MS = 1000 // one request a second to the one host const seen = new Set([start.href]) const queue = [start.href] const pages = [] for (let i = 0; i < queue.length && pages.length < MAX_PAGES; i++) { const response = await fetch(queue[i], { headers: { 'User-Agent': 'site-inventory ([email protected])' } }) if (response.ok && (response.headers.get('content-type') || '').includes('text/html')) { pages.push(queue[i]) for (const [, href] of (await response.text()).matchAll(/<a\s[^>]*?href=["']([^"'#]+)/gi)) { let link try { link = new URL(href, response.url) } catch { continue } if (link.origin === start.origin && !seen.has(link.href)) { seen.add(link.href); queue.push(link.href) } } } await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)) } console.log(pages.join('\n'))
The bulk screenshot job as a pipeline
A bulk screenshot job has seven stages, and each one exists because the job is too long to watch. A URL enters on the left and leaves as a file and a row in a manifest.
1List
One URL per line, from a CSV, a sitemap or a crawl.
2Normalise
Drop fragments and tracking parameters, then remove duplicates.
3Queue
Every URL waits its turn. Nothing starts all at once.
4Render
A fixed number run at the same moment, spaced to fit the rate limit.
5Retry
A 429 or 5xx waits, then tries again. The wait doubles each time.
6Store
Each file gets a name you can compute again from its URL.
7Manifest
One row per URL: url, status, file, error.
Normalise before you queue
Normalising the list is the cheapest stage of a bulk screenshot job, because every duplicate it removes is a render nobody pays for. The same page often appears several times: with and without a fragment, with a tracking parameter from a newsletter, with its query parameters in another order.
The function on the right makes those spellings identical, so a Set can drop the copies. It keeps every other query parameter, because ?page=2 is a different page.
function normalise(raw) {
const url = new URL(raw.trim()) // throws on anything that is not a full address
url.hash = '' // #section never reaches the server
for (const key of [...url.searchParams.keys()]) {
if (/^(utm_|fbclid$|gclid$)/.test(key)) url.searchParams.delete(key)
}
url.searchParams.sort()
return url.href // the host comes back in lower case
}
const urls = [...new Set(lines.filter(Boolean).map(normalise))]A bulk screenshot script in Node.js, with no packages
This Node.js script screenshots every URL in urls.txt with a fixed number of requests at once, a per-minute limit, retries with backoff and a results file. It uses only the standard library and needs Node.js 18 or later, where fetch is built in.[6]
// node bulk.mjs (Node.js 18 or later, no packages)
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
const API = 'https://curlshot.com/api/v1/screenshot'
const KEY = process.env.SCREENSHOT_ACCESS_KEY
const CONCURRENCY = 4 // renders at once
const PER_MINUTE = 60 // requests per minute
const TRIES = 4
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const lines = (await readFile('urls.txt', 'utf8')).split(/\r?\n/).map((line) => line.trim())
const urls = [...new Set(lines.filter(Boolean))]
// Readable and unique: the host and path, plus 8 characters of a hash of the whole URL.
function fileName(url) {
const { hostname, pathname } = new URL(url)
const slug = (hostname + pathname).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return `${slug.slice(0, 80)}-${createHash('sha256').update(url).digest('hex').slice(0, 8)}.png`
}
// Rate limit: every request, retries included, takes the next free start time.
let nextStart = 0
async function takeTurn() {
const at = Math.max(Date.now(), nextStart)
nextStart = at + 60_000 / PER_MINUTE
await sleep(at - Date.now())
}
// Retry-After is a number of seconds or an HTTP date. Without it: 1 s, 2 s, 4 s.
function retryDelay(response, attempt) {
const header = response?.headers.get('retry-after')
const ms = header ? (/^\d+$/.test(header) ? header * 1000 : Date.parse(header) - Date.now()) : NaN
return ms > 0 ? ms : 1000 * 2 ** (attempt - 1) + Math.random() * 250
}
async function capture(url) {
for (let attempt = 1; ; attempt++) {
await takeTurn()
const query = new URLSearchParams({ url, format: 'png' })
const response = await fetch(`${API}?${query}`, { headers: { 'X-Access-Key': KEY } }).catch(() => null)
if (response?.ok) {
const file = fileName(url)
await writeFile(`shots/${file}`, Buffer.from(await response.arrayBuffer()))
return { url, status: 'ok', file, error: '' }
}
const retryable = !response || response.status === 429 || response.status >= 500
if (!retryable || attempt === TRIES) {
const body = response ? await response.json().catch(() => ({})) : {}
return { url, status: 'failed', file: '', error: body.error_code || (response ? `HTTP ${response.status}` : 'network error') }
}
await sleep(retryDelay(response, attempt))
}
}
await mkdir('shots', { recursive: true })
const results = []
let next = 0
async function worker() {
while (next < urls.length) results.push(await capture(urls[next++]))
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
const cell = (value) => `"${String(value).replace(/"/g, '""')}"`
const rows = results.map((r) => [r.url, r.status, r.file, r.error].map(cell).join(','))
await writeFile('results.csv', ['url,status,file,error', ...rows].join('\n') + '\n')
console.log(`${results.filter((r) => r.status === 'ok').length} of ${urls.length} saved. See results.csv.`)
What each part of the script does
- The queue
CONCURRENCYworkers share one counter,next. Each takes the next URL when it finishes the last, so exactly that many renders are open at once.- The rate limit
takeTurnhands out start times60 / PER_MINUTEseconds apart. Retries take a turn too, so the script never sends faster than the limit.- Retries
- A network error, a
429and any5xxare tried again, up toTRIEStimes. A4xxsuch as a bad address or an empty quota is recorded at once: asking again cannot fix it. - Retry-After
- The header is either a whole number of seconds (
Retry-After: 120) or an HTTP date (Retry-After: Fri, 31 Dec 1999 23:59:59 GMT). The script reads both, and falls back to 1, 2, 4 seconds plus a little randomness.[3][4] - File names
- The host and path become a slug of
a-z,0-9and hyphens, cut at 80 characters, followed by 8 hex characters of the SHA-256 of the whole URL, so two addresses never share a file. - The manifest
results.csvhas one row per URL withurl,status,fileanderror. Every cell is quoted, so an address with a comma stays in one column.
What was tested
node --check. Its queue was also run with fetch replaced by a stub that answers 429 with both forms of Retry-After, 500, 400 and 200: it never had more than CONCURRENCY requests open, kept its starts 60 / PER_MINUTE seconds apart, waited as long as Retry-After asked, and wrote one row per URL. It was not run against live pages for this article, so the timings in the model above are yours to measure.The script calls the CurlShot screenshot endpoint and reads its key from the SCREENSHOT_ACCESS_KEY environment variable. For another service, change the API line and the header: the queue, the rate limit, the retries and the manifest stay the same.
How do you take thousands of screenshots without getting blocked?
Capturing thousands of pages of someone else's site is load on their server, and sites block what looks like load. The practice that avoids it is short.
CurlShot renders the address you give it. It does not read robots.txt for you, so this part is the job of whoever builds the list. This is practice, not legal advice.
Space the requests to one host
A thousand URLs on a thousand hosts is one visit each. A thousand URLs on one host is a load test. Sort the list so hosts alternate, or cap one host at a request every second or two.
Prefer your own sites, or ask
For someone else’s site, send its owner a short note that names the pages, the pace and the hour. An allowlist entry on their side is also what prevents the bot checks.
Read robots.txt where it applies
The rules live at
/robots.txton each host and are written for automatic clients that follow links. RFC 9309 says of them: "These rules are not a form of access authorization." They are a request, and honouring it is what keeps crawlers welcome.[2]Treat a broken robots.txt as "stay out"
Under RFC 9309, a
robots.txtthat answers4xxmeans there are no rules, and one that answers5xxmeans a crawler "MUST assume complete disallow". Fetch it once per host and keep it for a day at most.[2]Run big jobs off-peak
Each screenshot loads the page with all its images, scripts and fonts, which is more work for the site than a plain fetch of the HTML. Night in the site’s own time zone costs it the least.
How to name and store bulk screenshot files
A bulk screenshot file needs a name that is safe on every file system, unique per URL and possible to compute again from the URL alone. A slug plus a short hash gives all three.
| Scheme | Example | Good | Bad |
|---|---|---|---|
| Slug of the URL | Exampleexample-com-pricing.png | GoodReadable. You can find a page by eye. | Bad/a-b and /a/b become the same name. Long addresses need cutting, and query strings make it worse. |
| Hash of the URL | Example23a538fde85c5907.png | GoodFixed length, safe on every system, unique per URL, and you can compute it again later. | BadMeans nothing to a person. Useless without the manifest. |
| Slug plus short hash | Exampleexample-com-pricing-23a538fd.png | GoodReadable and unique. This is what the script above writes. | BadSlightly longer names. Still changes when the URL changes by one character. |
| Date folders | Example2026-10-28/example-com-pricing-23a538fd.png | GoodKeeps each run apart, so you can compare a page across days and delete a whole run at once. | BadStorage grows with every run. Decide how many runs to keep before the first one. |
Why a manifest beats a clever file name
A manifest is a file with one row per URL that records what happened to it. A file name can hold one fact about a screenshot. The manifest holds all of them: the exact URL, whether it worked, where the file is and why it failed.
It is also what makes a long job safe to interrupt. To continue, read the manifest, keep the rows that say ok, and queue only the rest.
"url","status","file","error"
"https://example.com/","ok","example-com-0f115db0.png",""
"https://example.com/pricing","ok","example-com-pricing-23a538fd.png",""
"https://example.com/old-page","failed","","navigation_failed"What fails when you screenshot thousands of URLs?
Six things fail in almost every large screenshot job, and two of them do not look like failures: the row says the capture worked, and the file shows the wrong page.
| Problem | How it shows up in the results | What to do |
|---|---|---|
| Timeouts on slow pages | How it shows up in the resultsA failed row with timeout (status 504). The same few URLs fail on every run. | What to doRetry once or twice, then give those URLs a longer timeout (30 seconds by default, 90 at most) instead of more retries. |
| Bot checks on a fraction of hosts | How it shows up in the resultsOften a row that says ok: the screenshot is a challenge or "access denied" page. Look for files that are unusually small or all alike. | What to doSlow down for that host and ask its owner to allow your job. Open a sample of files by eye before you trust the run. |
| Redirects to a login | How it shows up in the resultsA row that says ok and a screenshot of a sign-in form. Many pages produce the identical file. | What to doTake those URLs out of the public list and capture them in a separate, signed-in job. |
| Duplicate URLs with tracking parameters | How it shows up in the resultsTwo rows, two files, one page: ?utm_source=... and the plain address. Quota spent twice. | What to doNormalise before queuing: drop fragments and tracking parameters, sort the rest, then deduplicate. |
| Pages that never go idle | How it shows up in the resultstimeout on pages that look loaded: a chat widget, a video or analytics keeps the network busy, so wait_until=networkidle never arrives. | What to doUse wait_until=load (the default) with a short delay, or wait_for_selector for the element that proves the page is ready. |
| Quota running out mid-job | How it shows up in the resultsEvery row after a certain point fails with quota_exceeded (status 402). | What to doDo not retry: waiting seconds does not help. Check what is left before you start, and rerun only the failed rows from the manifest afterwards. |
The error codes in this table are the ones CurlShot returns. Other services name them differently.
The CurlShot bulk endpoint, exactly
The CurlShot bulk endpoint starts up to 100 screenshots with one POST and renders them in the background, so the queue, the retries of temporary errors and the waiting are done on our side. This is how it behaves.
- Endpoint
POST /bulk, with the access key in theX-Access-Keyheader.- Body
- JSON:
requests, a list of option sets, and an optionalwebhook_urlfor all of them. Each item needs exactly one ofurl,htmlormarkdown, and takes every option of a single request. - Items per call
- Up to 100. A plan can allow fewer, or no bulk calls at all (
feature_not_available). Split a longer list into several calls. - Answer
- Status
202at once, withbatch_id,batch_urlandjobs: onejob_id,status,job_urlandstatus_urlper request, in the order you sent them. - Validation
- The whole list is checked before anything is queued. One wrong item answers
invalid_optionswith itsindexandfield, and no job starts. - Polling
GETthebatch_url:status(processingordone),total,counts(queued,processing,done,failed) and every job. Status calls are free and have their own rate limit: 10 times the plan’s, never less than 300 a minute.- Webhook
- One signed
POSTper job, not one per batch, witheventscreenshot.completedorscreenshot.failedand thebatch_id. A failed delivery is tried again, 6 tries in all. - A finished job
screenshot.url,format,bytes,width,height,render_ms,cachedandexpires_at. A failed one haserror_codeanderror_messageinstead. Download the file beforeexpires_at.
curl -X POST "https://curlshot.com/api/v1/bulk" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://example.com/hooks/screenshot",
"requests": [
{
"url": "https://example.com/",
"format": "webp",
"viewport_width": 1280,
"viewport_height": 800,
"block_cookie_banners": true,
"cache": true
},
{
"url": "https://example.com/pricing",
"full_page": true,
"format": "jpeg",
"timeout": 60
},
{
"url": "https://example.com/blog",
"viewport_device": "iphone_15_pro",
"wait_until": "networkidle"
}
]
}'A list longer than 100 URLs is several calls. Each item costs one request of the minute, so the job still moves at your plan's requests per minute, and the model at the top of this page applies unchanged.
How a batch counts against the limits
| Limit | How a bulk call counts |
|---|---|
| Rate limit | How a bulk call countsA bulk call counts as one request per item. A batch of 25 uses 25 of the minute’s requests. If that many are not left, the call answers rate_limited with Retry-After and nothing is queued. |
| A batch larger than the limit | How a bulk call countsIt costs the whole minute, and is accepted only when the account has sent nothing else in the last minute or two. |
| Queue | How a bulk call countsAn account may have 100 jobs waiting or rendering for each render of its concurrency, and never fewer than 200. Over that: queue_limit, status 429, nothing queued. |
| Concurrency | How a bulk call countsThe jobs of one account render at most as many at once as its plan’s concurrency. The rest wait. |
| Quota | How a bulk call countsThe batch must fit in what is left of the period. If it does not, the call answers quota_exceeded and nothing is queued. |
What uses a screenshot of the quota
- A job that rendered and returned a fileCounts as 1
- A job that failed, with any errorFree
- An item with
"cache": trueanswered from a stored copyFree - The bulk call itselfFree
- A status call for a batch or a jobFree
- A download of a finished file from its
urlFree
Start with the bulk screenshots page, read every field in the bulk endpoint reference, and see async jobs and webhooks for the signature check on each delivery. The numbers of each plan are in usage and limits.
Bulk screenshot glossary
- Concurrency
- How many renders are running at the same moment. It limits how much work is in progress.
- Rate limit
- How many requests a service accepts in a window of time, usually a minute. It limits how fast work may start.
- Queue
- The list of work that is waiting. Items leave it in order as a slot becomes free.
- Backoff
- Waiting before a retry, and waiting longer before each new one. Exponential backoff doubles the wait: 1, 2, 4 seconds.
- Idempotent
- Safe to do twice. Handling the same message or URL a second time has the same effect as handling it once.
- Manifest
- A file that lists every item of a job with what happened to it: the URL, the status, the file name and the error.
- Webhook
- An address on your server that a service calls when a job ends, so you do not have to keep asking.
Questions people ask
How long does it take to screenshot 1,000 URLs?
It depends on three numbers: seconds per render, renders at once, and requests per minute. Time is the larger of URLs x seconds / concurrency and URLs / rate per minute x 60. As an example only, at 4 seconds a render, 5 at once and 60 requests a minute, 1,000 URLs take 16 min 40 s, set by the rate limit. Measure your own seconds per render on 20 URLs first.
How do I screenshot every page of a website?
Read the site’s sitemap.xml and collect every <loc> value, following sitemap index files to the sitemaps they list. If there is no sitemap, crawl the site from its home page, staying on the same origin. Deduplicate the list, then send it through a queue with limited concurrency.
Should I raise concurrency or the rate limit to go faster?
Raise the one that is the bottleneck. Requests per minute x seconds per render / 60 is the number of render slots the rate limit can keep busy. If your concurrency is above that number, more concurrency changes nothing and only a higher rate limit helps. If it is below, concurrency is the limit.
How do I avoid getting blocked when taking many screenshots?
Spread the requests so one host is not hit many times a second, run large jobs off-peak, honour robots.txt and any Retry-After header, and stop when a site answers 429 or 503. For a site you do not own, ask the owner: an allowlist entry prevents the bot checks that otherwise replace some pages with a challenge screen.
Which errors should a bulk screenshot script retry?
Retry network errors, 429 and 5xx answers, after the wait in Retry-After or a pause that doubles each time, and stop after three or four tries. Do not retry other 4xx answers such as an invalid address or a used-up quota: the same request fails the same way.
How many URLs can one CurlShot bulk request hold?
Up to 100 requests in one POST, each with its own options, on plans that include bulk; a plan can set a lower number. A longer list is sent as several calls. Each item counts as one request against the per-minute rate limit, and each successful render uses one screenshot of the quota.
Do failed screenshots count against the quota?
Not with CurlShot: only a successful render uses a screenshot. A failed job is free, and so is an item with cache set to true that is answered from a stored copy. There is a separate cap on how many renders of one account may fail per minute, so fix a request that keeps failing instead of retrying it in a loop.
Sources
- [1]sitemaps.org, Sitemaps XML format (protocol). Checked .
- [2]IETF / RFC Editor, RFC 9309: Robots Exclusion Protocol. Checked .
- [3]IETF / RFC Editor, RFC 9110: HTTP Semantics, section 10.2.3 Retry-After. Checked .
- [4]MDN Web Docs, Retry-After header. Checked .
- [5]IETF / RFC Editor, RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests). Checked .
- [6]Node.js documentation, Global objects: fetch. Checked .
Read next
- CalculatorScreenshot API vs headless browser: the real costA cost calculator with every assumption editable, the hidden work behind headless Chrome, and an honest checklist for build or buy.
- PlaybookWebsite change monitoring with screenshotsHow to get told when a web page changes: the capture and compare loop, three ways to detect a change, a noise checklist, a cron and Node.js setup, and tools.
- Rosetta stoneWebsite screenshot code examples in 8 languagesThe shortest working code to save a full-page screenshot of a website in curl, Node.js, Python, PHP, Go, Ruby, Java and C#, side by side.