#!/usr/bin/env python3 """ Unified Multi-Surface Scraper for VeriPath Audits. Extracts GBP + Apple Maps + Website, merges into verified JSON contract. Working surfaces: - Google Business Profile (via noworneverev/google-maps-scraper) - Apple Maps (headless Firefox → /data/search JSON) - Website (direct HTTP fetch) Bot-walled (headless detection, no free bypass): - Bing Places, Yelp """ import asyncio import json import sys import re import os from datetime import datetime, timezone # Ensure scraper is importable sys.path.insert(0, "/tmp/google-maps-scraper/src") sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from gmaps_scraper.scraper import GoogleMapsScraper from apple_scraper import scrape_apple from bing_provider import scrape_bing BASE_DIR = os.path.dirname(os.path.abspath(__file__)) async def geocode(query): """Get coordinates from Google Maps search.""" import urllib.request import urllib.parse search_url = f"https://www.google.com/maps/search/{urllib.parse.quote(query)}/" req = urllib.request.Request(search_url, headers={"User-Agent": "Mozilla/5.0"}) try: with urllib.request.urlopen(req, timeout=10) as resp: html = resp.read().decode("utf-8", errors="replace") except Exception: return None m = re.search(r'"center":\{"lat":(-?\d+\.\d+),"lng":(-?\d+\.\d+)}', html) if m: return float(m.group(1)), float(m.group(2)) m = re.search(r'@(-?\d+\.\d+),(-?\d+\.\d+),\d+z', html) if m: return float(m.group(1)), float(m.group(2)) return None def build_gmaps_url(query, coords): """Build Google Maps search URL.""" import urllib.parse encoded = urllib.parse.quote(query) if coords: lat, lon = coords return f"https://www.google.com/maps/search/{encoded}/@{lat},{lon},14z/data=!3m1!4b1" return f"https://www.google.com/maps/search/{encoded}/data=!3m1!4b1" # Known search-page titles (not real places) — same set the lib rejects _SEARCH_PAGE_TITLES = {"results", "Results", "検索結果", "搜尋結果", "搜索结果"} def _looks_like_match(place_name, business): """Reject search-page titles and names that share no significant token with the query.""" if not place_name: return False name = place_name.strip() if name.lower() in _SEARCH_PAGE_TITLES: return False # Significant tokens = words >=4 chars from the business query biz_tokens = {t.lower() for t in re.findall(r'[A-Za-z]{4,}', business)} name_tokens = {t.lower() for t in re.findall(r'[A-Za-z]{4,}', name)} if not biz_tokens: return True # Require at least one shared significant token (e.g. "Crystal", "Plumbing") return bool(biz_tokens & name_tokens) async def scrape_google(query, coords, business=""): """Scrape Google Business Profile data, validating the matched result.""" url = build_gmaps_url(query, coords) try: async with GoogleMapsScraper() as scraper: result = await scraper.scrape(url) if result and result.place: p = result.place # Validation gate: reject wrong-entity matches before trusting if not _looks_like_match(p.name, business or query): print(f" [REJECT] GBP matched wrong entity: '{p.name}' (expected ~'{business}')") return None # Hours: ["Thursday9 AM–6 PM", "SundayClosed", ...] hours_dict = {} if p.hours: for h in p.hours: m = re.match(r'(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)(.+)', h, re.IGNORECASE) if m: day = m.group(1).lower() time_str = m.group(2).strip() time_str = time_str.replace('\u202f', ' ').replace('\u2013', '-').replace('\ue14d', '').strip() hours_dict[day] = time_str return { # Strip PUA glyphs (map-pin \ue0b0 etc.) from address + phone — BMP PUA (U+E000–U+F8FF) breaks downstream normalize "name": p.name, "address": re.sub(r'[\ue000-\uf8ff]', '', p.address or '').strip(), "phone": re.sub(r'[\ue000-\uf8ff]', '', p.phone or '').strip() if p.phone else p.phone, "website": p.website, "rating": p.rating, "reviews": p.review_count, "hours": hours_dict, "category": p.category, "price_level": p.price_level, "description": p.description, "photos_count": p.photos_count, "latitude": p.latitude, "longitude": p.longitude, "url": p.google_maps_url, "permanently_closed": p.permanently_closed, "temporarily_closed": p.temporarily_closed, } except Exception as e: print(f" [FAIL] Google: {e}") return None def fetch_website_data(website_url): """Extract basic info from business website.""" if not website_url: return None import urllib.request try: req = urllib.request.Request(website_url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: html = resp.read().decode("utf-8", errors="replace") data = {"source_url": website_url} # Extract title m = re.search(r'