"""Public website enrichment helpers for dealer discovery. The helpers only use public pages. They do not guess emails, prices, MOQ, inventory, or cooperation terms. """ from __future__ import annotations import re import time from typing import Any, Dict, List, Set from urllib.parse import parse_qs, parse_qsl, urlencode, unquote, urlparse, urlunparse from playwright.sync_api import BrowserContext, Page try: from . import discovery_common as dc except ImportError: # pragma: no cover - direct script execution import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import discovery_common as dc CONTACT_LINK_HINTS = { "contact", "nous contacter", "contactez", "about", "a-propos", "apropos", "à propos", "qui sommes", "service", "services", "vehicule", "véhicule", "vehicules", "véhicules", "occasion", "location", } SOCIAL_OR_PLATFORM_HOSTS = { "facebook.com", "m.facebook.com", "fb.com", "instagram.com", "linkedin.com", "tiktok.com", "youtube.com", "youtu.be", "wa.me", "whatsapp.com", "google.com", "maps.google.com", "bing.com", "maps.bing.com", "gmail.com", "mail.google.com", "outlook.com", "hotmail.com", "yahoo.com", "goo.gl", } BARE_DOMAIN_RE = re.compile( r"\b(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9-]{1,63}(?:\.[a-z0-9][a-z0-9-]{1,63})+\b(?:/[^\s<>'\"]*)?)", re.IGNORECASE, ) GENERIC_NON_WEBSITE_DOMAINS = { "facebook.com", "fb.com", "instagram.com", "youtube.com", "youtu.be", "tiktok.com", "wa.me", "whatsapp.com", "google.com", "bing.com", "gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "goo.gl", } BUSINESS_SIGNALS = { "二手车销售/置换": ["occasion", "voiture d'occasion", "reprise", "used car", "achat", "vente"], "汽车销售/showroom": ["showroom", "concessionnaire", "vente automobile", "véhicules neufs", "vehicules neufs", "vehicle sales"], "进口/分销": ["importation", "importateur", "distribution", "distributeur", "import"], "租赁/车队服务": ["location", "rental", "fleet", "flotte", "lld", "leasing"], "商用车/工具车": ["utilitaire", "camion", "truck", "van", "mpv", "minibus", "bus"], "售后/维修支持": ["service après-vente", "après-vente", "sav", "garage", "réparation", "maintenance"], } BATCH_CAPACITY_SIGNALS = { "多门店/区域覆盖": ["réseau", "reseau", "succursale", "points de vente", "agence", "branches", "network"], "库存/展厅信号": ["stock", "parc auto", "showroom", "disponible", "inventory"], "进口/分销能力": ["importation", "importateur", "distribution", "distributeur"], "车队/租赁需求": ["fleet", "flotte", "location", "lld", "leasing"], "商用车适配": ["utilitaire", "camion", "truck", "van", "mpv", "minibus"], } def normalize_host(url: str) -> str: return urlparse(str(url or "")).netloc.casefold().removeprefix("www.") def is_social_or_platform_url(url: str) -> bool: host = normalize_host(url) return any(host == blocked or host.endswith("." + blocked) for blocked in SOCIAL_OR_PLATFORM_HOSTS) def is_company_website_url(url: str) -> bool: if not dc.is_external_business_url(url): return False if is_social_or_platform_url(url): return False parsed = urlparse(str(url or "")) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return False host = normalize_host(url) labels = host.split(".") if not labels: return False tld = labels[-1] if len(tld) > 6: return False if not (len(tld) == 2 or tld in {"com", "net", "org", "info", "biz", "auto", "cars", "shop", "store"}): return False return True def _same_site(url: str, base_url: str) -> bool: return normalize_host(url) == normalize_host(base_url) def _strip_tracking_query(url: str) -> str: parsed = urlparse(str(url or "")) if not parsed.scheme or not parsed.netloc: return url blocked_prefixes = ("utm_",) blocked_keys = {"fbclid", "gclid", "yclid", "mc_cid", "mc_eid", "igshid"} query_items = [ (key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key.casefold() not in blocked_keys and not key.casefold().startswith(blocked_prefixes) ] return urlunparse(parsed._replace(query=urlencode(query_items, doseq=True), fragment="")) def _unwrap_facebook_redirect(url: str) -> str: """Return the real destination when Facebook wraps an external link.""" parsed = urlparse(str(url or "")) host = parsed.netloc.casefold().removeprefix("www.") if host not in {"facebook.com", "m.facebook.com", "l.facebook.com"}: return url query = parse_qs(parsed.query) for key in ("u", "url", "href"): values = query.get(key) if values: return unquote(values[0]) return url def _candidate_urls_from_visible_text(text: str) -> List[str]: """Extract bare domains visible in Facebook contact/about sections.""" candidates: List[str] = [] for match in BARE_DOMAIN_RE.finditer(str(text or "")): raw = match.group(1).rstrip(".,;:)") if not raw or "@" in raw: continue url = raw if raw.startswith(("http://", "https://")) else f"https://{raw}" host = normalize_host(url) if host in GENERIC_NON_WEBSITE_DOMAINS: continue if is_company_website_url(url) and url not in candidates: candidates.append(url) return candidates def _extract_external_links(page: Page) -> List[Dict[str, str]]: try: return page.evaluate( """ () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({ href: a.href || '', text: (a.innerText || '').trim(), aria: (a.getAttribute('aria-label') || '').trim() })) """ ) except Exception: return [] def extract_company_website_from_facebook(page: Page) -> str: """Extract the best external company website visible on a Facebook page.""" candidates: List[tuple[int, str]] = [] for item in _extract_external_links(page): href = _strip_tracking_query(dc.normalize_website_url(_unwrap_facebook_redirect(item.get("href", "")))) if not is_company_website_url(href): visible_candidates = _candidate_urls_from_visible_text( " ".join([item.get("text", ""), item.get("aria", "")]) ) if not visible_candidates: continue href = _strip_tracking_query(visible_candidates[0]) label = " ".join([item.get("text", ""), item.get("aria", ""), href]).casefold() score = 0 if any(term in label for term in ["website", "site web", "site", "www.", ".ma", ".com", ".net", ".org"]): score += 2 if any(term in label for term in ["contact", "official", "officiel"]): score += 1 candidates.append((score, href)) try: body_text = page.locator("body").inner_text(timeout=5000) except Exception: body_text = "" for href in _candidate_urls_from_visible_text(body_text): candidates.append((1, _strip_tracking_query(href))) if not candidates: return "" candidates.sort(key=lambda item: (-item[0], len(item[1]))) return candidates[0][1] def _score_terms(text: str, signal_map: Dict[str, List[str]]) -> List[str]: lower = str(text or "").casefold() hits: List[str] = [] for label, terms in signal_map.items(): if any(term.casefold() in lower for term in terms): hits.append(label) return hits def summarize_business_cn(combined_text: str) -> str: business = _score_terms(combined_text, BUSINESS_SIGNALS) capacity = _score_terms(combined_text, BATCH_CAPACITY_SIGNALS) parts: List[str] = [] if business: parts.append("主营业务判断:" + ";".join(business[:4])) else: parts.append("主营业务判断:汽车渠道线索,需人工确认") if capacity: parts.append("批量采购能力判断:发现" + "、".join(capacity[:4]) + ",可评估小批量采购/分销潜力") else: parts.append("批量采购能力判断:暂未发现明确批量采购证据") return ";".join(parts) def classify_customer_type_cn(combined_text: str) -> str: return dc.classify_customer_type(str(combined_text or "")) def scrape_public_website(context: BrowserContext, website_url: str, max_pages: int = 5) -> Dict[str, Any]: """Scrape a public company website for contact and business evidence.""" if not is_company_website_url(website_url): return {"website": "", "email": "", "emails": [], "phone": "", "checked_urls": [], "business_summary": "", "evidence_notes": ["未发现可用公司官网"], "text": ""} checked: List[str] = [] queue: List[str] = [website_url] emails: List[str] = [] phones: List[str] = [] text_chunks: List[str] = [] errors: List[str] = [] page = context.new_page() page.set_viewport_size({"width": 1280, "height": 850}) try: while queue and len(checked) < max_pages: url = dc.normalize_website_url(queue.pop(0)) if url in checked or not is_company_website_url(url): continue if checked and not _same_site(url, website_url): continue checked.append(url) try: page.goto(url, wait_until="domcontentloaded", timeout=35000) time.sleep(1.2) body_text = page.locator("body").inner_text(timeout=12000) text_chunks.append(body_text[:5000]) links = _extract_external_links(page) mailto_text = " ".join(item.get("href", "") for item in links if item.get("href", "").casefold().startswith("mailto:")) for email in dc.extract_emails(body_text + " " + mailto_text): if email not in emails: emails.append(email) phone = dc.extract_phone(body_text) if phone and phone not in phones: phones.append(phone) for item in links: label = " ".join([item.get("href", ""), item.get("text", ""), item.get("aria", "")]).casefold() if not any(hint in label for hint in CONTACT_LINK_HINTS): continue href = dc.normalize_website_url(item.get("href", ""), page.url) if href and href not in checked and href not in queue and is_company_website_url(href) and _same_site(href, website_url): queue.append(href) except Exception as exc: errors.append(str(exc)[:120]) continue finally: try: page.close() except Exception: pass combined_text = "\n".join(text_chunks) summary = summarize_business_cn(combined_text) if combined_text else "主营业务判断:官网未能读取正文,详细信息待确认;批量采购能力判断:暂未发现明确证据" evidence: List[str] = [] if checked: evidence.append("官网检查页:" + ";".join(checked[:3])) if emails: evidence.append("官网发现公开邮箱") else: evidence.append("官网未发现公开邮箱") if errors and not text_chunks: evidence.append("官网读取失败:" + ";".join(errors[:2])) evidence.append(dc.MANUAL_REVIEW_FLAGS["detail"]) return { "website": _strip_tracking_query(dc.normalize_website_url(website_url)), "email": emails[0] if emails else "", "emails": emails, "phone": phones[0] if phones else "", "checked_urls": checked, "business_summary": summary, "evidence_notes": evidence, "text": combined_text[:12000], } def build_chinese_notes( facebook_url: str, facebook_text: str, website_url: str, website_result: Dict[str, Any], followers: str = "", post_analysis: str = "", detected_brands: List[str] | None = None, exclusivity: str = "", ) -> str: combined = "\n".join([facebook_text or "", website_result.get("text", "") or "", post_analysis or ""]) fb_summary = summarize_business_cn(facebook_text or post_analysis or "") website_summary = website_result.get("business_summary", "") contact_parts: List[str] = [] if website_result.get("email"): contact_parts.append("官网公开邮箱:" + website_result["email"]) if website_result.get("phone"): contact_parts.append("官网公开电话:" + website_result["phone"]) if not contact_parts: contact_parts.append("未发现新的官网公开联系方式") notes = [ "来源线索:Facebook主页 " + facebook_url, "Facebook证据:" + ("粉丝量:" + followers if followers else "已检查主页/About/近期帖子"), "官网证据:" + (website_url if website_url else "Facebook 未发现公司官网"), "主营业务判断:" + summarize_business_cn(combined).replace("主营业务判断:", "", 1), "联系方式证据:" + ";".join(contact_parts), ] if website_result.get("evidence_notes"): notes.append("官网检查:" + ";".join(website_result["evidence_notes"])) if post_analysis: notes.append("近期动态:" + post_analysis[:260]) if detected_brands: notes.append("品牌信号:涉及中国品牌 " + "、".join(detected_brands)) if exclusivity: notes.append("风险/待确认项:" + exclusivity) return " | ".join(part for part in notes if part)[:1500]