1#![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#[derive(Debug, thiserror::Error)]
65pub enum Error {
66 #[error("chrome launch failed: {0}")]
68 ChromeLaunch(String),
69
70 #[error("page error: {0}")]
72 Page(String),
73
74 #[error("invalid config: {0}")]
76 Config(String),
77
78 #[error("cdp: {0}")]
80 Cdp(#[from] chromiumoxide::error::CdpError),
81}
82
83pub type Result<T> = std::result::Result<T, Error>;
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct Place {
89 pub name: String,
91 pub address: Option<String>,
93 pub postcode: Option<String>,
98 pub city: Option<String>,
102 pub phone: Option<String>,
104 pub website: Option<String>,
106 pub maps_url: Option<String>,
108 pub latitude: Option<f64>,
110 pub longitude: Option<f64>,
112 pub source_query: Option<String>,
114}
115
116#[derive(Debug, Clone)]
118pub struct ScraperConfig {
119 pub headless: bool,
121 pub max_scroll_iterations: usize,
124 pub enrich: bool,
127 pub between_query_delay: Duration,
129 pub place_panel_delay: Duration,
133 pub place_panel_jitter: Duration,
140 pub max_places: Option<usize>,
150 pub nav_timeout: Duration,
152 pub proxy: Option<String>,
156 pub user_agent: Option<String>,
162 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
190pub struct MapsScraper {
194 browser: Browser,
195 handler_task: tokio::task::JoinHandle<()>,
196 cfg: ScraperConfig,
197}
198
199impl MapsScraper {
200 pub async fn launch(cfg: ScraperConfig) -> Result<Self> {
208 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 if let Some(ua) = cfg.user_agent.as_deref().filter(|u| !u.is_empty()) {
237 builder = builder.arg(format!("--user-agent={ua}"));
238 }
239 builder = if cfg.headless {
244 builder.new_headless_mode()
245 } else {
246 builder.with_head()
247 };
248 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 pub async fn search(&self, query: &str) -> Result<Vec<Place>> {
286 self.search_many(&[query]).await
287 }
288
289 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 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 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 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 let _ =
329 tokio::time::timeout(self.cfg.nav_timeout, page.find_element("div[role='feed']"))
330 .await;
331
332 let _ = scroll_feed(page, self.cfg.max_scroll_iterations).await;
334
335 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 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 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 let _ = tokio::time::timeout(self.cfg.nav_timeout, page.find_element("h1")).await;
387 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 seen_keys.insert(place_url.clone());
400 continue;
401 }
402 };
403
404 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 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 fn drop(&mut self) {
448 self.handler_task.abort();
449 }
450}
451
452#[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
476fn 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
488fn 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
503async 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
635fn 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
650fn jitter_ms(seed: u64, max_ms: u64) -> u64 {
653 if max_ms == 0 { 0 } else { seed % (max_ms + 1) }
654}
655
656fn 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
675fn 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 assert!(register_place(
714 &mut seen,
715 "example.de",
716 "https://maps.google.com/place/A"
717 ));
718 assert!(!register_place(
720 &mut seen,
721 "example.de",
722 "https://maps.google.com/place/B"
723 ));
724 assert!(register_place(
726 &mut seen,
727 "https://maps.google.com/place/C",
728 "https://maps.google.com/place/C"
729 ));
730 assert!(!register_place(
732 &mut seen,
733 "https://maps.google.com/place/C",
734 "https://maps.google.com/place/C"
735 ));
736 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 assert_eq!(jitter_ms(123_456, 0), 0);
758 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 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}