send_facebook_outreach.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Semi-automated Facebook follow/message execution through AdsPower.
  4. Default behavior is dry-run. Real execution requires --confirm plus
  5. --batch-confirmed after the whole preview has been shown to the user. Messenger
  6. sending is deliberately scoped to the Facebook Page header Message button and
  7. the matching Messenger dialog only.
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import json
  12. import random
  13. import re
  14. import sys
  15. import time
  16. from datetime import datetime, timedelta
  17. from math import ceil
  18. from pathlib import Path
  19. import sys
  20. sys.path.append(str(Path(__file__).resolve().parents[1]))
  21. from common.artifact_manager import resolve_artifact_path
  22. from typing import Any, Dict, List, Optional, Sequence
  23. from openpyxl import load_workbook
  24. SCRIPT_DIR = Path(__file__).resolve().parent
  25. SKILL_ROOT = SCRIPT_DIR.parents[1]
  26. sys.path.insert(0, str(SKILL_ROOT))
  27. from scripts.scraper.ads_power_client import AdsPowerClient # noqa: E402
  28. DEFAULT_ADS_POWER_URL = "http://127.0.0.1:50325"
  29. MAX_PER_RUN = 3
  30. DEFAULT_DAILY_FOLLOW_LIMIT = 20
  31. DEFAULT_DAILY_DM_LIMIT = 20
  32. DEFAULT_SESSION_MAX = 3
  33. FAILURE_STOP_THRESHOLD = 3
  34. DEFAULT_RISK_COOLDOWN_MINUTES = 24 * 60
  35. WAIT_TIERS = {
  36. "major": (90, 200),
  37. "minor": (30, 90),
  38. "technical": (0.5, 8),
  39. }
  40. PACING_STAGE_TIERS = {
  41. "page_open": "major",
  42. "after_follow": "major",
  43. "after_send": "major",
  44. "between_customers": "major",
  45. "session_cooldown": "major",
  46. "after_message_open": "minor",
  47. "before_send": "minor",
  48. }
  49. PACING_PROFILES = {
  50. "very_conservative": {
  51. "page_open": WAIT_TIERS["major"],
  52. "after_follow": WAIT_TIERS["major"],
  53. "after_message_open": WAIT_TIERS["minor"],
  54. "before_send": WAIT_TIERS["minor"],
  55. "after_send": WAIT_TIERS["major"],
  56. "between_customers": WAIT_TIERS["major"],
  57. "session_cooldown": (1200, 3600),
  58. },
  59. "conservative": {
  60. "page_open": WAIT_TIERS["major"],
  61. "after_follow": WAIT_TIERS["major"],
  62. "after_message_open": WAIT_TIERS["minor"],
  63. "before_send": WAIT_TIERS["minor"],
  64. "after_send": WAIT_TIERS["major"],
  65. "between_customers": WAIT_TIERS["major"],
  66. "session_cooldown": (600, 1800),
  67. },
  68. "standard": {
  69. "page_open": WAIT_TIERS["major"],
  70. "after_follow": WAIT_TIERS["major"],
  71. "after_message_open": WAIT_TIERS["minor"],
  72. "before_send": WAIT_TIERS["minor"],
  73. "after_send": WAIT_TIERS["major"],
  74. "between_customers": WAIT_TIERS["major"],
  75. "session_cooldown": (300, 900),
  76. },
  77. }
  78. RISK_STOP_PHRASES = [
  79. "we limit",
  80. "you are temporarily blocked",
  81. "you're temporarily blocked",
  82. "action blocked",
  83. "confirm your identity",
  84. "suspicious activity",
  85. "temporarily unavailable",
  86. "identity confirmation",
  87. "verification required",
  88. ]
  89. STATUS_VALUES = {
  90. "friend_success": "已加好友,待私信",
  91. "follow_success": "已关注,待私信",
  92. "dm_success": "已发私信",
  93. "both_success": "已关注,已发私信",
  94. "friend_failed": "加好友失败",
  95. "follow_failed": "关注失败",
  96. "dm_failed": "发送失败",
  97. }
  98. HEADER_ALIASES = {
  99. "index": ["序号", "Index", "No."],
  100. "status": ["建联状态", "建联情况", "状态", "Status"],
  101. "note": ["备注", "说明", "Notes"],
  102. }
  103. COMMENT_HINTS = ["comment", "留言", "reply", "回覆", "回复", "write a comment", "撰寫留言"]
  104. MESSAGE_BUTTON_RE = re.compile(r"(^|\s)(message|訊息)(\s|$)|send message|發送訊息|发送讯息|发送消息", re.I)
  105. FOLLOW_BUTTON_RE = re.compile(r"(^|\s)(follow|追蹤|关注)(\s|$)", re.I)
  106. FOLLOWING_BUTTON_RE = re.compile(r"following|追蹤中|已关注|已追蹤", re.I)
  107. def now_iso() -> str:
  108. return datetime.now().isoformat(timespec="seconds")
  109. def today_key() -> str:
  110. return datetime.now().strftime("%Y%m%d")
  111. def ledger_path(profile_id: str) -> Path:
  112. safe_profile = re.sub(r"[^0-9A-Za-z_.-]+", "_", profile_id.strip() or "unknown")
  113. return Path("logs") / f"facebook_account_ledger_{safe_profile}_{today_key()}.json"
  114. def empty_ledger(profile_id: str) -> Dict[str, Any]:
  115. return {
  116. "profile_id": profile_id,
  117. "date": today_key(),
  118. "follow_count": 0,
  119. "dm_count": 0,
  120. "failure_count": 0,
  121. "risk_events": [],
  122. "events": [],
  123. "last_run_at": "",
  124. "cooldown_until": "",
  125. }
  126. def load_ledger(profile_id: str) -> Dict[str, Any]:
  127. path = ledger_path(profile_id)
  128. if not path.exists():
  129. return empty_ledger(profile_id)
  130. try:
  131. data = json.loads(path.read_text(encoding="utf-8"))
  132. except Exception:
  133. data = empty_ledger(profile_id)
  134. defaults = empty_ledger(profile_id)
  135. for key, value in defaults.items():
  136. data.setdefault(key, value)
  137. return data
  138. def save_ledger(profile_id: str, ledger: Dict[str, Any]) -> None:
  139. path = ledger_path(profile_id)
  140. path.parent.mkdir(parents=True, exist_ok=True)
  141. path.write_text(json.dumps(ledger, ensure_ascii=False, indent=2), encoding="utf-8")
  142. def ledger_event(ledger: Dict[str, Any], event_type: str, dealer_name: str = "", detail: str = "") -> None:
  143. ledger["last_run_at"] = now_iso()
  144. ledger.setdefault("events", []).append({
  145. "timestamp": now_iso(),
  146. "type": event_type,
  147. "dealer_name": dealer_name,
  148. "detail": detail,
  149. })
  150. if event_type == "follow":
  151. ledger["follow_count"] = int(ledger.get("follow_count", 0)) + 1
  152. elif event_type == "dm":
  153. ledger["dm_count"] = int(ledger.get("dm_count", 0)) + 1
  154. elif event_type == "failure":
  155. ledger["failure_count"] = int(ledger.get("failure_count", 0)) + 1
  156. elif event_type == "risk":
  157. ledger.setdefault("risk_events", []).append({"timestamp": now_iso(), "dealer_name": dealer_name, "detail": detail})
  158. ledger["cooldown_until"] = (datetime.now() + timedelta(minutes=DEFAULT_RISK_COOLDOWN_MINUTES)).isoformat(timespec="seconds")
  159. def cooldown_active(ledger: Dict[str, Any]) -> str:
  160. raw = clean(ledger.get("cooldown_until", ""))
  161. if not raw:
  162. return ""
  163. try:
  164. until = datetime.fromisoformat(raw)
  165. except ValueError:
  166. return ""
  167. if until > datetime.now():
  168. return raw
  169. return ""
  170. def action_needs_follow(action: str) -> bool:
  171. return action in {"follow", "follow_dm"}
  172. def action_needs_dm(action: str) -> bool:
  173. return action in {"dm", "both", "follow_dm"}
  174. def remaining_capacity(ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int) -> Dict[str, int]:
  175. return {
  176. "follow": max(0, daily_follow_limit - int(ledger.get("follow_count", 0))),
  177. "dm": max(0, daily_dm_limit - int(ledger.get("dm_count", 0))),
  178. }
  179. def allowed_item_count(action: str, requested: int, ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int, session_max: int) -> int:
  180. capacity = remaining_capacity(ledger, daily_follow_limit, daily_dm_limit)
  181. limits = [requested, session_max]
  182. if action_needs_follow(action):
  183. limits.append(capacity["follow"])
  184. if action_needs_dm(action):
  185. limits.append(capacity["dm"])
  186. return max(0, min(limits))
  187. def pacing_range(profile: Dict[str, tuple[int, int]], stage: str) -> tuple[int, int]:
  188. return profile.get(stage, (10, 20))
  189. def pacing_stage_tier(stage: str) -> str:
  190. return PACING_STAGE_TIERS.get(stage, "technical")
  191. def sample_wait(profile: Dict[str, tuple[int, int]], stage: str) -> float:
  192. low, high = pacing_range(profile, stage)
  193. return random.uniform(low, high)
  194. def pause(profile: Dict[str, tuple[int, int]], stage: str, confirm: bool, reason: str) -> float:
  195. wait = sample_wait(profile, stage)
  196. print(f" -> pacing {reason}: {wait:.1f}s")
  197. if confirm:
  198. time.sleep(wait)
  199. return wait
  200. def estimate_schedule(items: List[Dict[str, Any]], action: str, profile: Dict[str, tuple[int, int]]) -> Dict[str, Any]:
  201. stages = ["page_open", "between_customers"]
  202. if action_needs_follow(action):
  203. stages.append("after_follow")
  204. if action_needs_dm(action):
  205. stages.extend(["after_message_open", "before_send", "after_send"])
  206. per_customer_min = sum(pacing_range(profile, stage)[0] for stage in stages)
  207. per_customer_max = sum(pacing_range(profile, stage)[1] for stage in stages)
  208. cooldown = pacing_range(profile, "session_cooldown")
  209. return {
  210. "customers": len(items),
  211. "estimated_seconds_min": per_customer_min * len(items) + cooldown[0],
  212. "estimated_seconds_max": per_customer_max * len(items) + cooldown[1],
  213. "estimated_minutes_min": ceil((per_customer_min * len(items) + cooldown[0]) / 60),
  214. "estimated_minutes_max": ceil((per_customer_max * len(items) + cooldown[1]) / 60),
  215. "stages": {stage: pacing_range(profile, stage) for stage in stages + ["session_cooldown"]},
  216. "stage_tiers": {stage: pacing_stage_tier(stage) for stage in stages + ["session_cooldown"]},
  217. "tier_ranges": WAIT_TIERS,
  218. }
  219. def build_schedule_preview(items: List[Dict[str, Any]], args: argparse.Namespace, ledger: Dict[str, Any], profile: Dict[str, tuple[int, int]]) -> Dict[str, Any]:
  220. return {
  221. "mode": "confirmed" if args.confirm else "dry_run",
  222. "risk_profile": args.risk_profile,
  223. "profile_id": args.profile_id,
  224. "today_used": {
  225. "follow": int(ledger.get("follow_count", 0)),
  226. "dm": int(ledger.get("dm_count", 0)),
  227. "failures": int(ledger.get("failure_count", 0)),
  228. "risk_events": len(ledger.get("risk_events", [])),
  229. },
  230. "today_remaining": remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit),
  231. "limits": {
  232. "daily_follow_limit": args.daily_follow_limit,
  233. "daily_dm_limit": args.daily_dm_limit,
  234. "session_max": args.session_max,
  235. "failure_stop_threshold": FAILURE_STOP_THRESHOLD,
  236. },
  237. "schedule": estimate_schedule(items, args.action, profile),
  238. "customers": [{"dealer_name": item.get("dealer_name", ""), "page_url": item.get("page_url", "")} for item in items],
  239. "stop_rules": [
  240. "Stop immediately on Facebook verification, temporary block, suspicious activity, or identity prompt.",
  241. "Stop when the account daily Follow/DM limit is exhausted.",
  242. "Stop when failure count reaches threshold.",
  243. ],
  244. }
  245. def detect_risk(page) -> str:
  246. try:
  247. text = page.content().casefold()
  248. except Exception:
  249. return ""
  250. for phrase in RISK_STOP_PHRASES:
  251. if phrase.casefold() in text:
  252. return phrase
  253. return ""
  254. def clean(value: Any) -> str:
  255. return "" if value is None else str(value).strip()
  256. def norm(value: str) -> str:
  257. return re.sub(r"\s+", " ", clean(value)).casefold()
  258. def name_tokens(name: str) -> List[str]:
  259. return [part for part in re.split(r"[^0-9a-zA-Z]+", name.casefold()) if len(part) >= 3]
  260. def get_element_text(locator) -> str:
  261. try:
  262. return clean(locator.inner_text(timeout=1000))
  263. except Exception:
  264. try:
  265. return clean(locator.get_attribute("aria-label"))
  266. except Exception:
  267. return ""
  268. def first_visible(locator, timeout_ms: int = 1800):
  269. try:
  270. count = locator.count() if locator else 0
  271. for idx in range(count):
  272. item = locator.nth(idx)
  273. if item.is_visible(timeout=timeout_ms):
  274. return item
  275. except Exception:
  276. return None
  277. return None
  278. def find_page_header_button(page, include_re: re.Pattern, exclude_re: Optional[re.Pattern] = None):
  279. """Find a visible action button in the Page header, not a post/comment area."""
  280. try:
  281. viewport = page.viewport_size or {"width": 1600, "height": 1000}
  282. max_y = viewport["height"] * 0.76
  283. min_y = 72
  284. min_x = viewport["width"] * 0.45
  285. candidates = []
  286. locator = page.locator("div[role='button'], a[role='button'], button")
  287. for idx in range(locator.count()):
  288. item = locator.nth(idx)
  289. if not item.is_visible(timeout=500):
  290. continue
  291. box = item.bounding_box()
  292. if not box:
  293. continue
  294. if box.get("y", 0) < min_y or box.get("y", 0) > max_y or box.get("x", 0) < min_x:
  295. continue
  296. text = get_element_text(item)
  297. aria = clean(item.get_attribute("aria-label"))
  298. combined = f"{text} {aria}".strip()
  299. if not include_re.search(combined):
  300. continue
  301. if exclude_re and exclude_re.search(combined):
  302. continue
  303. candidates.append((box.get("y", 0), box.get("x", 0), item, combined))
  304. if candidates:
  305. candidates.sort(key=lambda row: (row[0], row[1]))
  306. return candidates[0][2], candidates[0][3]
  307. except Exception:
  308. return None, ""
  309. return None, ""
  310. def click_follow(page, confirm: bool) -> Dict[str, Any]:
  311. result = {"action": "follow", "clicked": False, "already_active": False, "error": ""}
  312. try:
  313. active_button, active_label = find_page_header_button(page, FOLLOWING_BUTTON_RE)
  314. if active_button is not None:
  315. result["already_active"] = True
  316. print(f" -> Follow already active: {active_label}")
  317. return result
  318. button, label = find_page_header_button(page, FOLLOW_BUTTON_RE, exclude_re=FOLLOWING_BUTTON_RE)
  319. if button is None:
  320. result["error"] = "Follow button not found in Page header"
  321. return result
  322. print(f" [Follow] button is visible: {label}")
  323. if confirm:
  324. button.click()
  325. result["clicked"] = True
  326. print(" -> Follow clicked")
  327. else:
  328. print(" -> dry-run: follow not clicked")
  329. except Exception as exc: # pragma: no cover - browser dependent
  330. result["error"] = str(exc)
  331. return result
  332. def click_add_friend(page, confirm: bool) -> Dict[str, Any]:
  333. result = {"action": "add_friend", "clicked": False, "error": ""}
  334. try:
  335. button, label = find_page_header_button(page, re.compile(r"add\s*friend|ajouter|connect", re.I))
  336. if button is None:
  337. result["error"] = "Add Friend button not found in Page header"
  338. return result
  339. print(f" [Add Friend] button is visible: {label}")
  340. if confirm:
  341. button.click()
  342. result["clicked"] = True
  343. print(" -> clicked Add Friend")
  344. else:
  345. print(" -> dry-run: no click")
  346. except Exception as exc: # pragma: no cover - browser dependent
  347. result["error"] = str(exc)
  348. return result
  349. def open_message_dialog(page, confirm: bool) -> Dict[str, Any]:
  350. result = {"action": "open_message", "clicked": False, "error": ""}
  351. button, label = find_page_header_button(page, MESSAGE_BUTTON_RE)
  352. if button is None:
  353. result["error"] = "Message button not found in Page header"
  354. return result
  355. print(f" [Message] button is visible in Page header: {label}")
  356. if confirm:
  357. button.click()
  358. result["clicked"] = True
  359. print(" -> Message button clicked")
  360. else:
  361. print(" -> dry-run: message dialog not opened")
  362. return result
  363. def dialog_matches_customer(dialog, dealer_name: str) -> bool:
  364. text = norm(get_element_text(dialog))
  365. tokens = name_tokens(dealer_name)
  366. if not tokens:
  367. return False
  368. return sum(1 for token in tokens if token in text) >= min(2, len(tokens))
  369. def find_matching_messenger_dialog(page, dealer_name: str):
  370. """Return a right-side Messenger dialog whose visible title matches dealer_name."""
  371. try:
  372. viewport = page.viewport_size or {"width": 1600, "height": 1000}
  373. min_x = viewport["width"] * 0.42
  374. min_y = viewport["height"] * 0.28
  375. candidates = []
  376. containers = page.locator("div[role='dialog'], div[aria-label*='Messenger' i], div[aria-label*='訊息' i], div[aria-label*='Message' i]")
  377. for idx in range(containers.count()):
  378. dialog = containers.nth(idx)
  379. if not dialog.is_visible(timeout=500):
  380. continue
  381. box = dialog.bounding_box()
  382. if not box:
  383. continue
  384. if box.get("x", 0) < min_x or box.get("y", 0) < min_y:
  385. continue
  386. if not dialog_matches_customer(dialog, dealer_name):
  387. continue
  388. dialog_text = norm(get_element_text(dialog))
  389. if any(hint in dialog_text for hint in COMMENT_HINTS):
  390. continue
  391. candidates.append((box.get("x", 0), box.get("y", 0), dialog))
  392. if candidates:
  393. candidates.sort(key=lambda row: (row[0], row[1]), reverse=True)
  394. return candidates[0][2]
  395. except Exception:
  396. return None
  397. return None
  398. def find_dialog_textbox(dialog):
  399. selectors = [
  400. 'div[contenteditable="true"][role="textbox"]',
  401. 'div[contenteditable="true"][data-lexical-editor="true"]',
  402. '[aria-label="Aa"][contenteditable="true"]',
  403. '[aria-label*="Message" i][contenteditable="true"]',
  404. '[aria-label*="訊息" i][contenteditable="true"]',
  405. ]
  406. for selector in selectors:
  407. textbox = first_visible(dialog.locator(selector), timeout_ms=800)
  408. if textbox is not None:
  409. label = clean(textbox.get_attribute("aria-label"))
  410. text = norm(get_element_text(textbox))
  411. if any(hint in norm(label) or hint in text for hint in COMMENT_HINTS):
  412. return None, "Blocked: detected comment composer, not Messenger dialog"
  413. return textbox, ""
  414. return None, "Message textbox not found inside matching Messenger dialog"
  415. def facebook_thread_url(page_url: str) -> str:
  416. match = re.search(r"facebook\.com/([^/?#]+)", page_url or "", re.I)
  417. if not match:
  418. return ""
  419. slug = match.group(1).strip("/")
  420. if not slug or slug in {"messages", "profile.php", "pages"}:
  421. return ""
  422. return f"https://www.facebook.com/messages/t/{slug}"
  423. def messenger_body_matches_customer(body: str, dealer_name: str) -> bool:
  424. if not dealer_name:
  425. return False
  426. body_norm = norm(body)
  427. direct = norm(dealer_name)
  428. if direct and direct in body_norm:
  429. return True
  430. tokens = name_tokens(dealer_name)
  431. if not tokens:
  432. return False
  433. return sum(1 for token in tokens if token in body_norm) >= min(2, len(tokens))
  434. def find_messenger_thread_textbox(thread_page, dealer_name: str):
  435. try:
  436. body = thread_page.locator("body").inner_text(timeout=3000)
  437. except Exception:
  438. body = ""
  439. if not messenger_body_matches_customer(body, dealer_name):
  440. return None, f"Messenger thread title mismatch for {dealer_name}"
  441. locator = thread_page.locator('[role="textbox"], div[contenteditable="true"]')
  442. for idx in range(locator.count()):
  443. textbox = locator.nth(idx)
  444. try:
  445. if not textbox.is_visible(timeout=500):
  446. continue
  447. label = clean(textbox.get_attribute("aria-label"))
  448. nearby = norm(label + " " + get_element_text(textbox))
  449. if any(hint in nearby for hint in COMMENT_HINTS):
  450. return None, "Blocked: detected comment composer, not Messenger thread"
  451. box = textbox.bounding_box()
  452. if not box or box.get("y", 0) < 400:
  453. continue
  454. if dealer_name and dealer_name.casefold() in label.casefold():
  455. return textbox, ""
  456. if label or box:
  457. return textbox, ""
  458. except Exception:
  459. continue
  460. return None, "Messenger thread textbox not found"
  461. def send_dm_via_messenger_thread(page, dealer_name: str, text: str, confirm: bool, before_send_wait: tuple[int, int] = (0, 0)) -> Dict[str, Any]:
  462. result = {"action": "send_dm_thread", "clicked": False, "error": ""}
  463. thread_url = facebook_thread_url(page.url)
  464. if not thread_url:
  465. result["error"] = "Could not build Messenger thread URL from Facebook Page URL"
  466. return result
  467. if not confirm:
  468. print(f" -> dry-run: Messenger thread fallback available: {thread_url}")
  469. return result
  470. thread_page = page.context.new_page()
  471. thread_page.goto(thread_url, wait_until="domcontentloaded", timeout=60000)
  472. thread_page.wait_for_timeout(8000)
  473. thread_page.bring_to_front()
  474. textbox, error = find_messenger_thread_textbox(thread_page, dealer_name)
  475. if textbox is None:
  476. result["error"] = error
  477. return result
  478. textbox.click()
  479. try:
  480. existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
  481. except Exception:
  482. existing_text = ""
  483. if text[:80] not in existing_text:
  484. textbox.fill(text)
  485. print(" -> Message typed into Messenger thread")
  486. else:
  487. print(" -> existing drafted message detected; not inserting duplicate text")
  488. if before_send_wait != (0, 0):
  489. delay = random.uniform(*before_send_wait)
  490. print(f" -> pacing before send: {delay:.1f}s")
  491. time.sleep(delay)
  492. thread_page.keyboard.press("Enter")
  493. result["clicked"] = True
  494. print(" -> Message sent by Enter in Messenger thread")
  495. return result
  496. def send_dm(page, dealer_name: str, text: str, confirm: bool, message_open_wait: tuple[int, int] = (0, 0), before_send_wait: tuple[int, int] = (0, 0)) -> Dict[str, Any]:
  497. result = {"action": "send_dm", "clicked": False, "error": ""}
  498. try:
  499. dialog = find_matching_messenger_dialog(page, dealer_name)
  500. if dialog is None:
  501. opened = open_message_dialog(page, confirm=confirm)
  502. if opened.get("error"):
  503. print(f" ! Message button issue: {opened['error']}; trying Messenger thread fallback")
  504. fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
  505. if fallback.get("clicked"):
  506. result["clicked"] = True
  507. return result
  508. result["error"] = fallback.get("error") or opened["error"]
  509. return result
  510. if not confirm:
  511. print(" -> dry-run: no message sent")
  512. return result
  513. if message_open_wait != (0, 0):
  514. delay = random.uniform(*message_open_wait)
  515. print(f" -> pacing after message open: {delay:.1f}s")
  516. time.sleep(delay)
  517. for _ in range(10):
  518. time.sleep(0.8)
  519. dialog = find_matching_messenger_dialog(page, dealer_name)
  520. if dialog is not None:
  521. break
  522. if dialog is None:
  523. print(f" ! Messenger dialog for {dealer_name} not found; trying Messenger thread fallback")
  524. fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
  525. if fallback.get("clicked"):
  526. result["clicked"] = True
  527. return result
  528. result["error"] = fallback.get("error") or f"Messenger dialog for {dealer_name} not found or title mismatch"
  529. return result
  530. print(f" -> Messenger dialog matched: {dealer_name}")
  531. textbox, error = find_dialog_textbox(dialog)
  532. if textbox is None:
  533. result["error"] = error
  534. return result
  535. if not confirm:
  536. print(" -> dry-run: Messenger textbox found; no message typed")
  537. return result
  538. textbox.click()
  539. existing_text = ""
  540. try:
  541. existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
  542. except Exception:
  543. existing_text = ""
  544. if text[:80] not in existing_text:
  545. page.keyboard.insert_text(text)
  546. print(" -> Message typed into Messenger dialog")
  547. else:
  548. print(" -> existing drafted message detected; not inserting duplicate text")
  549. if before_send_wait != (0, 0):
  550. delay = random.uniform(*before_send_wait)
  551. print(f" -> pacing before send: {delay:.1f}s")
  552. time.sleep(delay)
  553. page.keyboard.press("Enter")
  554. result["clicked"] = True
  555. print(" -> Message sent by Enter")
  556. except Exception as exc: # pragma: no cover - browser dependent
  557. result["error"] = str(exc)
  558. return result
  559. def header_map(ws) -> Dict[str, int]:
  560. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1]) if clean(cell.value)}
  561. mapped: Dict[str, int] = {}
  562. for key, aliases in HEADER_ALIASES.items():
  563. for alias in aliases:
  564. if alias in raw:
  565. mapped[key] = raw[alias]
  566. break
  567. return mapped
  568. def update_workbook(excel_path: str, sheet_name: str, updates: List[Dict[str, str]]) -> int:
  569. wb = load_workbook(excel_path)
  570. if sheet_name not in wb.sheetnames:
  571. raise KeyError(f"Sheet not found: {sheet_name}")
  572. ws = wb[sheet_name]
  573. columns = header_map(ws)
  574. if "index" not in columns or "status" not in columns:
  575. raise RuntimeError("Workbook is missing 序号 or 建联状态 columns.")
  576. update_by_index = {str(item.get("index", "")).strip(): item for item in updates if str(item.get("index", "")).strip()}
  577. updated = 0
  578. for row in ws.iter_rows(min_row=2, values_only=False):
  579. row_index = clean(row[columns["index"]].value)
  580. if row_index not in update_by_index:
  581. continue
  582. update = update_by_index[row_index]
  583. if update.get("status"):
  584. row[columns["status"]].value = update["status"]
  585. if update.get("note_append") and "note" in columns:
  586. existing = clean(row[columns["note"]].value)
  587. separator = " | " if existing else ""
  588. row[columns["note"]].value = existing + separator + update["note_append"]
  589. updated += 1
  590. wb.save(excel_path)
  591. return updated
  592. def write_audit_log(log_path: Path, entries: List[Dict[str, Any]]) -> None:
  593. log_path.parent.mkdir(parents=True, exist_ok=True)
  594. with log_path.open("a", encoding="utf-8") as handle:
  595. for entry in entries:
  596. handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
  597. def select_english_dm(item: Dict[str, Any]) -> str:
  598. message = item.get("messages", {}).get("dm", {}).get("en", "")
  599. if not message:
  600. raise ValueError(f"Missing English DM text for {item.get('dealer_name', '(unknown)')}")
  601. return message
  602. def determine_status(action: str, friend_result: Optional[Dict[str, Any]], follow_result: Optional[Dict[str, Any]], dm_result: Optional[Dict[str, Any]]) -> tuple[str, str]:
  603. friend_ok = bool(friend_result and friend_result.get("clicked"))
  604. follow_ok = bool(follow_result and (follow_result.get("clicked") or follow_result.get("already_active")))
  605. dm_ok = bool(dm_result and dm_result.get("clicked"))
  606. if action == "friend":
  607. return (STATUS_VALUES["friend_success"], "") if friend_ok else (STATUS_VALUES["friend_failed"], friend_result.get("error", "unknown") if friend_result else "unknown")
  608. if action == "follow":
  609. return (STATUS_VALUES["follow_success"], "") if follow_ok else (STATUS_VALUES["follow_failed"], follow_result.get("error", "unknown") if follow_result else "unknown")
  610. if action == "dm":
  611. return (STATUS_VALUES["dm_success"], "") if dm_ok else (STATUS_VALUES["dm_failed"], dm_result.get("error", "unknown") if dm_result else "unknown")
  612. if action == "follow_dm":
  613. if follow_ok and dm_ok:
  614. return STATUS_VALUES["both_success"], ""
  615. if follow_ok:
  616. return STATUS_VALUES["follow_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
  617. if dm_ok:
  618. return STATUS_VALUES["dm_success"], follow_result.get("error", "follow not completed") if follow_result else "follow not completed"
  619. return STATUS_VALUES["dm_failed"], "follow and DM were not completed"
  620. if friend_ok and dm_ok:
  621. return STATUS_VALUES["both_success"], ""
  622. if friend_ok:
  623. return STATUS_VALUES["friend_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
  624. if dm_ok:
  625. return STATUS_VALUES["dm_success"], friend_result.get("error", "friend request not sent") if friend_result else "friend request not sent"
  626. return STATUS_VALUES["dm_failed"], "friend request and DM were not completed"
  627. def should_navigate(page, target_url: str, use_open_page: bool) -> bool:
  628. if not use_open_page:
  629. return True
  630. current = clean(getattr(page, "url", ""))
  631. if not current:
  632. return True
  633. return target_url.rstrip("/") not in current.rstrip("/")
  634. def find_open_page_for_target(browser, target_url: str):
  635. """Pick the most recently opened tab that already matches the target URL."""
  636. target = target_url.rstrip("/")
  637. fallback = None
  638. for context in browser.contexts:
  639. for candidate in context.pages:
  640. fallback = candidate
  641. try:
  642. current = clean(candidate.url).rstrip("/")
  643. except Exception:
  644. continue
  645. if target and target in current:
  646. fallback = candidate
  647. return fallback
  648. def main(argv: Optional[Sequence[str]] = None) -> int:
  649. parser = argparse.ArgumentParser(description="Run dry-run or confirmed English Facebook outreach in AdsPower.")
  650. parser.add_argument("--preview", required=True, help="JSON preview generated by prepare_facebook_outreach.py")
  651. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
  652. parser.add_argument("--ads-power-url", default=DEFAULT_ADS_POWER_URL, help="AdsPower local API URL")
  653. parser.add_argument("--action", choices=["friend", "follow", "dm", "both", "follow_dm"], default="follow_dm", help="Action to perform")
  654. parser.add_argument("--max-per-run", type=int, default=MAX_PER_RUN, help="Maximum customers per run. Default: 3.")
  655. parser.add_argument("--confirm", action="store_true", help="Allow real clicks/sends after the whole preview has been shown and approved")
  656. parser.add_argument("--batch-confirmed", action="store_true", help="Required with --confirm; means the full batch preview was shown in chat and approved once")
  657. parser.add_argument("--headless", action="store_true", help="Start AdsPower browser in headless mode if supported")
  658. parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open")
  659. parser.add_argument("--no-write-workbook", action="store_true", help="Do not write outreach status back to the workbook; default behavior unless --write-workbook is set")
  660. parser.add_argument("--write-workbook", action="store_true", help="Explicitly write outreach status back to the workbook")
  661. parser.add_argument("--use-open-page", action="store_true", help="Use the first currently open AdsPower page instead of creating a new tab")
  662. parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
  663. parser.add_argument("--risk-profile", choices=sorted(PACING_PROFILES), default="very_conservative", help="Pacing policy for compliant low-frequency outreach. Default: very_conservative.")
  664. parser.add_argument("--daily-follow-limit", type=int, default=DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
  665. parser.add_argument("--daily-dm-limit", type=int, default=DEFAULT_DAILY_DM_LIMIT, help="Per-profile daily DM/customer outreach cap. Default: 20.")
  666. parser.add_argument("--session-max", type=int, default=DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
  667. parser.add_argument("--dry-run-schedule", action="store_true", help="Print the pacing schedule, limits, and customer list without opening AdsPower.")
  668. args = parser.parse_args(argv)
  669. if args.auto_confirm_each:
  670. print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation via --batch-confirmed.")
  671. args.batch_confirmed = True
  672. if args.session_max < 1:
  673. raise ValueError("--session-max must be at least 1")
  674. if args.daily_follow_limit < 0 or args.daily_dm_limit < 0:
  675. raise ValueError("Daily limits cannot be negative")
  676. if args.confirm and not args.batch_confirmed:
  677. raise ValueError("Real Facebook sending requires --confirm --batch-confirmed after the full preview is shown in chat and approved.")
  678. preview_path = Path(args.preview).expanduser()
  679. if not preview_path.is_absolute():
  680. preview_path = Path.cwd() / preview_path
  681. if not preview_path.exists():
  682. raise FileNotFoundError(f"Preview file not found: {preview_path}")
  683. preview = json.loads(preview_path.read_text(encoding="utf-8"))
  684. language_policy = preview.get("language_policy", {})
  685. if language_policy.get("customer_facing") and language_policy.get("customer_facing") != "English":
  686. raise ValueError("Preview is not marked as English customer-facing. Regenerate with the updated prepare script.")
  687. all_items = preview.get("items", [])
  688. requested_count = max(0, args.max_per_run)
  689. pacing_profile = PACING_PROFILES[args.risk_profile]
  690. ledger = load_ledger(args.profile_id)
  691. active_cooldown = cooldown_active(ledger)
  692. if active_cooldown:
  693. raise RuntimeError(f"Profile {args.profile_id} is cooling down until {active_cooldown}; stop Facebook outreach for this account.")
  694. if ledger.get("risk_events"):
  695. raise RuntimeError(f"Profile {args.profile_id} has risk events today; stop Facebook outreach for this account.")
  696. if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
  697. raise RuntimeError(f"Profile {args.profile_id} reached today's failure threshold; stop Facebook outreach for this account.")
  698. allowed_count = allowed_item_count(args.action, requested_count, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
  699. items = all_items[:allowed_count]
  700. if requested_count and allowed_count < requested_count:
  701. print(f"Pacing limit reduced this run from {requested_count} to {allowed_count} customers.")
  702. schedule_preview = build_schedule_preview(items, args, ledger, pacing_profile)
  703. print(json.dumps({"schedule_preview": schedule_preview}, ensure_ascii=False, indent=2))
  704. if args.dry_run_schedule:
  705. print("Dry-run schedule only: browser execution skipped.")
  706. return 0
  707. if requested_count and not items:
  708. raise RuntimeError("No Facebook outreach capacity remains for this profile today.")
  709. source = preview.get("source", {})
  710. excel_path = source.get("excel", "")
  711. sheet_name = source.get("sheet", "Facebook")
  712. confirm_mode = bool(args.confirm)
  713. write_workbook = bool(args.write_workbook and not args.no_write_workbook)
  714. print(f"Mode: {'CONFIRMED batch execution' if confirm_mode else 'dry-run only'}")
  715. print("Customer-facing language: English")
  716. print(f"AdsPower profile: {args.profile_id}")
  717. print(f"Items: {len(items)} / {len(all_items)}")
  718. print(f"Workbook write: {'enabled' if write_workbook else 'disabled'}")
  719. print("-" * 60)
  720. client = AdsPowerClient(base_url=args.ads_power_url)
  721. audit_entries: List[Dict[str, Any]] = []
  722. status_updates: List[Dict[str, str]] = []
  723. stopped_early = False
  724. try:
  725. browser = client.start_browser(args.profile_id, headless=args.headless)
  726. page = client.get_open_page() if args.use_open_page else None
  727. page = page or client.new_page()
  728. for offset, item in enumerate(items, start=1):
  729. dm_text = select_english_dm(item)
  730. dealer_name = clean(item.get("dealer_name", ""))
  731. target_url = item.get("page_url", "")
  732. print(f"\n[{offset}/{len(items)}] {dealer_name} | {target_url}")
  733. print(f" Current status: {item.get('status', '未联系')} | Suggested action: {item.get('suggested_action', '')}")
  734. print(f" English DM preview:\n {dm_text[:500]}{'...' if len(dm_text) > 500 else ''}")
  735. if args.use_open_page:
  736. matched_page = find_open_page_for_target(browser, target_url)
  737. if matched_page is not None:
  738. page = matched_page
  739. if should_navigate(page, target_url, args.use_open_page):
  740. page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
  741. pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after page open")
  742. else:
  743. print(" -> using current open Page tab")
  744. pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after existing page focus")
  745. try:
  746. page.bring_to_front()
  747. time.sleep(1)
  748. except Exception:
  749. pass
  750. risk = detect_risk(page)
  751. if risk:
  752. print(f"\nRisk prompt detected: {risk}. Stopping immediately.")
  753. ledger_event(ledger, "risk", dealer_name, risk)
  754. save_ledger(args.profile_id, ledger)
  755. stopped_early = True
  756. break
  757. if confirm_mode:
  758. print(" -> batch preview already confirmed; executing this customer without per-row prompt")
  759. else:
  760. print(" -> dry-run: no click/send and no per-row prompt")
  761. friend_result = None
  762. follow_result = None
  763. dm_result = None
  764. if args.action in {"friend", "both"}:
  765. friend_result = click_add_friend(page, confirm=confirm_mode)
  766. if friend_result.get("error"):
  767. print(f" ! Add Friend: {friend_result['error']}")
  768. if args.action in {"follow", "follow_dm"}:
  769. follow_result = click_follow(page, confirm=confirm_mode)
  770. if follow_result.get("error"):
  771. print(f" ! Follow: {follow_result['error']}")
  772. elif confirm_mode and follow_result.get("clicked"):
  773. ledger_event(ledger, "follow", dealer_name)
  774. save_ledger(args.profile_id, ledger)
  775. pause(pacing_profile, "after_follow", confirm=confirm_mode, reason="after Follow")
  776. if args.action in {"dm", "both", "follow_dm"}:
  777. dm_result = send_dm(
  778. page,
  779. dealer_name,
  780. dm_text,
  781. confirm=confirm_mode,
  782. message_open_wait=pacing_range(pacing_profile, "after_message_open"),
  783. before_send_wait=pacing_range(pacing_profile, "before_send"),
  784. )
  785. if dm_result.get("error"):
  786. print(f" ! Message: {dm_result['error']}")
  787. elif confirm_mode and dm_result.get("clicked"):
  788. ledger_event(ledger, "dm", dealer_name)
  789. save_ledger(args.profile_id, ledger)
  790. pause(pacing_profile, "after_send", confirm=confirm_mode, reason="after DM send")
  791. new_status = ""
  792. error_note = ""
  793. if confirm_mode:
  794. new_status, error_note = determine_status(args.action, friend_result, follow_result, dm_result)
  795. status_updates.append({
  796. "index": str(item.get("index", "")),
  797. "status": new_status,
  798. "note_append": f"{now_iso()} Facebook {args.action}->{new_status}" + (f" err={error_note}" if error_note else ""),
  799. })
  800. if error_note:
  801. ledger_event(ledger, "failure", dealer_name, error_note)
  802. save_ledger(args.profile_id, ledger)
  803. audit_entries.append({
  804. "timestamp": now_iso(),
  805. "profile_id": args.profile_id,
  806. "target_url": target_url,
  807. "dealer_name": dealer_name,
  808. "action": args.action,
  809. "confirm_mode": confirm_mode,
  810. "auto_confirm_each": bool(args.auto_confirm_each),
  811. "language": "English",
  812. "new_status": new_status,
  813. "result": "dry_run" if not confirm_mode else ("success" if not error_note else "partial_or_failed"),
  814. "error": error_note,
  815. })
  816. pause(pacing_profile, "between_customers", confirm=confirm_mode, reason="between customers")
  817. if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
  818. print("Failure threshold reached. Stopping remaining customers for today.")
  819. stopped_early = True
  820. break
  821. capacity_now = remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit)
  822. if (action_needs_follow(args.action) and capacity_now["follow"] <= 0) or (action_needs_dm(args.action) and capacity_now["dm"] <= 0):
  823. print("Daily Follow/DM capacity exhausted. Stopping remaining customers.")
  824. stopped_early = True
  825. break
  826. except KeyboardInterrupt:
  827. print("\nInterrupted by user.")
  828. finally:
  829. print("Browser left open")
  830. try:
  831. if client.playwright:
  832. client.playwright.stop()
  833. except Exception:
  834. pass
  835. log_path = Path("logs") / f"facebook_social_{datetime.now().strftime('%Y%m%d')}.jsonl"
  836. if audit_entries:
  837. write_audit_log(log_path, audit_entries)
  838. print(f"\nAudit log written: {log_path}")
  839. save_ledger(args.profile_id, ledger)
  840. if args.confirm:
  841. cooldown = sample_wait(pacing_profile, "session_cooldown")
  842. print(f"Session cooldown recommendation: {cooldown / 60:.1f} minutes before the next Facebook outreach run.")
  843. if confirm_mode and status_updates and write_workbook:
  844. if not excel_path:
  845. backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
  846. backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
  847. print(f"Workbook path missing in preview. Status updates saved to: {backup}")
  848. else:
  849. try:
  850. updated = update_workbook(excel_path, sheet_name, status_updates)
  851. print(f"Workbook updated: {updated} rows ({excel_path})")
  852. except Exception as exc:
  853. backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
  854. backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
  855. print(f"Workbook update failed: {exc}")
  856. print(f"Status updates saved to: {backup}")
  857. if confirm_mode and status_updates and not write_workbook:
  858. print("Workbook update skipped")
  859. print("\nDone.")
  860. if stopped_early:
  861. print("Stopped early because a Facebook risk prompt was detected.")
  862. return 0
  863. if __name__ == "__main__":
  864. raise SystemExit(main())