Files
Leonard (VeriPath Agent) cd40e652a0 build(auditing): add sibling scrapers for self-contained repo
multi_scraper.py imports apple_scraper and bing_provider but they were not
committed to implementation/auditing/. Add them so the repo runs without
external file copies from the workdir.
2026-08-14 21:02:37 +00:00

155 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
Bing Places scraper via Bing Web Search (NOT Bing Maps).
Bing Maps blocks headless (WebGL wall), but Bing *web search* renders
full local business data in the DOM: name, address, phone, hours,
rating, reviews, categories, description.
Data is Yelp-sourced (Bing attributes to Yelp). Same as Apple Maps.
"""
import asyncio
import json
import re
import sys
from playwright.async_api import async_playwright
async def scrape_bing(query: str) -> dict | None:
"""Scrape Bing Places data from Bing web search results.
Args:
query: "Business Name City, ST"
Returns:
dict with name, address, phone, website, rating, reviews,
hours, category, description, reviews_sample, or None on failure.
"""
q = re.sub(r'\s+', ' ', query).strip()
url = f"https://www.bing.com/search?q={q.replace(' ', '+')}"
async with async_playwright() as pw:
browser = await pw.firefox.launch(headless=True)
context = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15",
locale="en-US",
timezone_id="America/Los_Angeles",
)
await context.add_init_script("Object.defineProperty(navigator,'webdriver',{get:()=>false});")
page = await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(8000)
body_text = await page.locator("body").text_content()
except Exception:
await browser.close()
return None
await browser.close()
# Find business section by first word of query
first_word = q.split()[0]
biz_idx = body_text.find(first_word)
if biz_idx < 0:
return None
# Grab 3000 chars from business mention — this is one long blob
section = body_text[biz_idx : biz_idx + 3000]
result = {}
# NAME: from first word up to the first URL
name_m = re.match(r"([A-Za-z\s&+,.\'-]{3,60}?)\s*https?://", section)
if not name_m:
name_m = re.match(r"([A-Za-z\s&+,.\'-]{3,60}?)\s*Share", section)
if not name_m:
name_m = re.match(r"([A-Za-z\s&+,.\'-]{3,60}?)\s*\d", section) # before rating
if name_m:
result["name"] = name_m.group(1).strip()
else:
return None
# WEBSITE
url_m = re.search(r"(https?://[a-zA-Z0-9._/-]+?)(?:\s|$)", section)
if url_m:
result["website"] = url_m.group(1).rstrip("/")
# RATING + REVIEWS: "5/5 (65 reviews)"
rating_m = re.search(r"(\d+(?:\.\d+)?)\s*/\s*5.*?\(\s*(\d+)\s*reviews?\)", section)
if rating_m:
result["rating"] = float(rating_m.group(1))
result["reviews"] = int(rating_m.group(2))
# CATEGORY: "reviews) · Beauty & spa in Cameron Park"
cat_m = re.search(r"reviews?\)\s*·\s*([A-Za-z\s&]+?)\s*in\s+", section)
if cat_m:
result["category"] = cat_m.group(1).strip()
# ADDRESS: "3460 Robin Ln Ste 4, Cameron Park, CA 95682"
addr_m = re.search(r"(\d+\s+[A-Za-z\s.,#SteSuite]+?\d{5})", section)
if addr_m:
result["address"] = addr_m.group(1).strip().rstrip(",.")
# PHONE: "(530) 350-9197"
phone_m = re.search(r"(\(\d{3}\)\s*\d{3}-\d{4})", section)
if not phone_m:
phone_m = re.search(r"(\d{3}-\d{3}-\d{4})", section)
if phone_m:
result["phone"] = phone_m.group(1)
# HOURS: "Thursday9 AM - 7 PMFriday9 AM - 6 PM..."
# Pattern: DayName + hours (with no space between day and time)
hours = {}
days_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
for day in days_order:
day_m = re.search(
rf"{day}(\d+(?::\d+)?\s*[AP]M?\s*-\s*\d+(?::\d+)?\s*[AP]M?|Closed|opens\s+\w+\s+\d+[AP]M?)",
section,
re.I,
)
if day_m:
hours[day.lower()] = day_m.group(1).strip()
if hours:
result["hours"] = hours
# DESCRIPTION: "AboutPhoenix Salon + Spa offers..."
about_idx = section.find("About")
if about_idx >= 0:
about_snip = section[about_idx:about_idx + 600]
# Skip "About" then business name
name = result.get("name", "")
name_idx = about_snip.find(name)
if name_idx >= 0:
desc_text = about_snip[name_idx + len(name):].strip()
# Grab first sentence
end = desc_text.find(".")
if end > 0:
result["description"] = desc_text[:end + 1].strip()
# REVIEWS: "Apr 21, 2026I have been a customer...Learn more ·"
reviews = []
for rm in re.finditer(
r"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4})(.*?)Learn more",
section,
re.DOTALL,
):
text = rm.group(2).strip().replace("", "")
text = re.sub(r'\s+', ' ', text)[:200]
if len(text) < 20:
continue
reviews.append({"text": text, "date": rm.group(1)})
if len(reviews) >= 3:
break
if reviews:
result["reviews_sample"] = reviews
return result if result.get("name") else None
if __name__ == "__main__":
query = sys.argv[1] if len(sys.argv) > 1 else "Phoenix Salon + Spa Cameron Park, CA"
result = asyncio.run(scrape_bing(query))
if result:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print("No data extracted")