search_google_maps.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """
  2. Google Maps Morocco dealer discovery with public website email extraction.
  3. Preview-first workflow:
  4. - Connect to an already-open AdsPower browser by profile ID.
  5. - Search Google Maps with Morocco-wide dealer/importer/showroom keywords.
  6. - Score and de-duplicate candidates before opening place detail pages.
  7. - Reject OEM local brand-country pages such as BYD Maroc or BMW Maroc.
  8. - Optionally deep-scrape place details and merchant websites for public emails.
  9. - Write to Excel only when --write-excel is explicitly passed.
  10. """
  11. import argparse
  12. import json
  13. import random
  14. import re
  15. import sys
  16. import time
  17. from datetime import datetime, timezone
  18. from pathlib import Path
  19. import sys
  20. sys.path.append(str(Path(__file__).resolve().parents[1]))
  21. from common.artifact_manager import resolve_artifact_path, create_backup_once
  22. from typing import Any, Dict, List, Optional, Set
  23. from urllib.parse import parse_qs, quote_plus, urlparse
  24. if hasattr(sys.stdout, "reconfigure"):
  25. sys.stdout.reconfigure(encoding="utf-8")
  26. if hasattr(sys.stderr, "reconfigure"):
  27. sys.stderr.reconfigure(encoding="utf-8")
  28. from playwright.sync_api import BrowserContext, Page, sync_playwright
  29. try:
  30. from ..common import append_records, resolve_workbook_path
  31. from . import discovery_common as dc
  32. except ImportError:
  33. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  34. from common import append_records, resolve_workbook_path
  35. from scraper import discovery_common as dc
  36. DEFAULT_EXCEL = ""
  37. DEFAULT_SHEET = "Google Maps"
  38. DEFAULT_CITY_SCOPE = "摩洛哥全国"
  39. NATIONWIDE_KEYWORDS = [
  40. "concessionnaire automobile Maroc",
  41. "concessionnaire multimarque Maroc",
  42. "showroom auto Maroc",
  43. "voiture occasion Maroc",
  44. "importateur automobile Maroc",
  45. "distributeur automobile Maroc",
  46. "groupe automobile Maroc",
  47. "concessionnaire utilitaire Maroc",
  48. "camion Maroc concessionnaire",
  49. "voiture chinoise Maroc showroom",
  50. ]
  51. CITY_KEYWORD_PATTERNS = [
  52. "concessionnaire automobile {city}",
  53. "showroom auto {city}",
  54. "voiture occasion {city}",
  55. "importateur automobile {city}",
  56. ]
  57. CONTACT_LINK_HINTS = {
  58. "contact", "nous contacter", "contactez", "about", "a-propos", "apropos",
  59. "à propos", "qui sommes", "mentions", "legal", "devis", "service client",
  60. }
  61. def build_default_keywords() -> List[str]:
  62. keywords = list(NATIONWIDE_KEYWORDS)
  63. for city in dc.MOROCCO_TARGET_CITIES:
  64. for pattern in CITY_KEYWORD_PATTERNS:
  65. keywords.append(pattern.format(city=city))
  66. deduped: List[str] = []
  67. seen: Set[str] = set()
  68. for keyword in keywords:
  69. key = keyword.casefold()
  70. if key not in seen:
  71. deduped.append(keyword)
  72. seen.add(key)
  73. return deduped
  74. DEFAULT_KEYWORDS = build_default_keywords()
  75. def dismiss_google_dialogs(page: Page) -> None:
  76. labels = [
  77. "Accept all", "I agree", "Tout accepter", "J'accepte", "Accepter", "Reject all",
  78. "Plus tard", "Not now", "Fermer", "Close",
  79. ]
  80. for label in labels:
  81. try:
  82. page.get_by_text(label, exact=False).first.click(timeout=1200)
  83. time.sleep(0.4)
  84. except Exception:
  85. pass
  86. def clean_place_name(value: str) -> str:
  87. text = dc.clean_space(value)
  88. text = re.sub(r"\b(Directions|Itinéraire|Website|Site Web|Call|Appeler)\b.*$", "", text, flags=re.I).strip()
  89. lines = [dc.clean_space(line) for line in text.splitlines() if dc.clean_space(line)]
  90. if lines:
  91. text = lines[0]
  92. text = re.sub(r"\s+\d(?:[.,]\d)?\s*\(?\d*\)?$", "", text).strip()
  93. return text
  94. def collect_place_results(page: Page, query: str, max_results: int) -> List[Dict[str, Any]]:
  95. search_url = f"https://www.google.com/maps/search/{quote_plus(query)}"
  96. print(f"搜索 Google Maps: {query}", flush=True)
  97. page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
  98. time.sleep(random.uniform(3, 5))
  99. dismiss_google_dialogs(page)
  100. for _ in range(5):
  101. try:
  102. feed = page.locator('div[role="feed"]').first
  103. if feed.count():
  104. feed.evaluate("el => el.scrollBy(0, 1400)")
  105. else:
  106. page.mouse.wheel(0, 1200)
  107. except Exception:
  108. page.mouse.wheel(0, 1200)
  109. time.sleep(random.uniform(1, 1.7))
  110. raw = page.evaluate(
  111. """
  112. () => {
  113. const anchors = Array.from(document.querySelectorAll('a[href*="/maps/place/"], a[href*="google.com/maps/place/"]'));
  114. return anchors.map((a) => {
  115. const node = a.closest('[role="article"]') || a.closest('.Nv2PK') || a.parentElement;
  116. const text = (node && node.innerText ? node.innerText : a.innerText || '').trim();
  117. const label = (a.getAttribute('aria-label') || '').trim();
  118. return {href: a.href || '', name: label || '', text};
  119. });
  120. }
  121. """
  122. )
  123. results: List[Dict[str, Any]] = []
  124. seen: Set[str] = set()
  125. for item in raw:
  126. href = dc.normalize_google_maps_url(item.get("href", ""))
  127. if not href or href in seen:
  128. continue
  129. name = clean_place_name(item.get("name") or item.get("text", ""))
  130. text = re.sub(r"\n{2,}", "\n", item.get("text", "")).strip()
  131. if not name or name.casefold() in {"results", "google maps", "directions"}:
  132. continue
  133. seen.add(href)
  134. results.append({
  135. "name": name,
  136. "href": item.get("href", ""),
  137. "text": text[:1200],
  138. "source_queries": [query],
  139. })
  140. if len(results) >= max_results:
  141. break
  142. print(f" 收集到 {len(results)} 个地图候选", flush=True)
  143. return results
  144. def unwrap_google_redirect(url: str) -> str:
  145. parsed = urlparse(str(url or ""))
  146. if "google." in parsed.netloc.casefold() and parsed.path == "/url":
  147. target = parse_qs(parsed.query).get("q", [""])[0]
  148. if target:
  149. return target
  150. return url
  151. def extract_website_link(page: Page) -> str:
  152. raw_links = page.evaluate(
  153. """
  154. () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
  155. href: a.href || '',
  156. text: (a.innerText || '').trim(),
  157. aria: (a.getAttribute('aria-label') || '').trim(),
  158. data: (a.getAttribute('data-item-id') || '').trim()
  159. }))
  160. """
  161. )
  162. for item in raw_links:
  163. href = unwrap_google_redirect(item.get("href", ""))
  164. label = " ".join([item.get("text", ""), item.get("aria", ""), item.get("data", "")]).casefold()
  165. if not dc.is_external_business_url(href):
  166. continue
  167. if "authority" in label or "website" in label or "site web" in label:
  168. return href
  169. for item in raw_links:
  170. href = unwrap_google_redirect(item.get("href", ""))
  171. if dc.is_external_business_url(href):
  172. return href
  173. return ""
  174. def extract_maps_phone(page: Page, text: str) -> str:
  175. try:
  176. phone = page.evaluate(
  177. """
  178. () => {
  179. const nodes = Array.from(document.querySelectorAll('button, a'));
  180. for (const el of nodes) {
  181. const href = el.href || '';
  182. const aria = el.getAttribute('aria-label') || '';
  183. const text = el.innerText || '';
  184. if (href.startsWith('tel:')) return href.replace(/^tel:/, '');
  185. const combined = `${aria} ${text}`;
  186. const match = combined.match(/(?:\+212|0)\s?\d[\d\s.-]{6,}\d/);
  187. if (match) return match[0];
  188. }
  189. return '';
  190. }
  191. """
  192. )
  193. if phone:
  194. return re.sub(r"\s+", " ", phone).strip()
  195. except Exception:
  196. pass
  197. return dc.extract_phone(text)
  198. def extract_rating(text: str) -> str:
  199. match = re.search(r"(\d[.,]\d)\s*\(?\s*(\d+[\d\s,.]*)?\s*(avis|reviews)?", text, re.I)
  200. if not match:
  201. return ""
  202. rating = match.group(1).replace(",", ".")
  203. reviews = dc.clean_space(match.group(2) or "")
  204. return f"{rating} ({reviews} avis)" if reviews else rating
  205. def same_site(url: str, base_url: str) -> bool:
  206. host = urlparse(url).netloc.casefold().removeprefix("www.")
  207. base_host = urlparse(base_url).netloc.casefold().removeprefix("www.")
  208. return bool(host and base_host and host == base_host)
  209. def scrape_public_email_from_website(context: BrowserContext, website_url: str, max_pages: int = 4) -> Dict[str, Any]:
  210. result = scrape_public_website_enrichment(context, website_url, max_pages=max_pages)
  211. return {
  212. "email": result.get("email", ""),
  213. "emails": result.get("emails", []),
  214. "sources": result.get("checked_urls", []),
  215. "checked_urls": result.get("checked_urls", []),
  216. "error": "" if result.get("checked_urls") else "no readable public website pages",
  217. }
  218. def extract_place_name(page: Page, fallback: str) -> str:
  219. for selector in ["h1", '[role="main"] h1']:
  220. try:
  221. value = page.locator(selector).first.inner_text(timeout=2500)
  222. value = clean_place_name(value)
  223. if value:
  224. return value
  225. except Exception:
  226. pass
  227. title = page.title().replace(" - Google Maps", "")
  228. return clean_place_name(title) or fallback
  229. def deep_scrape_place(page: Page, context: BrowserContext, candidate: Dict[str, Any], country: str) -> Optional[Dict[str, Any]]:
  230. print(f"深采 Google Maps: {candidate.get('name')}", flush=True)
  231. page.goto(candidate["href"], wait_until="domcontentloaded", timeout=60000)
  232. time.sleep(random.uniform(3, 5))
  233. dismiss_google_dialogs(page)
  234. try:
  235. body_text = page.locator("body").inner_text(timeout=15000)
  236. except Exception:
  237. body_text = ""
  238. name = extract_place_name(page, candidate.get("name", ""))
  239. name = dc.canonical_dealer_name(name, body_text + "\n" + candidate.get("text", ""))
  240. place_url = page.url or candidate.get("href", "")
  241. website = extract_website_link(page)
  242. phone = extract_maps_phone(page, body_text)
  243. rating = extract_rating(body_text)
  244. city = dc.extract_city(body_text, fallback=DEFAULT_CITY_SCOPE)
  245. combined = "\n".join([name, place_url, body_text, candidate.get("text", ""), " ".join(candidate.get("source_queries", []))])
  246. is_oem, brand = dc.looks_like_oem_local_branch(name, place_url, body_text)
  247. if is_oem:
  248. candidate["recommended_action"] = "skip_brand_branch"
  249. candidate.setdefault("risk_flags", []).append(f"深采确认疑似品牌官方页: {brand}")
  250. dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
  251. return None
  252. website_is_oem, website_brand = dc.looks_like_oem_brand_country_url(website)
  253. if not body_text.strip():
  254. candidate.setdefault("risk_flags", [])
  255. dc.add_manual_review_flag(candidate["risk_flags"], "detail")
  256. website_email = scrape_public_email_from_website(context, website) if website else {"email": "", "emails": [], "sources": [], "checked_urls": []}
  257. maps_email = dc.extract_email(body_text)
  258. if website_is_oem and website_email.get("email"):
  259. email = maps_email if maps_email and website_email.get("email") != maps_email else ""
  260. else:
  261. email = website_email.get("email") or maps_email
  262. customer_type = dc.classify_customer_type(combined)
  263. business = dc.summarize_business(combined)
  264. email_note = ""
  265. if email:
  266. if website_email.get("email") and not website_is_oem:
  267. email_note = "邮箱来源:官网公开页面 " + ", ".join(website_email.get("sources", [])[:2])
  268. else:
  269. email_note = "邮箱来源:Google Maps 页面公开文本"
  270. elif website_is_oem and website_email.get("email"):
  271. email_note = f"发现 {website_brand} 品牌官网邮箱,未写入客户邮箱;需人工确认独立经销主体联系方式"
  272. candidate.setdefault("risk_flags", [])
  273. dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
  274. elif website:
  275. email_note = "官网未发现公开邮箱"
  276. else:
  277. email_note = "无官网,未发现公开邮箱"
  278. note_parts = [
  279. "Google Maps深采",
  280. f"官网:{website}" if website else "无官网",
  281. f"电话:{phone}" if phone else "未发现电话",
  282. f"评分:{rating}" if rating else "",
  283. email_note,
  284. f"官网检查页:{', '.join(website_email.get('checked_urls', [])[:3])}" if website_email.get("checked_urls") else "",
  285. f"来源搜索词:{', '.join(candidate.get('source_queries', []))}",
  286. f"评分:{candidate.get('score')};原因:{'; '.join(candidate.get('score_reasons', []))}",
  287. ]
  288. if candidate.get("risk_flags"):
  289. note_parts.append("风险:" + "; ".join(candidate["risk_flags"]))
  290. return {
  291. "客户姓名/公司": name,
  292. "国家": country,
  293. "城市": city,
  294. "客户类型": customer_type,
  295. "主页/链接": place_url,
  296. "联系人": "",
  297. "职位": "",
  298. "电话/WhatsApp": phone,
  299. "邮箱": email,
  300. "主营业务": business,
  301. "建联状态": "未联系",
  302. "下次跟进": "",
  303. "备注": " | ".join(part for part in note_parts if part)[:1200],
  304. "_website": website,
  305. "_website_email_result": website_email,
  306. }
  307. def search_google_maps_dealers(
  308. profile_id: str,
  309. keywords: Optional[List[str]],
  310. ads_power_url: str,
  311. excel_path: str,
  312. sheet_name: str,
  313. max_results: int,
  314. max_results_per_query: int,
  315. min_score: int,
  316. deep_scrape: bool,
  317. country: str,
  318. no_excel: bool = False,
  319. ) -> Dict[str, Any]:
  320. if no_excel:
  321. existing_links: Set[str] = set()
  322. existing_names: Set[str] = set()
  323. else:
  324. existing_identity = dc.load_existing_identity(
  325. excel_path=excel_path,
  326. sheet_name=sheet_name,
  327. link_columns=["主页/链接"],
  328. name_columns=["客户姓名/公司"],
  329. )
  330. existing_links = existing_identity["links"]
  331. existing_names = existing_identity["names"]
  332. print(f"已从 {sheet_name} Sheet 加载 {len(existing_links)} 个 Google Maps 现有链接、{len(existing_names)} 个公司名用于去重", flush=True)
  333. ws_endpoint = dc.get_active_ws_endpoint(ads_power_url, profile_id)
  334. playwright = sync_playwright().start()
  335. browser = playwright.chromium.connect_over_cdp(ws_endpoint)
  336. context = browser.contexts[0] if browser.contexts else browser.new_context()
  337. page = context.new_page()
  338. page.set_viewport_size({"width": 1366, "height": 850})
  339. queries = keywords or DEFAULT_KEYWORDS
  340. raw_candidates: List[Dict[str, Any]] = []
  341. search_log: List[Dict[str, Any]] = []
  342. records: List[Dict[str, Any]] = []
  343. selected: List[Dict[str, Any]] = []
  344. scored: List[Dict[str, Any]] = []
  345. skipped_existing: List[Dict[str, Any]] = []
  346. try:
  347. for query in queries:
  348. items = collect_place_results(page, query, max_results_per_query)
  349. raw_candidates.extend(items)
  350. search_log.append({"query": query, "found": len(items), "url": page.url, "title": page.title()})
  351. time.sleep(random.uniform(2, 4))
  352. merged = dc.merge_candidates_by_url(raw_candidates, normalizer=dc.normalize_google_maps_url)
  353. scored_all = [
  354. dc.score_dealer_candidate(item, existing_links, existing_names, platform="Google Maps")
  355. for item in merged
  356. ]
  357. skipped_existing = [c for c in scored_all if c.get("recommended_action") == "skip_existing"]
  358. active_scored = [c for c in scored_all if c.get("recommended_action") != "skip_existing"]
  359. scored = dc.dedupe_dealer_groups(active_scored)
  360. scored.sort(key=lambda c: (-int(c.get("score", 0)), c.get("name", "")))
  361. selected = [
  362. c for c in scored
  363. if c.get("recommended_action") == "deep_scrape" and int(c.get("score", 0)) >= min_score
  364. ][:max_results]
  365. if deep_scrape:
  366. for candidate in selected:
  367. record = deep_scrape_place(page, context, candidate, country=country)
  368. if record:
  369. records.append(record)
  370. normalized = dc.normalize_google_maps_url(record.get("主页/链接", ""))
  371. if normalized:
  372. existing_links.add(normalized)
  373. time.sleep(random.uniform(2, 4))
  374. else:
  375. print("预览模式:已生成候选评分,不打开地图详情或官网。需要邮箱提取时加 --deep-scrape。", flush=True)
  376. finally:
  377. try:
  378. page.close()
  379. except Exception:
  380. pass
  381. playwright.stop()
  382. return {
  383. "generated_at": datetime.now(timezone.utc).isoformat(),
  384. "source": "Google Maps search via AdsPower active profile",
  385. "summary": {
  386. "profile_id": profile_id,
  387. "queries": queries,
  388. "raw_candidates": len(raw_candidates),
  389. "candidate_count": len(scored),
  390. "skipped_existing": len(skipped_existing),
  391. "selected_for_deep_scrape": len(selected),
  392. "deep_scrape": deep_scrape,
  393. "record_count": len(records),
  394. "records_with_email": len([r for r in records if r.get("邮箱")]),
  395. "min_score": min_score,
  396. "max_results": max_results,
  397. "existing_links": len(existing_links),
  398. "existing_names": len(existing_names),
  399. },
  400. "search_log": search_log,
  401. "candidates": scored,
  402. "skipped_existing": skipped_existing,
  403. "selected_candidates": selected,
  404. "records": records,
  405. }
  406. def main() -> None:
  407. parser = argparse.ArgumentParser(description="Google Maps Morocco dealer discovery with public email extraction")
  408. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID; must already be open")
  409. parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower local API URL")
  410. parser.add_argument("--keywords", default="", help="Comma-separated Google Maps search keywords")
  411. parser.add_argument("--excel", default=DEFAULT_EXCEL, help="Workbook for duplicate checking and optional write-back")
  412. parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Target sheet, normally Google Maps")
  413. parser.add_argument("--country", default="摩洛哥", help="Country value for records")
  414. parser.add_argument("--max-results", type=int, default=10, help="Maximum deep-scraped records")
  415. parser.add_argument("--max-results-per-query", type=int, default=6, help="Place links collected per query")
  416. parser.add_argument("--min-score", type=int, default=4, help="Minimum score for deep-scrape selection")
  417. parser.add_argument("--deep-scrape", action="store_true", help="Open selected place pages and merchant websites for emails")
  418. parser.add_argument("--write-excel", action="store_true", help="Write deep-scraped records to the workbook")
  419. parser.add_argument("--no-excel", action="store_true", help="Do not read or write Excel; pure JSON preview")
  420. parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
  421. parser.add_argument("--output", default="google_maps_candidate_preview.json", help="Output JSON path")
  422. args = parser.parse_args()
  423. if not args.no_excel:
  424. read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
  425. if read_workbook_info.get("path"):
  426. args.excel = str(read_workbook_info["path"])
  427. print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})", flush=True)
  428. result = search_google_maps_dealers(
  429. profile_id=args.profile_id,
  430. keywords=dc.parse_keywords(args.keywords),
  431. ads_power_url=args.ads_power_url,
  432. excel_path=args.excel,
  433. sheet_name=args.sheet,
  434. max_results=args.max_results,
  435. max_results_per_query=args.max_results_per_query,
  436. min_score=args.min_score,
  437. deep_scrape=args.deep_scrape,
  438. country=args.country,
  439. no_excel=args.no_excel,
  440. )
  441. output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
  442. output_path.parent.mkdir(parents=True, exist_ok=True)
  443. output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  444. print(f"已保存 Google Maps 候选结果: {output_path}", flush=True)
  445. if args.write_excel and not args.no_excel:
  446. write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
  447. if write_workbook_info.get("path"):
  448. args.excel = str(write_workbook_info["path"])
  449. print(f"Workbook for writing: {args.excel} ({write_workbook_info['source']})", flush=True)
  450. if not args.deep_scrape:
  451. print("未写入 Excel:需要先使用 --deep-scrape 生成 records。", flush=True)
  452. elif not result.get("records"):
  453. print("未写入 Excel:没有可写入记录。", flush=True)
  454. else:
  455. write_result = append_records(
  456. excel_path=args.excel,
  457. sheet_name=args.sheet,
  458. records=result["records"],
  459. dedup_keys=["客户姓名/公司", "主页/链接"],
  460. )
  461. print(f"Excel 回写结果: {write_result}", flush=True)
  462. else:
  463. print("默认预览模式:未写入 Excel。确认要入表时再使用 --deep-scrape --write-excel。", flush=True)
  464. if __name__ == "__main__":
  465. main()