| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749 |
- """
- Shared helpers for Morocco dealer discovery.
- The scrapers in this folder are preview-first lead research tools. This module
- keeps platform-specific scripts aligned on duplicate handling, OEM brand-page
- rejection, customer classification, and public contact extraction.
- """
- import json
- import re
- from pathlib import Path
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
- from urllib.parse import urljoin, urlparse, urlunparse
- import pandas as pd
- import requests
- try:
- from ..common.customer_taxonomy import classify_from_text
- except ImportError: # pragma: no cover - direct script execution
- import sys
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from common.customer_taxonomy import classify_from_text
- DEFAULT_CITY_SCOPE = "摩洛哥全国"
- MOROCCO_TARGET_CITIES = [
- "Casablanca",
- "Rabat",
- "Marrakech",
- "Tanger",
- "Fes",
- "Agadir",
- "Meknes",
- "Oujda",
- "Kenitra",
- "Tetouan",
- "Nador",
- "Safi",
- ]
- MOROCCO_TERMS = {
- "maroc", "morocco", "casablanca", "rabat", "marrakech", "tanger",
- "fes", "fès", "agadir", "meknes", "oujda", "kenitra", "tetouan",
- "tétouan", "nador", "safi", "settat", "dar bouazza", "el jadida",
- }
- CHINA_BRAND_TERMS = {
- "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton",
- "forland", "gac", "geely", "great wall", "haval", "jac", "jetour",
- "maxus", "mg", "omoda", "saic", "sitrak", "sinotruk", "shacman",
- "faw", "faw trucks", "yutong", "king long", "golden dragon", "higer",
- "wuling",
- }
- NON_CHINA_BRAND_TERMS = {
- "audi", "bmw", "chevrolet", "citroen", "citroën", "dacia", "daf",
- "fiat", "ford", "hino", "honda", "hyundai", "isuzu", "iveco", "jeep",
- "kia", "man", "mazda", "mercedes", "mercedes-benz", "mitsubishi",
- "nissan", "opel", "peugeot", "renault", "scania", "seat", "skoda",
- "suzuki", "toyota", "volkswagen", "volvo", "volvo trucks",
- }
- OEM_BRANCH_BRANDS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
- INDEPENDENT_CHANNEL_CLUES = {
- "auto hall": "autohall",
- "smaa": "smaa",
- "smeia": "smeia",
- "auto nejma": "autonejma",
- "la continentale": "lacontinentale",
- "prince auto": "princeauto",
- "kifal": "kifal",
- "autochek": "autochek",
- "bugshan": "bugshan",
- "bamotors": "bamotors",
- "sopriam": "sopriam",
- "cac": "cac",
- "centrale automobile": "centraleautomobile",
- "univers motors": "universmotors",
- "m-automotiv": "mautomotiv",
- "m automotiv": "mautomotiv",
- }
- INDEPENDENT_CHANNEL_DISPLAY = {
- "auto hall": "Auto Hall",
- "smaa": "SMAA",
- "smeia": "SMEIA",
- "auto nejma": "Auto Nejma",
- "la continentale": "La Continentale Auto",
- "prince auto": "Prince Auto",
- "kifal": "Kifal Auto",
- "autochek": "Autochek Morocco",
- "bugshan": "Bugshan Automotive",
- "bamotors": "Bamotors Maroc",
- "sopriam": "Sopriam",
- "centrale automobile": "Centrale Automobile Chérifienne",
- "univers motors": "Univers Motors",
- "m-automotiv": "M-AUTOMOTIV",
- "m automotiv": "M-AUTOMOTIV",
- }
- HIGH_INTENT_TERMS = {
- "concessionnaire", "dealer", "distributeur", "distribution", "importateur",
- "importation", "showroom", "groupe", "group", "automobile", "auto",
- "retail", "vente", "voiture", "vehicule", "v?hicule", "stock", "parc auto",
- "multimarque", "multi-brand", "v?hicules neufs", "vehicules neufs", "voitures neuves",
- "new vehicle", "new cars", "r?seau", "reseau", "dealer network",
- "fleet", "flotte", "utilitaire", "camion",
- }
- NEW_VEHICLE_SALES_TERMS = {
- "v?hicules neufs", "vehicules neufs", "voitures neuves", "new vehicle",
- "new vehicles", "new cars", "concessionnaire", "dealer", "showroom",
- "vente automobile", "vente de voitures", "stock", "parc auto", "retail",
- }
- IMPORT_DISTRIBUTION_TERMS = {
- "importateur", "importation", "importer", "distributeur", "distribution",
- "r?seau", "reseau", "dealer network", "national distributor", "regional distributor",
- "agent", "groupe", "group",
- }
- MULTIBRAND_TERMS = {
- "multimarque", "multi-brand", "multi brand", "multi marques", "plusieurs marques",
- "marques multiples", "showroom multimarque",
- }
- CONTACT_ENTRY_TERMS = {
- "whatsapp", "wa.me", "tel", "t?l", "telephone", "t?l?phone", "phone",
- "email", "mail", "contact", "linkedin.com", "facebook.com", "site web", "website",
- "+212", "@",
- }
- PURE_SERVICE_TERMS = {
- "garage r?paration", "garage reparation", "r?paration", "reparation", "repair",
- "diagnostic", "diag", "pi?ces d?tach?es", "pieces detachees", "spare parts",
- "pieces auto", "pi?ces auto", "pneus", "tires", "tyres", "tuning", "lavage",
- "car wash", "pare-brise", "assurance", "insurance",
- }
- RENTAL_FLEET_TERMS = {
- "location", "rental", "rent car", "rentcar", "flotte", "fleet", "leasing", "lld",
- }
- PERSONAL_SELLER_TERMS = {
- "particulier", "personal seller", "vendeur particulier", "marketplace seller", "profile.php",
- }
- DIRECT_BRANCH_TERMS = {
- "succursale", "branch", "filiale", "subsidiary", "official dealer", "concession officielle",
- "showroom officiel", "page officielle", "official page", "officiel", "official",
- }
- LOW_VALUE_TERMS = PURE_SERVICE_TERMS | {
- "aerospace", "a?ronautique", "software", "marketing", "emailing",
- "real estate", "immobilier", "location uniquement", "rental only",
- }
- CITY_PATTERNS = [
- "Casablanca", "Rabat", "Marrakech", "Tanger", "Fes", "Fès", "Agadir",
- "Meknes", "Oujda", "Kenitra", "Tetouan", "Tétouan", "Nador", "Safi",
- "Settat", "Dar Bouazza", "El Jadida",
- ]
- GENERIC_COMPANY_WORDS = {
- "maroc", "morocco", "ma", "officiel", "official", "page", "company",
- "automobile", "auto", "cars", "motors", "motor", "groupe", "group",
- "sarl", "sa", "llc", "ltd",
- }
- OEM_BRANCH_FILLER_TERMS = {
- "auto", "autos", "car", "cars", "motor", "motors", "truck", "trucks",
- "vehicle", "vehicles", "vehicule", "vehicules", "official", "officiel",
- }
- MANUAL_REVIEW_FLAGS = {
- "ownership": "主体归属待确认:无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。",
- "new_vehicle": "新车业务待确认:无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。",
- "platform_entity": "平台与行业渠道主体待确认:无法确认是真实机构主体,还是普通个人页、内容号或非正式资源页。",
- "platform_role": "平台与行业渠道作用待确认:无法确认其是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。",
- "phone_only": "仅电话/WhatsApp待人工确认:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需人工联系确认主体和实际业务。",
- "detail": "详细信息待确认:因登录限制、页面屏蔽、地区限制或网站无法访问,无法读取 Facebook、LinkedIn、官网等内容。",
- }
- def add_manual_review_flag(risks: List[str], key: str) -> None:
- flag = MANUAL_REVIEW_FLAGS.get(key, "")
- if flag and flag not in risks:
- risks.append(flag)
- def should_request_manual_review(score: int, recommended_action: str, reasons: Sequence[str]) -> bool:
- if recommended_action in {"skip_brand_branch", "skip_non_channel", "skip_existing"}:
- return False
- reason_text = " ".join(str(item) for item in reasons)
- value_markers = (
- '实际新整车销售',
- 'showroom',
- '库存',
- '进口/分销',
- '网络能力',
- '多品牌',
- '已知独立',
- '中国品牌',
- '商用车',
- '车队',
- '汽车行业平台',
- '汽车媒体',
- '行业活动',
- '协会',
- '商会',
- '车商联盟',
- '经销商资源',
- '平台与行业渠道',
- '搜索词意图强',
- '主页链接像汽车业务',
- 'stock',
- 'dealer',
- 'media',
- 'event',
- 'association',
- 'chamber',
- 'platform',
- '可建联入口',
- )
- return score >= 4 or any(marker in reason_text for marker in value_markers)
- def clean_space(value: Any) -> str:
- return re.sub(r"\s+", " ", str(value or "")).strip()
- def compact_text(value: Any) -> str:
- return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
- def matched_terms(text: str, terms: Iterable[str]) -> List[str]:
- text_lower = str(text or "").casefold()
- return sorted(term for term in terms if term.casefold() in text_lower)
- def normalize_url(value: Any) -> str:
- link = str(value or "").strip()
- if not link or link.casefold() == "nan":
- return ""
- parsed = urlparse(link)
- if not parsed.scheme or not parsed.netloc:
- return compact_text(link)
- netloc = parsed.netloc.casefold()
- if netloc.startswith("m."):
- netloc = "www." + netloc[2:]
- path = re.sub(r"/+", "/", parsed.path).rstrip("/")
- normalized = urlunparse((parsed.scheme.casefold(), netloc, path, "", "", ""))
- return normalized.rstrip("/").casefold()
- def normalize_linkedin_url(url: str) -> str:
- parsed = urlparse(str(url or "").strip())
- if "linkedin.com" not in parsed.netloc.casefold():
- return ""
- parts = [part for part in parsed.path.split("/") if part]
- if len(parts) < 2 or parts[0] != "company":
- return ""
- return f"https://www.linkedin.com/company/{parts[1]}/".casefold()
- def normalize_google_maps_url(url: str) -> str:
- parsed = urlparse(str(url or "").strip())
- if "google." not in parsed.netloc.casefold() or "/maps/" not in parsed.path:
- return normalize_url(url)
- path = parsed.path.rstrip("/")
- return urlunparse(("https", parsed.netloc.casefold(), path, "", "", "")).casefold()
- def normalized_company_key(name: Any) -> str:
- text = clean_space(name).casefold()
- if not text or text == "nan":
- return ""
- tokens = re.findall(r"[a-z0-9]+", text)
- tokens = [token for token in tokens if token not in GENERIC_COMPANY_WORDS]
- return "".join(tokens) or compact_text(text)
- def independent_channel_display_name(text: str) -> str:
- combined = str(text or "").casefold()
- for clue, display in INDEPENDENT_CHANNEL_DISPLAY.items():
- if clue in combined:
- return display
- return ""
- def canonical_dealer_name(name: str, text: str = "") -> str:
- combined = f"{name} {text}"
- display = independent_channel_display_name(combined)
- if not display:
- return clean_space(name)
- brand_terms = matched_terms(combined, OEM_BRANCH_BRANDS)
- name_key = normalized_company_key(name)
- display_key = normalized_company_key(display)
- if brand_terms and name_key != display_key:
- return display
- return clean_space(name) or display
- def dealer_group_key(candidate: Dict[str, Any]) -> str:
- name = clean_space(candidate.get("name") or candidate.get("公司名称") or candidate.get("客户姓名/公司"))
- combined = f"{name} {candidate.get('href', '')} {candidate.get('url', '')} {candidate.get('text', '')}".casefold()
- for clue, key in INDEPENDENT_CHANNEL_CLUES.items():
- if clue in combined:
- return key
- return normalized_company_key(name)
- def load_existing_identity(
- excel_path: str,
- sheet_name: str,
- link_columns: Sequence[str],
- name_columns: Sequence[str],
- ) -> Dict[str, Set[str]]:
- path = Path(excel_path)
- if not excel_path or not path.exists():
- return {"links": set(), "names": set()}
- try:
- df = pd.read_excel(path, sheet_name=sheet_name)
- except Exception:
- return {"links": set(), "names": set()}
- links: Set[str] = set()
- for col in link_columns:
- if col not in df.columns:
- continue
- for value in df[col].tolist():
- normalized = normalize_url(value)
- if "linkedin.com" in normalized:
- normalized = normalize_linkedin_url(str(value))
- elif "google." in normalized and "/maps/" in normalized:
- normalized = normalize_google_maps_url(str(value))
- if normalized:
- links.add(normalized)
- names: Set[str] = set()
- for col in name_columns:
- if col not in df.columns:
- continue
- for value in df[col].tolist():
- key = normalized_company_key(value)
- if key:
- names.add(key)
- return {"links": links, "names": names}
- def load_blocklist_json(blocklist_path: Optional[str], normalizer=normalize_url) -> Set[str]:
- if not blocklist_path:
- return set()
- path = Path(blocklist_path)
- if not path.exists():
- return set()
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except Exception:
- return set()
- values: Iterable[Any]
- if isinstance(data, list):
- values = data
- elif isinstance(data, dict) and "blocklist" in data:
- values = data["blocklist"]
- else:
- return set()
- return {normalized for normalized in (normalizer(str(value).strip()) for value in values) if normalized}
- def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
- resp = requests.get(
- f"{base_url.rstrip('/')}/api/v1/browser/active",
- params={"user_id": profile_id},
- timeout=10,
- )
- resp.raise_for_status()
- data = resp.json()
- if data.get("code") != 0:
- raise RuntimeError(f"获取活动浏览器失败: {data}")
- ws = (data.get("data") or {}).get("ws") or {}
- endpoint = ws.get("puppeteer") or ws.get("selenium")
- if not endpoint:
- raise RuntimeError(f"未找到已打开浏览器 ws endpoint,请确认 AdsPower profile 已打开: {profile_id}")
- return endpoint
- def looks_like_oem_local_branch(name: str, url: str = "", text: str = "") -> Tuple[bool, str]:
- combined = f"{name} {url} {text}".casefold()
- name_clean = clean_space(name).casefold()
- compact_name = compact_text(name)
- slug = urlparse(str(url or "")).path.strip("/").split("/")[-1]
- compact_slug = compact_text(slug)
- if any(clue in combined for clue in INDEPENDENT_CHANNEL_CLUES):
- return False, ""
- official_marker = re.search(r"\b(page officielle|official page|officiel|official)\b", combined)
- country_marker = re.search(r"\b(maroc|morocco|ma)\b", combined)
- for brand in OEM_BRANCH_BRANDS:
- brand_key = compact_text(brand)
- if not brand_key:
- continue
- direct_variants = {
- f"{brand_key}maroc",
- f"{brand_key}morocco",
- f"{brand_key}ma",
- f"maroc{brand_key}",
- f"morocco{brand_key}",
- }
- if compact_name in direct_variants or compact_slug in direct_variants:
- return True, brand
- filler_keys = {compact_text(term) for term in OEM_BRANCH_FILLER_TERMS}
- for country in ("maroc", "morocco", "ma"):
- if compact_name.startswith(brand_key) and compact_name.endswith(country):
- middle = compact_name[len(brand_key):-len(country)]
- if not middle or middle in filler_keys:
- return True, brand
- if compact_slug.startswith(brand_key) and compact_slug.endswith(country):
- middle = compact_slug[len(brand_key):-len(country)]
- if not middle or middle in filler_keys:
- return True, brand
- if re.fullmatch(rf"{re.escape(brand.casefold())}\s+(maroc|morocco)", name_clean):
- return True, brand
- if brand.casefold() in name_clean and official_marker and country_marker:
- return True, brand
- return False, ""
- def score_dealer_candidate(
- candidate: Dict[str, Any],
- existing_links: Optional[Set[str]] = None,
- existing_names: Optional[Set[str]] = None,
- platform: str = "",
- ) -> Dict[str, Any]:
- existing_links = existing_links or set()
- existing_names = existing_names or set()
- name = clean_space(candidate.get("name") or candidate.get("title") or "")
- href = clean_space(candidate.get("href") or candidate.get("url") or "")
- text = clean_space(candidate.get("text") or "")
- queries = candidate.get("source_queries", []) or []
- combined = f"{name} {href} {text} {' '.join(queries)}"
- combined_lower = combined.casefold()
- normalized_link = normalize_url(href)
- if "linkedin.com" in normalized_link:
- normalized_link = normalize_linkedin_url(href)
- elif "google." in normalized_link and "/maps/" in normalized_link:
- normalized_link = normalize_google_maps_url(href)
- name_key = normalized_company_key(name)
- if normalized_link and normalized_link in existing_links:
- return {**candidate, "score": -10, "score_reasons": ["Already exists in workbook"], "risk_flags": ["duplicate_existing_link"], "recommended_action": "skip_existing"}
- if name_key and name_key in existing_names:
- return {**candidate, "score": -9, "score_reasons": ["Company name already exists in workbook"], "risk_flags": ["duplicate_existing_name"], "recommended_action": "skip_existing"}
- score = 0
- reasons: List[str] = []
- risks: List[str] = []
- recommended_action = "preview_only"
- is_oem, brand = looks_like_oem_local_branch(name, href, text)
- brand_terms = matched_terms(combined, OEM_BRANCH_BRANDS)
- direct_branch_terms = matched_terms(combined, DIRECT_BRANCH_TERMS)
- if is_oem or (brand_terms and direct_branch_terms and not any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES)):
- score -= 10
- label = brand or ", ".join(brand_terms[:2]) or "brand"
- risks.append(f"疑似品牌当地分公司/官方主页/直营网点,排除: {label}")
- recommended_action = "skip_brand_branch"
- service_terms = matched_terms(combined, PURE_SERVICE_TERMS)
- if service_terms:
- score -= 6
- risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
- if recommended_action == "preview_only":
- recommended_action = "skip_non_channel"
- personal_terms = matched_terms(combined, PERSONAL_SELLER_TERMS)
- if personal_terms:
- score -= 5
- risks.append("疑似个人卖家,不纳入汽车渠道合作伙伴: " + ", ".join(personal_terms[:4]))
- if recommended_action == "preview_only":
- recommended_action = "skip_non_channel"
- sales_terms = matched_terms(combined, NEW_VEHICLE_SALES_TERMS)
- import_terms = matched_terms(combined, IMPORT_DISTRIBUTION_TERMS)
- multibrand_terms = matched_terms(combined, MULTIBRAND_TERMS)
- rental_terms = matched_terms(combined, RENTAL_FLEET_TERMS)
- contact_terms = matched_terms(combined, CONTACT_ENTRY_TERMS)
- morocco_terms = matched_terms(combined, MOROCCO_TERMS)
- has_actual_channel = bool(sales_terms or import_terms or multibrand_terms)
- ownership_needs_review = brand_terms and recommended_action != "skip_brand_branch" and not any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES) and not (import_terms or multibrand_terms)
- if sales_terms:
- score += 3
- reasons.append("实际新整车销售/showroom/库存信号: " + ", ".join(sales_terms[:5]))
- if import_terms:
- score += 3
- reasons.append("进口/分销/网络能力信号: " + ", ".join(import_terms[:5]))
- if multibrand_terms:
- score += 2
- reasons.append("多品牌经营信号: " + ", ".join(multibrand_terms[:4]))
- if morocco_terms:
- score += 1
- reasons.append("Morocco signal: " + ", ".join(morocco_terms[:4]))
- if any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES):
- score += 3
- reasons.append("已知独立汽车渠道主体")
- if href and any(host in href.casefold() for host in ["facebook.com", "linkedin.com", "google."]):
- score += 2
- reasons.append("可建联入口: 来源主页/平台页")
- elif contact_terms:
- score += 2
- reasons.append("可建联入口: " + ", ".join(contact_terms[:4]))
- china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
- if china_terms and recommended_action != "skip_brand_branch":
- score += 1
- reasons.append("中国品牌渠道语境: " + ", ".join(china_terms[:4]))
- commercial_terms = matched_terms(combined, {"utilitaire", "camion", "truck", "fleet", "flotte"})
- if commercial_terms:
- score += 1
- reasons.append("商用车/车队相关信号: " + ", ".join(commercial_terms[:4]))
- if rental_terms and not has_actual_channel:
- score += 1
- risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司,不作为汽车渠道合作伙伴: " + ", ".join(rental_terms[:4]))
- if brand_terms and sales_terms and not (import_terms or multibrand_terms) and recommended_action != "skip_brand_branch":
- risks.append("独立单品牌新车经销商可保留;排他协议及新增品牌权限待确认")
- score += 1
- new_vehicle_needs_review = False
- if not has_actual_channel and not rental_terms:
- score -= 3
- risks.append("未发现新整车销售、进口、分销或 showroom 证据")
- new_vehicle_needs_review = True
- if ownership_needs_review and should_request_manual_review(score, recommended_action, reasons):
- add_manual_review_flag(risks, "ownership")
- if new_vehicle_needs_review and should_request_manual_review(score, recommended_action, reasons):
- add_manual_review_flag(risks, "new_vehicle")
- canonical_name = canonical_dealer_name(name, text)
- if canonical_name and canonical_name != name:
- candidate = {**candidate, "canonical_name": canonical_name}
- reasons.append(f"映射到独立经销集团: {canonical_name}")
- if not reasons:
- reasons.append(f"{platform or '搜索'} 信号较弱,低优先级;如无更多渠道价值证据应跳过")
- threshold = 4 if platform.casefold() == "google maps" else 5
- if recommended_action == "preview_only" and score >= threshold:
- recommended_action = "deep_scrape"
- return {**candidate, "score": score, "score_reasons": reasons, "risk_flags": risks, "recommended_action": recommended_action}
- def merge_candidates_by_url(items: List[Dict[str, Any]], normalizer=normalize_url) -> List[Dict[str, Any]]:
- by_key: Dict[str, Dict[str, Any]] = {}
- for item in items:
- url = item.get("href") or item.get("url") or ""
- key = normalizer(url) or normalized_company_key(item.get("name", ""))
- if not key:
- continue
- if key not in by_key:
- by_key[key] = item
- continue
- existing = by_key[key]
- existing["source_queries"] = sorted(set(existing.get("source_queries", []) + item.get("source_queries", [])))
- snippets = existing.setdefault("snippets", [existing.get("text", "")])
- if item.get("text") and item["text"] not in snippets:
- snippets.append(item["text"])
- return list(by_key.values())
- def dedupe_dealer_groups(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- grouped: Dict[str, Dict[str, Any]] = {}
- for candidate in candidates:
- key = dealer_group_key(candidate)
- if not key:
- continue
- current = grouped.get(key)
- if not current:
- grouped[key] = candidate
- continue
- current_score = int(current.get("score", 0))
- candidate_score = int(candidate.get("score", 0))
- current_name = clean_space(current.get("name", ""))
- candidate_name = clean_space(candidate.get("name", ""))
- prefer_candidate = (
- candidate.get("recommended_action") != "skip_brand_branch"
- and (
- current.get("recommended_action") == "skip_brand_branch"
- or candidate_score > current_score
- or (candidate_score == current_score and len(candidate_name) < len(current_name))
- )
- )
- keeper = candidate if prefer_candidate else current
- merged = current if prefer_candidate else candidate
- keeper["source_queries"] = sorted(set(keeper.get("source_queries", []) + merged.get("source_queries", [])))
- keeper.setdefault("merged_duplicate_candidates", [])
- keeper["merged_duplicate_candidates"].append({
- "name": merged.get("name", ""),
- "url": merged.get("href") or merged.get("url", ""),
- "score": merged.get("score"),
- "recommended_action": merged.get("recommended_action"),
- })
- risks = set(keeper.get("risk_flags", []))
- risks.add("同一集团/重复名称候选已合并")
- keeper["risk_flags"] = sorted(risks)
- grouped[key] = keeper
- return sorted(grouped.values(), key=lambda c: (-int(c.get("score", 0)), clean_space(c.get("name", ""))))
- def extract_urls(text: str) -> List[str]:
- urls = re.findall(r"https?://[^\s<>()\"']+", str(text or ""))
- cleaned: List[str] = []
- for url in urls:
- url = url.rstrip(".,,。;;")
- if url not in cleaned:
- cleaned.append(url)
- return cleaned
- def extract_emails(text: str) -> List[str]:
- value = str(text or "")
- value = re.sub(r"\s*(?:\[at\]|\(at\)|\sat\s)\s*", "@", value, flags=re.I)
- value = re.sub(r"\s*(?:\[dot\]|\(dot\)|\sdot\s)\s*", ".", value, flags=re.I)
- emails = re.findall(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", value)
- filtered: List[str] = []
- for email in emails:
- email = email.strip(".,;:()[]<>").casefold()
- if email.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
- continue
- if email not in filtered:
- filtered.append(email)
- return filtered
- def extract_email(text: str) -> str:
- emails = extract_emails(text)
- return emails[0] if emails else ""
- def extract_phone(text: str) -> str:
- value = str(text or "")
- explicit = re.search(r"(?:Téléphone|Telephone|电话|Tél|Tel|WhatsApp|Phone)[::]?\s*([+()\d][+()\d\s.-]{6,}\d)", value, re.I)
- if explicit:
- return re.sub(r"\s+", " ", explicit.group(1)).strip()
- match = re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", value)
- return re.sub(r"\s+", " ", match.group(0)).strip() if match else ""
- def extract_city(text: str, fallback: str = DEFAULT_CITY_SCOPE) -> str:
- hits = []
- for city in CITY_PATTERNS:
- if re.search(rf"\b{re.escape(city)}\b", str(text or ""), re.I) and city not in hits:
- hits.append(city)
- return " / ".join(hits[:4]) if hits else fallback
- def classify_customer_type(text: str) -> str:
- _attribute, customer_type = classify_from_text(str(text or ""))
- return customer_type
- def summarize_business(text: str) -> str:
- lower = str(text or "").casefold()
- parts: List[str] = []
- if any(term in lower for term in ["occasion", "reprise", "voiture d'occasion"]):
- parts.append("二手车买卖/置换")
- if any(term in lower for term in ["concessionnaire", "showroom", "vente automobile", "汽车零售"]):
- parts.append("汽车销售/showroom")
- if any(term in lower for term in ["importateur", "distribution", "distributeur"]):
- parts.append("进口/分销")
- if any(term in lower for term in ["utilitaire", "camion", "truck", "fleet", "flotte"]):
- parts.append("商用车/车队")
- china = matched_terms(text, CHINA_BRAND_TERMS)
- non_china = matched_terms(text, NON_CHINA_BRAND_TERMS)
- if china:
- parts.append("涉及中国品牌: " + ", ".join(china[:4]))
- elif non_china:
- parts.append("主要品牌信号: " + ", ".join(non_china[:5]))
- return ";".join(parts) if parts else "汽车渠道线索,主营业务证据不足,需深搜确认"
- def parse_keywords(value: str) -> Optional[List[str]]:
- if not value.strip():
- return None
- return [item.strip() for item in value.split(",") if item.strip()]
- def is_external_business_url(url: str) -> bool:
- parsed = urlparse(str(url or ""))
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- return False
- host = parsed.netloc.casefold()
- blocked = [
- "google.", "gstatic.", "facebook.com", "instagram.com", "linkedin.com",
- "tiktok.com", "youtube.com", "youtu.be", "twitter.com", "x.com",
- "wa.me", "whatsapp.com", "maps.apple.com",
- ]
- return not any(term in host for term in blocked)
- def looks_like_oem_brand_country_url(url: str) -> Tuple[bool, str]:
- parsed = urlparse(str(url or ""))
- compact = compact_text(" ".join([parsed.netloc, parsed.path]))
- if not compact:
- return False, ""
- for brand in OEM_BRANCH_BRANDS:
- brand_key = compact_text(brand)
- if not brand_key:
- continue
- variants = {
- f"{brand_key}maroc",
- f"{brand_key}morocco",
- f"{brand_key}ma",
- f"maroc{brand_key}",
- f"morocco{brand_key}",
- }
- if any(variant in compact for variant in variants):
- return True, brand
- return False, ""
- def normalize_website_url(url: str, base_url: str = "") -> str:
- url = clean_space(url)
- if not url:
- return ""
- if base_url:
- url = urljoin(base_url, url)
- parsed = urlparse(url)
- if not parsed.scheme and parsed.netloc:
- url = "https:" + url
- elif not parsed.scheme:
- url = "https://" + url
- return url
|