omnifeed

ADR 0002: Waiting out Cloudflare challenges with a CSS selector

Context

A Cloudflare challenge page (“Just a moment…”) often clears by itself after a few seconds, then navigates to the real page. So with OMNIFEED_CRAWL4AI_CHALLENGE_WAIT set, every non-PDF /crawl sent crawl4ai a wait_for that held extraction until the challenge was gone. The wait was JavaScript:

js:() => !/^\s*(just a moment|un instant|...)\s*$/i.test(document.title)

crawl4ai 0.9.5 is a security release, and it refuses that wait.

Upstream will not bring it back

Decision

The challenge wait is a CSS selector, the form 0.9.5 accepts by design:

css::root:not(:has(script[src*="/cdn-cgi/challenge-platform/"][src*="/orchestrate/"])) :visible

Rollout order

The selector works on crawl4ai 0.9.4 as well as 0.9.5 (measured on both; older images were not tested, and the quickstart stacks pinned 0.9.2 until PR #72), so:

  1. Release omnifeed with the selector, on the crawl4ai you run today.
  2. Then move crawl4ai to 0.9.5.

The other way round, every non-PDF crawl fails with 400 between the two steps. The quickstart stacks (docker-compose.yml, scripts/container) move to 0.9.5 in the same PR, because they always run the omnifeed from the same checkout.

Default user agent: Chrome 156

The same PR moves the default OMNIFEED_CRAWL4AI_USER_AGENT from Chrome 153 to Chrome 156, the Chromium bundled in crawl4ai 0.9.5 (Playwright 1.64). A user agent that disagrees with the browser engine is itself a bot signal, so the default follows the image (DefaultCrawl4AIUserAgent in internal/config/config.go).

Options rejected

Option Why
Turn on crawl4ai’s JavaScript hooks (CRAWL4AI_HOOKS_ENABLED) A far bigger surface than one wait: any caller could run code in the browser
Set crawler.base_config.wait_for in the crawl4ai server config It works and is trusted, but every consumer of that crawl4ai server gets the wait, and a request cannot turn it off
Detect the challenge in the returned page and crawl again, instead of waiting Each retry starts a fresh challenge, so it rarely clears, and each try costs a full page load. The no_wait rung crawls again only to read the page, not to clear the challenge
Stay on crawl4ai 0.9.4 Misses the other Docker-server fixes in 0.9.5 (six advisories in all). The JavaScript hole itself matters less here: omnifeed’s stacks turn on /execute_js for the Reddit engine (CRAWL4AI_EXECUTE_JS_ENABLED), so its token holder can already run JavaScript in a page. It matters to a crawl4ai that leaves /execute_js off

Measurements

On 2026-10-25, against unclecode/crawl4ai:0.9.5 and 0.9.4, with the repo’s crawl4ai/config.yml:

Case Result
The old JavaScript wait HTTP 400 on 0.9.5
go.dev, Wikipedia, Hacker News, crunchbase (Cloudflare with jsd), upwork, indeed, glassdoor success, the same latency as with no wait
A simulated challenge that navigates away after 2.5 s waits, then returns the real page (about 3 s)
A simulated challenge that never clears Wait condition failed: Timeout after .... The no_wait rung and its captcha verdict came after this run, from review; unit tests cover them, not a live run
A page with a zero-height <html> success (it timed out without :visible)

Consequences

Sources