| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """Run the Facebook Page Follow + Messenger DM workflow.
- This is the module-2 orchestration entrypoint:
- 1. Read the Facebook sheet and generate an English outreach preview.
- 2. Run the AdsPower/Playwright Follow + Messenger DM executor.
- 3. Print the full human-readable preview before any real action.
- Real browser actions require --confirm-send, which passes one batch-level
- confirmation to the executor. Workbook updates require --write-workbook. The
- AdsPower browser is always kept open.
- """
- from __future__ import annotations
- import argparse
- import importlib.util
- import json
- import sys
- from datetime import datetime
- from pathlib import Path
- from typing import Any, List, Optional, Sequence
- sys.path.append(str(Path(__file__).resolve().parents[1]))
- from common.artifact_manager import resolve_artifact_path
- SCRIPT_DIR = Path(__file__).resolve().parent
- SKILL_ROOT = SCRIPT_DIR.parents[1]
- PROJECT_ROOT = Path.cwd()
- def load_module(name: str, path: Path):
- spec = importlib.util.spec_from_file_location(name, path)
- if spec is None or spec.loader is None:
- raise ImportError(f"Cannot load module: {path}")
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
- prepare_mod = load_module("prepare_facebook_outreach", SCRIPT_DIR / "prepare_facebook_outreach.py")
- send_mod = load_module("send_facebook_outreach", SCRIPT_DIR / "send_facebook_outreach.py")
- def timestamp() -> str:
- return datetime.now().strftime("%Y%m%d_%H%M%S")
- def resolve_output_path(raw: str, output_dir: str, run_id: str = "") -> Path:
- if output_dir and not raw:
- base = Path(output_dir).expanduser()
- if not base.is_absolute():
- base = Path.cwd() / base
- return base / f"facebook_follow_dm_preview_{timestamp()}.json"
- return resolve_artifact_path(
- raw,
- kind="facebook_follow_dm",
- default_name=f"facebook_follow_dm_preview_{timestamp()}.json",
- run_id=run_id or None,
- )
- def build_prepare_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
- prepare_args: List[str] = [
- "--sheet", args.sheet,
- "--output", str(preview_path),
- ]
- if args.excel:
- prepare_args.extend(["--excel", args.excel])
- for item in args.filter:
- prepare_args.extend(["--filter", item])
- if args.max_contacts > 0:
- prepare_args.extend(["--sample", str(args.max_contacts)])
- if args.include_sent:
- prepare_args.append("--include-sent")
- if args.seed is not None:
- prepare_args.extend(["--seed", str(args.seed)])
- return prepare_args
- def build_send_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
- send_args: List[str] = [
- "--preview", str(preview_path),
- "--profile-id", args.profile_id,
- "--ads-power-url", args.ads_power_url,
- "--action", "follow_dm",
- "--max-per-run", str(args.max_contacts),
- "--risk-profile", args.risk_profile,
- "--daily-follow-limit", str(args.daily_follow_limit),
- "--daily-dm-limit", str(args.daily_dm_limit),
- "--session-max", str(args.session_max),
- ]
- if args.confirm_send:
- send_args.extend(["--confirm", "--batch-confirmed"])
- if args.use_open_page:
- send_args.append("--use-open-page")
- send_args.append("--keep-browser-open")
- if args.dry_run_schedule:
- send_args.append("--dry-run-schedule")
- if args.write_workbook:
- send_args.append("--write-workbook")
- else:
- send_args.append("--no-write-workbook")
- return send_args
- def read_preview_summary(preview_path: Path) -> dict[str, Any]:
- data = json.loads(preview_path.read_text(encoding="utf-8"))
- return data.get("summary", {})
- def render_human_preview(preview_path: Path, max_chars: int = 16000) -> str:
- data = json.loads(preview_path.read_text(encoding="utf-8"))
- lines: List[str] = []
- summary = data.get("summary", {})
- lines.append("# Facebook Follow + Messenger DM 预览")
- lines.append(f"准备发送:{summary.get('ready_to_send', 0)} 条;跳过:{summary.get('skipped', 0)} 条")
- lines.append("")
- for idx, item in enumerate(data.get("items", []), start=1):
- fp = item.get("formatted_preview", {}) or {}
- lines.append(f"## {idx}. {item.get('dealer_name', '')}")
- lines.append(f"主页:{item.get('page_url', '')}")
- lines.append(f"客户判断:{fp.get('客户判断') or item.get('customer_judgment_cn', '')}")
- lines.append(f"推荐切入点:{fp.get('推荐切入点') or item.get('outreach_angle_cn', '')}")
- lines.append("英文首轮私信:")
- lines.append(item.get("recommended_message") or item.get("english_first_dm", ""))
- lines.append(f"风险提示:{fp.get('风险提示') or item.get('risk_note', '')}")
- lines.append("")
- rendered = "\n".join(lines).strip()
- if len(rendered) > max_chars:
- rendered = rendered[:max_chars] + "\n\n[预览过长,已截断;完整 JSON 见输出文件]"
- return rendered
- def build_schedule_preview(preview_path: Path, args: argparse.Namespace) -> dict[str, Any]:
- data = json.loads(preview_path.read_text(encoding="utf-8"))
- all_items = data.get("items", [])
- ledger = send_mod.load_ledger(args.profile_id)
- profile = send_mod.PACING_PROFILES[args.risk_profile]
- requested = max(0, args.max_contacts)
- allowed = send_mod.allowed_item_count("follow_dm", requested, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
- items = all_items[:allowed]
- return send_mod.build_schedule_preview(items, argparse.Namespace(
- confirm=args.confirm_send,
- risk_profile=args.risk_profile,
- profile_id=args.profile_id,
- daily_follow_limit=args.daily_follow_limit,
- daily_dm_limit=args.daily_dm_limit,
- session_max=args.session_max,
- action="follow_dm",
- ), ledger, profile)
- def render_schedule_markdown(schedule: dict[str, Any]) -> str:
- sched = schedule.get("schedule", {})
- used = schedule.get("today_used", {})
- remaining = schedule.get("today_remaining", {})
- limits = schedule.get("limits", {})
- lines = [
- "# Facebook 合规执行节奏预览",
- f"风险档位:{schedule.get('risk_profile')};账号:{schedule.get('profile_id')}",
- f"本轮客户:{sched.get('customers', 0)};预计耗时:{sched.get('estimated_minutes_min', 0)}-{sched.get('estimated_minutes_max', 0)} 分钟",
- f"今日已用:Follow {used.get('follow', 0)} / DM {used.get('dm', 0)} / 失败 {used.get('failures', 0)} / 风险 {used.get('risk_events', 0)}",
- f"今日剩余:Follow {remaining.get('follow', 0)} / DM {remaining.get('dm', 0)};单轮上限:{limits.get('session_max', 0)}",
- "等待分层:大层级 major=90-200s;小层级 minor=30-90s;技术等待 technical=0.5-8s,只用于 DOM/元素检测,不计入对外行为节奏。",
- "停止规则:出现验证、限流、异常活动、身份确认或失败阈值达到时立即停止,不关闭浏览器。",
- "",
- "阶段等待:",
- ]
- stages = sched.get("stages", {}) or {}
- stage_tiers = sched.get("stage_tiers", {}) or {}
- for stage, wait_range in stages.items():
- tier = stage_tiers.get(stage, "technical")
- if isinstance(wait_range, (list, tuple)) and len(wait_range) == 2:
- range_text = f"{wait_range[0]}-{wait_range[1]}s"
- else:
- range_text = str(wait_range)
- lines.append(f"- {stage}: {tier} / {range_text}")
- lines.append("")
- for idx, item in enumerate(schedule.get("customers", []), start=1):
- lines.append(f"{idx}. {item.get('dealer_name', '')} - {item.get('page_url', '')}")
- return "\n".join(lines)
- def main(argv: Optional[Sequence[str]] = None) -> int:
- parser = argparse.ArgumentParser(
- description="Generate Facebook outreach preview, then run Follow + Messenger DM through AdsPower/Playwright."
- )
- parser.add_argument("--excel", default="", help="Customer outreach workbook path. If omitted, use the workbook resolver.")
- parser.add_argument("--sheet", default="Facebook", help="Source sheet name. Default: Facebook.")
- parser.add_argument("--filter", action="append", default=[], help="Filter condition field=value. Can repeat.")
- parser.add_argument("--profile-id", required=True, help="AdsPower profile ID already logged into Facebook.")
- parser.add_argument("--ads-power-url", default=send_mod.DEFAULT_ADS_POWER_URL, help="AdsPower local API URL.")
- parser.add_argument("--max-contacts", type=int, default=3, help="Maximum customers for this run. Default: 3.")
- parser.add_argument("--output", default="", help="Preview JSON output path. Default: previews/facebook_follow_dm_preview_<time>.json.")
- parser.add_argument("--output-dir", default="", help="Preview directory when --output is omitted. Default uses runs/YYYYMMDD/<run_id>/.")
- parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
- parser.add_argument("--seed", type=int, default=None, help="Random seed for sampling.")
- parser.add_argument("--include-sent", action="store_true", help="Include already-contacted rows for follow-up testing.")
- parser.add_argument("--review-only", action="store_true", help="Only generate preview, do not connect to AdsPower.")
- 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.")
- parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
- parser.add_argument("--use-open-page", action="store_true", help="Use an already opened matching Facebook Page tab when possible.")
- parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open.")
- parser.add_argument("--write-workbook", action="store_true", help="Write status back to workbook after confirmed execution.")
- 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.")
- parser.add_argument("--daily-follow-limit", type=int, default=send_mod.DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
- 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.")
- parser.add_argument("--session-max", type=int, default=send_mod.DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
- parser.add_argument("--dry-run-schedule", action="store_true", help="Print pacing schedule and customer list without opening AdsPower.")
- args = parser.parse_args(argv)
- if args.max_contacts < 1:
- raise ValueError("--max-contacts must be at least 1")
- if args.max_contacts > 3 and not args.confirm_send:
- print("Warning: dry-run batch is above the recommended 3 contacts for Facebook outreach.")
- if args.auto_confirm_each:
- print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation after the chat preview.")
- preview_path = resolve_output_path(args.output, args.output_dir, args.run_id)
- preview_path.parent.mkdir(parents=True, exist_ok=True)
- print("Step 1/2: generating Facebook outreach preview...")
- prepare_rc = prepare_mod.main(build_prepare_args(args, preview_path))
- if prepare_rc != 0:
- return int(prepare_rc)
- summary = read_preview_summary(preview_path)
- print(json.dumps({"preview": str(preview_path), "summary": summary}, ensure_ascii=False, indent=2))
- print("\n" + render_human_preview(preview_path) + "\n")
- schedule = build_schedule_preview(preview_path, args)
- print("\n" + render_schedule_markdown(schedule) + "\n")
- if args.review_only:
- print("Review-only mode: browser execution skipped.")
- return 0
- if args.dry_run_schedule:
- print("Schedule-only mode: browser execution skipped.")
- return send_mod.main(build_send_args(args, preview_path))
- if not args.confirm_send:
- print("Step 2/2: dry-run only. Add --confirm-send for real Follow + Messenger DM.")
- else:
- print("Step 2/2: confirmed Follow + Messenger DM execution.")
- send_rc = send_mod.main(build_send_args(args, preview_path))
- return int(send_rc or 0)
- if __name__ == "__main__":
- raise SystemExit(main())
|