#!/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")