A paragraph that says "Example has a clean editor" asks the reader to take your word for it. The same paragraph with a screenshot of the editor under it shows them. AI writing tools and content teams know this, and most of them still ship text-only posts, because finding, capturing, cropping, uploading and describing an image for every product a post mentions is the slowest part of writing it. Parse the draft for outbound links, capture each linked site once, write the alt text from the page's own title and description, store the file in your bucket, and insert the image under the first mention.
This page is part of the use cases section. For small previews in lists rather than full-width images in articles, start from website thumbnails instead.
The request gallery
Articles need larger, more readable images than a thumbnail grid. These are the request shapes that work in long-form content.
| Image in the article | Key options | Why |
|---|---|---|
| Homepage, first screen | "viewport": {"width": 1280, "height": 800}, "output": {"format": "webp", "quality": 82} |
Wide enough to read the headline when the content column is 700 to 800 px |
| A section the article discusses | "capture": {"mode": "element", "selector": "table", "selector_required": false} |
Captures the first table; with selector_required false, a page without one falls back to a viewport capture instead of failing |
| A mobile app's site | "device": "iphone_14_pro" |
Mobile layout at the device's scale, for posts about mobile products |
| Clean of overlays | "block": {"cookie_banners": true, "ads": true, "chat_widgets": true} |
A consent dialog in an article image looks like a mistake |
The element fallback is useful for a writing tool that cannot know every site's markup. Ask for the part you hope exists, and accept the first screen when it does not. The element screenshots guide covers selector choice.
How it works
flowchart LR D[Draft Markdown] --> P[Find outbound links<br/>one per host, max 3] P -->|POST /v1/screenshot<br/>Accept: application/json| R[RenderScreenshot] R -->|metadata.title, description<br/>cache.key| P R -.->|background upload| B[(Your bucket<br/>articles/hash.ext)] P --> M[Markdown with image,<br/>alt text and caption] B --> C[Your CDN] --> M
The JSON response carries everything the article needs except the file itself: the page title and meta description for the alt text and caption, and the cache key, whose last segment is the stored file name. The file goes to your bucket through custom storage on a paid plan, with a storage.path that must contain {hash} and {ext}.
The script
// enrich.mjs: add a screenshot under the first mention of each site an article links to. // Usage: RS_API_KEY=rs_live_... PUBLIC_BUCKET_URL=https://media.example.com node enrich.mjs draft.md > final.md import { readFile } from 'node:fs/promises'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const BUCKET_URL = process.env.PUBLIC_BUCKET_URL; // public base URL of the bucket set up in the dashboard const PREFIX = 'articles/'; const MAX_SHOTS = 3; // Page titles come from other people's sites: keep them out of Markdown syntax. const clean = (s, max) => String(s ?? '').replace(/[\[\]()*_`<>\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max); async function capture(url) { const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ url, viewport: { width: 1280, height: 800 }, output: { format: 'webp', quality: 82 }, block: { cookie_banners: true, ads: true, chat_widgets: true }, wait: { until: 'networkidle', timeout: 20 }, // Popular sites show up in many articles: reuse a render up to 7 days old for 0 credits. cache: { available: true, ttl: 604800 }, storage: { enabled: true, path: `${PREFIX}{hash}.{ext}`, acl: 'public-read' }, }), signal: AbortSignal.timeout(60_000), }); const body = await res.json(); if (!res.ok) throw new Error(`${url}: ${body.error?.code ?? res.status}`); // With a {hash}.{ext} template, the stored file name is the last segment of the cache key. const file = body.cache.key.split('/').pop(); return { src: `${BUCKET_URL}/${PREFIX}${file}`, ...body.metadata, width: body.image.width, height: body.image.height }; } export async function enrich(markdown) { const paragraphs = markdown.split(/\n{2,}/); const done = new Set(); for (let i = 0; i < paragraphs.length && done.size < MAX_SHOTS; i++) { for (const [, , href] of paragraphs[i].matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g)) { const host = new URL(href).hostname.replace(/^www\./, ''); if (done.has(host) || done.size >= MAX_SHOTS) continue; done.add(host); try { const shot = await capture(new URL('/', href).href); const name = clean(shot.title, 80) || host; const alt = `Homepage of ${name} (${host})`; const caption = clean(shot.description, 160); paragraphs[i] += `\n\n\n*${host}${caption ? `: ${caption}` : ''}*`; } catch (err) { console.error(`skipped ${host}: ${err.message}`); // failed captures cost nothing } } } return paragraphs.join('\n\n'); } if (import.meta.url === `file://${process.argv[1]}`) { process.stdout.write(await enrich(await readFile(process.argv[2], 'utf8'))); }
Three choices in this script are deliberate:
- One image per host, three per article. A post that links to the same tool five times gets one screenshot, under the first mention. More than three images in a short post reads as filler.
- Titles are untrusted. The title comes from someone else's page.
cleanstrips brackets, backticks and line breaks, so a title cannot close the Markdown image syntax or inject a link. - The file name. With a
prefix/{hash}.{ext}template, the stored object's name is the last segment ofcache.key. Templates with{domain}or date parts need the same values the upload used, so this script keeps the flat form.
cache.available with a 7-day TTL matters for writing tools: the same popular products appear in many articles, and a repeat capture inside the TTL returns the earlier render for 0 credits. Because every request uses the same storage template, a cache hit points at the file the first render uploaded.
Tested on 2026-10-18 with Node.js 22.18.0 against a local stub that returns the API's JSON response shape. The run added images under the first mention of two hosts, skipped a repeated host and logged a site that timed out. A second run over the same draft returned the same file name. The background upload to a real bucket was not part of this test.
Alt text that helps
Screen readers and image search both read the alt attribute. The script writes Homepage of {title} ({host}), which is accurate and specific. The page's meta description becomes the caption when it exists, because it is the site's own one-line summary. For a richer description, send the image to a vision model and ask for one sentence about what it shows. The AI agents page covers capture settings for models.
Keep the capture date in the article's own metadata rather than in the alt text: with cache reuse, the render can be a few days older than the post.
What it costs
An AI writing tool that publishes 2,000 articles a month with up to three images each makes at most 6,000 renders, fewer once cache hits on popular sites kick in. That is within the Growth plan's 12,000 credits for $40 a month, about $0.0067 per image at the full 6,000. Failed captures cost nothing. Custom storage is on paid plans only: on Free, a request with a storage block fails with feature_not_available, so drop that line and use the CDN link while you test. See pricing.
Next steps
For screenshots of your own product in your docs, see documentation screenshots. Then create a free account and run the script over one of your drafts.