feat(auditing): add multi_scraper.py — multi-surface GBP/Apple/Bing/Website extractor

This commit is contained in:
2026-08-14 15:39:47 +00:00
parent 9bc9c948d2
commit a17fa8dc65
+319
View File
@@ -0,0 +1,319 @@
#!/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"
async def scrape_google(query, coords):
"""Scrape Google Business Profile data."""
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
# Hours: ["Thursday9 AM6 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 {
"name": p.name,
"address": p.address,
"phone": 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'<title>([^<]+)</title>', html, re.IGNORECASE)
if m:
data["title"] = m.group(1).strip()
# Extract meta description
m = re.search(r'<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\']', html, re.IGNORECASE)
if not m:
m = re.search(r'<meta[^>]*content=["\']([^"\']+)["\'][^>]*name=["\']description["\']', html, re.IGNORECASE)
if m:
data["description"] = m.group(1).strip()
# Extract phone from page
phone_match = re.search(r'(\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4})', html)
if phone_match:
data["phone_on_page"] = phone_match.group(1)
return data
except Exception as e:
print(f" [FAIL] Website: {e}")
return None
def merge_and_validate(gbp_data=None, apple_data=None, website_data=None, bing_data=None):
"""Merge data from all surfaces and flag mismatches."""
contract = {
"audit": {
"timestamp": datetime.now(timezone.utc).isoformat(),
"tool": "multi_scraper_v1",
"surfaces_checked": {
"google_business_profile": bool(gbp_data),
"apple_maps": bool(apple_data),
"bing_places": bool(bing_data),
"website": bool(website_data),
},
"verification": {},
},
"primary_source": "google_business_profile" if gbp_data else None,
"name": None,
"address": None,
"phone": None,
"website": None,
"rating": None,
"reviews": None,
"reviews_sample": None, # Yelp-sourced via Apple Maps proxy
"hours": None,
"category": None,
"price_level": None,
"photos_count": None,
"description": None,
"coordinates": None,
"closed_status": None,
"sources": {
"google_business_profile": gbp_data,
"apple_maps": apple_data,
"bing_places": bing_data,
"website": website_data,
},
}
# Cross-source NAP verification
sources = [("google", gbp_data), ("apple", apple_data)]
for field in ["name", "phone", "website"]:
values = {}
for src_name, src_data in sources:
if src_data and src_data.get(field):
values[src_name] = src_data[field]
if len(values) > 1:
first_val = list(values.values())[0]
mismatches = {k: v for k, v in values.items() if v != first_val}
if mismatches:
contract["audit"]["verification"][f"{field}_mismatch"] = {
"primary": first_val,
"conflicts": mismatches,
}
# Hours cross-reference
if gbp_data and apple_data:
gbp_hours = gbp_data.get("hours") or {}
apple_hours = apple_data.get("hours") or {}
if gbp_hours and apple_hours:
hour_mismatches = {}
for day in gbp_hours:
if day in apple_hours and gbp_hours[day] != apple_hours[day]:
hour_mismatches[day] = {
"google": gbp_hours[day],
"apple": apple_hours[day],
}
if hour_mismatches:
contract["audit"]["verification"]["hours_mismatch"] = hour_mismatches
# Use GBP as primary source
if gbp_data:
contract["name"] = gbp_data.get("name")
contract["address"] = re.sub(r'[\U000E0000-\U000EFFFF]', '', gbp_data.get("address", "")).strip()
contract["phone"] = re.sub(r'[\U000E0000-\U000EFFFF]', '', gbp_data.get("phone", "")).strip()
contract["website"] = gbp_data.get("website")
contract["rating"] = gbp_data.get("rating")
contract["reviews"] = gbp_data.get("reviews")
contract["reviews_sample"] = apple_data.get("reviews_sample") if apple_data else None
contract["hours"] = gbp_data.get("hours")
contract["category"] = gbp_data.get("category")
contract["price_level"] = gbp_data.get("price_level")
contract["photos_count"] = gbp_data.get("photos_count")
contract["description"] = gbp_data.get("description")
contract["coordinates"] = {
"lat": gbp_data.get("latitude"),
"lon": gbp_data.get("longitude"),
}
contract["closed_status"] = {
"permanently_closed": gbp_data.get("permanently_closed"),
"temporarily_closed": gbp_data.get("temporarily_closed"),
}
return contract
async def main():
if len(sys.argv) < 3:
print("Usage: multi_scraper.py <business_name> <city, state>")
sys.exit(1)
business = sys.argv[1]
location = sys.argv[2]
query = f"{business} {location}"
print(f"Scraping: {query}")
print("=" * 60)
# Step 1: Geocode
print("Geocoding location...")
coords = await geocode(query)
if coords:
print(f" Coords: {coords[0]}, {coords[1]}")
# Step 2: Scrape GBP (primary)
print("\nScraping Google Business Profile...")
gbp_data = await scrape_google(query, coords)
if gbp_data:
print(f" [OK] Google: {gbp_data['name']} ({gbp_data['rating']} ★, {gbp_data['reviews']} reviews)")
print(f" Hours: {gbp_data.get('hours', {})}")
# Step 3: Scrape Apple Maps
print("\nScraping Apple Maps...")
apple_data = await scrape_apple(query)
if apple_data:
print(f" [OK] Apple: {apple_data.get('name')} ({apple_data.get('rating')} ★, {apple_data.get('reviews')} reviews)")
if apple_data.get('hours'):
print(f" Hours: {apple_data.get('hours')}")
else:
print(" [FAIL] Apple Maps returned no data")
# Step 3b: Scrape Bing Places (via web search)
print("\nScraping Bing Places...")
await asyncio.sleep(5) # ponytail: rate limit, separate from Apple Maps browser launch
bing_data = await scrape_bing(query)
if bing_data:
print(f" [OK] Bing: {bing_data.get('name')} ({bing_data.get('rating')} ★, {bing_data.get('reviews')} reviews)")
if bing_data.get('hours'):
print(f" Hours: {bing_data.get('hours')}")
else:
print(" [FAIL] Bing returned no data")
# Step 4: Fetch website
print("\nFetching website...")
website_url = gbp_data.get("website") if gbp_data else None
if not website_url and apple_data:
website_url = apple_data.get("website")
website_data = fetch_website_data(website_url)
if website_data:
print(f" [OK] Website: {website_data.get('source_url')}")
else:
print(" [SKIP] No website URL found")
# Step 5: Merge and validate
print("\nMerging sources...")
contract = merge_and_validate(gbp_data=gbp_data, apple_data=apple_data, website_data=website_data, bing_data=bing_data)
# Step 6: Save
safe_name = re.sub(r"[^\w\s-]", "", business).strip().replace(" ", "_").lower()
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
filename = f"{safe_name}_multi_surface_{date_str}.json"
filepath = os.path.join(BASE_DIR, filename)
with open(filepath, "w") as f:
json.dump(contract, f, indent=2, ensure_ascii=False)
print(f"\nSaved: {filepath}")
print(f"Primary source: {contract['primary_source']}")
# Print verification summary
if contract["audit"]["verification"]:
print("\n⚠️ Mismatches detected:")
for key, val in contract["audit"]["verification"].items():
print(f" {key}: {json.dumps(val)}")
if __name__ == "__main__":
asyncio.run(main())