| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- """
- Facebook 经销商搜索与采集
- 使用 Playwright + AdsPower 指纹浏览器
- """
- import re
- import time
- import random
- import argparse
- import json
- from typing import List, Dict, Any, Optional
- from urllib.parse import urlencode, urlparse, parse_qs
- 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 playwright.sync_api import Page, TimeoutError as PlaywrightTimeout
- try:
- from deep_translator import GoogleTranslator
- TRANSLATOR_AVAILABLE = True
- except ImportError:
- TRANSLATOR_AVAILABLE = False
- GoogleTranslator = None
- # 导入同目录或上级 common 模块
- try:
- from .ads_power_client import AdsPowerClient
- from .website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
- from ..common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
- except ImportError:
- import sys
- sys.path.insert(0, str(Path(__file__).parent.parent))
- from scraper.ads_power_client import AdsPowerClient
- from scraper.website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
- from common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
- def is_mostly_chinese(text: str) -> bool:
- """判断文本是否主要为中文"""
- if not text:
- return False
- chinese_chars = sum(1 for c in text if "\u4e00" <= c <= "\u9fff")
- return chinese_chars / max(len(text), 1) > 0.3
- def translate_to_chinese(text: str) -> str:
- """将文本翻译为中文;失败或已是中文时返回原文"""
- if not text or len(text.strip()) < 3:
- return text
- if is_mostly_chinese(text):
- return text
- if not TRANSLATOR_AVAILABLE:
- return text
- try:
- translated = GoogleTranslator(source="auto", target="zh-CN").translate(text)
- time.sleep(0.3)
- return translated or text
- except Exception as exc:
- print(f"翻译失败,保留原文: {exc}")
- return text
- class FacebookScraper:
- """Facebook 经销商搜索采集器"""
- def __init__(
- self,
- ads_power_url: str = "http://127.0.0.1:50325",
- api_key: str = "",
- profile_id: str = "",
- max_results: int = 20,
- scroll_attempts: int = 5,
- headless: bool = False
- ):
- self.ads_power_url = ads_power_url
- self.api_key = api_key
- self.profile_id = profile_id
- self.max_results = max_results
- self.scroll_attempts = scroll_attempts
- self.headless = headless
- self.client: Optional[AdsPowerClient] = None
- self.page: Optional[Page] = None
- def start(self):
- """启动浏览器"""
- self.client = AdsPowerClient(self.ads_power_url, api_key=self.api_key)
- self.client.start_browser(self.profile_id, headless=self.headless)
- self.page = self.client.get_open_page() or self.client.new_page()
- self.page.set_viewport_size({"width": 1280, "height": 800})
- def stop(self):
- """断开 Playwright 连接,浏览器保持打开"""
- if self.client:
- self.client.close_browser()
- def search(self, query: str, region: str = "") -> List[Dict[str, Any]]:
- """
- 在 Facebook 搜索经销商主页
- """
- search_query = f"{query} {region}".strip() if region else query
- encoded_query = urlencode({"q": search_query})
- search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
- print(f"访问搜索页: {search_url}")
- self.page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
- # 等待页面加载
- time.sleep(random.uniform(3, 5))
- # 处理可能的登录弹窗或提示
- self._dismiss_dialogs()
- # 滚动加载结果
- page_links = self._collect_page_links()
- print(f"收集到 {len(page_links)} 个主页链接")
- # 采集每个主页详情
- records = []
- for idx, link in enumerate(page_links[:self.max_results], 1):
- print(f"[{idx}/{min(len(page_links), self.max_results)}] 采集: {link}")
- try:
- record = self._scrape_page(link)
- if record.get("客户姓名/公司"):
- record["主页/链接"] = link
- record["国家"] = "摩洛哥" # 默认,后续可按 region 推断
- record["城市"] = region
- record = enrich_record_with_brands(record)
- records.append(record)
- except Exception as e:
- print(f"采集失败 {link}: {e}")
- # 随机间隔,降低风控
- time.sleep(random.uniform(2, 4))
- return deduplicate_records(records)
- def _dismiss_dialogs(self):
- """尝试关闭可能的弹窗"""
- try:
- # 常见的 cookie/登录提示关闭按钮
- selectors = [
- '[aria-label="关闭"]',
- '[aria-label="Close"]',
- '[role="button"][tabindex="0"]',
- ]
- for sel in selectors:
- buttons = self.page.query_selector_all(sel)
- for btn in buttons:
- text = btn.inner_text() if btn else ""
- if any(k in text.lower() for k in ["close", "关闭", "not now", "以后再说", "ok"]):
- btn.click()
- time.sleep(0.5)
- except Exception:
- pass
- def _collect_page_links(self) -> List[str]:
- """从搜索结果页收集主页链接"""
- links = set()
- for attempt in range(self.scroll_attempts):
- # 获取当前页所有可能的页面链接
- elements = self.page.query_selector_all('a[href*="/"]')
- for el in elements:
- href = el.get_attribute("href")
- if href and self._is_page_link(href):
- links.add(self._normalize_facebook_url(href))
- print(f"滚动 {attempt + 1}/{self.scroll_attempts}, 当前收集 {len(links)} 个链接")
- if len(links) >= self.max_results:
- break
- # 滚动到底部
- self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
- time.sleep(random.uniform(2, 4))
- return list(links)
- def _is_page_link(self, href: str) -> bool:
- """判断是否是 Facebook 主页链接"""
- parsed = urlparse(href)
- if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
- return False
- # 排除搜索、个人资料、帖子等
- excluded_patterns = [
- "/search/", "/watch", "/groups", "/marketplace",
- "/messages", "/notifications", "/settings",
- "/photo.php", "/videos", "/posts", "/reel",
- "/profile.php"
- ]
- for pattern in excluded_patterns:
- if pattern in parsed.path:
- return False
- # 主页链接通常是 /xxx/ 形式
- path = parsed.path.strip("/")
- if not path or "/" in path:
- return False
- return True
- def _normalize_facebook_url(self, url: str) -> str:
- """标准化 Facebook URL"""
- parsed = urlparse(url)
- path = parsed.path.strip("/").split("/")[0]
- return f"https://www.facebook.com/{path}/"
- def _scrape_page(self, page_url: str) -> Dict[str, Any]:
- """采集单个 Facebook 主页信息"""
- record = {
- "客户姓名/公司": "",
- "客户类型": "",
- "公司官网": "",
- "联系人": "",
- "职位": "",
- "电话/WhatsApp": "",
- "邮箱": "",
- "主营业务": "",
- "建联状态": "未联系",
- "下次跟进": "",
- "备注": "",
- }
- self.page.goto(page_url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(3, 5))
- # 1. 先读取主页联络资料:官网、电话、邮箱、粉丝数和简介。About 只能补充,不覆盖主页已确认字段。
- home_page_text = ""
- try:
- home_page_text = self.page.inner_text("body")
- except Exception:
- pass
- website_from_home = extract_company_website_from_facebook(self.page)
- if website_from_home:
- record["公司官网"] = website_from_home
- print(f"从主页联络资料提取到公司官网: {website_from_home}")
- home_phone = self._extract_phone(home_page_text)
- if home_phone:
- record["电话/WhatsApp"] = home_phone
- print(f"从主页联络资料提取到电话: {home_phone}")
- home_email = self._extract_email(home_page_text)
- if home_email:
- record["邮箱"] = home_email
- print(f"从主页联络资料提取到邮箱: {home_email}")
- followers_from_home = self._extract_followers(home_page_text)
- # 2. 页面标题作为公司名称
- try:
- title = self.page.title()
- # Facebook 标题通常是 "Page Name | Facebook"
- record["客户姓名/公司"] = title.split("|")[0].strip() if "|" in title else title.replace("Facebook", "").strip()
- except Exception:
- pass
- # 3. 尝试多种方式获取页面名称
- name_selectors = [
- 'h1',
- '[role="main"] h2',
- '[data-pagelet="ProfileActions"] h1',
- 'a[aria-label]',
- ]
- for sel in name_selectors:
- try:
- el = self.page.query_selector(sel)
- if el:
- text = el.inner_text().strip()
- if text and len(text) < 100 and "Facebook" not in text:
- record["客户姓名/公司"] = text
- break
- except Exception:
- continue
- # 4. 主页联络资料读取完成后,再获取 About 信息
- about_url = f"{page_url.rstrip('/')}/about/"
- self.page.goto(about_url, wait_until="domcontentloaded", timeout=60000)
- time.sleep(random.uniform(2, 4))
- page_text = ""
- try:
- page_text = self.page.inner_text("body")
- except Exception:
- pass
- about_website = extract_company_website_from_facebook(self.page)
- if about_website:
- if not record.get("公司官网"):
- record["公司官网"] = about_website
- elif about_website not in record["公司官网"]:
- record["公司官网"] = record["公司官网"] + ";" + about_website
- company_website = record.get("公司官网", "").split(";")[0].strip()
- website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": []}
- if company_website:
- try:
- print(f"深搜公司官网: {company_website}")
- website_result = scrape_public_website(self.page.context, company_website, max_pages=5)
- except Exception as exc:
- website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": [f"官网深搜失败:{str(exc)[:80]}"]}
- # 4. 提取电话
- phone = self._extract_phone(page_text)
- if phone and not record.get("电话/WhatsApp"):
- record["电话/WhatsApp"] = phone
- # 5. 提取邮箱,About 只补充空字段
- email = self._extract_email(page_text)
- if email and not record.get("邮箱"):
- record["邮箱"] = email
- if not record.get("邮箱") and website_result.get("email"):
- record["邮箱"] = website_result.get("email", "")
- if not record.get("电话/WhatsApp") and website_result.get("phone"):
- record["电话/WhatsApp"] = website_result.get("phone", "")
- # 6. 提取主营业务/描述并翻译为中文
- description = self._extract_description(page_text)
- if description:
- record["主营业务"] = translate_to_chinese(description)
- # 7. 综合 Facebook 与官网证据推断客户类型和主营业务
- combined_business_text = "\n".join([home_page_text, page_text, website_result.get("text", "")])
- record["客户类型"] = classify_customer_type_cn(combined_business_text)
- record["主营业务"] = summarize_business_cn(combined_business_text)
- # 8. 备注:中文结构化摘要
- followers = followers_from_home or self._extract_followers(page_text)
- record["备注"] = build_chinese_notes(
- facebook_url=page_url,
- facebook_text="\n".join(["证据采集顺序:主页联络资料 -> About -> 官网深搜", home_page_text, page_text]),
- website_url=record.get("公司官网", ""),
- website_result=website_result,
- followers=followers,
- post_analysis="",
- detected_brands=[],
- exclusivity="",
- )
- if record.get("备注"):
- record["备注"] = "证据采集顺序:主页联络资料 -> About -> 官网深搜 | " + record["备注"]
- return record
- def _extract_phone(self, text: str) -> str:
- """从文本中提取电话号码"""
- if not text:
- return ""
- patterns = [
- r'\+212[\s\-]?\d[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
- r'\+212[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
- r'0\d[\s\-]?\d{4}[\s\-]?\d{4}',
- r'0\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
- ]
- for pattern in patterns:
- match = re.search(pattern, text)
- if match:
- return match.group(0).strip()
- return ""
- def _extract_email(self, text: str) -> str:
- """从文本中提取邮箱"""
- if not text:
- return ""
- pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
- matches = re.findall(pattern, text)
- # 过滤掉常见非企业邮箱
- excluded = ["@facebook.com", "@fb.com", "@example.com"]
- for m in matches:
- if all(e not in m.lower() for e in excluded):
- return m.strip()
- return ""
- def _extract_description(self, text: str) -> str:
- """提取页面描述/主营业务"""
- if not text:
- return ""
- lines = [line.strip() for line in text.split("\n") if line.strip()]
- # 找包含汽车相关关键词的短句
- keywords = ["car", "auto", "voiture", "vehicle", "motor", "dealer", "occasion", "automotive"]
- for line in lines[:50]:
- if any(k in line.lower() for k in keywords) and len(line) < 200:
- return line
- return ""
- def _extract_followers(self, text: str) -> str:
- """提取粉丝数"""
- if not text:
- return ""
- patterns = [
- r'(\d+[\d\s,.]*[KkMm]?)\s*(followers|fans|abonnés|likes)',
- r'(\d+[\d\s,.]*)\s*(followers|fans|abonnés|likes)',
- ]
- for pattern in patterns:
- match = re.search(pattern, text, re.IGNORECASE)
- if match:
- return f"{match.group(1).strip()} {match.group(2)}"
- return ""
- def _infer_dealer_type(self, text: str) -> str:
- """根据页面文本推断客户类型"""
- if not text:
- return "待判断"
- text_lower = text.lower()
- if any(k in text_lower for k in ["used car", "occasion", "二手车", "occaz"]):
- return "二手车商"
- if any(k in text_lower for k in ["dealer", "concessionnaire", "distributor", "经销商"]):
- return "经销商"
- if any(k in text_lower for k in ["rental", "location", "租车"]):
- return "租车公司"
- if any(k in text_lower for k in ["repair", "garage", "维修"]):
- return "汽车维修"
- return "待判断"
- def main():
- parser = argparse.ArgumentParser(description="Facebook 经销商搜索采集")
- parser.add_argument("--query", required=True, help="搜索关键词")
- parser.add_argument("--region", default="", help="地区过滤")
- parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
- parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
- parser.add_argument("--api-key", default="", help="AdsPower API Key(如已开启认证)")
- parser.add_argument("--max-results", type=int, default=10, help="最大采集数")
- parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
- parser.add_argument("--output", default="facebook_scraped.json", help="输出 JSON 文件")
- parser.add_argument("--excel", default="", help="建联表路径;不传时按项目优先级自动查找")
- parser.add_argument("--sheet", default="Facebook", help="回写 Sheet 名")
- parser.add_argument("--write-excel", action="store_true", help="确认写入建联表;否则只输出 JSON 预览")
- parser.add_argument("--headless", action="store_true", help="无头模式")
- args = parser.parse_args()
- scraper = FacebookScraper(
- ads_power_url=args.ads_power_url,
- api_key=args.api_key,
- profile_id=args.profile_id,
- max_results=args.max_results,
- headless=args.headless
- )
- records = []
- try:
- scraper.start()
- records = scraper.search(args.query, args.region)
- finally:
- scraper.stop()
- # 保存 JSON
- 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)
- with open(output_path, "w", encoding="utf-8") as f:
- json.dump(records, f, ensure_ascii=False, indent=2)
- print(f"已保存 {len(records)} 条记录到 {output_path}")
- # 可选回写 Excel
- if args.write_excel:
- workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
- args.excel = str(workbook_info["path"])
- print(f"Workbook for writing: {args.excel} ({workbook_info['source']})")
- result = append_records(
- excel_path=args.excel,
- sheet_name=args.sheet,
- records=records,
- dedup_keys=["客户姓名/公司", "城市", "主页/链接"]
- )
- print(f"Excel 回写结果: {result}")
- else:
- print("默认预览模式:未写入 Excel。确认要入表时再使用 --write-excel。")
- if __name__ == "__main__":
- main()
|