Skip to main content

google_maps_scraper/
lib.rs

1//! # google-maps-scraper
2//!
3//! Apify-style Google Maps scraper for Rust. Drives a real headless Chrome
4//! via the Chrome DevTools Protocol, searches Google Maps for any query,
5//! scrolls the results feed until exhaustion, then clicks each place card
6//! and extracts the public details (name, address, phone, website).
7//!
8//! ## When to use this
9//!
10//! - You want **lots** of Google Maps results (hundreds per query) without
11//!   paying for the official Places API.
12//! - You don't have an Apify subscription, or want a self-hosted scraper.
13//! - You're comfortable with the brittleness of DOM-based scraping (Google
14//!   occasionally changes selectors; this crate keeps them in one place
15//!   so updates are localised).
16//!
17//! ## Requirements
18//!
19//! - Chrome / Chromium installed locally. On macOS the auto-detect finds
20//!   `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`.
21//!   On Linux: `apt install chromium`. Override via the `CHROME` env var.
22//!
23//! ## Quick start
24//!
25//! ```no_run
26//! use google_maps_scraper::{MapsScraper, ScraperConfig};
27//!
28//! # #[tokio::main]
29//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
30//! let scraper = MapsScraper::launch(ScraperConfig::default()).await?;
31//!
32//! let places = scraper
33//!     .search_many(&["coffee shop Berlin", "bakery Munich"])
34//!     .await?;
35//!
36//! for p in &places {
37//!     println!("{} — {:?} — {:?}", p.name, p.website, p.phone);
38//! }
39//! # Ok(()) }
40//! ```
41//!
42//! ## Anti-detection notes
43//!
44//! Google's bot-detection adapts. The `--disable-blink-features=AutomationControlled`
45//! flag is set by default. For high-volume scraping use a residential proxy /
46//! Browserless service, slow down delay between queries, and don't reuse a
47//! single browser session for hundreds of queries.
48
49#![forbid(unsafe_code)]
50#![warn(missing_docs)]
51
52use std::collections::HashSet;
53use std::sync::LazyLock;
54use std::time::Duration;
55
56use chromiumoxide::{Browser, BrowserConfig, Page};
57use futures::StreamExt;
58use serde::{Deserialize, Serialize};
59use tracing::{info, warn};
60
61const SEARCH_URL_BASE: &str = "https://www.google.com/maps";
62
63/// All errors this crate produces.
64#[derive(Debug, thiserror::Error)]
65pub enum Error {
66    /// Chrome failed to launch (often: not installed, or missing libs on Linux).
67    #[error("chrome launch failed: {0}")]
68    ChromeLaunch(String),
69
70    /// Driving the page failed (navigation, evaluation, …).
71    #[error("page error: {0}")]
72    Page(String),
73
74    /// A `ScraperConfig` value was rejected (e.g. an invalid proxy).
75    #[error("invalid config: {0}")]
76    Config(String),
77
78    /// Underlying chromiumoxide error.
79    #[error("cdp: {0}")]
80    Cdp(#[from] chromiumoxide::error::CdpError),
81}
82
83/// Result type used throughout the crate.
84pub type Result<T> = std::result::Result<T, Error>;
85
86/// One Google Maps place that the scraper extracted.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct Place {
89    /// Display name (from H1 on the detail panel).
90    pub name: String,
91    /// Full address as shown in the panel.
92    pub address: Option<String>,
93    /// 5-digit postcode parsed from the address. Populated only when the
94    /// address contains a `NNNNN City` segment (German postal format); `None`
95    /// otherwise. The match is a heuristic and may also fire on other 5-digit
96    /// formats (e.g. a US ZIP + city).
97    pub postcode: Option<String>,
98    /// City parsed alongside the postcode from a `NNNNN City` segment (German
99    /// postal format). `None` when no such segment is present. Same heuristic
100    /// caveat as [`Place::postcode`].
101    pub city: Option<String>,
102    /// First phone number listed.
103    pub phone: Option<String>,
104    /// External website URL listed in the panel.
105    pub website: Option<String>,
106    /// The Google Maps URL (`/maps/place/...`) of this entry.
107    pub maps_url: Option<String>,
108    /// Latitude parsed from the `@lat,lng` segment of `maps_url`, if present.
109    pub latitude: Option<f64>,
110    /// Longitude parsed from the `@lat,lng` segment of `maps_url`, if present.
111    pub longitude: Option<f64>,
112    /// The query that produced this hit.
113    pub source_query: Option<String>,
114}
115
116/// Scraper configuration.
117#[derive(Debug, Clone)]
118pub struct ScraperConfig {
119    /// Run Chrome in headless mode (default: true). Use `false` for debugging.
120    pub headless: bool,
121    /// How many `scrollTop = scrollHeight` iterations to perform per search
122    /// before assuming the feed is exhausted. Default: 30.
123    pub max_scroll_iterations: usize,
124    /// If false, skip clicking each result and only return surface data
125    /// (name, maps_url) — much faster but no website / phone. Default: true.
126    pub enrich: bool,
127    /// Delay between consecutive HTTP requests inside Chrome.
128    pub between_query_delay: Duration,
129    /// Per-place delay after clicking (gives the panel time to render). The
130    /// effective wait is this plus a random `0..=place_panel_jitter` — see
131    /// [`ScraperConfig::place_panel_jitter`].
132    pub place_panel_delay: Duration,
133    /// Upper bound on the random extra delay added *on top of*
134    /// [`ScraperConfig::place_panel_delay`] before each place visit. A fresh
135    /// value in `0..=place_panel_jitter` is drawn per place to de-regularise the
136    /// request cadence (mild bot-detection mitigation, not a guarantee). The
137    /// jitter is purely additive, so `place_panel_delay` is always the minimum;
138    /// set this to `Duration::ZERO` to disable it. Default: 750 ms.
139    pub place_panel_jitter: Duration,
140    /// Maximum number of unique places to return per query.
141    ///
142    /// `None` (default) means **unlimited** — a dense query (e.g. a feed of
143    /// several hundred results) will then perform one Chrome navigation per
144    /// place with no upper bound, so a single call can run for many minutes.
145    /// Set this when you need to bound run time. Note the cap counts *added*
146    /// (deduplicated) places, so in **enrich** mode a few extra navigations may
147    /// still occur for duplicates before the cap is reached (in non-enrich mode
148    /// dedup is pre-navigation, so no navigations are wasted).
149    pub max_places: Option<usize>,
150    /// Timeout for each page navigation / JS evaluation step. Default: 30s.
151    pub nav_timeout: Duration,
152    /// Optional proxy passed to Chrome as `--proxy-server=<value>`.
153    /// If `None`, falls back to the `PROXY_URL` environment variable.
154    /// The value must not contain whitespace (rejected at launch).
155    pub proxy: Option<String>,
156    /// Optional `User-Agent` override.
157    ///
158    /// `None` (default) leaves Chrome to report its own, always-current UA,
159    /// which stays consistent with the real TLS/HTTP2 fingerprint. Set this
160    /// only if you need to pin a specific UA string.
161    pub user_agent: Option<String>,
162    /// Optional WebSocket URL of a remote Chrome (e.g. a Browserless instance)
163    /// to connect to instead of launching a local browser. If `None`, falls
164    /// back to the `BROWSERLESS_URL` environment variable.
165    ///
166    /// When set, the browser is reached via `Browser::connect`, so local
167    /// launch arguments (`headless`, `proxy`, window size, user agent) are
168    /// controlled by the remote endpoint and ignored here.
169    pub browserless_url: Option<String>,
170}
171
172impl Default for ScraperConfig {
173    fn default() -> Self {
174        Self {
175            headless: true,
176            max_scroll_iterations: 30,
177            enrich: true,
178            between_query_delay: Duration::from_secs(2),
179            place_panel_delay: Duration::from_millis(1500),
180            place_panel_jitter: Duration::from_millis(750),
181            max_places: None,
182            nav_timeout: Duration::from_secs(30),
183            proxy: None,
184            user_agent: None,
185            browserless_url: None,
186        }
187    }
188}
189
190/// The scraper. Holds an active Chrome browser process.
191///
192/// Drop the scraper to close Chrome.
193pub struct MapsScraper {
194    browser: Browser,
195    handler_task: tokio::task::JoinHandle<()>,
196    cfg: ScraperConfig,
197}
198
199impl MapsScraper {
200    /// Launch (or connect to) a Chrome browser and return a ready-to-use scraper.
201    ///
202    /// If [`ScraperConfig::browserless_url`] is set (or the `BROWSERLESS_URL`
203    /// environment variable is present), this connects to that remote Chrome
204    /// over the DevTools WebSocket instead of launching a local browser — handy
205    /// when no local Chrome is available or for high-volume scraping through a
206    /// managed Chrome service.
207    pub async fn launch(cfg: ScraperConfig) -> Result<Self> {
208        // Remote Chrome (Browserless): explicit config field wins, else env var.
209        let remote = cfg
210            .browserless_url
211            .clone()
212            .or_else(|| std::env::var("BROWSERLESS_URL").ok())
213            .filter(|u| !u.is_empty());
214
215        let (browser, mut handler) = if let Some(ws_url) = remote {
216            info!(endpoint = %redact_url(&ws_url), "connecting to remote Chrome");
217            let proxy_configured = cfg.proxy.as_deref().is_some_and(|p| !p.is_empty())
218                || std::env::var("PROXY_URL").is_ok_and(|p| !p.is_empty());
219            if proxy_configured {
220                warn!(
221                    "proxy config is ignored when connecting to a remote Chrome; \
222                     configure the proxy on the remote endpoint instead"
223                );
224            }
225            Browser::connect(ws_url)
226                .await
227                .map_err(|e| Error::ChromeLaunch(e.to_string()))?
228        } else {
229            let mut builder = BrowserConfig::builder()
230                .arg("--lang=en-US,en")
231                .arg("--no-first-run")
232                .arg("--no-default-browser-check")
233                .arg("--disable-blink-features=AutomationControlled")
234                .arg("--window-size=1280,1024");
235            // Only override the UA when explicitly configured (see field docs).
236            if let Some(ua) = cfg.user_agent.as_deref().filter(|u| !u.is_empty()) {
237                builder = builder.arg(format!("--user-agent={ua}"));
238            }
239            // Use Chrome's *new* headless mode when headless: unlike the old
240            // `--headless`, it reports a normal, current user-agent with no
241            // `HeadlessChrome` token — so the default `user_agent: None` does not
242            // leak that bot-detection signal.
243            builder = if cfg.headless {
244                builder.new_headless_mode()
245            } else {
246                builder.with_head()
247            };
248            // Proxy: explicit config field wins, otherwise fall back to PROXY_URL.
249            if let Some(proxy) = cfg
250                .proxy
251                .clone()
252                .or_else(|| std::env::var("PROXY_URL").ok())
253                .filter(|p| !p.is_empty())
254            {
255                check_proxy(&proxy)?;
256                info!(server = %redact_url(&proxy), "using proxy server");
257                builder = builder.arg(format!("--proxy-server={proxy}"));
258            }
259            let browser_cfg = builder
260                .build()
261                .map_err(|e| Error::ChromeLaunch(e.to_string()))?;
262
263            Browser::launch(browser_cfg)
264                .await
265                .map_err(|e| Error::ChromeLaunch(e.to_string()))?
266        };
267
268        let handler_task = tokio::spawn(async move { while (handler.next().await).is_some() {} });
269
270        Ok(Self {
271            browser,
272            handler_task,
273            cfg,
274        })
275    }
276
277    /// Run a single Google Maps text search and return the extracted places.
278    ///
279    /// # Performance
280    /// Each call opens a fresh Chrome tab, visits the Maps homepage (consent
281    /// dismissal, including a fixed ~3 s settle delay), runs the one query, and
282    /// closes the tab. Calling this in a loop pays that per-call setup N times.
283    /// For multiple queries prefer [`MapsScraper::search_many`], which reuses
284    /// one tab for all of them.
285    pub async fn search(&self, query: &str) -> Result<Vec<Place>> {
286        self.search_many(&[query]).await
287    }
288
289    /// Run several queries through one browser session.
290    /// Results are deduped by website domain (and by maps_url when no website).
291    pub async fn search_many(&self, queries: &[&str]) -> Result<Vec<Place>> {
292        let page = self
293            .browser
294            .new_page("about:blank")
295            .await
296            .map_err(|e| Error::Page(e.to_string()))?;
297
298        // Run the scrape on a dedicated tab and always close it afterwards —
299        // even if the body returns early with an error — so a failed run never
300        // leaks an open Chrome tab for the lifetime of the scraper.
301        let result = self.search_many_on_page(&page, queries).await;
302        let _ = page.close().await;
303        result
304    }
305
306    async fn search_many_on_page(&self, page: &Page, queries: &[&str]) -> Result<Vec<Place>> {
307        // Visit the maps homepage once to handle the consent banner.
308        goto_with_timeout(page, "https://www.google.com/maps", self.cfg.nav_timeout).await?;
309        tokio::time::sleep(Duration::from_secs(3)).await;
310        let _ = dismiss_consent(page).await;
311
312        // Plain owned collections: `search_many_on_page` runs sequentially on a
313        // single task (no `tokio::spawn` shares these), so an `Arc<Mutex<…>>`
314        // would add async-lock overhead and a never-taken panic path for nothing.
315        let mut out: Vec<Place> = Vec::new();
316        let mut seen_keys: HashSet<String> = HashSet::new();
317
318        for (i, q) in queries.iter().enumerate() {
319            info!(progress = i + 1, total = queries.len(), query = %q, "scanning");
320            let url = format!("{}/search/{}/", SEARCH_URL_BASE, urlencoding::encode(q));
321            if let Err(e) = goto_with_timeout(page, &url, self.cfg.nav_timeout).await {
322                warn!("goto error: {e}");
323                continue;
324            }
325            tokio::time::sleep(self.cfg.between_query_delay).await;
326
327            // Wait for results panel (bounded so a stalled render can't hang us).
328            let _ =
329                tokio::time::timeout(self.cfg.nav_timeout, page.find_element("div[role='feed']"))
330                    .await;
331
332            // Scroll until stable
333            let _ = scroll_feed(page, self.cfg.max_scroll_iterations).await;
334
335            // Collect place URLs in the feed. Only keep https:// links so a
336            // tampered DOM can't feed us a `javascript:` / `data:` URL to navigate to.
337            let urls: Vec<String> = collect_place_urls(page)
338                .await
339                .unwrap_or_default()
340                .into_iter()
341                .filter(|u| u.starts_with("https://"))
342                .collect();
343            info!(found = urls.len(), "feed collected");
344
345            // Number of unique places added for *this* query, used to honor max_places.
346            let mut added_this_query = 0usize;
347
348            if !self.cfg.enrich {
349                for u in urls {
350                    if self.cfg.max_places.is_some_and(|m| added_this_query >= m) {
351                        break;
352                    }
353                    if seen_keys.insert(u.clone()) {
354                        let (latitude, longitude) = parse_coords_from_maps_url(&u);
355                        out.push(Place {
356                            name: String::new(),
357                            maps_url: Some(u),
358                            latitude,
359                            longitude,
360                            source_query: Some((*q).to_string()),
361                            ..Default::default()
362                        });
363                        added_this_query += 1;
364                    }
365                }
366                continue;
367            }
368
369            for place_url in urls {
370                if self.cfg.max_places.is_some_and(|m| added_this_query >= m) {
371                    break;
372                }
373                // Fast-path: skip a place URL we have already visited (within this
374                // query or a previous one) before paying for a full navigation.
375                if seen_keys.contains(&place_url) {
376                    continue;
377                }
378                if let Err(e) = goto_with_timeout(page, &place_url, self.cfg.nav_timeout).await {
379                    warn!("place goto: {e}");
380                    continue;
381                }
382                // Give the detail panel a chance to render before extracting:
383                // wait (bounded) for the title `<h1>` that `extract_place_details`
384                // reads, then let the rest settle. This reduces — but cannot fully
385                // prevent — empty results on a slow render.
386                let _ = tokio::time::timeout(self.cfg.nav_timeout, page.find_element("h1")).await;
387                // Settle delay + random jitter so consecutive place visits don't
388                // form a fixed-interval (easily-flagged) pattern.
389                let max_jitter_ms =
390                    u64::try_from(self.cfg.place_panel_jitter.as_millis()).unwrap_or(u64::MAX);
391                let jitter = Duration::from_millis(jitter_ms(time_seed(), max_jitter_ms));
392                tokio::time::sleep(self.cfg.place_panel_delay + jitter).await;
393                let detail = match extract_place_details(page).await {
394                    Ok(d) => d,
395                    Err(e) => {
396                        warn!("extract: {e}");
397                        // Still register the URL so a consistently-failing place
398                        // is not re-navigated on every subsequent query.
399                        seen_keys.insert(place_url.clone());
400                        continue;
401                    }
402                };
403
404                // Dedup by website domain (falling back to the maps URL when there
405                // is no website), and register the place. See `register_place`.
406                let key = detail.website_domain().unwrap_or_else(|| place_url.clone());
407                let is_new = register_place(&mut seen_keys, &key, &place_url);
408                if !is_new {
409                    continue;
410                }
411
412                let (postcode, city) =
413                    parse_german_address(detail.address.as_deref().unwrap_or(""));
414                let (latitude, longitude) = parse_coords_from_maps_url(&place_url);
415                out.push(Place {
416                    name: detail.name.unwrap_or_default(),
417                    address: detail.address,
418                    postcode,
419                    city,
420                    phone: detail.phone,
421                    website: detail.website,
422                    maps_url: Some(place_url),
423                    latitude,
424                    longitude,
425                    source_query: Some((*q).to_string()),
426                });
427                added_this_query += 1;
428            }
429        }
430
431        Ok(out)
432    }
433
434    /// Close Chrome cleanly. (Drop also works.)
435    pub async fn close(mut self) -> Result<()> {
436        let _ = self.browser.close().await;
437        self.handler_task.abort();
438        Ok(())
439    }
440}
441
442impl Drop for MapsScraper {
443    /// Abort the CDP handler task if the scraper is dropped without an explicit
444    /// `close()` (e.g. on panic or early return). `chromiumoxide::Browser` has
445    /// its own `Drop` that signals Chrome to shut down, so this just makes sure
446    /// the background polling task does not outlive the scraper.
447    fn drop(&mut self) {
448        self.handler_task.abort();
449    }
450}
451
452// ───────── internals ─────────
453
454#[derive(Debug, Default)]
455struct PlaceDetailRaw {
456    name: Option<String>,
457    address: Option<String>,
458    phone: Option<String>,
459    website: Option<String>,
460}
461
462impl PlaceDetailRaw {
463    fn website_domain(&self) -> Option<String> {
464        let w = self.website.as_deref()?;
465        let parsed = url::Url::parse(w).ok()?;
466        Some(
467            parsed
468                .host_str()
469                .unwrap_or("")
470                .trim_start_matches("www.")
471                .to_string(),
472        )
473    }
474}
475
476/// Reject a proxy value that Chrome cannot use as a single `--proxy-server`
477/// argument. A value with whitespace is malformed: Chrome receives the whole
478/// `--proxy-server=<value>` as one token, silently fails to apply the proxy, and
479/// falls back to a **direct** connection — leaking the real IP. Failing loudly
480/// here surfaces the misconfiguration instead.
481fn check_proxy(proxy: &str) -> Result<()> {
482    if proxy.contains(char::is_whitespace) {
483        return Err(Error::Config("proxy must not contain whitespace".into()));
484    }
485    Ok(())
486}
487
488/// Strip credentials from a URL so it is safe to log: removes any userinfo
489/// (`user:pass@`) and the query string (which may carry a `?token=...`).
490/// Falls back to `"<redacted>"` when the value does not parse as a URL.
491fn redact_url(raw: &str) -> String {
492    match url::Url::parse(raw) {
493        Ok(mut u) => {
494            let _ = u.set_username("");
495            let _ = u.set_password(None);
496            u.set_query(None);
497            u.to_string()
498        }
499        Err(_) => "<redacted>".to_string(),
500    }
501}
502
503/// Navigate `page` to `url`, failing with [`Error::Page`] if it does not
504/// complete within `timeout`. Prevents a stalled network/render from blocking
505/// the async task indefinitely.
506async fn goto_with_timeout(page: &Page, url: &str, timeout: Duration) -> Result<()> {
507    tokio::time::timeout(timeout, page.goto(url))
508        .await
509        .map_err(|_| Error::Page(format!("navigation timed out after {timeout:?}: {url}")))??;
510    Ok(())
511}
512
513async fn dismiss_consent(page: &Page) {
514    let selectors = [
515        "button[aria-label*='Alle akzeptieren']",
516        "button[aria-label*='Alle ablehnen']",
517        "button[aria-label*='Accept all']",
518        "button[aria-label*='Reject all']",
519        "form[action*='consent.google.com'] button",
520    ];
521    for sel in selectors {
522        if let Ok(el) = page.find_element(sel).await {
523            let _ = el.click().await;
524            tokio::time::sleep(Duration::from_secs(2)).await;
525            break;
526        }
527    }
528}
529
530async fn scroll_feed(page: &Page, max_iters: usize) -> Result<()> {
531    let mut last_height = -1.0_f64;
532    let mut stable = 0;
533    for _ in 0..max_iters {
534        let new_height: f64 = page
535            .evaluate(
536                "(() => { const f = document.querySelector(\"div[role='feed']\"); if (!f) return -1; f.scrollTop = f.scrollHeight; return f.scrollHeight; })()",
537            )
538            .await?
539            .into_value()
540            .unwrap_or(-1.0);
541        if new_height < 0.0 {
542            break;
543        }
544        if (new_height - last_height).abs() < 1.0 {
545            stable += 1;
546            if stable >= 3 {
547                break;
548            }
549        } else {
550            stable = 0;
551        }
552        last_height = new_height;
553        tokio::time::sleep(Duration::from_millis(900)).await;
554    }
555    Ok(())
556}
557
558async fn collect_place_urls(page: &Page) -> Result<Vec<String>> {
559    let raw: Vec<String> = page
560        .evaluate(
561            "Array.from(document.querySelectorAll(\"div[role='feed'] a[href*='/maps/place/']\")).map(a => a.href)",
562        )
563        .await?
564        .into_value()
565        .unwrap_or_default();
566    let mut seen = HashSet::new();
567    let mut out = Vec::new();
568    for u in raw {
569        if seen.insert(u.clone()) {
570            out.push(u);
571        }
572    }
573    Ok(out)
574}
575
576async fn extract_place_details(page: &Page) -> Result<PlaceDetailRaw> {
577    let js = r#"
578        (() => {
579            const out = {};
580            const h1 = document.querySelector('h1');
581            out.name = h1 ? h1.textContent.trim() : null;
582
583            const addr = document.querySelector('[data-item-id="address"]');
584            out.address = addr ? addr.getAttribute('aria-label') || addr.textContent.trim() : null;
585
586            const phone = document.querySelector('[data-item-id^="phone"]');
587            out.phone = phone ? (phone.getAttribute('aria-label') || phone.textContent.trim()) : null;
588
589            const authority = document.querySelector('a[data-item-id="authority"]')
590                || document.querySelector('[data-item-id="authority"] a')
591                || document.querySelector('a[aria-label*="Website"]');
592            out.website = authority ? authority.href : null;
593            return out;
594        })()
595    "#;
596    let raw: serde_json::Value = page.evaluate(js).await?.into_value().unwrap_or_default();
597    let mut d = PlaceDetailRaw::default();
598    if let Some(s) = raw.get("name").and_then(|v| v.as_str()) {
599        d.name = Some(s.to_string());
600    }
601    if let Some(s) = raw.get("address").and_then(|v| v.as_str()) {
602        d.address = Some(
603            s.trim_start_matches("Adresse: ")
604                .trim_start_matches("Address: ")
605                .to_string(),
606        );
607    }
608    if let Some(s) = raw.get("phone").and_then(|v| v.as_str()) {
609        d.phone = Some(
610            s.trim_start_matches("Telefon: ")
611                .trim_start_matches("Phone: ")
612                .trim()
613                .to_string(),
614        );
615    }
616    if let Some(s) = raw.get("website").and_then(|v| v.as_str()) {
617        d.website = Some(s.to_string());
618    }
619    Ok(d)
620}
621
622static GERMAN_ADDRESS_RE: LazyLock<regex::Regex> =
623    LazyLock::new(|| regex::Regex::new(r"(\d{5})\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-/. ]{1,40})").unwrap());
624
625fn parse_german_address(addr: &str) -> (Option<String>, Option<String>) {
626    if let Some(cap) = GERMAN_ADDRESS_RE.captures(addr) {
627        return (
628            cap.get(1).map(|m| m.as_str().to_string()),
629            cap.get(2).map(|m| m.as_str().trim().to_string()),
630        );
631    }
632    (None, None)
633}
634
635/// Record a freshly-extracted place in the `seen` set and report whether it is
636/// new (i.e. should be kept). `domain_key` is the dedup key — the website domain
637/// when the place has one, otherwise the maps URL. `raw_url` is the place's maps
638/// URL, which is *always* registered so the navigation fast-path can skip exact
639/// repeats later, even for a place whose dedup key is its domain.
640///
641/// Note: `seen` mixes two key namespaces — bare website domains (`example.de`)
642/// and full `https://…` URLs. They can never collide because a domain has no
643/// URL scheme.
644fn register_place(seen: &mut HashSet<String>, domain_key: &str, raw_url: &str) -> bool {
645    let is_new = seen.insert(domain_key.to_string());
646    seen.insert(raw_url.to_string());
647    is_new
648}
649
650/// Map a seed to a value in `0..=max_ms` (inclusive). Non-cryptographic — used
651/// only to spread out the delay between place visits, so a weak seed is fine.
652fn jitter_ms(seed: u64, max_ms: u64) -> u64 {
653    if max_ms == 0 { 0 } else { seed % (max_ms + 1) }
654}
655
656/// A cheap, std-only seed for [`jitter_ms`]. Mixes the wall-clock nanoseconds
657/// with a process-wide call counter through `DefaultHasher` (SipHash), so the
658/// result is well-distributed across the `u64` range and — thanks to the
659/// counter — never repeats nor correlates between successive calls, even if the
660/// clock is coarse. Not cryptographic; only used to de-regularise delays.
661fn time_seed() -> u64 {
662    use std::hash::Hasher;
663    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
664    let nanos = std::time::SystemTime::now()
665        .duration_since(std::time::UNIX_EPOCH)
666        .map(|d| d.as_nanos() as u64)
667        .unwrap_or(0);
668    let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
669    let mut hasher = std::collections::hash_map::DefaultHasher::new();
670    hasher.write_u64(nanos);
671    hasher.write_u64(count);
672    hasher.finish()
673}
674
675/// Parse the `@<lat>,<lng>` segment that Google Maps embeds in place URLs,
676/// e.g. `https://www.google.com/maps/place/.../@52.5200,13.4050,17z/...`.
677/// Returns `(None, None)` if the URL has no coordinate segment.
678fn parse_coords_from_maps_url(url: &str) -> (Option<f64>, Option<f64>) {
679    static COORDS_RE: LazyLock<regex::Regex> =
680        LazyLock::new(|| regex::Regex::new(r"@(-?\d+\.\d+),(-?\d+\.\d+)").unwrap());
681    if let Some(cap) = COORDS_RE.captures(url) {
682        let lat = cap.get(1).and_then(|m| m.as_str().parse::<f64>().ok());
683        let lng = cap.get(2).and_then(|m| m.as_str().parse::<f64>().ok());
684        return (lat, lng);
685    }
686    (None, None)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn parses_de_address() {
695        let (pc, city) = parse_german_address("Hauptstr. 12, 10115 Berlin, Deutschland");
696        assert_eq!(pc.as_deref(), Some("10115"));
697        assert!(city.unwrap().starts_with("Berlin"));
698    }
699
700    #[test]
701    fn website_domain_from_url() {
702        let p = PlaceDetailRaw {
703            website: Some("https://www.example.de/path?x=1".to_string()),
704            ..Default::default()
705        };
706        assert_eq!(p.website_domain().as_deref(), Some("example.de"));
707    }
708
709    #[test]
710    fn register_place_dedup() {
711        let mut seen = HashSet::new();
712        // (a) New domain → kept, both keys registered.
713        assert!(register_place(
714            &mut seen,
715            "example.de",
716            "https://maps.google.com/place/A"
717        ));
718        // (b) Same domain via a *different* URL → discarded.
719        assert!(!register_place(
720            &mut seen,
721            "example.de",
722            "https://maps.google.com/place/B"
723        ));
724        // (c) New website-less place (key == raw URL) → kept.
725        assert!(register_place(
726            &mut seen,
727            "https://maps.google.com/place/C",
728            "https://maps.google.com/place/C"
729        ));
730        // (d) The same website-less place again → discarded.
731        assert!(!register_place(
732            &mut seen,
733            "https://maps.google.com/place/C",
734            "https://maps.google.com/place/C"
735        ));
736        // Every visited raw URL is registered for the fast-path.
737        assert!(seen.contains("https://maps.google.com/place/A"));
738        assert!(seen.contains("https://maps.google.com/place/B"));
739    }
740
741    #[test]
742    fn config_defaults() {
743        let c = ScraperConfig::default();
744        assert!(c.headless);
745        assert!(c.enrich);
746        assert_eq!(c.max_scroll_iterations, 30);
747        assert_eq!(c.max_places, None);
748        assert!(c.proxy.is_none());
749        assert!(c.user_agent.is_none());
750        assert!(c.browserless_url.is_none());
751        assert_eq!(c.place_panel_jitter, Duration::from_millis(750));
752    }
753
754    #[test]
755    fn jitter_within_bounds() {
756        // Zero jitter is disabled regardless of seed.
757        assert_eq!(jitter_ms(123_456, 0), 0);
758        // Any seed maps into 0..=max inclusive.
759        for seed in [0u64, 1, 750, 751, u64::MAX] {
760            assert!(jitter_ms(seed, 750) <= 750);
761        }
762        assert_eq!(jitter_ms(750, 750), 750);
763        assert_eq!(jitter_ms(751, 750), 0);
764    }
765
766    #[test]
767    fn check_proxy_rejects_whitespace() {
768        assert!(check_proxy("http://user:pass@host:8080").is_ok());
769        assert!(check_proxy("socks5://10.0.0.1:1080").is_ok());
770        // Whitespace makes a malformed proxy Chrome would silently ignore.
771        assert!(check_proxy("http://h:1 --disable-web-security").is_err());
772        assert!(check_proxy("http://h:1\t--foo").is_err());
773    }
774
775    #[test]
776    fn redact_url_strips_credentials_and_token() {
777        assert_eq!(
778            redact_url("http://user:pass@proxy.example:8080"),
779            "http://proxy.example:8080/"
780        );
781        assert_eq!(
782            redact_url("wss://chrome.browserless.io?token=secret123"),
783            "wss://chrome.browserless.io/"
784        );
785        assert_eq!(redact_url("not a url"), "<redacted>");
786    }
787
788    #[test]
789    fn parses_coords_from_maps_url() {
790        let url = "https://www.google.com/maps/place/Cafe/@52.5200066,13.404954,17z/data=abc";
791        let (lat, lng) = parse_coords_from_maps_url(url);
792        assert_eq!(lat, Some(52.5200066));
793        assert_eq!(lng, Some(13.404954));
794    }
795
796    #[test]
797    fn coords_negative_and_missing() {
798        let (lat, lng) =
799            parse_coords_from_maps_url("https://maps.google.com/.../@-33.8688,151.2093,15z");
800        assert_eq!(lat, Some(-33.8688));
801        assert_eq!(lng, Some(151.2093));
802        assert_eq!(
803            parse_coords_from_maps_url("https://example.com/no-coords"),
804            (None, None)
805        );
806    }
807}