search_active_dealers.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880
  1. """
  2. Facebook 高活跃度摩洛哥经销商搜索与采集
  3. - 使用多个本地化的搜索关键词
  4. - 过滤个人资料/群组/非主页链接
  5. - 先生成候选预览并评分
  6. - 仅在显式 --deep-scrape 时打开高分主页深采
  7. - 仅在显式 --write-excel 时写入 Excel
  8. """
  9. import re
  10. import sys
  11. import time
  12. import random
  13. import argparse
  14. import json
  15. from pathlib import Path
  16. import sys
  17. sys.path.append(str(Path(__file__).resolve().parents[1]))
  18. from common.artifact_manager import resolve_artifact_path, create_backup_once
  19. from typing import List, Dict, Any, Set, Tuple, Optional
  20. from urllib.parse import urlencode, urlparse
  21. # Windows 控制台输出中文/阿拉伯文时避免 GBK 编码错误
  22. if hasattr(sys.stdout, "reconfigure"):
  23. sys.stdout.reconfigure(encoding="utf-8")
  24. if hasattr(sys.stderr, "reconfigure"):
  25. sys.stderr.reconfigure(encoding="utf-8")
  26. import pandas as pd
  27. from playwright.sync_api import Page
  28. try:
  29. from .ads_power_client import AdsPowerClient
  30. from .scrape_single_page import scrape_page_record
  31. from . import discovery_common as dc
  32. from ..common import append_records, resolve_workbook_path
  33. except ImportError:
  34. import sys
  35. sys.path.insert(0, str(Path(__file__).parent.parent))
  36. from scraper.ads_power_client import AdsPowerClient
  37. from scraper.scrape_single_page import scrape_page_record
  38. from scraper import discovery_common as dc
  39. from common import append_records, resolve_workbook_path
  40. PAGE_NAME_BLACKLIST = {
  41. "通知", "个人资料", "帖子", "简介", "提及", "好友", "照片", "视频",
  42. "Notifications", "Profile", "Posts", "Photos", "Videos", "About",
  43. "Friends", "Home", "Page", "Facebook", "Unknown",
  44. }
  45. DEFAULT_CITY_SCOPE = "摩洛哥全国"
  46. MOROCCO_TARGET_CITIES = [
  47. "Casablanca",
  48. "Rabat",
  49. "Marrakech",
  50. "Tanger",
  51. "Fes",
  52. "Agadir",
  53. "Meknes",
  54. "Oujda",
  55. "Kenitra",
  56. "Tetouan",
  57. "Nador",
  58. "Safi",
  59. ]
  60. NATIONWIDE_KEYWORDS = [
  61. "concessionnaire voiture chinoise Maroc",
  62. "distributeur voitures chinoises Maroc",
  63. "importateur voitures chinoises Maroc",
  64. "showroom voiture chinoise Maroc",
  65. "groupe automobile marques chinoises Maroc",
  66. "concessionnaire multimarque voitures chinoises Maroc",
  67. "concessionnaire utilitaire chinois Maroc",
  68. "importateur camion chinois Maroc",
  69. "distributeur camion chinois Maroc",
  70. "concessionnaire camion Maroc",
  71. "concessionnaire Chery Maroc",
  72. "concessionnaire DFSK Maroc",
  73. "concessionnaire Foton Maroc",
  74. "concessionnaire JAC Maroc",
  75. "concessionnaire Jetour Maroc",
  76. "concessionnaire Geely Maroc",
  77. "concessionnaire SITRAK Maroc",
  78. "distributeur Sinotruk Maroc",
  79. "distributeur Shacman Maroc",
  80. "camion chinois Maroc",
  81. "import voiture chine Maroc",
  82. "voiture chinoise Maroc",
  83. "importateur auto chine Maroc",
  84. "groupe automobile Maroc",
  85. "concessionnaire automobile Maroc",
  86. "concessionnaire multimarque Maroc",
  87. "importateur voiture occasion Maroc",
  88. "voiture occasion Maroc",
  89. "showroom auto Maroc",
  90. ]
  91. CITY_KEYWORD_PATTERNS = [
  92. "concessionnaire automobile {city}",
  93. "voiture occasion {city}",
  94. "showroom auto {city}",
  95. ]
  96. def build_default_keywords() -> List[str]:
  97. """Build nationwide Morocco queries plus city waves for large and small dealers."""
  98. keywords = list(NATIONWIDE_KEYWORDS)
  99. for city in MOROCCO_TARGET_CITIES:
  100. for pattern in CITY_KEYWORD_PATTERNS:
  101. keywords.append(pattern.format(city=city))
  102. deduped: List[str] = []
  103. seen: Set[str] = set()
  104. for keyword in keywords:
  105. key = keyword.casefold()
  106. if key not in seen:
  107. deduped.append(keyword)
  108. seen.add(key)
  109. return deduped
  110. DEFAULT_KEYWORDS = build_default_keywords()
  111. PATH_BLACKLIST = {
  112. "photo", "photos", "video", "videos", "watch", "home", "messages",
  113. "notifications", "settings", "events", "marketplace", "friends",
  114. "friend", "login", "logout", "recover", "help", "support", "privacy",
  115. "terms", "ads", "business", "creatorstudio", "gaming", "jobs",
  116. "weather", "places", "explore", "bookmarks", "memories", "saved",
  117. "reels", "stories", "shop", "donate", "fundraisers", "pages",
  118. "groups", "profile.php",
  119. }
  120. BUSINESS_SUFFIXES = {
  121. "ma", "com", "net", "org", "fr", "uk", "eu", "info", "shop", "store",
  122. "auto", "cars", "voiture", "maroc", "morocco", "casa", "group",
  123. }
  124. CHINA_BRAND_TERMS = {
  125. "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton", "gac",
  126. "geely", "great wall", "haval", "jac", "jetour", "maxus", "mg", "omoda",
  127. "saic", "wuling", "sitrak", "sinotruk", "shacman", "faw", "yutong",
  128. "king long", "golden dragon", "higer", "forland",
  129. }
  130. NON_CHINA_BRAND_TERMS = {
  131. "audi", "bmw", "chevrolet", "citroen", "dacia", "daf", "fiat", "ford",
  132. "hino", "honda", "hyundai", "isuzu", "iveco", "jeep", "kia", "man",
  133. "mazda", "mercedes", "mitsubishi", "nissan", "opel", "peugeot",
  134. "renault", "scania", "seat", "skoda", "suzuki", "toyota", "volkswagen",
  135. "volvo",
  136. }
  137. OEM_BRANCH_BRAND_TERMS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
  138. OEM_BRANCH_COUNTRY_SUFFIXES = {"maroc", "morocco", "ma"}
  139. OEM_BRANCH_FILLER_TERMS = {
  140. "auto", "autos", "car", "cars", "motor", "motors", "truck", "trucks",
  141. "vehicle", "vehicles", "vehicule", "vehicules", "official", "officiel",
  142. }
  143. COMMERCIAL_VEHICLE_TERMS = {
  144. "truck", "trucks", "camion", "camions", "utilitaire", "utilitaires",
  145. "heavy", "light truck", "pickup", "van", "mpv", "bus", "fleet", "flotte",
  146. }
  147. HIGH_INTENT_TERMS = {
  148. "import", "importateur", "importation", "chine", "chinoise", "chinese",
  149. "concessionnaire", "dealer", "distributeur", "distribution", "multimarque",
  150. "groupe", "group", "showroom", "occasion", "voiture occasion",
  151. "automobile", "auto", "revendeur", "vendeur", "vente", "stock", "parc auto",
  152. }
  153. AUTOMOTIVE_URL_TERMS = {
  154. "auto", "cars", "car", "moteur", "motor", "voiture", "vehicule", "vehicle",
  155. "maroc", "morocco", "casa", "casablanca", "rabat", "marrakech", "tanger",
  156. "fes", "agadir", "meknes", "oujda", "kenitra", "tetouan", "nador", "safi",
  157. "group", "groupe", "garage", "truck", "camion",
  158. "sitrak", "sinotruk", "foton", "chery", "dfsk", "byd", "haval", "jac",
  159. }
  160. NEW_VEHICLE_SALES_TERMS = {
  161. "v?hicules neufs", "vehicules neufs", "voitures neuves", "new vehicle",
  162. "new cars", "concessionnaire", "dealer", "showroom", "vente", "stock", "parc auto",
  163. }
  164. IMPORT_DISTRIBUTION_TERMS = {
  165. "import", "importateur", "importation", "distributeur", "distribution",
  166. "groupe", "group", "r?seau", "reseau", "dealer network",
  167. }
  168. MULTIBRAND_TERMS = {
  169. "multimarque", "multi-brand", "multi brand", "plusieurs marques", "marques multiples",
  170. }
  171. CONTACT_ENTRY_TERMS = {
  172. "facebook.com", "whatsapp", "wa.me", "tel", "phone", "telephone", "t?l?phone",
  173. "email", "mail", "contact", "linkedin", "+212", "@",
  174. }
  175. PURE_SERVICE_TERMS = {
  176. "garage r?paration", "garage reparation", "diagnostic", "diag", "pieces", "pi?ces",
  177. "spare", "parts", "pneus", "tires", "tyres", "lavage", "wash", "repair", "reparation",
  178. }
  179. RENTAL_FLEET_TERMS = {"location", "rental", "rent car", "rentcar", "fleet", "flotte", "leasing", "lld"}
  180. PERSONAL_SELLER_TERMS = {"particulier", "personal seller", "vendeur particulier", "profile.php"}
  181. DIRECT_BRANCH_TERMS = {"succursale", "branch", "filiale", "subsidiary", "officiel", "official", "page officielle", "official page"}
  182. LOW_VALUE_TERMS = PURE_SERVICE_TERMS | {
  183. "assurance", "insurance", "immobilier", "emploi", "marketing", "software",
  184. }
  185. def compact_text(value: str) -> str:
  186. """Normalize a short page/name hint for brand-branch detection."""
  187. return re.sub(r"[^a-z0-9]+", "", value.lower())
  188. def looks_like_oem_local_branch(slug: str) -> Tuple[bool, str]:
  189. """
  190. Detect official brand-country pages such as BYD Maroc or BMW Morocco.
  191. These are usually OEM local branches or brand pages, not independent dealer
  192. channels. Independent entities with owner/channel names should pass through.
  193. """
  194. compact = compact_text(slug)
  195. if not compact:
  196. return False, ""
  197. fillers = {compact_text(term) for term in OEM_BRANCH_FILLER_TERMS}
  198. countries = {compact_text(term) for term in OEM_BRANCH_COUNTRY_SUFFIXES}
  199. for brand in OEM_BRANCH_BRAND_TERMS:
  200. brand_key = compact_text(brand)
  201. if not brand_key or brand_key not in compact:
  202. continue
  203. for country in countries:
  204. direct_variants = {
  205. f"{brand_key}{country}",
  206. f"{country}{brand_key}",
  207. f"{brand_key}official{country}",
  208. f"{brand_key}officiel{country}",
  209. f"{brand_key}{country}official",
  210. f"{brand_key}{country}officiel",
  211. }
  212. if compact in direct_variants:
  213. return True, brand
  214. if compact.startswith(brand_key) and compact.endswith(country):
  215. middle = compact[len(brand_key):-len(country)] if country else ""
  216. if not middle or middle in fillers:
  217. return True, brand
  218. if compact.startswith(country) and compact.endswith(brand_key):
  219. middle = compact[len(country):-len(brand_key)]
  220. if not middle or middle in fillers:
  221. return True, brand
  222. return False, ""
  223. def normalize_link(value: Any) -> str:
  224. """Normalize page links for pre-scrape duplicate checks."""
  225. if value is None:
  226. return ""
  227. link = str(value).strip()
  228. if not link or link.lower() == "nan":
  229. return ""
  230. link = link.split("#", 1)[0].split("?", 1)[0].rstrip("/")
  231. replacements = {
  232. "https://m.facebook.com/": "https://www.facebook.com/",
  233. "http://m.facebook.com/": "https://www.facebook.com/",
  234. "http://facebook.com/": "https://www.facebook.com/",
  235. "https://facebook.com/": "https://www.facebook.com/",
  236. }
  237. for src, dst in replacements.items():
  238. link = link.replace(src, dst)
  239. return link.lower()
  240. def load_existing_links(excel_path: str, sheet_name: str) -> Set[str]:
  241. """Load existing homepage links from the target workbook before scraping."""
  242. if not excel_path:
  243. return set()
  244. path = Path(excel_path)
  245. if not path.exists():
  246. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  247. df = pd.read_excel(path, sheet_name=sheet_name)
  248. if "主页/链接" not in df.columns:
  249. return set()
  250. return {
  251. normalized
  252. for normalized in (normalize_link(value) for value in df["主页/链接"].tolist())
  253. if normalized
  254. }
  255. def load_blocklist_json(blocklist_path: Optional[str]) -> Set[str]:
  256. """Load additional Facebook page URLs to skip from a JSON blocklist file."""
  257. if not blocklist_path:
  258. return set()
  259. path = Path(blocklist_path)
  260. if not path.exists():
  261. return set()
  262. try:
  263. data = json.loads(path.read_text(encoding="utf-8"))
  264. except Exception:
  265. return set()
  266. if isinstance(data, list):
  267. return {normalized for normalized in (normalize_link(value) for value in data) if normalized}
  268. if isinstance(data, dict) and "blocklist" in data:
  269. values = data["blocklist"]
  270. return {normalized for normalized in (normalize_link(value) for value in values) if normalized}
  271. return set()
  272. def is_page_link(href: str) -> bool:
  273. """判断是否是 Facebook 公开主页链接,排除个人资料、群组、搜索等"""
  274. parsed = urlparse(href)
  275. if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
  276. return False
  277. path = parsed.path.strip("/").lower()
  278. if not path or "/" in path:
  279. return False
  280. if path in PATH_BLACKLIST:
  281. return False
  282. if not re.match(r"^[A-Za-z0-9._\-]+$", path):
  283. return False
  284. if "." in path:
  285. parts = path.split(".")
  286. if len(parts) == 2 and len(parts[0]) >= 2 and len(parts[1]) >= 2:
  287. suffix = parts[1].rstrip("0123456789").lower()
  288. if suffix not in BUSINESS_SUFFIXES:
  289. return False
  290. return True
  291. def normalize_facebook_url(url: str) -> str:
  292. """标准化为 https://www.facebook.com/{pagename}/"""
  293. parsed = urlparse(url)
  294. path = parsed.path.strip("/").split("/")[0]
  295. return f"https://www.facebook.com/{path}/"
  296. def page_name_hint(url: str) -> str:
  297. """Return the Facebook slug as a weak candidate name hint."""
  298. parsed = urlparse(url)
  299. return parsed.path.strip("/").split("/")[0]
  300. def matched_terms(text: str, terms: Set[str]) -> List[str]:
  301. text_lower = text.lower()
  302. return sorted(term for term in terms if term in text_lower)
  303. def score_candidate(url: str, source_queries: List[str]) -> Dict[str, Any]:
  304. """Score a candidate before opening its homepage."""
  305. source_queries = sorted(set(q for q in source_queries if q))
  306. query_text = " ".join(source_queries).lower()
  307. slug = page_name_hint(url)
  308. slug_text = re.sub(r"[._\-]+", " ", slug).lower()
  309. combined = f"{query_text} {slug_text} {url}".casefold()
  310. score = 0
  311. reasons: List[str] = []
  312. risks: List[str] = []
  313. recommended_action = "preview_only"
  314. is_oem_branch, oem_brand = looks_like_oem_local_branch(slug)
  315. brand_terms = matched_terms(combined, OEM_BRANCH_BRAND_TERMS)
  316. direct_branch_terms = matched_terms(combined, DIRECT_BRANCH_TERMS)
  317. if is_oem_branch or (brand_terms and direct_branch_terms):
  318. score -= 10
  319. risks.append(f"疑似汽车品牌当地官方分公司/官方主页/直营网点,非目标经销渠道: {oem_brand or ', '.join(brand_terms[:2])}")
  320. recommended_action = "skip_brand_branch"
  321. service_terms = matched_terms(combined, PURE_SERVICE_TERMS)
  322. if service_terms:
  323. score -= 6
  324. risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
  325. if recommended_action == "preview_only":
  326. recommended_action = "skip_non_channel"
  327. personal_terms = matched_terms(combined, PERSONAL_SELLER_TERMS)
  328. if personal_terms:
  329. score -= 5
  330. risks.append("疑似个人卖家,不纳入汽车渠道合作伙伴: " + ", ".join(personal_terms[:4]))
  331. if recommended_action == "preview_only":
  332. recommended_action = "skip_non_channel"
  333. sales_terms = matched_terms(combined, NEW_VEHICLE_SALES_TERMS)
  334. import_terms = matched_terms(combined, IMPORT_DISTRIBUTION_TERMS)
  335. multibrand_terms = matched_terms(combined, MULTIBRAND_TERMS)
  336. rental_terms = matched_terms(combined, RENTAL_FLEET_TERMS)
  337. contact_terms = matched_terms(combined, CONTACT_ENTRY_TERMS)
  338. china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
  339. commercial_terms = matched_terms(combined, COMMERCIAL_VEHICLE_TERMS)
  340. high_intent_terms = matched_terms(query_text, HIGH_INTENT_TERMS)
  341. slug_terms = matched_terms(slug_text, AUTOMOTIVE_URL_TERMS)
  342. has_actual_channel = bool(sales_terms or import_terms or multibrand_terms)
  343. ownership_needs_review = brand_terms and recommended_action != "skip_brand_branch" and not (import_terms or multibrand_terms)
  344. if sales_terms:
  345. score += 3
  346. reasons.append("实际新整车销售/showroom/库存信号: " + ", ".join(sales_terms[:5]))
  347. if import_terms:
  348. score += 3
  349. reasons.append("进口/分销/网络能力信号: " + ", ".join(import_terms[:5]))
  350. if multibrand_terms:
  351. score += 2
  352. reasons.append("多品牌经营信号: " + ", ".join(multibrand_terms[:4]))
  353. if china_terms and recommended_action != "skip_brand_branch":
  354. score += 1
  355. reasons.append("命中中国品牌/商用车品牌: " + ", ".join(china_terms[:5]))
  356. if commercial_terms:
  357. score += 1
  358. reasons.append("命中商用车/车队相关词: " + ", ".join(commercial_terms[:5]))
  359. if high_intent_terms:
  360. score += min(3, len(high_intent_terms))
  361. reasons.append("搜索词意图强: " + ", ".join(high_intent_terms[:5]))
  362. if slug_terms:
  363. score += 1
  364. reasons.append("主页链接像汽车业务: " + ", ".join(slug_terms[:5]))
  365. if contact_terms or "facebook.com" in url.casefold():
  366. score += 2
  367. reasons.append("可建联入口: Facebook Page")
  368. if len(source_queries) > 1:
  369. extra = min(2, len(source_queries) - 1)
  370. score += extra
  371. reasons.append(f"被 {len(source_queries)} 个搜索词重复命中")
  372. if rental_terms and not has_actual_channel:
  373. score += 1
  374. risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司,不作为汽车渠道合作伙伴: " + ", ".join(rental_terms[:4]))
  375. if brand_terms and sales_terms and not (import_terms or multibrand_terms) and recommended_action != "skip_brand_branch":
  376. score += 1
  377. risks.append("独立单品牌新车经销商可保留;排他协议及新增品牌权限待确认")
  378. new_vehicle_needs_review = False
  379. if not has_actual_channel and not rental_terms:
  380. score -= 3
  381. risks.append("缺少新整车销售、进口、分销或 showroom 证据,建议先人工看预览")
  382. new_vehicle_needs_review = True
  383. if ownership_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
  384. dc.add_manual_review_flag(risks, "ownership")
  385. if new_vehicle_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
  386. dc.add_manual_review_flag(risks, "new_vehicle")
  387. if not reasons:
  388. reasons.append("仅从 Facebook 搜索结果获得,需人工判断")
  389. return {"url": url, "normalized_url": normalize_link(url), "candidate_name_hint": slug, "source_queries": source_queries, "score": score, "score_reasons": reasons, "risk_flags": risks, "recommended_action": recommended_action}
  390. def enrich_record_with_candidate_signals(record: Dict[str, Any], candidate: Dict[str, Any]) -> Dict[str, Any]:
  391. """Apply the local expanded brand terms even if the shared detector is not writable."""
  392. combined = " ".join([
  393. str(record.get("客户姓名/公司", "")),
  394. str(record.get("主页/链接", "")),
  395. str(record.get("主营业务", "")),
  396. str(record.get("备注", "")),
  397. str(record.get("candidate_reasons", "")),
  398. " ".join(candidate.get("score_reasons", [])),
  399. " ".join(candidate.get("source_queries", [])),
  400. ])
  401. china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
  402. if china_terms:
  403. existing = record.get("detected_brands", []) or []
  404. if not isinstance(existing, list):
  405. existing = [str(existing)]
  406. record["detected_brands"] = sorted(set(existing + china_terms))
  407. record["exclusivity_assessment"] = "已代理中国品牌,需评估"
  408. return record
  409. def collect_page_links(
  410. page: Page,
  411. query: str,
  412. max_links: int = 10,
  413. scroll_attempts: int = 3,
  414. ) -> List[str]:
  415. """访问 Facebook search/top 并收集主页链接"""
  416. encoded = urlencode({"q": query})
  417. search_url = f"https://www.facebook.com/search/top/?{encoded}"
  418. print(f"\n搜索关键词: {query}", flush=True)
  419. print(f"访问: {search_url}", flush=True)
  420. try:
  421. page.goto(search_url, wait_until="domcontentloaded", timeout=30000)
  422. time.sleep(random.uniform(2, 4))
  423. except Exception as e:
  424. print(f" 搜索页加载失败: {e}", flush=True)
  425. return []
  426. try:
  427. for sel in ['[aria-label="关闭"]', '[aria-label="Close"]']:
  428. for btn in page.query_selector_all(sel):
  429. try:
  430. btn.click()
  431. time.sleep(0.5)
  432. except Exception:
  433. pass
  434. except Exception:
  435. pass
  436. links: Set[str] = set()
  437. start_time = time.time()
  438. for attempt in range(scroll_attempts):
  439. selectors = [
  440. 'a[href*="facebook.com/"]',
  441. '[role="article"] a',
  442. 'a[href*="/"]',
  443. ]
  444. for sel in selectors:
  445. try:
  446. for el in page.query_selector_all(sel):
  447. try:
  448. href = el.get_attribute("href") or ""
  449. if is_page_link(href):
  450. links.add(normalize_facebook_url(href))
  451. except Exception:
  452. continue
  453. except Exception as e:
  454. print(f" 选择器 {sel} 提取失败: {e}", flush=True)
  455. continue
  456. print(f" 滚动 {attempt + 1}/{scroll_attempts}, 已收集 {len(links)} 个主页链接", flush=True)
  457. if len(links) >= max_links:
  458. break
  459. if time.time() - start_time > 30:
  460. print(" 搜索时间超过 30 秒,提前结束", flush=True)
  461. break
  462. page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
  463. time.sleep(random.uniform(1, 2))
  464. return list(links)[:max_links]
  465. def looks_old_from_time_text(time_text: str) -> bool:
  466. """根据帖子时间文本判断是否明显陈旧。"""
  467. if not time_text:
  468. return False
  469. text = time_text.lower()
  470. if re.search(r"(201[0-9]|202[0-3])", text):
  471. return True
  472. if re.search(r"\d+\s*(year|years|yr|yrs|年)", text):
  473. return True
  474. month_match = re.search(r"(\d+)\s*(month|months|mo|月)", text)
  475. if month_match and int(month_match.group(1)) > 3:
  476. return True
  477. return False
  478. def looks_recently_active(time_text: str) -> bool:
  479. """判断时间文本是否暗示近期活跃(90 天内)。"""
  480. if not time_text:
  481. return False
  482. text = time_text.lower()
  483. recent_units = [
  484. "h", "hr", "hrs", "hour", "hours", "小时",
  485. "min", "mins", "minute", "minutes", "分钟",
  486. "d", "day", "days", "天",
  487. "w", "week", "weeks", "周",
  488. "just now", "刚刚", "now", "现在", "昨天", "yesterday",
  489. ]
  490. if any(unit in text for unit in recent_units):
  491. return True
  492. month_match = re.search(r"\b(\d+)\s*(month|months|mo|月)\b", text)
  493. if month_match and int(month_match.group(1)) <= 3:
  494. return True
  495. if re.search(r"(2024|2025|2026)", text):
  496. return True
  497. return False
  498. def extract_latest_time_from_notes(notes: str) -> str:
  499. """从备注字段中解析最近发帖时间"""
  500. if not notes:
  501. return ""
  502. match = re.search(r"最近发帖时间:([^|]+)", notes)
  503. if match:
  504. return match.group(1).strip()
  505. return ""
  506. def build_candidates(
  507. all_page_links: List[str],
  508. link_sources: Dict[str, Set[str]],
  509. existing_links: Set[str],
  510. min_candidate_score: int,
  511. ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
  512. """Create scored candidates and skipped-existing records."""
  513. candidates: List[Dict[str, Any]] = []
  514. skipped_existing: List[Dict[str, Any]] = []
  515. for link in all_page_links:
  516. normalized = normalize_link(link)
  517. source_queries = sorted(link_sources.get(link, set()))
  518. candidate = score_candidate(link, source_queries)
  519. if normalized and normalized in existing_links:
  520. candidate["recommended_action"] = "skip_existing"
  521. skipped_existing.append(candidate)
  522. print(f" 已在表中,跳过不访问主页: {link}")
  523. continue
  524. if candidate.get("recommended_action") == "skip_brand_branch":
  525. print(f" 疑似品牌当地分公司/官方页,跳过深采: {link}")
  526. elif candidate["score"] >= min_candidate_score:
  527. candidate["recommended_action"] = "deep_scrape"
  528. candidates.append(candidate)
  529. candidates.sort(key=lambda c: (-int(c.get("score", 0)), c.get("url", "")))
  530. skipped_existing.sort(key=lambda c: c.get("url", ""))
  531. return candidates, skipped_existing
  532. def search_active_dealers(
  533. profile_id: str,
  534. keywords: List[str] = None,
  535. city: str = DEFAULT_CITY_SCOPE,
  536. country: str = "摩洛哥",
  537. max_links_per_keyword: int = 5,
  538. max_pages_to_scrape: int = 20,
  539. min_active_score: int = 1,
  540. min_candidate_score: int = 3,
  541. ads_power_url: str = "http://127.0.0.1:50325",
  542. api_key: str = "",
  543. headless: bool = False,
  544. existing_links: Set[str] = None,
  545. deep_scrape: bool = False,
  546. ) -> Dict[str, Any]:
  547. """
  548. 执行多关键词搜索。默认只生成候选预览;显式 deep_scrape=True 才打开高分主页。
  549. min_active_score: 0=不过滤, 1=过滤明显陈旧的, 2=仅保留明确近期活跃的
  550. """
  551. if keywords is None:
  552. keywords = DEFAULT_KEYWORDS
  553. if existing_links is None:
  554. existing_links = set()
  555. client = AdsPowerClient(ads_power_url, api_key=api_key)
  556. client.start_browser(profile_id, headless=headless)
  557. page = client.get_open_page() or client.new_page()
  558. page.set_viewport_size({"width": 1280, "height": 800})
  559. all_page_links: List[str] = []
  560. link_sources: Dict[str, Set[str]] = {}
  561. records: List[Dict[str, Any]] = []
  562. candidates: List[Dict[str, Any]] = []
  563. skipped_existing: List[Dict[str, Any]] = []
  564. try:
  565. for query in keywords:
  566. links = collect_page_links(
  567. page=page,
  568. query=query,
  569. max_links=max_links_per_keyword,
  570. scroll_attempts=5,
  571. )
  572. print(f" 关键词 '{query}' 收集到 {len(links)} 个主页链接")
  573. for link in links:
  574. if link not in all_page_links:
  575. all_page_links.append(link)
  576. link_sources.setdefault(link, set()).add(query)
  577. time.sleep(random.uniform(2, 4))
  578. print(f"\n去重后共 {len(all_page_links)} 个搜索到主页,开始候选评分")
  579. candidates, skipped_existing = build_candidates(
  580. all_page_links=all_page_links,
  581. link_sources=link_sources,
  582. existing_links=existing_links,
  583. min_candidate_score=min_candidate_score,
  584. )
  585. if skipped_existing:
  586. print(f"已按 Excel 现有主页链接跳过 {len(skipped_existing)} 个重复主页")
  587. selected_candidates = [c for c in candidates if c.get("recommended_action") == "deep_scrape"]
  588. if len(selected_candidates) > max_pages_to_scrape:
  589. print(f"高分候选 {len(selected_candidates)} 个,限制深采前 {max_pages_to_scrape} 个")
  590. selected_candidates = selected_candidates[:max_pages_to_scrape]
  591. if not deep_scrape:
  592. print("预览模式:已生成候选评分,不打开主页深采。需要深采时加 --deep-scrape。")
  593. else:
  594. for idx, candidate in enumerate(selected_candidates, 1):
  595. link = candidate["url"]
  596. print(f"\n[{idx}/{len(selected_candidates)}] 深采主页: {link} (score={candidate['score']})")
  597. try:
  598. record = scrape_page_record(
  599. page=page,
  600. page_url=link,
  601. city=city,
  602. country=country,
  603. )
  604. name = record.get("客户姓名/公司", "")
  605. dealer_type = record.get("客户类型", "")
  606. if name and name != "Unknown" and name not in PAGE_NAME_BLACKLIST and dealer_type != "个人用户":
  607. record["candidate_score"] = candidate.get("score", 0)
  608. record["candidate_reasons"] = "; ".join(candidate.get("score_reasons", []))
  609. record["risk_flags"] = "; ".join(candidate.get("risk_flags", []))
  610. record = enrich_record_with_candidate_signals(record, candidate)
  611. records.append(record)
  612. normalized = normalize_link(link)
  613. if normalized:
  614. existing_links.add(normalized)
  615. else:
  616. print(f" 跳过无效主页: {name} ({dealer_type})")
  617. except Exception as e:
  618. print(f"采集失败 {link}: {e}", flush=True)
  619. time.sleep(random.uniform(2, 4))
  620. finally:
  621. print("\n断开 Playwright 连接,浏览器保持打开...", flush=True)
  622. client.close_browser()
  623. if records:
  624. print(f"\n深采完成,原始记录 {len(records)} 条")
  625. if min_active_score >= 1:
  626. filtered = []
  627. for r in records:
  628. latest_time = extract_latest_time_from_notes(r.get("备注", ""))
  629. if not looks_old_from_time_text(latest_time):
  630. filtered.append(r)
  631. else:
  632. print(f" 过滤陈旧账号: {r.get('客户姓名/公司')} ({latest_time})")
  633. records = filtered
  634. print(f"过滤明显陈旧后剩余 {len(records)} 条")
  635. def sort_key(r):
  636. latest_time = extract_latest_time_from_notes(r.get("备注", ""))
  637. if looks_recently_active(latest_time):
  638. return 0
  639. if not latest_time or latest_time == "未识别":
  640. return 1
  641. if looks_old_from_time_text(latest_time):
  642. return 3
  643. return 2
  644. records.sort(key=sort_key)
  645. return {
  646. "summary": {
  647. "keywords": keywords,
  648. "found_links": len(all_page_links),
  649. "candidate_count": len(candidates),
  650. "skipped_existing": len(skipped_existing),
  651. "selected_for_deep_scrape": len([c for c in candidates if c.get("recommended_action") == "deep_scrape"][:max_pages_to_scrape]),
  652. "deep_scrape": deep_scrape,
  653. "scraped_records": len(records),
  654. "min_candidate_score": min_candidate_score,
  655. "max_pages_to_scrape": max_pages_to_scrape,
  656. },
  657. "candidates": candidates,
  658. "skipped_existing": skipped_existing,
  659. "records": records,
  660. }
  661. def main():
  662. parser = argparse.ArgumentParser(description="Facebook 摩洛哥经销商候选搜索、评分与可选深采")
  663. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
  664. parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
  665. parser.add_argument("--api-key", default="", help="AdsPower API Key")
  666. parser.add_argument("--keywords", default="", help="逗号分隔的搜索关键词,留空使用默认列表")
  667. parser.add_argument("--city", default=DEFAULT_CITY_SCOPE, help="城市或覆盖范围,默认摩洛哥全国")
  668. parser.add_argument("--country", default="摩洛哥", help="国家")
  669. parser.add_argument("--max-links-per-keyword", type=int, default=5, help="每个关键词最多收集链接数;全国搜索默认较小以控制账号风险")
  670. parser.add_argument("--max-pages", type=int, default=20, help="--deep-scrape 时最多打开深采的新主页数")
  671. parser.add_argument("--min-candidate-score", type=int, default=3, help="候选进入深采队列的最低分")
  672. parser.add_argument("--min-active-score", type=int, default=1, choices=[0, 1, 2], help="活跃度过滤: 0=不过滤, 1=过滤明显陈旧, 2=仅保留明确近期活跃")
  673. parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
  674. parser.add_argument("--output", default="facebook_candidate_preview.json", help="候选预览/深采输出 JSON 文件")
  675. parser.add_argument("--excel", default="", help="用于预去重和可选回写的 Excel 路径;写表时必须显式提供")
  676. parser.add_argument("--sheet", default="Facebook", help="Sheet 名")
  677. parser.add_argument("--headless", action="store_true", help="无头模式")
  678. parser.add_argument("--deep-scrape", action="store_true", help="打开高分候选主页并深采 About + 最近帖子")
  679. parser.add_argument("--write-excel", action="store_true", help="确认后将深采记录写入 Excel;默认只输出 JSON 预览")
  680. parser.add_argument("--blocklist-json", default="", help="可选 JSON 文件,包含额外要跳过的 Facebook 主页链接")
  681. parser.add_argument("--no-excel", action="store_true", help="不读取也不写入 Excel,只输出 JSON")
  682. args = parser.parse_args()
  683. keywords = None
  684. if args.keywords:
  685. keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
  686. existing_links: Set[str] = set()
  687. if not args.no_excel:
  688. read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
  689. if read_workbook_info.get("path"):
  690. args.excel = str(read_workbook_info["path"])
  691. print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})")
  692. if not args.no_excel and args.excel:
  693. try:
  694. existing_links = load_existing_links(args.excel, args.sheet)
  695. print(f"已从 Excel 加载 {len(existing_links)} 个现有主页链接,采集前将直接跳过")
  696. except FileNotFoundError as e:
  697. print(f"未读取到现有 Excel,跳过预去重: {e}")
  698. blocklist_links = load_blocklist_json(args.blocklist_json or None)
  699. if blocklist_links:
  700. existing_links = existing_links | blocklist_links
  701. print(f"已从黑名单 JSON 加载 {len(blocklist_links)} 个额外链接,合并后去重库共 {len(existing_links)} 个")
  702. result = search_active_dealers(
  703. profile_id=args.profile_id,
  704. keywords=keywords,
  705. city=args.city,
  706. country=args.country,
  707. max_links_per_keyword=args.max_links_per_keyword,
  708. max_pages_to_scrape=args.max_pages,
  709. min_active_score=args.min_active_score,
  710. min_candidate_score=args.min_candidate_score,
  711. ads_power_url=args.ads_power_url,
  712. api_key=args.api_key,
  713. headless=args.headless,
  714. existing_links=existing_links,
  715. deep_scrape=args.deep_scrape,
  716. )
  717. output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
  718. output_path.parent.mkdir(parents=True, exist_ok=True)
  719. with open(output_path, "w", encoding="utf-8") as f:
  720. json.dump(result, f, ensure_ascii=False, indent=2)
  721. print(f"\n已保存候选/采集结果到 {output_path}")
  722. if args.write_excel and not args.no_excel:
  723. write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
  724. args.excel = str(write_workbook_info["path"])
  725. if write_workbook_info.get("created"):
  726. print(f"Created blank workbook from skill template: {args.excel}", flush=True)
  727. records = result.get("records", [])
  728. if not records:
  729. print("No Excel written: no deep-scraped records. Use --deep-scrape first.")
  730. else:
  731. try:
  732. write_result = append_records(
  733. excel_path=args.excel,
  734. sheet_name=args.sheet,
  735. records=records,
  736. dedup_keys=["客户姓名/公司", "城市", "主页/链接"],
  737. )
  738. print(f"Excel write result: {write_result}", flush=True)
  739. except PermissionError as e:
  740. print(f"Excel write failed. Close the workbook and retry: {e}", flush=True)
  741. else:
  742. print("Preview mode: Excel was not written. Use --deep-scrape --write-excel after confirmation.")
  743. if __name__ == "__main__":
  744. main()