How to Build a Twitter Bot That Captures Post Screenshots
Build a policy-aware bot that finds X posts through the official API and renders compliant screenshot cards without scraping the X website.
Direct answer: Do not build this bot by opening X pages with Playwright and scraping or screenshotting them. X’s Automation rules warn against scripting the website, and its Terms prohibit crawling or scraping without prior written consent. Use the official X API to find and retrieve posts, then render an image from the returned data through a presentation method that you have checked against the current Developer Agreement and display rules.
1. Decide what the bot is allowed to do
The first implementation decision is authorization. X’s Automation rules say: Use non-API-based forms of automation, such as scripting the >x< website. The use of these techniques may result in the permanent suspension of your account.
X’s Terms also state that crawling or scraping the Services in any form, for any purpose without our prior written consent is expressly prohibited
. Read the current Automation rules and Terms of Service before launch.
This guide therefore uses this flow:
- Receive a URL, search phrase, hashtag, author, or other authorized trigger.
- Call the official X API with an approved app and token.
- Build a local card from API fields such as text, author name, timestamp, and media URLs.
- Render that card to PNG with a browser or another renderer you are permitted to use.
- Store, display, or share it only after checking attribution, branding, retention, and redistribution requirements.
A technical screenshot function does not grant permission to automate X. Playwright is suitable for pages and content you are authorized to automate, but its existence does not change X policy.
2. Create a narrow, idempotent workflow
Choose one trigger before writing code:
- User-submitted URL: the user gives the bot a post URL, which you resolve through the API.
- Recent search: poll a query for matching posts. X documents Recent Search for the last seven days and up to 100 posts per request.
- Full-archive search: search older posts when your account has the required pay-per-use or Enterprise access. The reviewed documentation describes requests of up to 500 posts.
- Authorized source list: monitor known authors or topics with an explicit product and policy review.
Give each output a deterministic key such as the post ID plus a renderer version. Save that key before rendering so retries do not create duplicate images. Keep API credentials in environment variables, never in source code or logs.
3. Find posts through the official X API
The X API search documentation lists app registration, a developer account, and app keys or tokens as prerequisites. Search operators support keywords, exact phrases, hashtags, mentions, URLs, authors, language, and content type. Access levels and prices can change, so confirm the current entitlement in the developer console.
Recent Search with cURL
curl --get 'https://api.x.com/2/tweets/search/recent' \
--data-urlencode 'query=from:example has:links -is:retweet' \
--data-urlencode 'max_results=10' \
--data-urlencode 'tweet.fields=id,text,created_at,author_id,attachments' \
--data-urlencode 'expansions=author_id,attachments.media_keys' \
--data-urlencode 'user.fields=name,username,profile_image_url' \
--data-urlencode 'media.fields=type,url,preview_image_url' \
-H "Authorization: Bearer $X_BEARER_TOKEN"
Recent Search with Python
import os
import requests
params = {
"query": "from:example has:links -is:retweet",
"max_results": 10,
"tweet.fields": "id,text,created_at,author_id,attachments",
"expansions": "author_id,attachments.media_keys",
"user.fields": "name,username,profile_image_url",
"media.fields": "type,url,preview_image_url",
}
response = requests.get(
"https://api.x.com/2/tweets/search/recent",
params=params,
headers={"Authorization": f"Bearer {os.environ['X_BEARER_TOKEN']}"},
timeout=30,
)
response.raise_for_status()
print(response.json())
Recent Search with Node.js
const params = new URLSearchParams({
query: 'from:example has:links -is:retweet',
max_results: '10',
'tweet.fields': 'id,text,created_at,author_id,attachments',
expansions: 'author_id,attachments.media_keys',
'user.fields': 'name,username,profile_image_url',
'media.fields': 'type,url,preview_image_url'
});
const response = await fetch(`https://api.x.com/2/tweets/search/recent?${params}`, {
headers: { Authorization: `Bearer ${process.env.X_BEARER_TOKEN}` }
});
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const result = await response.json();
console.log(JSON.stringify(result, null, 2));
For a single post URL, extract the numeric status ID, then use the API’s post lookup endpoint available to your access level. Request only fields you need. Treat missing, deleted, withheld, or unavailable media as a normal state rather than a rendering failure.
4. Render an image from API data
Construct a card from returned fields instead of loading the X website. The exact layout, branding, attribution, and redistribution plan must be checked against the X Developer Guidelines and current agreement. The guidance requires attribution and proper branding, limits changes to display formatting, and says deleted content should be removed within 24 hours. Verify the binding rules for your use case before retaining or publishing images.
Complete Node.js renderer with Playwright
This example accepts a JSON file produced by your API client, writes a local HTML card, and captures that authorized local page. Install Playwright with npm install playwright, then install its browser with npx playwright install chromium.
import fs from 'node:fs/promises';
import { chromium } from 'playwright';
const input = JSON.parse(await fs.readFile(process.argv[2] || 'post.json', 'utf8'));
const post = input.data;
const users = new Map((input.includes?.users || []).map((u) => [u.id, u]));
const author = users.get(post.author_id) || { name: 'Unknown author', username: 'unknown' };
function escapeHtml(value = '') {
return value.replace(/[<>&"']/g, (c) => ({
'<': '<', '>': '>', '&': '&', '"': '"', "'": '''
}[c]));
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; }
body { margin: 0; width: 1200px; min-height: 675px; background: #fff; color: #111; font-family: Arial, sans-serif; }
.card { padding: 72px; min-height: 675px; display: flex; flex-direction: column; justify-content: space-between; }
.author { font-size: 30px; font-weight: 700; } .handle { color: #666; font-size: 22px; margin-top: 8px; }
.text { white-space: pre-wrap; font-size: 38px; line-height: 1.25; margin: 48px 0; }
.meta { color: #666; font-size: 20px; }
</style>
</head>
<body><main class="card">
<div><div class="author">${escapeHtml(author.name)}</div>
<div class="handle">@${escapeHtml(author.username)}</div>
<div class="text">${escapeHtml(post.text)}</div></div>
<div class="meta">${escapeHtml(post.created_at || '')} · Post ID ${escapeHtml(post.id)}</div>
</main></body></html>`;
await fs.writeFile('render-card.html', html, 'utf8');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1200, height: 675 }, deviceScaleFactor: 2 });
await page.goto(`file://${process.cwd()}/render-card.html`, { waitUntil: 'load' });
await page.screenshot({ path: `post-${post.id}.png`, type: 'png' });
} finally {
await browser.close();
}
Playwright supports viewport screenshots, full-page screenshots, element screenshots, and in-memory buffers; see the official screenshot documentation. Use those capabilities only for content and pages you are authorized to automate.
5. Handle media, accessibility, and policy details
- Use expanded media objects when available; do not assume every post has an image or that every media URL is present.
- Preserve the author’s identity, attribution, and required branding. Do not alter post meaning through truncation or misleading overlays.
- Provide alt text from the post text and media description where available. Keep a text representation alongside the image.
- Store the source post ID and retrieval time with every image. This makes deletion and re-render requests possible.
- When a post is deleted or becomes unavailable, remove retained copies within the period required by current X guidance; the reviewed guidance specifies 24 hours.
- If the bot posts, replies, or mentions users, review the separate automation rules. This screenshot workflow does not need public posting, unsolicited replies, or keyword-triggered mentions.
6. Reliability and operational design
- Retries: retry transient HTTP 429 and 5xx responses with exponential backoff and a maximum attempt count. Do not retry authentication errors indefinitely.
- Rate limits: read the response headers and current API documentation, then pace workers below the account limit. Queue jobs instead of launching an unbounded batch.
- Idempotency: key work by post ID, requested variant, and renderer version. A repeated webhook or poll result should produce one artifact.
- Freshness: record the API response timestamp. A later re-render may differ if author data, media, or policy state changes.
- Deletion handling: run a reconciliation job that checks retained IDs and removes images for deleted or unavailable content.
- Observability: log request IDs, post IDs, status codes, render duration, and output location. Redact bearer tokens and private user data.
7. Troubleshooting
| Error | Likely cause | Fix |
|---|---|---|
| 401 or 403 from X | Missing, expired, or insufficient token; wrong app permissions. | Create or rotate the token in the developer console and confirm the endpoint is included in your entitlement. |
| 400 invalid query | Unsupported operator, malformed URL, or query outside the endpoint’s syntax. | Reduce the query to one known operator, validate it against the search documentation, then add terms back. |
| 429 Too Many Requests | Endpoint or project rate limit reached. | Honor reset headers, add exponential backoff, and reduce polling or page size. |
| No matching posts | Recent Search only covers its documented time window, or filters exclude results. | Check the time range, remove filters one at a time, and confirm whether full-archive access is available. |
| Author or media is missing | The request omitted expansions, the post has no media, or data is unavailable. | Request the required expansions and render a text-only fallback. |
| Playwright browser not found | Package installed without browser binaries. | Run npx playwright install chromium in the deployment image. |
| Blank or clipped image | Fixed viewport is too small or content was not measured. | Use a larger viewport, an element screenshot, or calculate card height before capture. |
| Image cannot be published | Attribution, branding, deletion, or redistribution requirements were not met. | Pause distribution, review current X guidelines and agreement, and update the card and retention workflow. |
8. Performance and cost considerations
API usage and rendering are separate workloads. Reduce API calls by requesting only needed fields, using the largest allowed page size, and persisting the last seen post ID. Keep a bounded render queue so Chromium processes do not exhaust memory. Reuse a browser process for a batch while isolating each page, and close pages after capture.
Costs depend on your X API entitlement, request volume, storage, and compute. The reviewed sources do not establish a universal API price or guarantee access for a particular account. Check the developer console before committing to a polling interval or archive search.
9. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. For an authorized public URL, one GET request returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for all parameters.
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}`);
For this use case, ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Confirm that the target URL and your intended use are authorized before capturing it.
Create a free ScreenshotNeo account with 1,000 screenshots a month and no card.
10. FAQ
Can I use Playwright directly on x.com?
Only with specific permission covering the intended automation. X’s rules warn against scripting its website, so the API-plus-rendered-card approach is the safer default.
Can the bot capture deleted posts?
No. Design reconciliation and deletion handling so retained images are removed when the source post is deleted or unavailable.
Is a screenshot of API data automatically approved?
No. Confirm display, attribution, branding, retention, and redistribution requirements for your exact design and audience.
Should this bot reply to users?
Not for screenshot generation. Avoid adding automated replies or mentions unless you have a separate, reviewed use case with consent and opt-out handling.
How do I render a post with no image?
Use a text-first card and preserve the post ID, author attribution, timestamp, and any required branding.


