fix(crawler): send a realistic browser User-Agent (Cloudflare evasion)
Some checks failed
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
deploy / test-backend (push) Failing after 6m10s

The crawler's headless Chromium advertised the default HeadlessChrome UA,
which Cloudflare's bot detection challenges on sight — over Tor that meant an
unsolvable 'Just a moment' page on every fetch, so session verification kept
classifying pages as transient and the metadata pass failed at
'acquire browser lease'. (Confirmed empirically: same Tor exit + a normal
Chrome UA returns the real page with the #logo sentinel; the HeadlessChrome
UA returns the challenge.) The existing user_agent setting only fed reqwest,
never the browser. Override the launcher's UA with a realistic non-headless
default (CRAWLER_USER_AGENT to customize); the UA may contain spaces, unlike
CRAWLER_BROWSER_ARGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:41:00 +02:00
parent 4262efeff7
commit 91d058c426
4 changed files with 61 additions and 8 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.81.1"
version = "0.81.2"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.81.1"
version = "0.81.2"
edition = "2021"
default-run = "mangalord"

View File

@@ -54,13 +54,26 @@ pub struct LaunchOptions {
/// defaults. Example: `vec!["--lang=de-DE".into(),
/// "--window-size=1280,800".into()]`.
pub extra_args: Vec<String>,
/// User-Agent for the browser. `None` uses [`DEFAULT_USER_AGENT`].
/// Critically, this is NEVER Chromium's built-in headless UA (which
/// contains the `HeadlessChrome` token that Cloudflare's bot detection
/// flags — over Tor that means an unsolvable "Just a moment" challenge
/// on every page). Sourced from `CRAWLER_USER_AGENT`.
pub user_agent: Option<String>,
}
/// A realistic, non-headless Chrome UA matching the bundled engine major.
/// Replaces Chromium's default `HeadlessChrome/<v>` UA, which anti-bot
/// services (Cloudflare) challenge on sight.
pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
impl LaunchOptions {
pub fn headed() -> Self {
Self {
mode: BrowserMode::Headed,
extra_args: Vec::new(),
user_agent: None,
}
}
@@ -68,13 +81,17 @@ impl LaunchOptions {
Self {
mode: BrowserMode::Headless,
extra_args: Vec::new(),
user_agent: None,
}
}
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
/// `headless`) and `CRAWLER_BROWSER_ARGS` (whitespace-separated
/// Chromium flags). Flags containing whitespace aren't supported
/// through the env var — use the programmatic API for those.
/// `headless`), `CRAWLER_BROWSER_ARGS` (whitespace-separated
/// Chromium flags), and `CRAWLER_USER_AGENT` (the browser UA;
/// blank/unset → [`DEFAULT_USER_AGENT`]). Flags containing whitespace
/// aren't supported through `CRAWLER_BROWSER_ARGS` — use the
/// programmatic API (or `CRAWLER_USER_AGENT` for the UA, which may
/// contain spaces) for those.
pub fn from_env() -> Self {
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
Ok("headed") => BrowserMode::Headed,
@@ -83,7 +100,16 @@ impl LaunchOptions {
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
.map(|s| parse_args(&s))
.unwrap_or_default();
Self { mode, extra_args }
let user_agent = std::env::var("CRAWLER_USER_AGENT")
.ok()
.filter(|s| !s.trim().is_empty());
Self { mode, extra_args, user_agent }
}
/// The effective browser UA: the configured override, else the
/// non-headless [`DEFAULT_USER_AGENT`].
pub fn effective_user_agent(&self) -> &str {
self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT)
}
}
@@ -211,7 +237,11 @@ pub async fn launch(options: LaunchOptions) -> anyhow::Result<Handle> {
// Chromium's sandbox wants. Disable it; the crawler runs in its
// own container anyway.
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage");
.arg("--disable-dev-shm-usage")
// Override Chromium's default headless UA (contains `HeadlessChrome`,
// which Cloudflare challenges) with a realistic one. Placed before
// extra_args so an explicit override there still wins.
.arg(format!("--user-agent={}", options.effective_user_agent()));
for arg in &options.extra_args {
builder = builder.arg(arg);
}
@@ -345,6 +375,29 @@ mod tests {
assert_eq!(LaunchOptions::headed().mode, BrowserMode::Headed);
}
#[test]
fn default_user_agent_is_not_headless() {
// The whole point: never advertise `HeadlessChrome`, which Cloudflare
// challenges (especially over Tor) — that was the crawler's silent
// failure mode. A bare LaunchOptions must still yield a realistic UA.
assert!(
!DEFAULT_USER_AGENT.contains("Headless"),
"default UA must not contain the Headless token"
);
assert_eq!(LaunchOptions::headless().effective_user_agent(), DEFAULT_USER_AGENT);
assert!(!LaunchOptions::default().effective_user_agent().contains("Headless"));
}
#[test]
fn configured_user_agent_overrides_default() {
let opts = LaunchOptions {
mode: BrowserMode::Headless,
extra_args: Vec::new(),
user_agent: Some("Custom/1.0".to_string()),
};
assert_eq!(opts.effective_user_agent(), "Custom/1.0");
}
// Regression: if another Arc<Browser> outlives `Handle::close`, the
// old code awaited the driver task forever because the chromiumoxide
// handler stream doesn't return None on its own. Aborting the driver

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.81.1",
"version": "0.81.2",
"private": true,
"type": "module",
"scripts": {