| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- """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]
|