collect_facebook_conversations.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Collect matched Facebook Messenger conversations through AdsPower + Playwright.
  4. This collector is read-only: it never sends messages, writes the workbook, or
  5. closes the user's AdsPower browser. Translation and intent analysis are
  6. performed by the agent in a separate JSON artifact before workbook write-back.
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import hashlib
  11. import json
  12. import random
  13. import re
  14. import sys
  15. import time
  16. from datetime import datetime, timezone
  17. from pathlib import Path
  18. from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
  19. from urllib.parse import parse_qs, urlparse
  20. from openpyxl import load_workbook
  21. SCRIPT_DIR = Path(__file__).resolve().parent
  22. SKILL_ROOT = SCRIPT_DIR.parents[1]
  23. COMMON_DIR = SKILL_ROOT / "scripts" / "common"
  24. SCRAPER_DIR = SKILL_ROOT / "scripts" / "scraper"
  25. for directory in (COMMON_DIR, SCRAPER_DIR):
  26. if str(directory) not in sys.path:
  27. sys.path.insert(0, str(directory))
  28. from ads_power_client import AdsPowerClient # type: ignore # noqa: E402
  29. from artifact_manager import new_run_id, resolve_artifact_path # type: ignore # noqa: E402
  30. from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
  31. FACEBOOK_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
  32. CONVERSATION_SHEET = "Facebook对话记录"
  33. DEFAULT_MAX_MESSAGES = 2000
  34. MAJOR_WAIT = (90.0, 200.0)
  35. MINOR_WAIT = (30.0, 90.0)
  36. TECHNICAL_WAIT = (0.5, 8.0)
  37. HEADER_ALIASES = {
  38. "index": ["序号", "编号", "ID"],
  39. "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
  40. "facebook_link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
  41. "attribute": ["客户属性"],
  42. "customer_type": ["客户类型"],
  43. "business": ["主营业务", "公司主营业务"],
  44. "status": ["建联状态", "建联情况"],
  45. "note": ["备注", "说明"],
  46. }
  47. RISK_MARKERS = [
  48. "temporarily blocked",
  49. "verification required",
  50. "confirm your identity",
  51. "suspicious activity",
  52. "try again later",
  53. "暂时封锁",
  54. "验证",
  55. "确认身份",
  56. "异常活动",
  57. ]
  58. OUTGOING_MARKERS = [
  59. "you sent",
  60. "sent by you",
  61. "您发送",
  62. "你发送",
  63. "你已发送",
  64. "vous avez envoyé",
  65. "envoyé par vous",
  66. "لقد أرسلت",
  67. # 我方标准建联话术片段(来自 social_outreach_library 及实际发送话术)
  68. "a low-cost new-vehicle line could be",
  69. "i am looking at auto channels",
  70. "if some of your buyers want",
  71. "wuling could be reviewed as an",
  72. "your page shows import or distribution",
  73. "your page shows rental or fleet",
  74. "your page shows showroom or dealer",
  75. "your page shows used-car or occasion",
  76. "your used-car or occasion activity and",
  77. "huatu overseas can support wuling export",
  78. "huatu overseas supports wuling export",
  79. "would a short model and price-range overview",
  80. "would a short first-batch fit check",
  81. "should i send a short",
  82. "is this something your team would normally evaluate",
  83. "does your team usually evaluate vehicle sourcing",
  84. "wuling is worth a low-pressure first-batch review",
  85. "worth connecting",
  86. "thought it would be useful to connect",
  87. "open to connect",
  88. "wuling export as an affordable",
  89. "hi, i'm chris",
  90. "wuling overseas business department",
  91. "wuling has sold over 30 million",
  92. "caught my attention as we review",
  93. "would a one-page",
  94. "may i send",
  95. ]
  96. SYSTEM_MARKERS = [
  97. "messages and calls are secured",
  98. "end-to-end encrypted",
  99. "created this group",
  100. "joined the conversation",
  101. "changed the theme",
  102. "missed a call",
  103. "通话和消息",
  104. "端到端加密",
  105. "加入了对话",
  106. "更改了主题",
  107. ]
  108. AUTO_REPLY_MARKERS = [
  109. "automated response",
  110. "automatic reply",
  111. "auto-reply",
  112. "away message",
  113. "réponse automatique",
  114. "message automatique",
  115. "رسالة تلقائية",
  116. "自动回复",
  117. ]
  118. READ_ONLY_MARKERS = ["seen", "已读", "vu", "تمت المشاهدة"]
  119. def clean(value: Any) -> str:
  120. if value is None:
  121. return ""
  122. return re.sub(r"\s+", " ", str(value).strip())
  123. def now_iso() -> str:
  124. return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
  125. def normalize_name(value: str) -> str:
  126. value = clean(value).casefold()
  127. value = re.sub(r"https?://|www\.", " ", value)
  128. value = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", " ", value)
  129. return " ".join(part for part in value.split() if len(part) > 1)
  130. def page_identity(url: str) -> Tuple[str, str]:
  131. parsed = urlparse(clean(url))
  132. host = parsed.netloc.casefold().removeprefix("www.")
  133. if "facebook.com" not in host:
  134. return "", ""
  135. query = parse_qs(parsed.query)
  136. if parsed.path.rstrip("/").casefold() == "/profile.php" and query.get("id"):
  137. value = clean(query["id"][0])
  138. return value, value
  139. parts = [part for part in parsed.path.split("/") if part]
  140. if not parts:
  141. return "", ""
  142. reserved = {"pages", "groups", "marketplace", "watch", "messages", "home.php"}
  143. if parts[0].casefold() in reserved:
  144. if parts[0].casefold() == "pages" and parts[-1].isdigit():
  145. return parts[-1], parts[-1]
  146. return "", ""
  147. slug = parts[0]
  148. return slug, slug
  149. def thread_url_for(page_url: str) -> Tuple[str, str]:
  150. slug, page_id = page_identity(page_url)
  151. identity = page_id or slug
  152. if not identity:
  153. return "", ""
  154. return f"https://www.facebook.com/messages/t/{identity}", identity
  155. def header_map(ws) -> Dict[str, int]:
  156. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  157. mapped: Dict[str, int] = {}
  158. for key, aliases in HEADER_ALIASES.items():
  159. for alias in aliases:
  160. if alias in raw:
  161. mapped[key] = raw[alias]
  162. break
  163. return mapped
  164. def cell_value(ws, row: int, columns: Dict[str, int], key: str) -> str:
  165. column = columns.get(key)
  166. return clean(ws.cell(row=row, column=column).value) if column else ""
  167. def read_customers(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
  168. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  169. if sheet_name not in wb.sheetnames:
  170. raise KeyError(f"Sheet not found: {sheet_name}")
  171. ws = wb[sheet_name]
  172. columns = header_map(ws)
  173. missing = [key for key in ("index", "company", "facebook_link") if key not in columns]
  174. if missing:
  175. raise RuntimeError(f"Facebook sheet is missing required columns: {', '.join(missing)}")
  176. rows: List[Dict[str, str]] = []
  177. for row in range(2, ws.max_row + 1):
  178. link = cell_value(ws, row, columns, "facebook_link")
  179. name = cell_value(ws, row, columns, "company")
  180. thread_url, identity = thread_url_for(link)
  181. if not name or not thread_url:
  182. continue
  183. rows.append(
  184. {
  185. "excel_row": str(row),
  186. "index": cell_value(ws, row, columns, "index"),
  187. "company": name,
  188. "facebook_link": link,
  189. "thread_url": thread_url,
  190. "expected_identity": identity,
  191. "attribute": cell_value(ws, row, columns, "attribute"),
  192. "customer_type": cell_value(ws, row, columns, "customer_type"),
  193. "business": cell_value(ws, row, columns, "business"),
  194. "status": cell_value(ws, row, columns, "status"),
  195. "note": cell_value(ws, row, columns, "note"),
  196. }
  197. )
  198. wb.close()
  199. return rows
  200. def read_existing_record_ids(workbook_path: Path, sheet_name: str = CONVERSATION_SHEET) -> Set[str]:
  201. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  202. if sheet_name not in wb.sheetnames:
  203. wb.close()
  204. return set()
  205. ws = wb[sheet_name]
  206. headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
  207. record_col = headers.get("记录ID")
  208. if not record_col:
  209. wb.close()
  210. return set()
  211. result = {
  212. clean(ws.cell(row=row, column=record_col).value)
  213. for row in range(2, ws.max_row + 1)
  214. if clean(ws.cell(row=row, column=record_col).value)
  215. }
  216. wb.close()
  217. return result
  218. def paced_wait(label: str, bounds: Tuple[float, float], enabled: bool = True) -> float:
  219. if not enabled:
  220. return 0.0
  221. seconds = random.uniform(*bounds)
  222. print(f" -> {label}: {seconds:.1f}s", flush=True)
  223. time.sleep(seconds)
  224. return seconds
  225. def risk_text(page) -> str:
  226. try:
  227. text = clean(page.locator("body").inner_text(timeout=5000)).casefold()
  228. except Exception:
  229. return ""
  230. return next((marker for marker in RISK_MARKERS if marker.casefold() in text), "")
  231. def visible_thread_title(page) -> str:
  232. selectors = [
  233. "[role='main'] h1",
  234. "[role='main'] h2",
  235. "header h1",
  236. "header h2",
  237. "a[role='link'][href*='/messages/t/']",
  238. ]
  239. for selector in selectors:
  240. try:
  241. locator = page.locator(selector)
  242. for idx in range(min(locator.count(), 8)):
  243. text = clean(locator.nth(idx).inner_text(timeout=1000))
  244. if text and len(text) <= 160:
  245. return text
  246. except Exception:
  247. continue
  248. return ""
  249. def name_match(expected: str, observed: str) -> bool:
  250. expected_norm = normalize_name(expected)
  251. observed_norm = normalize_name(observed)
  252. if not expected_norm or not observed_norm:
  253. return False
  254. if expected_norm in observed_norm or observed_norm in expected_norm:
  255. return True
  256. expected_tokens = set(expected_norm.split())
  257. observed_tokens = set(observed_norm.split())
  258. overlap = expected_tokens & observed_tokens
  259. return bool(overlap) and len(overlap) / max(1, min(len(expected_tokens), len(observed_tokens))) >= 0.6
  260. def validate_thread(page, customer: Dict[str, str]) -> Dict[str, Any]:
  261. current_url = clean(page.url)
  262. expected_identity = customer["expected_identity"].casefold()
  263. parsed = urlparse(current_url)
  264. path_parts = [part.casefold() for part in parsed.path.split("/") if part]
  265. url_match = "messages" in path_parts and "t" in path_parts and expected_identity in path_parts
  266. title = visible_thread_title(page)
  267. title_match = name_match(customer["company"], title)
  268. # 线程 URL messages/t/<identity> 由客户主页 identity 精确构建,URL 匹配即可
  269. # 确认线程归属;页面标题可能命中消息内容而非线程名,标题仅作参考记录。
  270. matched = bool(url_match)
  271. return {
  272. "matched": matched,
  273. "url_match": url_match,
  274. "title_match": title_match,
  275. "observed_title": title,
  276. "current_url": current_url,
  277. "match_basis": "thread_url" if matched else "",
  278. }
  279. EXTRACT_SCRIPT = r"""
  280. () => {
  281. const main = document.querySelector('[role="main"]') || document.body;
  282. const selectors = [
  283. '[role="row"]',
  284. '[data-testid*="message" i]',
  285. '[data-scope*="message" i]',
  286. 'div[aria-label*="message" i]',
  287. 'div[aria-label*="sent" i]'
  288. ];
  289. const nodes = [];
  290. const seen = new Set();
  291. for (const selector of selectors) {
  292. for (const node of main.querySelectorAll(selector)) {
  293. if (!(node instanceof HTMLElement)) continue;
  294. const rect = node.getBoundingClientRect();
  295. if (rect.width < 24 || rect.height < 12) continue;
  296. const text = (node.innerText || '').trim();
  297. const aria = (node.getAttribute('aria-label') || '').trim();
  298. if (!text && !aria && !node.querySelector('img,video,audio,[role="img"]')) continue;
  299. const key = [text, aria, Math.round(rect.top), Math.round(rect.left)].join('|');
  300. if (seen.has(key)) continue;
  301. seen.add(key);
  302. const timeNode = node.querySelector('time[datetime], abbr[data-tooltip-content], [data-tooltip-content]');
  303. const datetime = timeNode
  304. ? (timeNode.getAttribute('datetime') || timeNode.getAttribute('data-tooltip-content') || timeNode.textContent || '').trim()
  305. : '';
  306. const idNode = node.closest('[data-message-id], [data-testid], [id]') || node;
  307. const rawId = idNode.getAttribute('data-message-id') || idNode.getAttribute('id') || '';
  308. const senderNode = node.querySelector('h3,h4,strong,a[role="link"]');
  309. const sender = senderNode ? (senderNode.textContent || '').trim() : '';
  310. const hrefs = Array.from(node.querySelectorAll('a[href]')).map(a => a.href).filter(Boolean).slice(0, 8);
  311. nodes.push({
  312. text,
  313. aria,
  314. datetime,
  315. raw_id: rawId,
  316. sender,
  317. hrefs,
  318. left: rect.left,
  319. width: rect.width,
  320. viewport_width: window.innerWidth,
  321. image_count: node.querySelectorAll('img,[role="img"]').length,
  322. video_count: node.querySelectorAll('video').length,
  323. audio_count: node.querySelectorAll('audio').length,
  324. file_count: node.querySelectorAll('a[download],a[href*="/file/"],a[href*="attachment"]').length
  325. });
  326. }
  327. }
  328. return nodes;
  329. }
  330. """
  331. def message_type(item: Dict[str, Any]) -> str:
  332. if item.get("audio_count"):
  333. return "语音"
  334. if item.get("video_count"):
  335. return "视频"
  336. if item.get("file_count"):
  337. return "文件"
  338. if item.get("image_count") and not clean(item.get("text")):
  339. return "图片"
  340. return "文本"
  341. def classify_direction(item: Dict[str, Any]) -> str:
  342. combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
  343. if any(marker.casefold() in combined for marker in OUTGOING_MARKERS):
  344. return "我方发送"
  345. # 主页简介/粉丝量信息不是对话消息,标记为系统消息(非客户回复)
  346. if "people follow" in combined or "followers" in combined:
  347. return "系统消息"
  348. viewport = float(item.get("viewport_width") or 0)
  349. left = float(item.get("left") or 0)
  350. width = float(item.get("width") or 0)
  351. if viewport and left + width / 2 >= viewport * 0.58:
  352. return "我方发送"
  353. return "客户回复"
  354. def classify_raw_kind(item: Dict[str, Any]) -> Tuple[str, List[str]]:
  355. combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
  356. risks: List[str] = []
  357. if any(marker.casefold() in combined for marker in SYSTEM_MARKERS):
  358. return "系统消息", risks
  359. if any(marker.casefold() in combined for marker in AUTO_REPLY_MARKERS):
  360. risks.append("auto_reply")
  361. return "自动回复", risks
  362. if any(marker.casefold() == combined or marker.casefold() in combined for marker in READ_ONLY_MARKERS):
  363. return "已读提示", risks
  364. return message_type(item), risks
  365. def stable_record_id(thread_id: str, item: Dict[str, Any], direction: str, ordinal: int) -> str:
  366. raw_id = clean(item.get("raw_id"))
  367. if raw_id and len(raw_id) >= 6:
  368. return f"fb:{raw_id}"
  369. basis = "|".join(
  370. [
  371. thread_id,
  372. clean(item.get("datetime")),
  373. direction,
  374. clean(item.get("sender")),
  375. clean(item.get("text")),
  376. ";".join(item.get("hrefs") or []),
  377. str(ordinal),
  378. ]
  379. )
  380. return "fbh:" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
  381. def normalize_messages(raw_items: Iterable[Dict[str, Any]], thread_id: str) -> List[Dict[str, Any]]:
  382. messages: List[Dict[str, Any]] = []
  383. seen: Set[str] = set()
  384. for ordinal, item in enumerate(raw_items):
  385. text = clean(item.get("text"))
  386. aria = clean(item.get("aria"))
  387. if not text and not aria and not any(item.get(key) for key in ("image_count", "video_count", "audio_count", "file_count")):
  388. continue
  389. direction = classify_direction(item)
  390. kind, risks = classify_raw_kind(item)
  391. record_id = stable_record_id(thread_id, item, direction, ordinal)
  392. if record_id in seen:
  393. continue
  394. seen.add(record_id)
  395. messages.append(
  396. {
  397. "record_id": record_id,
  398. "message_time_raw": clean(item.get("datetime")),
  399. "direction": direction,
  400. "sender": clean(item.get("sender")),
  401. "original_text": text or aria,
  402. "message_type": kind,
  403. "attachment_links": item.get("hrefs") or [],
  404. "raw_aria": aria,
  405. "risk_flags": risks,
  406. }
  407. )
  408. return messages
  409. def find_scroll_container(page) -> bool:
  410. return bool(
  411. page.evaluate(
  412. r"""
  413. () => {
  414. const main = document.querySelector('[role="main"]') || document.body;
  415. const nodes = [main, ...main.querySelectorAll('*')].filter(el => {
  416. const style = getComputedStyle(el);
  417. return el.scrollHeight - el.clientHeight > 240 && ['auto','scroll'].includes(style.overflowY);
  418. });
  419. nodes.sort((a, b) => (b.clientWidth * b.clientHeight) - (a.clientWidth * a.clientHeight));
  420. const target = nodes[0];
  421. if (!target) return false;
  422. target.dataset.wulingConversationScroller = '1';
  423. target.scrollTop = 0;
  424. return true;
  425. }
  426. """
  427. )
  428. )
  429. def scroll_history_to_start(page, max_messages: int, pacing: bool) -> Dict[str, Any]:
  430. stable_rounds = 0
  431. previous_signature = ""
  432. estimated_count = 0
  433. truncated = False
  434. if not find_scroll_container(page):
  435. return {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
  436. rounds = 0
  437. while stable_rounds < 3:
  438. rounds += 1
  439. raw = page.evaluate(EXTRACT_SCRIPT)
  440. estimated_count = len(raw)
  441. first = raw[0] if raw else {}
  442. signature = clean(first.get("raw_id")) or clean(first.get("datetime")) or clean(first.get("text"))[:120]
  443. stable_rounds = stable_rounds + 1 if signature == previous_signature else 0
  444. previous_signature = signature
  445. if estimated_count >= max_messages:
  446. truncated = True
  447. break
  448. page.evaluate(
  449. r"""
  450. () => {
  451. const target = document.querySelector('[data-wuling-conversation-scroller="1"]');
  452. if (target) target.scrollTop = 0;
  453. }
  454. """
  455. )
  456. paced_wait("minor history expansion wait", MINOR_WAIT, pacing)
  457. if rounds >= 200:
  458. truncated = True
  459. break
  460. return {
  461. "scroll_rounds": rounds,
  462. "history_start_reached": stable_rounds >= 3,
  463. "history_truncated": truncated,
  464. "estimated_loaded_nodes": estimated_count,
  465. }
  466. def collect_customer(page, customer: Dict[str, str], mode: str, max_messages: int, pacing: bool) -> Dict[str, Any]:
  467. page.goto(customer["thread_url"], wait_until="domcontentloaded", timeout=90000)
  468. paced_wait("technical thread readiness", TECHNICAL_WAIT, pacing)
  469. # 快速模式(--no-pacing)下跳过节流,但必须保证消息 DOM 已渲染:
  470. try:
  471. page.wait_for_selector(
  472. '[role="main"] div[aria-label*="message" i], [role="main"] [data-testid*="message" i], [role="main"] [role="row"]',
  473. timeout=8000,
  474. )
  475. except Exception:
  476. pass
  477. if not pacing:
  478. import time as _time
  479. _time.sleep(2.0)
  480. marker = risk_text(page)
  481. if marker:
  482. return {"customer": customer, "status": "risk_stop", "risk_flags": [f"facebook_risk:{marker}"], "messages": []}
  483. paced_wait("major new-thread wait", MAJOR_WAIT, pacing)
  484. match = validate_thread(page, customer)
  485. if not match["matched"]:
  486. return {
  487. "customer": customer,
  488. "status": "thread_match_failed",
  489. "thread_match": match,
  490. "risk_flags": ["thread_match_failed"],
  491. "messages": [],
  492. }
  493. history = {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
  494. if mode == "initial_full":
  495. history = scroll_history_to_start(page, max_messages, pacing)
  496. raw_items = page.evaluate(EXTRACT_SCRIPT)
  497. messages = normalize_messages(raw_items, customer["expected_identity"])[:max_messages]
  498. if len(messages) >= max_messages:
  499. history["history_truncated"] = True
  500. return {
  501. "customer": customer,
  502. "status": "collected",
  503. "thread_id": customer["expected_identity"],
  504. "thread_match": match,
  505. "history": history,
  506. "risk_flags": ["history_truncated"] if history.get("history_truncated") else [],
  507. "messages": messages,
  508. }
  509. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  510. parser = argparse.ArgumentParser(description="Collect matched Facebook Messenger conversations via AdsPower + Playwright.")
  511. mode = parser.add_mutually_exclusive_group()
  512. mode.add_argument("--initial-full", action="store_true", help="Scroll each matched thread to the available history start.")
  513. mode.add_argument("--incremental", action="store_true", help="Collect only records not already stored in Facebook对话记录 (default).")
  514. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID.")
  515. parser.add_argument("--adspower-url", default="http://127.0.0.1:50325")
  516. parser.add_argument("--api-key", default="")
  517. parser.add_argument("--excel", default="")
  518. parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
  519. parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
  520. parser.add_argument("--max-customers", type=int, default=0, help="0 means all matching Facebook rows.")
  521. parser.add_argument("--max-messages-per-thread", type=int, default=DEFAULT_MAX_MESSAGES)
  522. parser.add_argument("--output", default="")
  523. parser.add_argument("--run-id", default="")
  524. parser.add_argument("--no-pacing", action="store_true", help=argparse.SUPPRESS)
  525. return parser.parse_args(argv)
  526. def main(argv: Optional[Sequence[str]] = None) -> int:
  527. args = parse_args(argv)
  528. run_id = args.run_id or new_run_id("facebook_conversation_collect")
  529. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  530. workbook = resolved.get("path")
  531. if not workbook:
  532. raise FileNotFoundError("No outreach workbook found. Pass --excel.")
  533. workbook_path = Path(workbook)
  534. customers = read_customers(workbook_path, args.facebook_sheet)
  535. if args.max_customers > 0:
  536. customers = customers[: args.max_customers]
  537. existing_ids = read_existing_record_ids(workbook_path, args.conversation_sheet)
  538. mode = "initial_full" if args.initial_full else "incremental"
  539. pacing = not args.no_pacing
  540. output_path = resolve_artifact_path(
  541. args.output,
  542. kind="facebook_conversations",
  543. default_name="facebook_conversations_raw.json",
  544. run_id=run_id,
  545. )
  546. client = AdsPowerClient(args.adspower_url, args.api_key)
  547. threads: List[Dict[str, Any]] = []
  548. stopped_for_risk = False
  549. try:
  550. browser = client.start_browser(args.profile_id)
  551. if not browser.contexts:
  552. raise RuntimeError("AdsPower browser has no Playwright context.")
  553. context = browser.contexts[0]
  554. page = context.pages[0] if context.pages else context.new_page()
  555. for index, customer in enumerate(customers):
  556. if index:
  557. paced_wait("major customer-switch wait", MAJOR_WAIT, pacing)
  558. print(f"[{index + 1}/{len(customers)}] {customer['company']}", flush=True)
  559. result = collect_customer(page, customer, mode, args.max_messages_per_thread, pacing)
  560. if mode == "incremental":
  561. result["messages"] = [
  562. message for message in result.get("messages", []) if message.get("record_id") not in existing_ids
  563. ]
  564. threads.append(result)
  565. if result.get("status") == "risk_stop":
  566. stopped_for_risk = True
  567. break
  568. finally:
  569. client.detach()
  570. payload = {
  571. "schema_version": "4.26",
  572. "run_id": run_id,
  573. "created_at": now_iso(),
  574. "mode": mode,
  575. "profile_id": args.profile_id,
  576. "workbook": str(workbook_path),
  577. "facebook_sheet": args.facebook_sheet,
  578. "conversation_sheet": args.conversation_sheet,
  579. "read_only": True,
  580. "messages_sent": 0,
  581. "browser_left_open": True,
  582. "stopped_for_risk": stopped_for_risk,
  583. "summary": {
  584. "customers_selected": len(customers),
  585. "threads_attempted": len(threads),
  586. "threads_matched": sum(1 for thread in threads if thread.get("status") == "collected"),
  587. "thread_match_failed": sum(1 for thread in threads if thread.get("status") == "thread_match_failed"),
  588. "new_messages": sum(len(thread.get("messages", [])) for thread in threads),
  589. "history_truncated": sum(1 for thread in threads if thread.get("history", {}).get("history_truncated")),
  590. },
  591. "threads": threads,
  592. "agent_next_step": "Translate both directions to Chinese, analyze only effective customer replies, preview in chat, then run write_facebook_conversations.py with the validated analysis JSON.",
  593. }
  594. output_path.parent.mkdir(parents=True, exist_ok=True)
  595. output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  596. print(json.dumps({"output": str(output_path), **payload["summary"], "browser_left_open": True}, ensure_ascii=False, indent=2))
  597. return 2 if stopped_for_risk else 0
  598. if __name__ == "__main__":
  599. raise SystemExit(main())