cd40e652a0
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.
155 lines
5.4 KiB
Python
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")
|