collect_facebook_conversations.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  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 = "Facebook"
  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. ]
  68. SYSTEM_MARKERS = [
  69. "messages and calls are secured",
  70. "end-to-end encrypted",
  71. "created this group",
  72. "joined the conversation",
  73. "changed the theme",
  74. "missed a call",
  75. "通话和消息",
  76. "端到端加密",
  77. "加入了对话",
  78. "更改了主题",
  79. ]
  80. AUTO_REPLY_MARKERS = [
  81. "automated response",
  82. "automatic reply",
  83. "auto-reply",
  84. "away message",
  85. "réponse automatique",
  86. "message automatique",
  87. "رسالة تلقائية",
  88. "自动回复",
  89. ]
  90. READ_ONLY_MARKERS = ["seen", "已读", "vu", "تمت المشاهدة"]
  91. def clean(value: Any) -> str:
  92. if value is None:
  93. return ""
  94. return re.sub(r"\s+", " ", str(value).strip())
  95. def now_iso() -> str:
  96. return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
  97. def normalize_name(value: str) -> str:
  98. value = clean(value).casefold()
  99. value = re.sub(r"https?://|www\.", " ", value)
  100. value = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", " ", value)
  101. return " ".join(part for part in value.split() if len(part) > 1)
  102. def page_identity(url: str) -> Tuple[str, str]:
  103. parsed = urlparse(clean(url))
  104. host = parsed.netloc.casefold().removeprefix("www.")
  105. if "facebook.com" not in host:
  106. return "", ""
  107. query = parse_qs(parsed.query)
  108. if parsed.path.rstrip("/").casefold() == "/profile.php" and query.get("id"):
  109. value = clean(query["id"][0])
  110. return value, value
  111. parts = [part for part in parsed.path.split("/") if part]
  112. if not parts:
  113. return "", ""
  114. reserved = {"pages", "groups", "marketplace", "watch", "messages", "home.php"}
  115. if parts[0].casefold() in reserved:
  116. if parts[0].casefold() == "pages" and parts[-1].isdigit():
  117. return parts[-1], parts[-1]
  118. return "", ""
  119. slug = parts[0]
  120. return slug, slug
  121. def thread_url_for(page_url: str) -> Tuple[str, str]:
  122. slug, page_id = page_identity(page_url)
  123. identity = page_id or slug
  124. if not identity:
  125. return "", ""
  126. return f"https://www.facebook.com/messages/t/{identity}", identity
  127. def header_map(ws) -> Dict[str, int]:
  128. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  129. mapped: Dict[str, int] = {}
  130. for key, aliases in HEADER_ALIASES.items():
  131. for alias in aliases:
  132. if alias in raw:
  133. mapped[key] = raw[alias]
  134. break
  135. return mapped
  136. def cell_value(ws, row: int, columns: Dict[str, int], key: str) -> str:
  137. column = columns.get(key)
  138. return clean(ws.cell(row=row, column=column).value) if column else ""
  139. def read_customers(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
  140. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  141. if sheet_name not in wb.sheetnames:
  142. raise KeyError(f"Sheet not found: {sheet_name}")
  143. ws = wb[sheet_name]
  144. columns = header_map(ws)
  145. missing = [key for key in ("index", "company", "facebook_link") if key not in columns]
  146. if missing:
  147. raise RuntimeError(f"Facebook sheet is missing required columns: {', '.join(missing)}")
  148. rows: List[Dict[str, str]] = []
  149. for row in range(2, ws.max_row + 1):
  150. link = cell_value(ws, row, columns, "facebook_link")
  151. name = cell_value(ws, row, columns, "company")
  152. thread_url, identity = thread_url_for(link)
  153. if not name or not thread_url:
  154. continue
  155. rows.append(
  156. {
  157. "excel_row": str(row),
  158. "index": cell_value(ws, row, columns, "index"),
  159. "company": name,
  160. "facebook_link": link,
  161. "thread_url": thread_url,
  162. "expected_identity": identity,
  163. "attribute": cell_value(ws, row, columns, "attribute"),
  164. "customer_type": cell_value(ws, row, columns, "customer_type"),
  165. "business": cell_value(ws, row, columns, "business"),
  166. "status": cell_value(ws, row, columns, "status"),
  167. "note": cell_value(ws, row, columns, "note"),
  168. }
  169. )
  170. wb.close()
  171. return rows
  172. def read_existing_record_ids(workbook_path: Path, sheet_name: str = CONVERSATION_SHEET) -> Set[str]:
  173. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  174. if sheet_name not in wb.sheetnames:
  175. wb.close()
  176. return set()
  177. ws = wb[sheet_name]
  178. headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
  179. record_col = headers.get("记录ID")
  180. if not record_col:
  181. wb.close()
  182. return set()
  183. result = {
  184. clean(ws.cell(row=row, column=record_col).value)
  185. for row in range(2, ws.max_row + 1)
  186. if clean(ws.cell(row=row, column=record_col).value)
  187. }
  188. wb.close()
  189. return result
  190. def paced_wait(label: str, bounds: Tuple[float, float], enabled: bool = True) -> float:
  191. if not enabled:
  192. return 0.0
  193. seconds = random.uniform(*bounds)
  194. print(f" -> {label}: {seconds:.1f}s", flush=True)
  195. time.sleep(seconds)
  196. return seconds
  197. def risk_text(page) -> str:
  198. try:
  199. text = clean(page.locator("body").inner_text(timeout=5000)).casefold()
  200. except Exception:
  201. return ""
  202. return next((marker for marker in RISK_MARKERS if marker.casefold() in text), "")
  203. def visible_thread_title(page) -> str:
  204. selectors = [
  205. "[role='main'] h1",
  206. "[role='main'] h2",
  207. "header h1",
  208. "header h2",
  209. "a[role='link'][href*='/messages/t/']",
  210. ]
  211. for selector in selectors:
  212. try:
  213. locator = page.locator(selector)
  214. for idx in range(min(locator.count(), 8)):
  215. text = clean(locator.nth(idx).inner_text(timeout=1000))
  216. if text and len(text) <= 160:
  217. return text
  218. except Exception:
  219. continue
  220. return ""
  221. def name_match(expected: str, observed: str) -> bool:
  222. expected_norm = normalize_name(expected)
  223. observed_norm = normalize_name(observed)
  224. if not expected_norm or not observed_norm:
  225. return False
  226. if expected_norm in observed_norm or observed_norm in expected_norm:
  227. return True
  228. expected_tokens = set(expected_norm.split())
  229. observed_tokens = set(observed_norm.split())
  230. overlap = expected_tokens & observed_tokens
  231. return bool(overlap) and len(overlap) / max(1, min(len(expected_tokens), len(observed_tokens))) >= 0.6
  232. def validate_thread(page, customer: Dict[str, str]) -> Dict[str, Any]:
  233. current_url = clean(page.url)
  234. expected_identity = customer["expected_identity"].casefold()
  235. parsed = urlparse(current_url)
  236. path_parts = [part.casefold() for part in parsed.path.split("/") if part]
  237. url_match = "messages" in path_parts and "t" in path_parts and expected_identity in path_parts
  238. title = visible_thread_title(page)
  239. title_match = name_match(customer["company"], title)
  240. matched = bool(url_match and title_match)
  241. return {
  242. "matched": matched,
  243. "url_match": url_match,
  244. "title_match": title_match,
  245. "observed_title": title,
  246. "current_url": current_url,
  247. "match_basis": "thread_url+title" if matched else "",
  248. }
  249. EXTRACT_SCRIPT = r"""
  250. () => {
  251. const main = document.querySelector('[role="main"]') || document.body;
  252. const selectors = [
  253. '[role="row"]',
  254. '[data-testid*="message" i]',
  255. '[data-scope*="message" i]',
  256. 'div[aria-label*="message" i]',
  257. 'div[aria-label*="sent" i]'
  258. ];
  259. const nodes = [];
  260. const seen = new Set();
  261. for (const selector of selectors) {
  262. for (const node of main.querySelectorAll(selector)) {
  263. if (!(node instanceof HTMLElement)) continue;
  264. const rect = node.getBoundingClientRect();
  265. if (rect.width < 24 || rect.height < 12) continue;
  266. const text = (node.innerText || '').trim();
  267. const aria = (node.getAttribute('aria-label') || '').trim();
  268. if (!text && !aria && !node.querySelector('img,video,audio,[role="img"]')) continue;
  269. const key = [text, aria, Math.round(rect.top), Math.round(rect.left)].join('|');
  270. if (seen.has(key)) continue;
  271. seen.add(key);
  272. const timeNode = node.querySelector('time[datetime], abbr[data-tooltip-content], [data-tooltip-content]');
  273. const datetime = timeNode
  274. ? (timeNode.getAttribute('datetime') || timeNode.getAttribute('data-tooltip-content') || timeNode.textContent || '').trim()
  275. : '';
  276. const idNode = node.closest('[data-message-id], [data-testid], [id]') || node;
  277. const rawId = idNode.getAttribute('data-message-id') || idNode.getAttribute('id') || '';
  278. const senderNode = node.querySelector('h3,h4,strong,a[role="link"]');
  279. const sender = senderNode ? (senderNode.textContent || '').trim() : '';
  280. const hrefs = Array.from(node.querySelectorAll('a[href]')).map(a => a.href).filter(Boolean).slice(0, 8);
  281. nodes.push({
  282. text,
  283. aria,
  284. datetime,
  285. raw_id: rawId,
  286. sender,
  287. hrefs,
  288. left: rect.left,
  289. width: rect.width,
  290. viewport_width: window.innerWidth,
  291. image_count: node.querySelectorAll('img,[role="img"]').length,
  292. video_count: node.querySelectorAll('video').length,
  293. audio_count: node.querySelectorAll('audio').length,
  294. file_count: node.querySelectorAll('a[download],a[href*="/file/"],a[href*="attachment"]').length
  295. });
  296. }
  297. }
  298. return nodes;
  299. }
  300. """
  301. def message_type(item: Dict[str, Any]) -> str:
  302. if item.get("audio_count"):
  303. return "语音"
  304. if item.get("video_count"):
  305. return "视频"
  306. if item.get("file_count"):
  307. return "文件"
  308. if item.get("image_count") and not clean(item.get("text")):
  309. return "图片"
  310. return "文本"
  311. def classify_direction(item: Dict[str, Any]) -> str:
  312. combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
  313. if any(marker.casefold() in combined for marker in OUTGOING_MARKERS):
  314. return "我方发送"
  315. viewport = float(item.get("viewport_width") or 0)
  316. left = float(item.get("left") or 0)
  317. width = float(item.get("width") or 0)
  318. if viewport and left + width / 2 >= viewport * 0.58:
  319. return "我方发送"
  320. return "客户回复"
  321. def classify_raw_kind(item: Dict[str, Any]) -> Tuple[str, List[str]]:
  322. combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
  323. risks: List[str] = []
  324. if any(marker.casefold() in combined for marker in SYSTEM_MARKERS):
  325. return "系统消息", risks
  326. if any(marker.casefold() in combined for marker in AUTO_REPLY_MARKERS):
  327. risks.append("auto_reply")
  328. return "自动回复", risks
  329. if any(marker.casefold() == combined or marker.casefold() in combined for marker in READ_ONLY_MARKERS):
  330. return "已读提示", risks
  331. return message_type(item), risks
  332. def stable_record_id(thread_id: str, item: Dict[str, Any], direction: str, ordinal: int) -> str:
  333. raw_id = clean(item.get("raw_id"))
  334. if raw_id and len(raw_id) >= 6:
  335. return f"fb:{raw_id}"
  336. basis = "|".join(
  337. [
  338. thread_id,
  339. clean(item.get("datetime")),
  340. direction,
  341. clean(item.get("sender")),
  342. clean(item.get("text")),
  343. ";".join(item.get("hrefs") or []),
  344. str(ordinal),
  345. ]
  346. )
  347. return "fbh:" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
  348. def normalize_messages(raw_items: Iterable[Dict[str, Any]], thread_id: str) -> List[Dict[str, Any]]:
  349. messages: List[Dict[str, Any]] = []
  350. seen: Set[str] = set()
  351. for ordinal, item in enumerate(raw_items):
  352. text = clean(item.get("text"))
  353. aria = clean(item.get("aria"))
  354. if not text and not aria and not any(item.get(key) for key in ("image_count", "video_count", "audio_count", "file_count")):
  355. continue
  356. direction = classify_direction(item)
  357. kind, risks = classify_raw_kind(item)
  358. record_id = stable_record_id(thread_id, item, direction, ordinal)
  359. if record_id in seen:
  360. continue
  361. seen.add(record_id)
  362. messages.append(
  363. {
  364. "record_id": record_id,
  365. "message_time_raw": clean(item.get("datetime")),
  366. "direction": direction,
  367. "sender": clean(item.get("sender")),
  368. "original_text": text or aria,
  369. "message_type": kind,
  370. "attachment_links": item.get("hrefs") or [],
  371. "raw_aria": aria,
  372. "risk_flags": risks,
  373. }
  374. )
  375. return messages
  376. def find_scroll_container(page) -> bool:
  377. return bool(
  378. page.evaluate(
  379. r"""
  380. () => {
  381. const main = document.querySelector('[role="main"]') || document.body;
  382. const nodes = [main, ...main.querySelectorAll('*')].filter(el => {
  383. const style = getComputedStyle(el);
  384. return el.scrollHeight - el.clientHeight > 240 && ['auto','scroll'].includes(style.overflowY);
  385. });
  386. nodes.sort((a, b) => (b.clientWidth * b.clientHeight) - (a.clientWidth * a.clientHeight));
  387. const target = nodes[0];
  388. if (!target) return false;
  389. target.dataset.wulingConversationScroller = '1';
  390. target.scrollTop = 0;
  391. return true;
  392. }
  393. """
  394. )
  395. )
  396. def scroll_history_to_start(page, max_messages: int, pacing: bool) -> Dict[str, Any]:
  397. stable_rounds = 0
  398. previous_signature = ""
  399. estimated_count = 0
  400. truncated = False
  401. if not find_scroll_container(page):
  402. return {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
  403. rounds = 0
  404. while stable_rounds < 3:
  405. rounds += 1
  406. raw = page.evaluate(EXTRACT_SCRIPT)
  407. estimated_count = len(raw)
  408. first = raw[0] if raw else {}
  409. signature = clean(first.get("raw_id")) or clean(first.get("datetime")) or clean(first.get("text"))[:120]
  410. stable_rounds = stable_rounds + 1 if signature == previous_signature else 0
  411. previous_signature = signature
  412. if estimated_count >= max_messages:
  413. truncated = True
  414. break
  415. page.evaluate(
  416. r"""
  417. () => {
  418. const target = document.querySelector('[data-wuling-conversation-scroller="1"]');
  419. if (target) target.scrollTop = 0;
  420. }
  421. """
  422. )
  423. paced_wait("minor history expansion wait", MINOR_WAIT, pacing)
  424. if rounds >= 200:
  425. truncated = True
  426. break
  427. return {
  428. "scroll_rounds": rounds,
  429. "history_start_reached": stable_rounds >= 3,
  430. "history_truncated": truncated,
  431. "estimated_loaded_nodes": estimated_count,
  432. }
  433. def collect_customer(page, customer: Dict[str, str], mode: str, max_messages: int, pacing: bool) -> Dict[str, Any]:
  434. page.goto(customer["thread_url"], wait_until="domcontentloaded", timeout=90000)
  435. paced_wait("technical thread readiness", TECHNICAL_WAIT, pacing)
  436. marker = risk_text(page)
  437. if marker:
  438. return {"customer": customer, "status": "risk_stop", "risk_flags": [f"facebook_risk:{marker}"], "messages": []}
  439. paced_wait("major new-thread wait", MAJOR_WAIT, pacing)
  440. match = validate_thread(page, customer)
  441. if not match["matched"]:
  442. return {
  443. "customer": customer,
  444. "status": "thread_match_failed",
  445. "thread_match": match,
  446. "risk_flags": ["thread_match_failed"],
  447. "messages": [],
  448. }
  449. history = {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
  450. if mode == "initial_full":
  451. history = scroll_history_to_start(page, max_messages, pacing)
  452. raw_items = page.evaluate(EXTRACT_SCRIPT)
  453. messages = normalize_messages(raw_items, customer["expected_identity"])[:max_messages]
  454. if len(messages) >= max_messages:
  455. history["history_truncated"] = True
  456. return {
  457. "customer": customer,
  458. "status": "collected",
  459. "thread_id": customer["expected_identity"],
  460. "thread_match": match,
  461. "history": history,
  462. "risk_flags": ["history_truncated"] if history.get("history_truncated") else [],
  463. "messages": messages,
  464. }
  465. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  466. parser = argparse.ArgumentParser(description="Collect matched Facebook Messenger conversations via AdsPower + Playwright.")
  467. mode = parser.add_mutually_exclusive_group()
  468. mode.add_argument("--initial-full", action="store_true", help="Scroll each matched thread to the available history start.")
  469. mode.add_argument("--incremental", action="store_true", help="Collect only records not already stored in Facebook对话记录 (default).")
  470. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID.")
  471. parser.add_argument("--adspower-url", default="http://127.0.0.1:50325")
  472. parser.add_argument("--api-key", default="")
  473. parser.add_argument("--excel", default="")
  474. parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
  475. parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
  476. parser.add_argument("--max-customers", type=int, default=0, help="0 means all matching Facebook rows.")
  477. parser.add_argument("--max-messages-per-thread", type=int, default=DEFAULT_MAX_MESSAGES)
  478. parser.add_argument("--output", default="")
  479. parser.add_argument("--run-id", default="")
  480. parser.add_argument("--no-pacing", action="store_true", help=argparse.SUPPRESS)
  481. return parser.parse_args(argv)
  482. def main(argv: Optional[Sequence[str]] = None) -> int:
  483. args = parse_args(argv)
  484. run_id = args.run_id or new_run_id("facebook_conversation_collect")
  485. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  486. workbook = resolved.get("path")
  487. if not workbook:
  488. raise FileNotFoundError("No outreach workbook found. Pass --excel.")
  489. workbook_path = Path(workbook)
  490. customers = read_customers(workbook_path, args.facebook_sheet)
  491. if args.max_customers > 0:
  492. customers = customers[: args.max_customers]
  493. existing_ids = read_existing_record_ids(workbook_path, args.conversation_sheet)
  494. mode = "initial_full" if args.initial_full else "incremental"
  495. pacing = not args.no_pacing
  496. output_path = resolve_artifact_path(
  497. args.output,
  498. kind="facebook_conversations",
  499. default_name="facebook_conversations_raw.json",
  500. run_id=run_id,
  501. )
  502. client = AdsPowerClient(args.adspower_url, args.api_key)
  503. threads: List[Dict[str, Any]] = []
  504. stopped_for_risk = False
  505. try:
  506. browser = client.start_browser(args.profile_id)
  507. if not browser.contexts:
  508. raise RuntimeError("AdsPower browser has no Playwright context.")
  509. context = browser.contexts[0]
  510. page = context.pages[0] if context.pages else context.new_page()
  511. for index, customer in enumerate(customers):
  512. if index:
  513. paced_wait("major customer-switch wait", MAJOR_WAIT, pacing)
  514. print(f"[{index + 1}/{len(customers)}] {customer['company']}", flush=True)
  515. result = collect_customer(page, customer, mode, args.max_messages_per_thread, pacing)
  516. if mode == "incremental":
  517. result["messages"] = [
  518. message for message in result.get("messages", []) if message.get("record_id") not in existing_ids
  519. ]
  520. threads.append(result)
  521. if result.get("status") == "risk_stop":
  522. stopped_for_risk = True
  523. break
  524. finally:
  525. client.detach()
  526. payload = {
  527. "schema_version": "4.26",
  528. "run_id": run_id,
  529. "created_at": now_iso(),
  530. "mode": mode,
  531. "profile_id": args.profile_id,
  532. "workbook": str(workbook_path),
  533. "facebook_sheet": args.facebook_sheet,
  534. "conversation_sheet": args.conversation_sheet,
  535. "read_only": True,
  536. "messages_sent": 0,
  537. "browser_left_open": True,
  538. "stopped_for_risk": stopped_for_risk,
  539. "summary": {
  540. "customers_selected": len(customers),
  541. "threads_attempted": len(threads),
  542. "threads_matched": sum(1 for thread in threads if thread.get("status") == "collected"),
  543. "thread_match_failed": sum(1 for thread in threads if thread.get("status") == "thread_match_failed"),
  544. "new_messages": sum(len(thread.get("messages", [])) for thread in threads),
  545. "history_truncated": sum(1 for thread in threads if thread.get("history", {}).get("history_truncated")),
  546. },
  547. "threads": threads,
  548. "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.",
  549. }
  550. output_path.parent.mkdir(parents=True, exist_ok=True)
  551. output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  552. print(json.dumps({"output": str(output_path), **payload["summary"], "browser_left_open": True}, ensure_ascii=False, indent=2))
  553. return 2 if stopped_for_risk else 0
  554. if __name__ == "__main__":
  555. raise SystemExit(main())