website_deep_scraper.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. """Public website enrichment helpers for dealer discovery.
  2. The helpers only use public pages. They do not guess emails, prices, MOQ,
  3. inventory, or cooperation terms.
  4. """
  5. from __future__ import annotations
  6. import re
  7. import time
  8. from typing import Any, Dict, List, Set
  9. from urllib.parse import parse_qs, parse_qsl, urlencode, unquote, urlparse, urlunparse
  10. from playwright.sync_api import BrowserContext, Page
  11. try:
  12. from . import discovery_common as dc
  13. except ImportError: # pragma: no cover - direct script execution
  14. import sys
  15. from pathlib import Path
  16. sys.path.insert(0, str(Path(__file__).resolve().parent))
  17. import discovery_common as dc
  18. CONTACT_LINK_HINTS = {
  19. "contact",
  20. "nous contacter",
  21. "contactez",
  22. "about",
  23. "a-propos",
  24. "apropos",
  25. "à propos",
  26. "qui sommes",
  27. "service",
  28. "services",
  29. "vehicule",
  30. "véhicule",
  31. "vehicules",
  32. "véhicules",
  33. "occasion",
  34. "location",
  35. }
  36. SOCIAL_OR_PLATFORM_HOSTS = {
  37. "facebook.com",
  38. "m.facebook.com",
  39. "fb.com",
  40. "instagram.com",
  41. "linkedin.com",
  42. "tiktok.com",
  43. "youtube.com",
  44. "youtu.be",
  45. "wa.me",
  46. "whatsapp.com",
  47. "google.com",
  48. "maps.google.com",
  49. "bing.com",
  50. "maps.bing.com",
  51. "gmail.com",
  52. "mail.google.com",
  53. "outlook.com",
  54. "hotmail.com",
  55. "yahoo.com",
  56. "goo.gl",
  57. }
  58. BARE_DOMAIN_RE = re.compile(
  59. r"\b(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9-]{1,63}(?:\.[a-z0-9][a-z0-9-]{1,63})+\b(?:/[^\s<>'\"]*)?)",
  60. re.IGNORECASE,
  61. )
  62. GENERIC_NON_WEBSITE_DOMAINS = {
  63. "facebook.com",
  64. "fb.com",
  65. "instagram.com",
  66. "youtube.com",
  67. "youtu.be",
  68. "tiktok.com",
  69. "wa.me",
  70. "whatsapp.com",
  71. "google.com",
  72. "bing.com",
  73. "gmail.com",
  74. "outlook.com",
  75. "hotmail.com",
  76. "yahoo.com",
  77. "goo.gl",
  78. }
  79. BUSINESS_SIGNALS = {
  80. "二手车销售/置换": ["occasion", "voiture d'occasion", "reprise", "used car", "achat", "vente"],
  81. "汽车销售/showroom": ["showroom", "concessionnaire", "vente automobile", "véhicules neufs", "vehicules neufs", "vehicle sales"],
  82. "进口/分销": ["importation", "importateur", "distribution", "distributeur", "import"],
  83. "租赁/车队服务": ["location", "rental", "fleet", "flotte", "lld", "leasing"],
  84. "商用车/工具车": ["utilitaire", "camion", "truck", "van", "mpv", "minibus", "bus"],
  85. "售后/维修支持": ["service après-vente", "après-vente", "sav", "garage", "réparation", "maintenance"],
  86. }
  87. BATCH_CAPACITY_SIGNALS = {
  88. "多门店/区域覆盖": ["réseau", "reseau", "succursale", "points de vente", "agence", "branches", "network"],
  89. "库存/展厅信号": ["stock", "parc auto", "showroom", "disponible", "inventory"],
  90. "进口/分销能力": ["importation", "importateur", "distribution", "distributeur"],
  91. "车队/租赁需求": ["fleet", "flotte", "location", "lld", "leasing"],
  92. "商用车适配": ["utilitaire", "camion", "truck", "van", "mpv", "minibus"],
  93. }
  94. def normalize_host(url: str) -> str:
  95. return urlparse(str(url or "")).netloc.casefold().removeprefix("www.")
  96. def is_social_or_platform_url(url: str) -> bool:
  97. host = normalize_host(url)
  98. return any(host == blocked or host.endswith("." + blocked) for blocked in SOCIAL_OR_PLATFORM_HOSTS)
  99. def is_company_website_url(url: str) -> bool:
  100. if not dc.is_external_business_url(url):
  101. return False
  102. if is_social_or_platform_url(url):
  103. return False
  104. parsed = urlparse(str(url or ""))
  105. if parsed.scheme not in {"http", "https"} or not parsed.netloc:
  106. return False
  107. host = normalize_host(url)
  108. labels = host.split(".")
  109. if not labels:
  110. return False
  111. tld = labels[-1]
  112. if len(tld) > 6:
  113. return False
  114. if not (len(tld) == 2 or tld in {"com", "net", "org", "info", "biz", "auto", "cars", "shop", "store"}):
  115. return False
  116. return True
  117. def _same_site(url: str, base_url: str) -> bool:
  118. return normalize_host(url) == normalize_host(base_url)
  119. def _strip_tracking_query(url: str) -> str:
  120. parsed = urlparse(str(url or ""))
  121. if not parsed.scheme or not parsed.netloc:
  122. return url
  123. blocked_prefixes = ("utm_",)
  124. blocked_keys = {"fbclid", "gclid", "yclid", "mc_cid", "mc_eid", "igshid"}
  125. query_items = [
  126. (key, value)
  127. for key, value in parse_qsl(parsed.query, keep_blank_values=True)
  128. if key.casefold() not in blocked_keys and not key.casefold().startswith(blocked_prefixes)
  129. ]
  130. return urlunparse(parsed._replace(query=urlencode(query_items, doseq=True), fragment=""))
  131. def _unwrap_facebook_redirect(url: str) -> str:
  132. """Return the real destination when Facebook wraps an external link."""
  133. parsed = urlparse(str(url or ""))
  134. host = parsed.netloc.casefold().removeprefix("www.")
  135. if host not in {"facebook.com", "m.facebook.com", "l.facebook.com"}:
  136. return url
  137. query = parse_qs(parsed.query)
  138. for key in ("u", "url", "href"):
  139. values = query.get(key)
  140. if values:
  141. return unquote(values[0])
  142. return url
  143. def _candidate_urls_from_visible_text(text: str) -> List[str]:
  144. """Extract bare domains visible in Facebook contact/about sections."""
  145. candidates: List[str] = []
  146. for match in BARE_DOMAIN_RE.finditer(str(text or "")):
  147. raw = match.group(1).rstrip(".,;:)")
  148. if not raw or "@" in raw:
  149. continue
  150. url = raw if raw.startswith(("http://", "https://")) else f"https://{raw}"
  151. host = normalize_host(url)
  152. if host in GENERIC_NON_WEBSITE_DOMAINS:
  153. continue
  154. if is_company_website_url(url) and url not in candidates:
  155. candidates.append(url)
  156. return candidates
  157. def _extract_external_links(page: Page) -> List[Dict[str, str]]:
  158. try:
  159. return page.evaluate(
  160. """
  161. () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
  162. href: a.href || '',
  163. text: (a.innerText || '').trim(),
  164. aria: (a.getAttribute('aria-label') || '').trim()
  165. }))
  166. """
  167. )
  168. except Exception:
  169. return []
  170. def extract_company_website_from_facebook(page: Page) -> str:
  171. """Extract the best external company website visible on a Facebook page."""
  172. candidates: List[tuple[int, str]] = []
  173. for item in _extract_external_links(page):
  174. href = _strip_tracking_query(dc.normalize_website_url(_unwrap_facebook_redirect(item.get("href", ""))))
  175. if not is_company_website_url(href):
  176. visible_candidates = _candidate_urls_from_visible_text(
  177. " ".join([item.get("text", ""), item.get("aria", "")])
  178. )
  179. if not visible_candidates:
  180. continue
  181. href = _strip_tracking_query(visible_candidates[0])
  182. label = " ".join([item.get("text", ""), item.get("aria", ""), href]).casefold()
  183. score = 0
  184. if any(term in label for term in ["website", "site web", "site", "www.", ".ma", ".com", ".net", ".org"]):
  185. score += 2
  186. if any(term in label for term in ["contact", "official", "officiel"]):
  187. score += 1
  188. candidates.append((score, href))
  189. try:
  190. body_text = page.locator("body").inner_text(timeout=5000)
  191. except Exception:
  192. body_text = ""
  193. for href in _candidate_urls_from_visible_text(body_text):
  194. candidates.append((1, _strip_tracking_query(href)))
  195. if not candidates:
  196. return ""
  197. candidates.sort(key=lambda item: (-item[0], len(item[1])))
  198. return candidates[0][1]
  199. def _score_terms(text: str, signal_map: Dict[str, List[str]]) -> List[str]:
  200. lower = str(text or "").casefold()
  201. hits: List[str] = []
  202. for label, terms in signal_map.items():
  203. if any(term.casefold() in lower for term in terms):
  204. hits.append(label)
  205. return hits
  206. def summarize_business_cn(combined_text: str) -> str:
  207. business = _score_terms(combined_text, BUSINESS_SIGNALS)
  208. capacity = _score_terms(combined_text, BATCH_CAPACITY_SIGNALS)
  209. parts: List[str] = []
  210. if business:
  211. parts.append("主营业务判断:" + ";".join(business[:4]))
  212. else:
  213. parts.append("主营业务判断:汽车渠道线索,需人工确认")
  214. if capacity:
  215. parts.append("批量采购能力判断:发现" + "、".join(capacity[:4]) + ",可评估小批量采购/分销潜力")
  216. else:
  217. parts.append("批量采购能力判断:暂未发现明确批量采购证据")
  218. return ";".join(parts)
  219. def classify_customer_type_cn(combined_text: str) -> str:
  220. return dc.classify_customer_type(str(combined_text or ""))
  221. def scrape_public_website(context: BrowserContext, website_url: str, max_pages: int = 5) -> Dict[str, Any]:
  222. """Scrape a public company website for contact and business evidence."""
  223. if not is_company_website_url(website_url):
  224. return {"website": "", "email": "", "emails": [], "phone": "", "checked_urls": [], "business_summary": "", "evidence_notes": ["未发现可用公司官网"], "text": ""}
  225. checked: List[str] = []
  226. queue: List[str] = [website_url]
  227. emails: List[str] = []
  228. phones: List[str] = []
  229. text_chunks: List[str] = []
  230. errors: List[str] = []
  231. page = context.new_page()
  232. page.set_viewport_size({"width": 1280, "height": 850})
  233. try:
  234. while queue and len(checked) < max_pages:
  235. url = dc.normalize_website_url(queue.pop(0))
  236. if url in checked or not is_company_website_url(url):
  237. continue
  238. if checked and not _same_site(url, website_url):
  239. continue
  240. checked.append(url)
  241. try:
  242. page.goto(url, wait_until="domcontentloaded", timeout=35000)
  243. time.sleep(1.2)
  244. body_text = page.locator("body").inner_text(timeout=12000)
  245. text_chunks.append(body_text[:5000])
  246. links = _extract_external_links(page)
  247. mailto_text = " ".join(item.get("href", "") for item in links if item.get("href", "").casefold().startswith("mailto:"))
  248. for email in dc.extract_emails(body_text + " " + mailto_text):
  249. if email not in emails:
  250. emails.append(email)
  251. phone = dc.extract_phone(body_text)
  252. if phone and phone not in phones:
  253. phones.append(phone)
  254. for item in links:
  255. label = " ".join([item.get("href", ""), item.get("text", ""), item.get("aria", "")]).casefold()
  256. if not any(hint in label for hint in CONTACT_LINK_HINTS):
  257. continue
  258. href = dc.normalize_website_url(item.get("href", ""), page.url)
  259. if href and href not in checked and href not in queue and is_company_website_url(href) and _same_site(href, website_url):
  260. queue.append(href)
  261. except Exception as exc:
  262. errors.append(str(exc)[:120])
  263. continue
  264. finally:
  265. try:
  266. page.close()
  267. except Exception:
  268. pass
  269. combined_text = "\n".join(text_chunks)
  270. summary = summarize_business_cn(combined_text) if combined_text else "主营业务判断:官网未能读取正文,详细信息待确认;批量采购能力判断:暂未发现明确证据"
  271. evidence: List[str] = []
  272. if checked:
  273. evidence.append("官网检查页:" + ";".join(checked[:3]))
  274. if emails:
  275. evidence.append("官网发现公开邮箱")
  276. else:
  277. evidence.append("官网未发现公开邮箱")
  278. if errors and not text_chunks:
  279. evidence.append("官网读取失败:" + ";".join(errors[:2]))
  280. evidence.append(dc.MANUAL_REVIEW_FLAGS["detail"])
  281. return {
  282. "website": _strip_tracking_query(dc.normalize_website_url(website_url)),
  283. "email": emails[0] if emails else "",
  284. "emails": emails,
  285. "phone": phones[0] if phones else "",
  286. "checked_urls": checked,
  287. "business_summary": summary,
  288. "evidence_notes": evidence,
  289. "text": combined_text[:12000],
  290. }
  291. def build_chinese_notes(
  292. facebook_url: str,
  293. facebook_text: str,
  294. website_url: str,
  295. website_result: Dict[str, Any],
  296. followers: str = "",
  297. post_analysis: str = "",
  298. detected_brands: List[str] | None = None,
  299. exclusivity: str = "",
  300. ) -> str:
  301. combined = "\n".join([facebook_text or "", website_result.get("text", "") or "", post_analysis or ""])
  302. fb_summary = summarize_business_cn(facebook_text or post_analysis or "")
  303. website_summary = website_result.get("business_summary", "")
  304. contact_parts: List[str] = []
  305. if website_result.get("email"):
  306. contact_parts.append("官网公开邮箱:" + website_result["email"])
  307. if website_result.get("phone"):
  308. contact_parts.append("官网公开电话:" + website_result["phone"])
  309. if not contact_parts:
  310. contact_parts.append("未发现新的官网公开联系方式")
  311. notes = [
  312. "来源线索:Facebook主页 " + facebook_url,
  313. "Facebook证据:" + ("粉丝量:" + followers if followers else "已检查主页/About/近期帖子"),
  314. "官网证据:" + (website_url if website_url else "Facebook 未发现公司官网"),
  315. "主营业务判断:" + summarize_business_cn(combined).replace("主营业务判断:", "", 1),
  316. "联系方式证据:" + ";".join(contact_parts),
  317. ]
  318. if website_result.get("evidence_notes"):
  319. notes.append("官网检查:" + ";".join(website_result["evidence_notes"]))
  320. if post_analysis:
  321. notes.append("近期动态:" + post_analysis[:260])
  322. if detected_brands:
  323. notes.append("品牌信号:涉及中国品牌 " + "、".join(detected_brands))
  324. if exclusivity:
  325. notes.append("风险/待确认项:" + exclusivity)
  326. return " | ".join(part for part in notes if part)[:1500]