My evidence pipeline was saving Cloudflare block pages as evidence
A third-party wrapper died, and the failure came back as a plausible-looking image with an HTTP 200 attached. What broke, and what I changed.
I build a web service that preserves evidence of harassment on social platforms.
The core feature is a single thing: automatically capture a real screenshot of the offending post.
There was no substitute for it. I built an alternative that pulled the text through an API and rendered a tidy "evidence card" image, and threw it away. An image you can author freely afterwards proves nothing.
Here's the conclusion first. Third-party wrappers eventually die, and when they do, the failure comes back as a plausible-looking image rather than an error.
The first approach was refused by the other side
I started with Cloudflare Browser Rendering. The wiring worked. The capture didn't.
- X blocks headless browsers. The request times out
- YouTube refuses script injection under a Trusted Types CSP. There's no way to make it render the comment
Neither is a bug in my implementation — that is how they are built. So I declared Cloudflare alone impossible for this and moved to a service with a real browser and bot avoidance behind it.
Both captures started working. For X, open the post page and clip the tweet element. For YouTube, open the URL with &lc= and screenshot just that comment element.
Element screenshots have one trap worth knowing: selector_algorithm=clip returns a blank image when the element sits below the fold. The selector matches, the capture "succeeds," and the file is empty. That took a while to see.
ytd-comment-thread-renderer:has(a[href*="lc=ID"])
A parameter that had worked started returning 400
I wanted timestamps rendered in Japan time, so I passed time_zone: Asia/Tokyo.
One day every request started coming back 400. Every capture failed.
The provider had narrowed which timezones they accept. Nothing changed on my side.
I could diagnose it immediately only because I was storing the raw error body in the database. The response went into rawPayload.screenshotError, so opening one row told me why. Without that, this starts as "captures stopped working, no idea why."
The fix was to drop the parameter, leave the rendering in GMT, and record the legally meaningful capture timestamp on my side, in JST. How a third party displays a time is their business and can change. What I record is mine and doesn't.
This was the bad one
For X captures I was going through a third-party embed wrapper called twitframe.
That domain had been retired.
Here is what that looks like from the inside. The capture succeeds. HTTP 200. An image is written to storage. The image is a Cloudflare block page.
So: as evidence of online harassment, I was storing error screens.
If it had failed, I'd have known. It didn't. A stored, plausible-looking image doesn't stand out when you scan a list of them. You find out when someone needs to actually use it.
Two causes stacked:
1. A dependency on a third-party wrapper. When the domain dies, nobody tells you 2. ignore_host_errors: true was set. That says "continue capturing even if the host returns an error" — which is precisely permission to save a block page as a success
ignore_host_errors is reasonable when a slightly broken capture beats no capture. In a feature that handles evidence, it is only a hazard.
X capture now goes through the official embed:
platform.twitter.com/embed/Tweet.html?id=
A third-party wrapper ends when that person stops caring. An official embed is maintained because the platform wants it maintained. Both are "someone else's," but only one has a reason to keep existing.
Making it faster removed the evidence
Scanning was slow, so I moved capture from synchronous to background. It got faster.
Saved evidence stopped having screenshots attached.
The save path assumed "by the time we get here, the screenshot exists" and copied it forward. Make the earlier stage async and that assumption quietly stops holding.
The fix is two-layered:
- scanning responds immediately and captures in the background
- at the moment evidence is saved, check for the screenshot and capture it right there if missing
Anything missed also gets picked up by the next scan.
When you move a synchronous step to the background, audit every later stage that inherited data assuming it was synchronous. Optimise on speed alone and this is the shape of the breakage.
What I'd take away
- Third-party wrappers die. Use the official embed
- When they die, the failure arrives as a plausible image, not an exception
- Flags like
ignore_host_errorsthat mean "continue even if it looks wrong" are pure hazard when the artifact is evidence - Provider parameters change. Keeping the raw error body makes the day it changes a five-minute diagnosis
- Separate values that depend on someone else's rendering (timezone) from values you must record yourself (capture time)
- Move something to async, then re-check what the later stages assumed
"The capture succeeded" and "we captured evidence" turned out to be different claims. The first is visible in the status code. The second requires looking at the pixels.
Separately from this, I build a desktop AI agent called Wisp. It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.