send_facebook_outreach.py 44 KB

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