fix(crawler): send a realistic browser User-Agent (Cloudflare evasion)
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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.81.1"
|
version = "0.81.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.81.1"
|
version = "0.81.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,26 @@ pub struct LaunchOptions {
|
|||||||
/// defaults. Example: `vec!["--lang=de-DE".into(),
|
/// defaults. Example: `vec!["--lang=de-DE".into(),
|
||||||
/// "--window-size=1280,800".into()]`.
|
/// "--window-size=1280,800".into()]`.
|
||||||
pub extra_args: Vec<String>,
|
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 {
|
impl LaunchOptions {
|
||||||
pub fn headed() -> Self {
|
pub fn headed() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: BrowserMode::Headed,
|
mode: BrowserMode::Headed,
|
||||||
extra_args: Vec::new(),
|
extra_args: Vec::new(),
|
||||||
|
user_agent: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +81,17 @@ impl LaunchOptions {
|
|||||||
Self {
|
Self {
|
||||||
mode: BrowserMode::Headless,
|
mode: BrowserMode::Headless,
|
||||||
extra_args: Vec::new(),
|
extra_args: Vec::new(),
|
||||||
|
user_agent: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
|
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
|
||||||
/// `headless`) and `CRAWLER_BROWSER_ARGS` (whitespace-separated
|
/// `headless`), `CRAWLER_BROWSER_ARGS` (whitespace-separated
|
||||||
/// Chromium flags). Flags containing whitespace aren't supported
|
/// Chromium flags), and `CRAWLER_USER_AGENT` (the browser UA;
|
||||||
/// through the env var — use the programmatic API for those.
|
/// 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 {
|
pub fn from_env() -> Self {
|
||||||
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
|
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
|
||||||
Ok("headed") => BrowserMode::Headed,
|
Ok("headed") => BrowserMode::Headed,
|
||||||
@@ -83,7 +100,16 @@ impl LaunchOptions {
|
|||||||
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
|
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
|
||||||
.map(|s| parse_args(&s))
|
.map(|s| parse_args(&s))
|
||||||
.unwrap_or_default();
|
.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
|
// Chromium's sandbox wants. Disable it; the crawler runs in its
|
||||||
// own container anyway.
|
// own container anyway.
|
||||||
.arg("--no-sandbox")
|
.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 {
|
for arg in &options.extra_args {
|
||||||
builder = builder.arg(arg);
|
builder = builder.arg(arg);
|
||||||
}
|
}
|
||||||
@@ -345,6 +375,29 @@ mod tests {
|
|||||||
assert_eq!(LaunchOptions::headed().mode, BrowserMode::Headed);
|
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
|
// Regression: if another Arc<Browser> outlives `Handle::close`, the
|
||||||
// old code awaited the driver task forever because the chromiumoxide
|
// old code awaited the driver task forever because the chromiumoxide
|
||||||
// handler stream doesn't return None on its own. Aborting the driver
|
// handler stream doesn't return None on its own. Aborting the driver
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.81.1",
|
"version": "0.81.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user