| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902 |
- """
- Collect Morocco auto-dealer leads from vertical auto sites and business directories.
- This script uses an already-open AdsPower browser profile, searches six approved
- source websites, deep-scrapes candidate/company pages, follows public merchant
- website contact pages for emails, and writes results to a dedicated workbook
- sheet. It intentionally does not use Moteur.
- """
- import argparse
- import json
- import random
- import re
- import shutil
- import sys
- import time
- from dataclasses import dataclass, field
- from datetime import datetime, timezone
- from pathlib import Path
- import sys
- sys.path.append(str(Path(__file__).resolve().parents[1]))
- from common.artifact_manager import resolve_artifact_path, create_backup_once
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
- from urllib.parse import quote_plus, urljoin, urlparse
- import requests
- from openpyxl import Workbook, load_workbook
- from playwright.sync_api import BrowserContext, Page, sync_playwright
- try:
- from . import discovery_common as dc
- from ..common import resolve_workbook_path
- except ImportError:
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from scraper import discovery_common as dc
- from common import resolve_workbook_path
- if hasattr(sys.stdout, "reconfigure"):
- sys.stdout.reconfigure(encoding="utf-8")
- if hasattr(sys.stderr, "reconfigure"):
- sys.stderr.reconfigure(encoding="utf-8")
- DEFAULT_EXCEL_DIR = Path.cwd()
- DEFAULT_SHEET = "汽车网站精选线索"
- DEFAULT_COUNTRY = "摩洛哥"
- DEFAULT_CITY = "摩洛哥全国"
- DEFAULT_ADSPOWER_URL = "http://127.0.0.1:50325"
- HEADERS = [
- "序号",
- "客户姓名/公司",
- "国家",
- "城市",
- "客户类型",
- "主页/链接",
- "来源网站",
- "联系人",
- "职位",
- "电话/WhatsApp",
- "邮箱",
- "主营业务",
- "建联状态",
- "下次跟进",
- "备注",
- ]
- CONTACT_LINK_HINTS = {
- "contact",
- "contactez",
- "nous contacter",
- "a propos",
- "à propos",
- "apropos",
- "qui sommes",
- "mentions",
- "legal",
- "devis",
- }
- HIGH_VALUE_TERMS = {
- "importation",
- "importateur",
- "importateurs",
- "véhicules neufs",
- "vehicules neufs",
- "voitures neuves",
- "professionnel",
- "professionnels",
- "concessionnaire",
- "concessionnaires",
- "distributeur",
- "distributeurs",
- "showroom",
- "stock",
- "parc auto",
- "location",
- "lld",
- "leasing",
- "flotte",
- "fleet",
- "utilitaire",
- "utilitaires",
- "camionnette",
- "fourgon",
- "pick-up",
- "pickup",
- "mpv",
- "minibus",
- "économique",
- "economique",
- }
- LOW_VALUE_TERMS = {
- "garage réparation",
- "garage reparation",
- "diagnostic",
- "pièces détachées",
- "pieces detachees",
- "assurance",
- "lavage",
- "car wash",
- "pare-brise",
- "immobilier",
- "emploi",
- }
- @dataclass
- class SourceConfig:
- name: str
- domains: Tuple[str, ...]
- queries: List[str]
- start_urls: List[str] = field(default_factory=list)
- SOURCES = [
- SourceConfig(
- name="OtoMoto.ma",
- domains=("otomoto.ma",),
- start_urls=["https://otomoto.ma/guide/professionnels", "https://otomoto.ma/"],
- queries=[
- "site:otomoto.ma Maroc professionnel concessionnaire automobile",
- "site:otomoto.ma Maroc revendeur automobile professionnel",
- "site:otomoto.ma Maroc importateur voiture professionnel",
- ],
- ),
- SourceConfig(
- name="Wandaloo",
- domains=("wandaloo.com",),
- start_urls=["https://www.wandaloo.com/neuf/maroc/concessionnaire.html"],
- queries=[
- "site:wandaloo.com/neuf/maroc concessionnaire distributeur automobile Maroc",
- "site:wandaloo.com/neuf/maroc importateur showroom Maroc",
- "site:wandaloo.com/neuf/maroc utilitaire concessionnaire Maroc",
- ],
- ),
- SourceConfig(
- name="Kerix",
- domains=("kerix.net",),
- start_urls=["https://www.kerix.net/fr/annuaire-entreprise/automobiles.html"],
- queries=[
- "site:kerix.net Maroc automobiles importation concessionnaire",
- "site:kerix.net Maroc concessionnaires régionaux automobiles",
- "site:kerix.net Maroc vehicules utilitaires importateur",
- ],
- ),
- SourceConfig(
- name="Kompass",
- domains=("kompass.com",),
- start_urls=["https://ma.kompass.com/y/importer/a/vehicules-utilitaires/66340/"],
- queries=[
- "site:ma.kompass.com Maroc importateur véhicules utilitaires",
- "site:ma.kompass.com Maroc concessionnaire automobile",
- "site:ma.kompass.com Maroc distributeur véhicules automobiles",
- ],
- ),
- SourceConfig(
- name="Maroc Annuaire",
- domains=("marocannuaire.org",),
- start_urls=["https://marocannuaire.org/Annuaire/Activite.php?activite=Automobile+%28Concessionnaires%29"],
- queries=[
- "site:marocannuaire.org Automobile Concessionnaires Maroc email",
- "site:marocannuaire.org importateur automobile Maroc email",
- "site:marocannuaire.org location voitures Maroc entreprise",
- ],
- ),
- SourceConfig(
- name="Telecontact",
- domains=("telecontact.ma",),
- start_urls=["https://www.telecontact.ma/villes/automobiles-agents-concessionnaires.php"],
- queries=[
- "site:telecontact.ma automobiles agents concessionnaires Maroc",
- "site:telecontact.ma concessionnaire automobile Casablanca Maroc",
- "site:telecontact.ma véhicules utilitaires concessionnaire Maroc",
- ],
- ),
- ]
- def clean(value: Any) -> str:
- return re.sub(r"\s+", " ", str(value or "")).strip()
- def is_source_url(url: str, source: SourceConfig) -> bool:
- host = urlparse(str(url or "")).netloc.casefold()
- return any(domain in host for domain in source.domains)
- def is_html_candidate(url: str) -> bool:
- if not url.startswith(("http://", "https://")):
- return False
- lowered = url.casefold()
- blocked_parts = [
- "facebook.com",
- "instagram.com",
- "linkedin.com",
- "youtube.com",
- "wa.me",
- "whatsapp",
- "google.",
- "bing.com",
- "/login",
- "/signup",
- "/privacy",
- ]
- if any(part in lowered for part in blocked_parts):
- return False
- if re.search(r"\.(pdf|jpg|jpeg|png|gif|webp|zip|rar)(?:$|\?)", lowered):
- return False
- return True
- def find_workbook(base_dir: Path = DEFAULT_EXCEL_DIR) -> Path:
- candidates: List[Path] = []
- for path in base_dir.glob("*.xlsx"):
- if path.name.startswith("~$") or "_backup_" in path.name or "_with_" in path.name:
- continue
- try:
- workbook = load_workbook(path, read_only=True, data_only=False)
- if any(sheet in workbook.sheetnames for sheet in ["Facebook", "Google Maps", "LinkedIn", "本地汽车网站"]):
- candidates.append(path)
- workbook.close()
- except Exception:
- continue
- if not candidates:
- raise FileNotFoundError("未找到摩洛哥客户建联表")
- return sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True)[0]
- def get_active_profile(base_url: str) -> Tuple[str, str]:
- resp = requests.get(f"{base_url.rstrip('/')}/api/v1/browser/local-active", timeout=10)
- resp.raise_for_status()
- data = resp.json()
- active = ((data.get("data") or {}).get("list") or [])
- if not active:
- raise RuntimeError("未发现已打开的 AdsPower 浏览器配置")
- item = active[0]
- ws = (item.get("ws") or {}).get("puppeteer") or (item.get("ws") or {}).get("selenium")
- if not ws:
- raise RuntimeError(f"已打开配置没有 ws endpoint: {item}")
- return item.get("user_id", ""), ws
- def dismiss_dialogs(page: Page) -> None:
- labels = [
- "Accept all",
- "Tout accepter",
- "J'accepte",
- "Accepter",
- "Reject all",
- "Plus tard",
- "Fermer",
- "Close",
- "OK",
- ]
- for label in labels:
- try:
- page.get_by_text(label, exact=False).first.click(timeout=800)
- time.sleep(0.3)
- except Exception:
- pass
- def safe_body_text(page: Page, timeout: int = 12000) -> str:
- try:
- return page.locator("body").inner_text(timeout=timeout)
- except Exception:
- return ""
- def page_links(page: Page) -> List[Dict[str, str]]:
- try:
- return page.evaluate(
- """
- () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
- href: a.href || '',
- raw: a.getAttribute('href') || '',
- text: (a.innerText || '').trim(),
- aria: (a.getAttribute('aria-label') || '').trim()
- }))
- """
- )
- except Exception:
- return []
- def collect_bing_results(page: Page, source: SourceConfig, query: str, limit: int) -> List[Dict[str, Any]]:
- url = f"https://www.bing.com/search?q={quote_plus(query)}"
- print(f"搜索 {source.name}: {query}", flush=True)
- page.goto(url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(2.5, 4.0))
- dismiss_dialogs(page)
- for _ in range(2):
- page.mouse.wheel(0, 1000)
- time.sleep(random.uniform(0.7, 1.2))
- links = page.evaluate(
- """
- () => Array.from(document.querySelectorAll('li.b_algo h2 a, a[href]')).map((a) => ({
- href: a.href || '',
- text: (a.innerText || a.getAttribute('aria-label') || '').trim()
- }))
- """
- )
- results: List[Dict[str, Any]] = []
- seen: Set[str] = set()
- for item in links:
- href = item.get("href", "")
- if not is_source_url(href, source) or not is_html_candidate(href):
- continue
- key = dc.normalize_url(href)
- if not key or key in seen:
- continue
- seen.add(key)
- results.append({
- "url": href,
- "title": clean(item.get("text")),
- "source": source.name,
- "source_queries": [query],
- "collection_method": "bing",
- })
- if len(results) >= limit:
- break
- return results
- def collect_start_url_links(page: Page, source: SourceConfig, start_url: str, limit: int) -> List[Dict[str, Any]]:
- print(f"打开来源页 {source.name}: {start_url}", flush=True)
- page.goto(start_url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(2.5, 4.0))
- dismiss_dialogs(page)
- for _ in range(3):
- page.mouse.wheel(0, 1200)
- time.sleep(random.uniform(0.7, 1.2))
- body = safe_body_text(page)[:3000]
- results: List[Dict[str, Any]] = [{
- "url": page.url,
- "title": clean(page.title()),
- "text_hint": body,
- "source": source.name,
- "source_queries": [start_url],
- "collection_method": "start_url",
- }]
- seen = {dc.normalize_url(page.url)}
- for item in page_links(page):
- href = urljoin(page.url, item.get("href", ""))
- if not is_source_url(href, source) or not is_html_candidate(href):
- continue
- label = clean(" ".join([item.get("text", ""), item.get("aria", "")]))
- if not label or len(label) > 120:
- continue
- key = dc.normalize_url(href)
- if not key or key in seen:
- continue
- if not any(term in f"{label} {href}".casefold() for term in ["concession", "auto", "voiture", "garage", "import", "vehicule", "véhicule", "dealer", "annuaire", "societe", "entreprise"]):
- continue
- seen.add(key)
- results.append({
- "url": href,
- "title": label,
- "source": source.name,
- "source_queries": [start_url],
- "collection_method": "start_url_link",
- })
- if len(results) >= limit:
- break
- return results
- def merge_candidates(candidates: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
- merged: Dict[str, Dict[str, Any]] = {}
- for candidate in candidates:
- key = dc.normalize_url(candidate.get("url", "")) or dc.normalized_company_key(candidate.get("title"))
- if not key:
- continue
- if key not in merged:
- merged[key] = candidate
- continue
- existing = merged[key]
- existing["source_queries"] = sorted(set(existing.get("source_queries", []) + candidate.get("source_queries", [])))
- if candidate.get("text_hint"):
- existing["text_hint"] = clean(" ".join([existing.get("text_hint", ""), candidate.get("text_hint", "")]))[:4000]
- return list(merged.values())
- def extract_company_name(page: Page, candidate: Dict[str, Any], body: str) -> str:
- for selector in ["h1", "h2"]:
- try:
- value = clean(page.locator(selector).first.inner_text(timeout=1500))
- if value and len(value) <= 100:
- return re.sub(r"\s*[-|].*$", "", value).strip()
- except Exception:
- pass
- title = clean(page.title() or candidate.get("title", ""))
- title = re.sub(r"\s*[-|]\s*(Kerix|Kompass|Telecontact|Wandaloo|OtoMoto|Maroc Annuaire).*$", "", title, flags=re.I)
- return title[:100] or clean(candidate.get("title", ""))[:100]
- def extract_external_websites(current_url: str, links: List[Dict[str, str]], source: SourceConfig) -> List[str]:
- websites: List[str] = []
- source_hosts = set(source.domains)
- for item in links:
- href = urljoin(current_url, item.get("href") or item.get("raw") or "")
- if not dc.is_external_business_url(href):
- continue
- host = urlparse(href).netloc.casefold().removeprefix("www.")
- if any(domain in host for domain in source_hosts):
- continue
- label = clean(" ".join([item.get("text", ""), item.get("aria", ""), item.get("raw", "")])).casefold()
- if any(term in label for term in ["site web", "website", "web", "www", "visiter", "voir le site", "site internet"]) or len(websites) < 2:
- normalized = href.split("#")[0]
- if normalized not in websites:
- websites.append(normalized)
- return websites[:3]
- def same_host(url: str, base_url: str) -> bool:
- return urlparse(url).netloc.casefold().removeprefix("www.") == urlparse(base_url).netloc.casefold().removeprefix("www.")
- def scrape_website_email(context: BrowserContext, website_url: str, max_pages: int = 4) -> Dict[str, Any]:
- if not website_url or not dc.is_external_business_url(website_url):
- return {"email": "", "emails": [], "sources": [], "checked_urls": []}
- checked: List[str] = []
- queue: List[str] = [website_url]
- emails: List[str] = []
- sources: 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 (checked and not same_host(url, website_url)):
- continue
- checked.append(url)
- try:
- print(f" 检查官网邮箱: {url}", flush=True)
- page.goto(url, wait_until="domcontentloaded", timeout=35000)
- time.sleep(random.uniform(1.2, 2.2))
- dismiss_dialogs(page)
- body = safe_body_text(page)
- links = page_links(page)
- mailto_text = " ".join(item.get("raw", "") for item in links if item.get("raw", "").casefold().startswith("mailto:"))
- for email in dc.extract_emails(body + " " + mailto_text):
- if email not in emails:
- emails.append(email)
- sources.append(url)
- if emails:
- break
- for item in links:
- label = clean(" ".join([item.get("raw", ""), 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") or item.get("raw") or "", page.url)
- if href and href not in checked and href not in queue and same_host(href, website_url):
- queue.append(href)
- except Exception:
- continue
- finally:
- try:
- page.close()
- except Exception:
- pass
- return {"email": emails[0] if emails else "", "emails": emails, "sources": sources, "checked_urls": checked}
- def score_record(name: str, url: str, body: str, source: str) -> Tuple[int, List[str], List[str]]:
- combined = f"{name} {url} {body}".casefold()
- score = 0
- reasons: List[str] = []
- risks: List[str] = []
- sales_terms = [term for term in ["véhicules neufs", "vehicules neufs", "voitures neuves", "concessionnaire", "showroom", "vente automobile", "stock", "parc auto", "professionnel"] if term in combined]
- import_terms = [term for term in ["importation", "importateur", "importateurs", "distributeur", "distributeurs", "réseau", "reseau", "points de vente", "succursales", "agences"] if term in combined]
- multibrand_terms = [term for term in ["multimarque", "multi-brand", "multi brand", "plusieurs marques", "marques multiples"] if term in combined]
- rental_terms = [term for term in ["location", "lld", "leasing", "flotte", "fleet"] if term in combined]
- service_terms = [term for term in ["garage réparation", "garage reparation", "diagnostic", "pièces détachées", "pieces detachees", "pneus", "tires", "lavage", "car wash", "pare-brise", "assurance", "immobilier", "emploi"] if term in combined]
- has_actual_channel = bool(sales_terms or 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 source in {"Kerix", "Kompass", "Maroc Annuaire", "Telecontact"}:
- score += 1
- reasons.append("annuaire professionnel")
- if re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", body):
- score += 2
- reasons.append("可建联入口: 电话/WhatsApp")
- if dc.extract_emails(body):
- score += 2
- reasons.append("可建联入口: email public")
- if any(term in combined for term in ["facebook.com", "linkedin.com", "whatsapp", "wa.me", "contact"]):
- score += 1
- reasons.append("可建联入口: social/contact link")
- if any(term in combined for term in ["stock", "annonces", "véhicules disponibles", "vehicules disponibles", "parc"]):
- score += 1
- reasons.append("stock véhicules")
- if service_terms:
- score -= 6
- risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
- if rental_terms and not has_actual_channel:
- score += 1
- risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司: " + ", ".join(rental_terms[:4]))
- 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 new_vehicle_needs_review and dc.should_request_manual_review(score, "preview_only", reasons):
- dc.add_manual_review_flag(risks, "new_vehicle")
- return score, reasons, risks
- def summarize_record(
- source: str,
- score: int,
- reasons: List[str],
- risks: List[str],
- page_url: str,
- website: str,
- email_source: str,
- queries: List[str],
- ) -> str:
- capability = "批量采购能力判断:"
- reason_text = "、".join(reasons[:8]) if reasons else "证据不足,低优先级;如无更多渠道价值证据应跳过"
- if any(term in reason_text for term in ["multi-site", "stock", "importation", "distributeur", "utilitaire", "flotte", "location"]):
- capability += "有批量/车队/分销潜力"
- else:
- capability += "待确认"
- parts = [
- f"来源线索:{source};详情页 {page_url}",
- f"主营业务判断:{reason_text}",
- capability,
- f"库存/门店/租赁/进口证据:{reason_text}",
- f"联系方式证据:官网 {website}" if website else "联系方式证据:未发现独立官网",
- f"邮箱来源:{email_source}" if email_source else "邮箱来源:未发现公开邮箱",
- f"来源搜索词:{', '.join(queries[:3])}",
- f"评分:{score}",
- ]
- if risks:
- parts.append("风险/待确认项:" + "、".join(risks[:5]))
- return " | ".join(parts)[:1400]
- def deep_scrape_candidate(page: Page, context: BrowserContext, candidate: Dict[str, Any], source: SourceConfig) -> Optional[Dict[str, Any]]:
- url = candidate["url"]
- print(f"深采 {source.name}: {url}", flush=True)
- try:
- page.goto(url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(2.0, 3.4))
- dismiss_dialogs(page)
- body = safe_body_text(page)
- except Exception as exc:
- return {
- "_skip": True,
- "_skip_reason": f"打开失败: {exc}",
- "url": url,
- "source": source.name,
- }
- if not body or len(body) < 80:
- return {"_skip": True, "_skip_reason": "页面正文过短", "url": url, "source": source.name}
- name = extract_company_name(page, candidate, body)
- if not name or len(name) < 2:
- return {"_skip": True, "_skip_reason": "未识别公司名", "url": url, "source": source.name}
- is_oem, brand = dc.looks_like_oem_local_branch(name, url, body)
- if is_oem:
- return {"_skip": True, "_skip_reason": f"疑似品牌官方国家页: {brand}", "url": url, "source": source.name}
- score, reasons, risks = score_record(name, url, body, source.name)
- if score < 3:
- return {"_skip": True, "_skip_reason": f"评分过低: {score}", "url": url, "source": source.name}
- links = page_links(page)
- websites = extract_external_websites(page.url, links, source)
- website = websites[0] if websites else ""
- page_emails = dc.extract_emails(body + " " + " ".join(item.get("raw", "") for item in links))
- website_email_result = scrape_website_email(context, website) if website else {"email": "", "emails": [], "sources": [], "checked_urls": []}
- email = website_email_result.get("email") or (page_emails[0] if page_emails else "")
- email_source = ""
- if website_email_result.get("email"):
- email_source = "官网公开页面 " + ", ".join(website_email_result.get("sources", [])[:2])
- elif email:
- email_source = "来源网站页面公开文本"
- combined_text = f"{name}\n{body}\n{' '.join(candidate.get('source_queries', []))}"
- phone = dc.extract_phone(body)
- city = dc.extract_city(combined_text, fallback=DEFAULT_CITY)
- business = dc.summarize_business(combined_text)
- customer_type = dc.classify_customer_type(combined_text)
- note = summarize_record(
- source=source.name,
- score=score,
- reasons=reasons,
- risks=risks,
- page_url=page.url,
- website=website,
- email_source=email_source,
- queries=candidate.get("source_queries", []),
- )
- return {
- "序号": "",
- "客户姓名/公司": name,
- "国家": DEFAULT_COUNTRY,
- "城市": city,
- "客户类型": customer_type,
- "主页/链接": page.url,
- "来源网站": source.name,
- "联系人": "",
- "职位": "",
- "电话/WhatsApp": phone,
- "邮箱": email,
- "主营业务": business,
- "建联状态": "未联系",
- "下次跟进": "",
- "备注": note,
- "_score": score,
- "_score_reasons": reasons,
- "_risk_flags": risks,
- "_website": website,
- "_website_email_result": website_email_result,
- }
- def split_multi(value: str) -> List[str]:
- return [clean(part) for part in re.split(r"[;;|]+", str(value or "")) if clean(part)]
- def append_unique_text(old: str, addition: str, separator: str = " | ") -> str:
- old = clean(old)
- addition = clean(addition)
- if not addition:
- return old
- if not old:
- return addition
- if addition in old:
- return old
- return (old + separator + addition)[:3000]
- def merge_sources(old: str, addition: str) -> str:
- values = []
- for item in split_multi(old) + split_multi(addition):
- if item and item not in values:
- values.append(item)
- return ";".join(values)
- def duplicate_key(record: Dict[str, Any]) -> List[str]:
- keys = []
- website = record.get("_website") or ""
- if website:
- keys.append("website:" + dc.normalize_url(website))
- name = record.get("客户姓名/公司")
- if name:
- keys.append("name:" + dc.normalized_company_key(name))
- for field in ["电话/WhatsApp", "邮箱", "主页/链接"]:
- value = clean(record.get(field))
- if value:
- normalized = dc.normalize_url(value) if field == "主页/链接" else value.casefold()
- keys.append(f"{field}:{normalized}")
- return [key for key in keys if key and not key.endswith(":")]
- def prepare_sheet(workbook: Workbook, sheet_name: str):
- if sheet_name in workbook.sheetnames:
- ws = workbook[sheet_name]
- for idx, header in enumerate(HEADERS, start=1):
- ws.cell(row=1, column=idx).value = header
- return ws
- ws = workbook.create_sheet(sheet_name)
- ws.append(HEADERS)
- return ws
- def read_existing_sheet_index(ws) -> Dict[str, int]:
- index: Dict[str, int] = {}
- header_map = {clean(ws.cell(1, col).value): col for col in range(1, ws.max_column + 1)}
- for row in range(2, ws.max_row + 1):
- record = {header: ws.cell(row, col).value for header, col in header_map.items()}
- for key in duplicate_key(record):
- index[key] = row
- return index
- def write_records_to_workbook(excel_path: Path, sheet_name: str, records: List[Dict[str, Any]]) -> Dict[str, Any]:
- locks = sorted(p.name for p in excel_path.parent.glob("~$*.xlsx"))
- if locks:
- raise PermissionError("检测到 Excel 临时锁文件: " + ", ".join(locks))
- backup_path = create_backup_once(excel_path, purpose="auto_websites", run_id="auto_websites")
- wb = load_workbook(excel_path)
- ws = prepare_sheet(wb, sheet_name)
- header_map = {header: idx for idx, header in enumerate(HEADERS, start=1)}
- existing_index = read_existing_sheet_index(ws)
- appended = 0
- merged = 0
- no_email = 0
- for record in records:
- if not record.get("邮箱"):
- no_email += 1
- row = None
- for key in duplicate_key(record):
- if key in existing_index:
- row = existing_index[key]
- break
- if row:
- merged += 1
- for field in ["来源网站", "备注"]:
- col = header_map[field]
- if field == "来源网站":
- ws.cell(row, col).value = merge_sources(ws.cell(row, col).value, record.get(field, ""))
- else:
- ws.cell(row, col).value = append_unique_text(ws.cell(row, col).value, record.get(field, ""))
- for field in ["电话/WhatsApp", "邮箱", "主页/链接", "主营业务", "客户类型", "城市"]:
- col = header_map[field]
- if not clean(ws.cell(row, col).value) and clean(record.get(field)):
- ws.cell(row, col).value = record.get(field)
- continue
- appended += 1
- row = ws.max_row + 1
- record["序号"] = row - 1
- for header, col in header_map.items():
- ws.cell(row, col).value = record.get(header, "")
- for key in duplicate_key(record):
- existing_index[key] = row
- wb.save(excel_path)
- wb.close()
- return {
- "backup_path": str(backup_path),
- "appended": appended,
- "merged": merged,
- "no_email": no_email,
- "sheet": sheet_name,
- "workbook": str(excel_path),
- }
- def collect_records(
- profile_id: str,
- ws_endpoint: str,
- max_per_source: int,
- max_candidates_per_source: int,
- queries_per_source: int,
- start_urls_per_source: int,
- ) -> Dict[str, Any]:
- playwright = sync_playwright().start()
- browser = playwright.chromium.connect_over_cdp(ws_endpoint)
- context = browser.contexts[0] if browser.contexts else browser.new_context()
- page = context.new_page()
- page.set_viewport_size({"width": 1366, "height": 850})
- source_logs: List[Dict[str, Any]] = []
- records: List[Dict[str, Any]] = []
- skipped: List[Dict[str, Any]] = []
- try:
- for source in SOURCES:
- raw: List[Dict[str, Any]] = []
- errors: List[str] = []
- try:
- for start_url in source.start_urls[:start_urls_per_source]:
- raw.extend(collect_start_url_links(page, source, start_url, max_candidates_per_source))
- time.sleep(random.uniform(1.2, 2.0))
- for query in source.queries[:queries_per_source]:
- raw.extend(collect_bing_results(page, source, query, max_candidates_per_source))
- time.sleep(random.uniform(1.5, 2.5))
- except Exception as exc:
- errors.append(str(exc))
- candidates = merge_candidates(raw)[:max_candidates_per_source]
- source_records: List[Dict[str, Any]] = []
- for candidate in candidates:
- if len(source_records) >= max_per_source:
- break
- result = deep_scrape_candidate(page, context, candidate, source)
- if not result:
- continue
- if result.get("_skip"):
- skipped.append(result)
- continue
- source_records.append(result)
- records.append(result)
- time.sleep(random.uniform(1.4, 2.4))
- source_logs.append({
- "source": source.name,
- "raw_candidates": len(raw),
- "merged_candidates": len(candidates),
- "records": len(source_records),
- "errors": errors,
- })
- finally:
- try:
- page.close()
- except Exception:
- pass
- playwright.stop()
- return {
- "generated_at": datetime.now(timezone.utc).isoformat(),
- "profile_id": profile_id,
- "records": records,
- "skipped": skipped,
- "source_logs": source_logs,
- }
- def main() -> None:
- parser = argparse.ArgumentParser(description="Collect Morocco auto website leads via AdsPower")
- parser.add_argument("--profile-id", default="", help="AdsPower profile ID; defaults to current local active browser")
- parser.add_argument("--ads-power-url", default=DEFAULT_ADSPOWER_URL)
- parser.add_argument("--excel", default="", help="Workbook path; defaults to detected Morocco outreach workbook")
- parser.add_argument("--sheet", default=DEFAULT_SHEET)
- parser.add_argument("--max-per-source", type=int, default=5)
- parser.add_argument("--max-candidates-per-source", type=int, default=12)
- parser.add_argument("--queries-per-source", type=int, default=3)
- parser.add_argument("--start-urls-per-source", type=int, default=1)
- parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
- parser.add_argument("--output", default="auto_website_leads.json")
- parser.add_argument("--write-excel", action="store_true")
- args = parser.parse_args()
- if args.profile_id:
- ws_endpoint = dc.get_active_ws_endpoint(args.ads_power_url, args.profile_id)
- profile_id = args.profile_id
- else:
- profile_id, ws_endpoint = get_active_profile(args.ads_power_url)
- workbook_info = resolve_workbook_path(args.excel, create_from_template=args.write_excel)
- excel_path = workbook_info.get("path")
- if excel_path and (args.write_excel or workbook_info.get("source") != "missing"):
- print(f"Workbook resolved: {excel_path} ({workbook_info['source']})", flush=True)
- result = collect_records(
- profile_id=profile_id,
- ws_endpoint=ws_endpoint,
- max_per_source=args.max_per_source,
- max_candidates_per_source=args.max_candidates_per_source,
- queries_per_source=max(0, args.queries_per_source),
- start_urls_per_source=max(0, args.start_urls_per_source),
- )
- result["summary"] = {
- "record_count": len(result["records"]),
- "records_with_email": len([r for r in result["records"] if r.get("邮箱")]),
- "sources": {log["source"]: log["records"] for log in result["source_logs"]},
- "skipped_count": len(result["skipped"]),
- "excel": str(excel_path) if excel_path else "",
- "sheet": args.sheet,
- }
- output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"已保存采集结果: {output_path}", flush=True)
- if args.write_excel:
- if not excel_path:
- raise SystemExit("使用 --write-excel 时必须提供 --excel 工作簿路径,或在当前目录放置可识别的建联表。")
- write_result = write_records_to_workbook(excel_path, args.sheet, result["records"])
- result["write_result"] = write_result
- output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- print("Excel 写入结果: " + json.dumps(write_result, ensure_ascii=False), flush=True)
- else:
- print("预览模式:未写入 Excel。", flush=True)
- if __name__ == "__main__":
- main()
|