search_facebook.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. """
  2. Facebook 经销商搜索与采集
  3. 使用 Playwright + AdsPower 指纹浏览器
  4. """
  5. import re
  6. import time
  7. import random
  8. import argparse
  9. import json
  10. from typing import List, Dict, Any, Optional
  11. from urllib.parse import urlencode, urlparse, parse_qs
  12. from pathlib import Path
  13. import sys
  14. sys.path.append(str(Path(__file__).resolve().parents[1]))
  15. from common.artifact_manager import resolve_artifact_path, create_backup_once
  16. from playwright.sync_api import Page, TimeoutError as PlaywrightTimeout
  17. try:
  18. from deep_translator import GoogleTranslator
  19. TRANSLATOR_AVAILABLE = True
  20. except ImportError:
  21. TRANSLATOR_AVAILABLE = False
  22. GoogleTranslator = None
  23. # 导入同目录或上级 common 模块
  24. try:
  25. from .ads_power_client import AdsPowerClient
  26. from .website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
  27. from ..common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
  28. except ImportError:
  29. import sys
  30. sys.path.insert(0, str(Path(__file__).parent.parent))
  31. from scraper.ads_power_client import AdsPowerClient
  32. from scraper.website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
  33. from common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
  34. def is_mostly_chinese(text: str) -> bool:
  35. """判断文本是否主要为中文"""
  36. if not text:
  37. return False
  38. chinese_chars = sum(1 for c in text if "\u4e00" <= c <= "\u9fff")
  39. return chinese_chars / max(len(text), 1) > 0.3
  40. def translate_to_chinese(text: str) -> str:
  41. """将文本翻译为中文;失败或已是中文时返回原文"""
  42. if not text or len(text.strip()) < 3:
  43. return text
  44. if is_mostly_chinese(text):
  45. return text
  46. if not TRANSLATOR_AVAILABLE:
  47. return text
  48. try:
  49. translated = GoogleTranslator(source="auto", target="zh-CN").translate(text)
  50. time.sleep(0.3)
  51. return translated or text
  52. except Exception as exc:
  53. print(f"翻译失败,保留原文: {exc}")
  54. return text
  55. class FacebookScraper:
  56. """Facebook 经销商搜索采集器"""
  57. def __init__(
  58. self,
  59. ads_power_url: str = "http://127.0.0.1:50325",
  60. api_key: str = "",
  61. profile_id: str = "",
  62. max_results: int = 20,
  63. scroll_attempts: int = 5,
  64. headless: bool = False
  65. ):
  66. self.ads_power_url = ads_power_url
  67. self.api_key = api_key
  68. self.profile_id = profile_id
  69. self.max_results = max_results
  70. self.scroll_attempts = scroll_attempts
  71. self.headless = headless
  72. self.client: Optional[AdsPowerClient] = None
  73. self.page: Optional[Page] = None
  74. def start(self):
  75. """启动浏览器"""
  76. self.client = AdsPowerClient(self.ads_power_url, api_key=self.api_key)
  77. self.client.start_browser(self.profile_id, headless=self.headless)
  78. self.page = self.client.get_open_page() or self.client.new_page()
  79. self.page.set_viewport_size({"width": 1280, "height": 800})
  80. def stop(self):
  81. """断开 Playwright 连接,浏览器保持打开"""
  82. if self.client:
  83. self.client.close_browser()
  84. def search(self, query: str, region: str = "") -> List[Dict[str, Any]]:
  85. """
  86. 在 Facebook 搜索经销商主页
  87. """
  88. search_query = f"{query} {region}".strip() if region else query
  89. encoded_query = urlencode({"q": search_query})
  90. search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
  91. print(f"访问搜索页: {search_url}")
  92. self.page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
  93. # 等待页面加载
  94. time.sleep(random.uniform(3, 5))
  95. # 处理可能的登录弹窗或提示
  96. self._dismiss_dialogs()
  97. # 滚动加载结果
  98. page_links = self._collect_page_links()
  99. print(f"收集到 {len(page_links)} 个主页链接")
  100. # 采集每个主页详情
  101. records = []
  102. for idx, link in enumerate(page_links[:self.max_results], 1):
  103. print(f"[{idx}/{min(len(page_links), self.max_results)}] 采集: {link}")
  104. try:
  105. record = self._scrape_page(link)
  106. if record.get("客户姓名/公司"):
  107. record["主页/链接"] = link
  108. record["国家"] = "摩洛哥" # 默认,后续可按 region 推断
  109. record["城市"] = region
  110. record = enrich_record_with_brands(record)
  111. records.append(record)
  112. except Exception as e:
  113. print(f"采集失败 {link}: {e}")
  114. # 随机间隔,降低风控
  115. time.sleep(random.uniform(2, 4))
  116. return deduplicate_records(records)
  117. def _dismiss_dialogs(self):
  118. """尝试关闭可能的弹窗"""
  119. try:
  120. # 常见的 cookie/登录提示关闭按钮
  121. selectors = [
  122. '[aria-label="关闭"]',
  123. '[aria-label="Close"]',
  124. '[role="button"][tabindex="0"]',
  125. ]
  126. for sel in selectors:
  127. buttons = self.page.query_selector_all(sel)
  128. for btn in buttons:
  129. text = btn.inner_text() if btn else ""
  130. if any(k in text.lower() for k in ["close", "关闭", "not now", "以后再说", "ok"]):
  131. btn.click()
  132. time.sleep(0.5)
  133. except Exception:
  134. pass
  135. def _collect_page_links(self) -> List[str]:
  136. """从搜索结果页收集主页链接"""
  137. links = set()
  138. for attempt in range(self.scroll_attempts):
  139. # 获取当前页所有可能的页面链接
  140. elements = self.page.query_selector_all('a[href*="/"]')
  141. for el in elements:
  142. href = el.get_attribute("href")
  143. if href and self._is_page_link(href):
  144. links.add(self._normalize_facebook_url(href))
  145. print(f"滚动 {attempt + 1}/{self.scroll_attempts}, 当前收集 {len(links)} 个链接")
  146. if len(links) >= self.max_results:
  147. break
  148. # 滚动到底部
  149. self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
  150. time.sleep(random.uniform(2, 4))
  151. return list(links)
  152. def _is_page_link(self, href: str) -> bool:
  153. """判断是否是 Facebook 主页链接"""
  154. parsed = urlparse(href)
  155. if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
  156. return False
  157. # 排除搜索、个人资料、帖子等
  158. excluded_patterns = [
  159. "/search/", "/watch", "/groups", "/marketplace",
  160. "/messages", "/notifications", "/settings",
  161. "/photo.php", "/videos", "/posts", "/reel",
  162. "/profile.php"
  163. ]
  164. for pattern in excluded_patterns:
  165. if pattern in parsed.path:
  166. return False
  167. # 主页链接通常是 /xxx/ 形式
  168. path = parsed.path.strip("/")
  169. if not path or "/" in path:
  170. return False
  171. return True
  172. def _normalize_facebook_url(self, url: str) -> str:
  173. """标准化 Facebook URL"""
  174. parsed = urlparse(url)
  175. path = parsed.path.strip("/").split("/")[0]
  176. return f"https://www.facebook.com/{path}/"
  177. def _scrape_page(self, page_url: str) -> Dict[str, Any]:
  178. """采集单个 Facebook 主页信息"""
  179. record = {
  180. "客户姓名/公司": "",
  181. "客户类型": "",
  182. "公司官网": "",
  183. "联系人": "",
  184. "职位": "",
  185. "电话/WhatsApp": "",
  186. "邮箱": "",
  187. "主营业务": "",
  188. "建联状态": "未联系",
  189. "下次跟进": "",
  190. "备注": "",
  191. }
  192. self.page.goto(page_url, wait_until="domcontentloaded", timeout=60000)
  193. time.sleep(random.uniform(3, 5))
  194. # 1. 先读取主页联络资料:官网、电话、邮箱、粉丝数和简介。About 只能补充,不覆盖主页已确认字段。
  195. home_page_text = ""
  196. try:
  197. home_page_text = self.page.inner_text("body")
  198. except Exception:
  199. pass
  200. website_from_home = extract_company_website_from_facebook(self.page)
  201. if website_from_home:
  202. record["公司官网"] = website_from_home
  203. print(f"从主页联络资料提取到公司官网: {website_from_home}")
  204. home_phone = self._extract_phone(home_page_text)
  205. if home_phone:
  206. record["电话/WhatsApp"] = home_phone
  207. print(f"从主页联络资料提取到电话: {home_phone}")
  208. home_email = self._extract_email(home_page_text)
  209. if home_email:
  210. record["邮箱"] = home_email
  211. print(f"从主页联络资料提取到邮箱: {home_email}")
  212. followers_from_home = self._extract_followers(home_page_text)
  213. # 2. 页面标题作为公司名称
  214. try:
  215. title = self.page.title()
  216. # Facebook 标题通常是 "Page Name | Facebook"
  217. record["客户姓名/公司"] = title.split("|")[0].strip() if "|" in title else title.replace("Facebook", "").strip()
  218. except Exception:
  219. pass
  220. # 3. 尝试多种方式获取页面名称
  221. name_selectors = [
  222. 'h1',
  223. '[role="main"] h2',
  224. '[data-pagelet="ProfileActions"] h1',
  225. 'a[aria-label]',
  226. ]
  227. for sel in name_selectors:
  228. try:
  229. el = self.page.query_selector(sel)
  230. if el:
  231. text = el.inner_text().strip()
  232. if text and len(text) < 100 and "Facebook" not in text:
  233. record["客户姓名/公司"] = text
  234. break
  235. except Exception:
  236. continue
  237. # 4. 主页联络资料读取完成后,再获取 About 信息
  238. about_url = f"{page_url.rstrip('/')}/about/"
  239. self.page.goto(about_url, wait_until="domcontentloaded", timeout=60000)
  240. time.sleep(random.uniform(2, 4))
  241. page_text = ""
  242. try:
  243. page_text = self.page.inner_text("body")
  244. except Exception:
  245. pass
  246. about_website = extract_company_website_from_facebook(self.page)
  247. if about_website:
  248. if not record.get("公司官网"):
  249. record["公司官网"] = about_website
  250. elif about_website not in record["公司官网"]:
  251. record["公司官网"] = record["公司官网"] + ";" + about_website
  252. company_website = record.get("公司官网", "").split(";")[0].strip()
  253. website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": []}
  254. if company_website:
  255. try:
  256. print(f"深搜公司官网: {company_website}")
  257. website_result = scrape_public_website(self.page.context, company_website, max_pages=5)
  258. except Exception as exc:
  259. website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": [f"官网深搜失败:{str(exc)[:80]}"]}
  260. # 4. 提取电话
  261. phone = self._extract_phone(page_text)
  262. if phone and not record.get("电话/WhatsApp"):
  263. record["电话/WhatsApp"] = phone
  264. # 5. 提取邮箱,About 只补充空字段
  265. email = self._extract_email(page_text)
  266. if email and not record.get("邮箱"):
  267. record["邮箱"] = email
  268. if not record.get("邮箱") and website_result.get("email"):
  269. record["邮箱"] = website_result.get("email", "")
  270. if not record.get("电话/WhatsApp") and website_result.get("phone"):
  271. record["电话/WhatsApp"] = website_result.get("phone", "")
  272. # 6. 提取主营业务/描述并翻译为中文
  273. description = self._extract_description(page_text)
  274. if description:
  275. record["主营业务"] = translate_to_chinese(description)
  276. # 7. 综合 Facebook 与官网证据推断客户类型和主营业务
  277. combined_business_text = "\n".join([home_page_text, page_text, website_result.get("text", "")])
  278. record["客户类型"] = classify_customer_type_cn(combined_business_text)
  279. record["主营业务"] = summarize_business_cn(combined_business_text)
  280. # 8. 备注:中文结构化摘要
  281. followers = followers_from_home or self._extract_followers(page_text)
  282. record["备注"] = build_chinese_notes(
  283. facebook_url=page_url,
  284. facebook_text="\n".join(["证据采集顺序:主页联络资料 -> About -> 官网深搜", home_page_text, page_text]),
  285. website_url=record.get("公司官网", ""),
  286. website_result=website_result,
  287. followers=followers,
  288. post_analysis="",
  289. detected_brands=[],
  290. exclusivity="",
  291. )
  292. if record.get("备注"):
  293. record["备注"] = "证据采集顺序:主页联络资料 -> About -> 官网深搜 | " + record["备注"]
  294. return record
  295. def _extract_phone(self, text: str) -> str:
  296. """从文本中提取电话号码"""
  297. if not text:
  298. return ""
  299. patterns = [
  300. r'\+212[\s\-]?\d[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
  301. r'\+212[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
  302. r'0\d[\s\-]?\d{4}[\s\-]?\d{4}',
  303. r'0\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
  304. ]
  305. for pattern in patterns:
  306. match = re.search(pattern, text)
  307. if match:
  308. return match.group(0).strip()
  309. return ""
  310. def _extract_email(self, text: str) -> str:
  311. """从文本中提取邮箱"""
  312. if not text:
  313. return ""
  314. pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
  315. matches = re.findall(pattern, text)
  316. # 过滤掉常见非企业邮箱
  317. excluded = ["@facebook.com", "@fb.com", "@example.com"]
  318. for m in matches:
  319. if all(e not in m.lower() for e in excluded):
  320. return m.strip()
  321. return ""
  322. def _extract_description(self, text: str) -> str:
  323. """提取页面描述/主营业务"""
  324. if not text:
  325. return ""
  326. lines = [line.strip() for line in text.split("\n") if line.strip()]
  327. # 找包含汽车相关关键词的短句
  328. keywords = ["car", "auto", "voiture", "vehicle", "motor", "dealer", "occasion", "automotive"]
  329. for line in lines[:50]:
  330. if any(k in line.lower() for k in keywords) and len(line) < 200:
  331. return line
  332. return ""
  333. def _extract_followers(self, text: str) -> str:
  334. """提取粉丝数"""
  335. if not text:
  336. return ""
  337. patterns = [
  338. r'(\d+[\d\s,.]*[KkMm]?)\s*(followers|fans|abonnés|likes)',
  339. r'(\d+[\d\s,.]*)\s*(followers|fans|abonnés|likes)',
  340. ]
  341. for pattern in patterns:
  342. match = re.search(pattern, text, re.IGNORECASE)
  343. if match:
  344. return f"{match.group(1).strip()} {match.group(2)}"
  345. return ""
  346. def _infer_dealer_type(self, text: str) -> str:
  347. """根据页面文本推断客户类型"""
  348. if not text:
  349. return "待判断"
  350. text_lower = text.lower()
  351. if any(k in text_lower for k in ["used car", "occasion", "二手车", "occaz"]):
  352. return "二手车商"
  353. if any(k in text_lower for k in ["dealer", "concessionnaire", "distributor", "经销商"]):
  354. return "经销商"
  355. if any(k in text_lower for k in ["rental", "location", "租车"]):
  356. return "租车公司"
  357. if any(k in text_lower for k in ["repair", "garage", "维修"]):
  358. return "汽车维修"
  359. return "待判断"
  360. def main():
  361. parser = argparse.ArgumentParser(description="Facebook 经销商搜索采集")
  362. parser.add_argument("--query", required=True, help="搜索关键词")
  363. parser.add_argument("--region", default="", help="地区过滤")
  364. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
  365. parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
  366. parser.add_argument("--api-key", default="", help="AdsPower API Key(如已开启认证)")
  367. parser.add_argument("--max-results", type=int, default=10, help="最大采集数")
  368. parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
  369. parser.add_argument("--output", default="facebook_scraped.json", help="输出 JSON 文件")
  370. parser.add_argument("--excel", default="", help="建联表路径;不传时按项目优先级自动查找")
  371. parser.add_argument("--sheet", default="Facebook", help="回写 Sheet 名")
  372. parser.add_argument("--write-excel", action="store_true", help="确认写入建联表;否则只输出 JSON 预览")
  373. parser.add_argument("--headless", action="store_true", help="无头模式")
  374. args = parser.parse_args()
  375. scraper = FacebookScraper(
  376. ads_power_url=args.ads_power_url,
  377. api_key=args.api_key,
  378. profile_id=args.profile_id,
  379. max_results=args.max_results,
  380. headless=args.headless
  381. )
  382. records = []
  383. try:
  384. scraper.start()
  385. records = scraper.search(args.query, args.region)
  386. finally:
  387. scraper.stop()
  388. # 保存 JSON
  389. output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
  390. output_path.parent.mkdir(parents=True, exist_ok=True)
  391. with open(output_path, "w", encoding="utf-8") as f:
  392. json.dump(records, f, ensure_ascii=False, indent=2)
  393. print(f"已保存 {len(records)} 条记录到 {output_path}")
  394. # 可选回写 Excel
  395. if args.write_excel:
  396. workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
  397. args.excel = str(workbook_info["path"])
  398. print(f"Workbook for writing: {args.excel} ({workbook_info['source']})")
  399. result = append_records(
  400. excel_path=args.excel,
  401. sheet_name=args.sheet,
  402. records=records,
  403. dedup_keys=["客户姓名/公司", "城市", "主页/链接"]
  404. )
  405. print(f"Excel 回写结果: {result}")
  406. else:
  407. print("默认预览模式:未写入 Excel。确认要入表时再使用 --write-excel。")
  408. if __name__ == "__main__":
  409. main()