run_facebook_follow_dm.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Run the Facebook Page Follow + Messenger DM workflow.
  4. This is the module-2 orchestration entrypoint:
  5. 1. Read the Facebook sheet and generate an English outreach preview.
  6. 2. Run the AdsPower/Playwright Follow + Messenger DM executor.
  7. 3. Print the full human-readable preview before any real action.
  8. Real browser actions require --confirm-send, which passes one batch-level
  9. confirmation to the executor. Workbook updates require --write-workbook. The
  10. AdsPower browser is always kept open.
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import importlib.util
  15. import json
  16. import sys
  17. from datetime import datetime
  18. from pathlib import Path
  19. from typing import Any, List, Optional, Sequence
  20. sys.path.append(str(Path(__file__).resolve().parents[1]))
  21. from common.artifact_manager import resolve_artifact_path
  22. SCRIPT_DIR = Path(__file__).resolve().parent
  23. SKILL_ROOT = SCRIPT_DIR.parents[1]
  24. PROJECT_ROOT = Path.cwd()
  25. def load_module(name: str, path: Path):
  26. spec = importlib.util.spec_from_file_location(name, path)
  27. if spec is None or spec.loader is None:
  28. raise ImportError(f"Cannot load module: {path}")
  29. module = importlib.util.module_from_spec(spec)
  30. spec.loader.exec_module(module)
  31. return module
  32. prepare_mod = load_module("prepare_facebook_outreach", SCRIPT_DIR / "prepare_facebook_outreach.py")
  33. send_mod = load_module("send_facebook_outreach", SCRIPT_DIR / "send_facebook_outreach.py")
  34. def timestamp() -> str:
  35. return datetime.now().strftime("%Y%m%d_%H%M%S")
  36. def resolve_output_path(raw: str, output_dir: str, run_id: str = "") -> Path:
  37. if output_dir and not raw:
  38. base = Path(output_dir).expanduser()
  39. if not base.is_absolute():
  40. base = Path.cwd() / base
  41. return base / f"facebook_follow_dm_preview_{timestamp()}.json"
  42. return resolve_artifact_path(
  43. raw,
  44. kind="facebook_follow_dm",
  45. default_name=f"facebook_follow_dm_preview_{timestamp()}.json",
  46. run_id=run_id or None,
  47. )
  48. def build_prepare_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
  49. prepare_args: List[str] = [
  50. "--sheet", args.sheet,
  51. "--output", str(preview_path),
  52. ]
  53. if args.excel:
  54. prepare_args.extend(["--excel", args.excel])
  55. for item in args.filter:
  56. prepare_args.extend(["--filter", item])
  57. if args.max_contacts > 0:
  58. prepare_args.extend(["--sample", str(args.max_contacts)])
  59. if args.include_sent:
  60. prepare_args.append("--include-sent")
  61. if args.seed is not None:
  62. prepare_args.extend(["--seed", str(args.seed)])
  63. return prepare_args
  64. def build_send_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
  65. send_args: List[str] = [
  66. "--preview", str(preview_path),
  67. "--profile-id", args.profile_id,
  68. "--ads-power-url", args.ads_power_url,
  69. "--action", "follow_dm",
  70. "--max-per-run", str(args.max_contacts),
  71. "--risk-profile", args.risk_profile,
  72. "--daily-follow-limit", str(args.daily_follow_limit),
  73. "--daily-dm-limit", str(args.daily_dm_limit),
  74. "--session-max", str(args.session_max),
  75. ]
  76. if args.confirm_send:
  77. send_args.extend(["--confirm", "--batch-confirmed"])
  78. if args.use_open_page:
  79. send_args.append("--use-open-page")
  80. send_args.append("--keep-browser-open")
  81. if args.dry_run_schedule:
  82. send_args.append("--dry-run-schedule")
  83. if args.write_workbook:
  84. send_args.append("--write-workbook")
  85. else:
  86. send_args.append("--no-write-workbook")
  87. return send_args
  88. def read_preview_summary(preview_path: Path) -> dict[str, Any]:
  89. data = json.loads(preview_path.read_text(encoding="utf-8"))
  90. return data.get("summary", {})
  91. def render_human_preview(preview_path: Path, max_chars: int = 16000) -> str:
  92. data = json.loads(preview_path.read_text(encoding="utf-8"))
  93. lines: List[str] = []
  94. summary = data.get("summary", {})
  95. lines.append("# Facebook Follow + Messenger DM 预览")
  96. lines.append(f"准备发送:{summary.get('ready_to_send', 0)} 条;跳过:{summary.get('skipped', 0)} 条")
  97. lines.append("")
  98. for idx, item in enumerate(data.get("items", []), start=1):
  99. fp = item.get("formatted_preview", {}) or {}
  100. lines.append(f"## {idx}. {item.get('dealer_name', '')}")
  101. lines.append(f"主页:{item.get('page_url', '')}")
  102. lines.append(f"客户判断:{fp.get('客户判断') or item.get('customer_judgment_cn', '')}")
  103. lines.append(f"推荐切入点:{fp.get('推荐切入点') or item.get('outreach_angle_cn', '')}")
  104. lines.append("英文首轮私信:")
  105. lines.append(item.get("recommended_message") or item.get("english_first_dm", ""))
  106. lines.append(f"风险提示:{fp.get('风险提示') or item.get('risk_note', '')}")
  107. lines.append("")
  108. rendered = "\n".join(lines).strip()
  109. if len(rendered) > max_chars:
  110. rendered = rendered[:max_chars] + "\n\n[预览过长,已截断;完整 JSON 见输出文件]"
  111. return rendered
  112. def build_schedule_preview(preview_path: Path, args: argparse.Namespace) -> dict[str, Any]:
  113. data = json.loads(preview_path.read_text(encoding="utf-8"))
  114. all_items = data.get("items", [])
  115. ledger = send_mod.load_ledger(args.profile_id)
  116. profile = send_mod.PACING_PROFILES[args.risk_profile]
  117. requested = max(0, args.max_contacts)
  118. allowed = send_mod.allowed_item_count("follow_dm", requested, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
  119. items = all_items[:allowed]
  120. return send_mod.build_schedule_preview(items, argparse.Namespace(
  121. confirm=args.confirm_send,
  122. risk_profile=args.risk_profile,
  123. profile_id=args.profile_id,
  124. daily_follow_limit=args.daily_follow_limit,
  125. daily_dm_limit=args.daily_dm_limit,
  126. session_max=args.session_max,
  127. action="follow_dm",
  128. ), ledger, profile)
  129. def render_schedule_markdown(schedule: dict[str, Any]) -> str:
  130. sched = schedule.get("schedule", {})
  131. used = schedule.get("today_used", {})
  132. remaining = schedule.get("today_remaining", {})
  133. limits = schedule.get("limits", {})
  134. lines = [
  135. "# Facebook 合规执行节奏预览",
  136. f"风险档位:{schedule.get('risk_profile')};账号:{schedule.get('profile_id')}",
  137. f"本轮客户:{sched.get('customers', 0)};预计耗时:{sched.get('estimated_minutes_min', 0)}-{sched.get('estimated_minutes_max', 0)} 分钟",
  138. f"今日已用:Follow {used.get('follow', 0)} / DM {used.get('dm', 0)} / 失败 {used.get('failures', 0)} / 风险 {used.get('risk_events', 0)}",
  139. f"今日剩余:Follow {remaining.get('follow', 0)} / DM {remaining.get('dm', 0)};单轮上限:{limits.get('session_max', 0)}",
  140. "等待分层:大层级 major=90-200s;小层级 minor=30-90s;技术等待 technical=0.5-8s,只用于 DOM/元素检测,不计入对外行为节奏。",
  141. "停止规则:出现验证、限流、异常活动、身份确认或失败阈值达到时立即停止,不关闭浏览器。",
  142. "",
  143. "阶段等待:",
  144. ]
  145. stages = sched.get("stages", {}) or {}
  146. stage_tiers = sched.get("stage_tiers", {}) or {}
  147. for stage, wait_range in stages.items():
  148. tier = stage_tiers.get(stage, "technical")
  149. if isinstance(wait_range, (list, tuple)) and len(wait_range) == 2:
  150. range_text = f"{wait_range[0]}-{wait_range[1]}s"
  151. else:
  152. range_text = str(wait_range)
  153. lines.append(f"- {stage}: {tier} / {range_text}")
  154. lines.append("")
  155. for idx, item in enumerate(schedule.get("customers", []), start=1):
  156. lines.append(f"{idx}. {item.get('dealer_name', '')} - {item.get('page_url', '')}")
  157. return "\n".join(lines)
  158. def main(argv: Optional[Sequence[str]] = None) -> int:
  159. parser = argparse.ArgumentParser(
  160. description="Generate Facebook outreach preview, then run Follow + Messenger DM through AdsPower/Playwright."
  161. )
  162. parser.add_argument("--excel", default="", help="Customer outreach workbook path. If omitted, use the workbook resolver.")
  163. parser.add_argument("--sheet", default="Facebook", help="Source sheet name. Default: Facebook.")
  164. parser.add_argument("--filter", action="append", default=[], help="Filter condition field=value. Can repeat.")
  165. parser.add_argument("--profile-id", required=True, help="AdsPower profile ID already logged into Facebook.")
  166. parser.add_argument("--ads-power-url", default=send_mod.DEFAULT_ADS_POWER_URL, help="AdsPower local API URL.")
  167. parser.add_argument("--max-contacts", type=int, default=3, help="Maximum customers for this run. Default: 3.")
  168. parser.add_argument("--output", default="", help="Preview JSON output path. Default: previews/facebook_follow_dm_preview_<time>.json.")
  169. parser.add_argument("--output-dir", default="", help="Preview directory when --output is omitted. Default uses runs/YYYYMMDD/<run_id>/.")
  170. parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
  171. parser.add_argument("--seed", type=int, default=None, help="Random seed for sampling.")
  172. parser.add_argument("--include-sent", action="store_true", help="Include already-contacted rows for follow-up testing.")
  173. parser.add_argument("--review-only", action="store_true", help="Only generate preview, do not connect to AdsPower.")
  174. parser.add_argument("--confirm-send", action="store_true", help="Allow real Follow + Messenger DM after the full preview is shown in chat and approved once.")
  175. parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
  176. parser.add_argument("--use-open-page", action="store_true", help="Use an already opened matching Facebook Page tab when possible.")
  177. parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open.")
  178. parser.add_argument("--write-workbook", action="store_true", help="Write status back to workbook after confirmed execution.")
  179. parser.add_argument("--risk-profile", choices=sorted(send_mod.PACING_PROFILES), default="very_conservative", help="Pacing policy for compliant low-frequency outreach. Default: very_conservative.")
  180. parser.add_argument("--daily-follow-limit", type=int, default=send_mod.DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
  181. parser.add_argument("--daily-dm-limit", type=int, default=send_mod.DEFAULT_DAILY_DM_LIMIT, help="Per-profile daily DM/customer outreach cap. Default: 20.")
  182. parser.add_argument("--session-max", type=int, default=send_mod.DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
  183. parser.add_argument("--dry-run-schedule", action="store_true", help="Print pacing schedule and customer list without opening AdsPower.")
  184. args = parser.parse_args(argv)
  185. if args.max_contacts < 1:
  186. raise ValueError("--max-contacts must be at least 1")
  187. if args.max_contacts > 3 and not args.confirm_send:
  188. print("Warning: dry-run batch is above the recommended 3 contacts for Facebook outreach.")
  189. if args.auto_confirm_each:
  190. print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation after the chat preview.")
  191. preview_path = resolve_output_path(args.output, args.output_dir, args.run_id)
  192. preview_path.parent.mkdir(parents=True, exist_ok=True)
  193. print("Step 1/2: generating Facebook outreach preview...")
  194. prepare_rc = prepare_mod.main(build_prepare_args(args, preview_path))
  195. if prepare_rc != 0:
  196. return int(prepare_rc)
  197. summary = read_preview_summary(preview_path)
  198. print(json.dumps({"preview": str(preview_path), "summary": summary}, ensure_ascii=False, indent=2))
  199. print("\n" + render_human_preview(preview_path) + "\n")
  200. schedule = build_schedule_preview(preview_path, args)
  201. print("\n" + render_schedule_markdown(schedule) + "\n")
  202. if args.review_only:
  203. print("Review-only mode: browser execution skipped.")
  204. return 0
  205. if args.dry_run_schedule:
  206. print("Schedule-only mode: browser execution skipped.")
  207. return send_mod.main(build_send_args(args, preview_path))
  208. if not args.confirm_send:
  209. print("Step 2/2: dry-run only. Add --confirm-send for real Follow + Messenger DM.")
  210. else:
  211. print("Step 2/2: confirmed Follow + Messenger DM execution.")
  212. send_rc = send_mod.main(build_send_args(args, preview_path))
  213. return int(send_rc or 0)
  214. if __name__ == "__main__":
  215. raise SystemExit(main())