| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """Semi-automated Facebook follow/message execution through AdsPower.
- Default behavior is dry-run. Real execution requires --confirm plus
- --batch-confirmed after the whole preview has been shown to the user. Messenger
- sending is deliberately scoped to the Facebook Page header Message button and
- the matching Messenger dialog only.
- """
- from __future__ import annotations
- import argparse
- import json
- import random
- import re
- import sys
- import time
- from datetime import datetime, timedelta
- from math import ceil
- from pathlib import Path
- import sys
- sys.path.append(str(Path(__file__).resolve().parents[1]))
- from common.artifact_manager import resolve_artifact_path
- from typing import Any, Dict, List, Optional, Sequence
- from openpyxl import load_workbook
- SCRIPT_DIR = Path(__file__).resolve().parent
- SKILL_ROOT = SCRIPT_DIR.parents[1]
- sys.path.insert(0, str(SKILL_ROOT))
- from scripts.scraper.ads_power_client import AdsPowerClient # noqa: E402
- DEFAULT_ADS_POWER_URL = "http://127.0.0.1:50325"
- MAX_PER_RUN = 3
- DEFAULT_DAILY_FOLLOW_LIMIT = 20
- DEFAULT_DAILY_DM_LIMIT = 20
- DEFAULT_SESSION_MAX = 3
- FAILURE_STOP_THRESHOLD = 3
- DEFAULT_RISK_COOLDOWN_MINUTES = 24 * 60
- WAIT_TIERS = {
- "major": (90, 200),
- "minor": (30, 90),
- "technical": (0.5, 8),
- }
- PACING_STAGE_TIERS = {
- "page_open": "major",
- "after_follow": "major",
- "after_send": "major",
- "between_customers": "major",
- "session_cooldown": "major",
- "after_message_open": "minor",
- "before_send": "minor",
- }
- PACING_PROFILES = {
- "very_conservative": {
- "page_open": WAIT_TIERS["major"],
- "after_follow": WAIT_TIERS["major"],
- "after_message_open": WAIT_TIERS["minor"],
- "before_send": WAIT_TIERS["minor"],
- "after_send": WAIT_TIERS["major"],
- "between_customers": WAIT_TIERS["major"],
- "session_cooldown": (1200, 3600),
- },
- "conservative": {
- "page_open": WAIT_TIERS["major"],
- "after_follow": WAIT_TIERS["major"],
- "after_message_open": WAIT_TIERS["minor"],
- "before_send": WAIT_TIERS["minor"],
- "after_send": WAIT_TIERS["major"],
- "between_customers": WAIT_TIERS["major"],
- "session_cooldown": (600, 1800),
- },
- "standard": {
- "page_open": WAIT_TIERS["major"],
- "after_follow": WAIT_TIERS["major"],
- "after_message_open": WAIT_TIERS["minor"],
- "before_send": WAIT_TIERS["minor"],
- "after_send": WAIT_TIERS["major"],
- "between_customers": WAIT_TIERS["major"],
- "session_cooldown": (300, 900),
- },
- }
- RISK_STOP_PHRASES = [
- "we limit",
- "you are temporarily blocked",
- "you're temporarily blocked",
- "action blocked",
- "confirm your identity",
- "suspicious activity",
- "temporarily unavailable",
- "identity confirmation",
- "verification required",
- ]
- STATUS_VALUES = {
- "friend_success": "已加好友,待私信",
- "follow_success": "已关注,待私信",
- "dm_success": "已发私信",
- "both_success": "已关注,已发私信",
- "friend_failed": "加好友失败",
- "follow_failed": "关注失败",
- "dm_failed": "发送失败",
- }
- HEADER_ALIASES = {
- "index": ["序号", "Index", "No."],
- "status": ["建联状态", "建联情况", "状态", "Status"],
- "note": ["备注", "说明", "Notes"],
- }
- COMMENT_HINTS = ["comment", "留言", "reply", "回覆", "回复", "write a comment", "撰寫留言"]
- MESSAGE_BUTTON_RE = re.compile(r"(^|\s)(message|訊息)(\s|$)|send message|發送訊息|发送讯息|发送消息", re.I)
- FOLLOW_BUTTON_RE = re.compile(r"(^|\s)(follow|追蹤|关注)(\s|$)", re.I)
- FOLLOWING_BUTTON_RE = re.compile(r"following|追蹤中|已关注|已追蹤", re.I)
- def now_iso() -> str:
- return datetime.now().isoformat(timespec="seconds")
- def today_key() -> str:
- return datetime.now().strftime("%Y%m%d")
- def ledger_path(profile_id: str) -> Path:
- safe_profile = re.sub(r"[^0-9A-Za-z_.-]+", "_", profile_id.strip() or "unknown")
- return Path("logs") / f"facebook_account_ledger_{safe_profile}_{today_key()}.json"
- def empty_ledger(profile_id: str) -> Dict[str, Any]:
- return {
- "profile_id": profile_id,
- "date": today_key(),
- "follow_count": 0,
- "dm_count": 0,
- "failure_count": 0,
- "risk_events": [],
- "events": [],
- "last_run_at": "",
- "cooldown_until": "",
- }
- def load_ledger(profile_id: str) -> Dict[str, Any]:
- path = ledger_path(profile_id)
- if not path.exists():
- return empty_ledger(profile_id)
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except Exception:
- data = empty_ledger(profile_id)
- defaults = empty_ledger(profile_id)
- for key, value in defaults.items():
- data.setdefault(key, value)
- return data
- def save_ledger(profile_id: str, ledger: Dict[str, Any]) -> None:
- path = ledger_path(profile_id)
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(ledger, ensure_ascii=False, indent=2), encoding="utf-8")
- def ledger_event(ledger: Dict[str, Any], event_type: str, dealer_name: str = "", detail: str = "") -> None:
- ledger["last_run_at"] = now_iso()
- ledger.setdefault("events", []).append({
- "timestamp": now_iso(),
- "type": event_type,
- "dealer_name": dealer_name,
- "detail": detail,
- })
- if event_type == "follow":
- ledger["follow_count"] = int(ledger.get("follow_count", 0)) + 1
- elif event_type == "dm":
- ledger["dm_count"] = int(ledger.get("dm_count", 0)) + 1
- elif event_type == "failure":
- ledger["failure_count"] = int(ledger.get("failure_count", 0)) + 1
- elif event_type == "risk":
- ledger.setdefault("risk_events", []).append({"timestamp": now_iso(), "dealer_name": dealer_name, "detail": detail})
- ledger["cooldown_until"] = (datetime.now() + timedelta(minutes=DEFAULT_RISK_COOLDOWN_MINUTES)).isoformat(timespec="seconds")
- def cooldown_active(ledger: Dict[str, Any]) -> str:
- raw = clean(ledger.get("cooldown_until", ""))
- if not raw:
- return ""
- try:
- until = datetime.fromisoformat(raw)
- except ValueError:
- return ""
- if until > datetime.now():
- return raw
- return ""
- def action_needs_follow(action: str) -> bool:
- return action in {"follow", "follow_dm"}
- def action_needs_dm(action: str) -> bool:
- return action in {"dm", "both", "follow_dm"}
- def remaining_capacity(ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int) -> Dict[str, int]:
- return {
- "follow": max(0, daily_follow_limit - int(ledger.get("follow_count", 0))),
- "dm": max(0, daily_dm_limit - int(ledger.get("dm_count", 0))),
- }
- def allowed_item_count(action: str, requested: int, ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int, session_max: int) -> int:
- capacity = remaining_capacity(ledger, daily_follow_limit, daily_dm_limit)
- limits = [requested, session_max]
- if action_needs_follow(action):
- limits.append(capacity["follow"])
- if action_needs_dm(action):
- limits.append(capacity["dm"])
- return max(0, min(limits))
- def pacing_range(profile: Dict[str, tuple[int, int]], stage: str) -> tuple[int, int]:
- return profile.get(stage, (10, 20))
- def pacing_stage_tier(stage: str) -> str:
- return PACING_STAGE_TIERS.get(stage, "technical")
- def sample_wait(profile: Dict[str, tuple[int, int]], stage: str) -> float:
- low, high = pacing_range(profile, stage)
- return random.uniform(low, high)
- def pause(profile: Dict[str, tuple[int, int]], stage: str, confirm: bool, reason: str) -> float:
- wait = sample_wait(profile, stage)
- print(f" -> pacing {reason}: {wait:.1f}s")
- if confirm:
- time.sleep(wait)
- return wait
- def estimate_schedule(items: List[Dict[str, Any]], action: str, profile: Dict[str, tuple[int, int]]) -> Dict[str, Any]:
- stages = ["page_open", "between_customers"]
- if action_needs_follow(action):
- stages.append("after_follow")
- if action_needs_dm(action):
- stages.extend(["after_message_open", "before_send", "after_send"])
- per_customer_min = sum(pacing_range(profile, stage)[0] for stage in stages)
- per_customer_max = sum(pacing_range(profile, stage)[1] for stage in stages)
- cooldown = pacing_range(profile, "session_cooldown")
- return {
- "customers": len(items),
- "estimated_seconds_min": per_customer_min * len(items) + cooldown[0],
- "estimated_seconds_max": per_customer_max * len(items) + cooldown[1],
- "estimated_minutes_min": ceil((per_customer_min * len(items) + cooldown[0]) / 60),
- "estimated_minutes_max": ceil((per_customer_max * len(items) + cooldown[1]) / 60),
- "stages": {stage: pacing_range(profile, stage) for stage in stages + ["session_cooldown"]},
- "stage_tiers": {stage: pacing_stage_tier(stage) for stage in stages + ["session_cooldown"]},
- "tier_ranges": WAIT_TIERS,
- }
- 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]:
- return {
- "mode": "confirmed" if args.confirm else "dry_run",
- "risk_profile": args.risk_profile,
- "profile_id": args.profile_id,
- "today_used": {
- "follow": int(ledger.get("follow_count", 0)),
- "dm": int(ledger.get("dm_count", 0)),
- "failures": int(ledger.get("failure_count", 0)),
- "risk_events": len(ledger.get("risk_events", [])),
- },
- "today_remaining": remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit),
- "limits": {
- "daily_follow_limit": args.daily_follow_limit,
- "daily_dm_limit": args.daily_dm_limit,
- "session_max": args.session_max,
- "failure_stop_threshold": FAILURE_STOP_THRESHOLD,
- },
- "schedule": estimate_schedule(items, args.action, profile),
- "customers": [{"dealer_name": item.get("dealer_name", ""), "page_url": item.get("page_url", "")} for item in items],
- "stop_rules": [
- "Stop immediately on Facebook verification, temporary block, suspicious activity, or identity prompt.",
- "Stop when the account daily Follow/DM limit is exhausted.",
- "Stop when failure count reaches threshold.",
- ],
- }
- def detect_risk(page) -> str:
- try:
- text = page.content().casefold()
- except Exception:
- return ""
- for phrase in RISK_STOP_PHRASES:
- if phrase.casefold() in text:
- return phrase
- return ""
- def clean(value: Any) -> str:
- return "" if value is None else str(value).strip()
- def norm(value: str) -> str:
- return re.sub(r"\s+", " ", clean(value)).casefold()
- def name_tokens(name: str) -> List[str]:
- return [part for part in re.split(r"[^0-9a-zA-Z]+", name.casefold()) if len(part) >= 3]
- def get_element_text(locator) -> str:
- try:
- return clean(locator.inner_text(timeout=1000))
- except Exception:
- try:
- return clean(locator.get_attribute("aria-label"))
- except Exception:
- return ""
- def first_visible(locator, timeout_ms: int = 1800):
- try:
- count = locator.count() if locator else 0
- for idx in range(count):
- item = locator.nth(idx)
- if item.is_visible(timeout=timeout_ms):
- return item
- except Exception:
- return None
- return None
- def find_page_header_button(page, include_re: re.Pattern, exclude_re: Optional[re.Pattern] = None):
- """Find a visible action button in the Page header, not a post/comment area."""
- try:
- viewport = page.viewport_size or {"width": 1600, "height": 1000}
- max_y = viewport["height"] * 0.76
- min_y = 72
- min_x = viewport["width"] * 0.45
- candidates = []
- locator = page.locator("div[role='button'], a[role='button'], button")
- for idx in range(locator.count()):
- item = locator.nth(idx)
- if not item.is_visible(timeout=500):
- continue
- box = item.bounding_box()
- if not box:
- continue
- if box.get("y", 0) < min_y or box.get("y", 0) > max_y or box.get("x", 0) < min_x:
- continue
- text = get_element_text(item)
- aria = clean(item.get_attribute("aria-label"))
- combined = f"{text} {aria}".strip()
- if not include_re.search(combined):
- continue
- if exclude_re and exclude_re.search(combined):
- continue
- candidates.append((box.get("y", 0), box.get("x", 0), item, combined))
- if candidates:
- candidates.sort(key=lambda row: (row[0], row[1]))
- return candidates[0][2], candidates[0][3]
- except Exception:
- return None, ""
- return None, ""
- def click_follow(page, confirm: bool) -> Dict[str, Any]:
- result = {"action": "follow", "clicked": False, "already_active": False, "error": ""}
- try:
- active_button, active_label = find_page_header_button(page, FOLLOWING_BUTTON_RE)
- if active_button is not None:
- result["already_active"] = True
- print(f" -> Follow already active: {active_label}")
- return result
- button, label = find_page_header_button(page, FOLLOW_BUTTON_RE, exclude_re=FOLLOWING_BUTTON_RE)
- if button is None:
- result["error"] = "Follow button not found in Page header"
- return result
- print(f" [Follow] button is visible: {label}")
- if confirm:
- button.click()
- result["clicked"] = True
- print(" -> Follow clicked")
- else:
- print(" -> dry-run: follow not clicked")
- except Exception as exc: # pragma: no cover - browser dependent
- result["error"] = str(exc)
- return result
- def click_add_friend(page, confirm: bool) -> Dict[str, Any]:
- result = {"action": "add_friend", "clicked": False, "error": ""}
- try:
- button, label = find_page_header_button(page, re.compile(r"add\s*friend|ajouter|connect", re.I))
- if button is None:
- result["error"] = "Add Friend button not found in Page header"
- return result
- print(f" [Add Friend] button is visible: {label}")
- if confirm:
- button.click()
- result["clicked"] = True
- print(" -> clicked Add Friend")
- else:
- print(" -> dry-run: no click")
- except Exception as exc: # pragma: no cover - browser dependent
- result["error"] = str(exc)
- return result
- def open_message_dialog(page, confirm: bool) -> Dict[str, Any]:
- result = {"action": "open_message", "clicked": False, "error": ""}
- button, label = find_page_header_button(page, MESSAGE_BUTTON_RE)
- if button is None:
- result["error"] = "Message button not found in Page header"
- return result
- print(f" [Message] button is visible in Page header: {label}")
- if confirm:
- button.click()
- result["clicked"] = True
- print(" -> Message button clicked")
- else:
- print(" -> dry-run: message dialog not opened")
- return result
- def dialog_matches_customer(dialog, dealer_name: str) -> bool:
- text = norm(get_element_text(dialog))
- tokens = name_tokens(dealer_name)
- if not tokens:
- return False
- return sum(1 for token in tokens if token in text) >= min(2, len(tokens))
- def find_matching_messenger_dialog(page, dealer_name: str):
- """Return a right-side Messenger dialog whose visible title matches dealer_name."""
- try:
- viewport = page.viewport_size or {"width": 1600, "height": 1000}
- min_x = viewport["width"] * 0.42
- min_y = viewport["height"] * 0.28
- candidates = []
- containers = page.locator("div[role='dialog'], div[aria-label*='Messenger' i], div[aria-label*='訊息' i], div[aria-label*='Message' i]")
- for idx in range(containers.count()):
- dialog = containers.nth(idx)
- if not dialog.is_visible(timeout=500):
- continue
- box = dialog.bounding_box()
- if not box:
- continue
- if box.get("x", 0) < min_x or box.get("y", 0) < min_y:
- continue
- if not dialog_matches_customer(dialog, dealer_name):
- continue
- dialog_text = norm(get_element_text(dialog))
- if any(hint in dialog_text for hint in COMMENT_HINTS):
- continue
- candidates.append((box.get("x", 0), box.get("y", 0), dialog))
- if candidates:
- candidates.sort(key=lambda row: (row[0], row[1]), reverse=True)
- return candidates[0][2]
- except Exception:
- return None
- return None
- def find_dialog_textbox(dialog):
- selectors = [
- 'div[contenteditable="true"][role="textbox"]',
- 'div[contenteditable="true"][data-lexical-editor="true"]',
- '[aria-label="Aa"][contenteditable="true"]',
- '[aria-label*="Message" i][contenteditable="true"]',
- '[aria-label*="訊息" i][contenteditable="true"]',
- ]
- for selector in selectors:
- textbox = first_visible(dialog.locator(selector), timeout_ms=800)
- if textbox is not None:
- label = clean(textbox.get_attribute("aria-label"))
- text = norm(get_element_text(textbox))
- if any(hint in norm(label) or hint in text for hint in COMMENT_HINTS):
- return None, "Blocked: detected comment composer, not Messenger dialog"
- return textbox, ""
- return None, "Message textbox not found inside matching Messenger dialog"
- def facebook_thread_url(page_url: str) -> str:
- match = re.search(r"facebook\.com/([^/?#]+)", page_url or "", re.I)
- if not match:
- return ""
- slug = match.group(1).strip("/")
- if not slug or slug in {"messages", "profile.php", "pages"}:
- return ""
- return f"https://www.facebook.com/messages/t/{slug}"
- def messenger_body_matches_customer(body: str, dealer_name: str) -> bool:
- if not dealer_name:
- return False
- body_norm = norm(body)
- direct = norm(dealer_name)
- if direct and direct in body_norm:
- return True
- tokens = name_tokens(dealer_name)
- if not tokens:
- return False
- return sum(1 for token in tokens if token in body_norm) >= min(2, len(tokens))
- def find_messenger_thread_textbox(thread_page, dealer_name: str):
- try:
- body = thread_page.locator("body").inner_text(timeout=3000)
- except Exception:
- body = ""
- if not messenger_body_matches_customer(body, dealer_name):
- return None, f"Messenger thread title mismatch for {dealer_name}"
- locator = thread_page.locator('[role="textbox"], div[contenteditable="true"]')
- for idx in range(locator.count()):
- textbox = locator.nth(idx)
- try:
- if not textbox.is_visible(timeout=500):
- continue
- label = clean(textbox.get_attribute("aria-label"))
- nearby = norm(label + " " + get_element_text(textbox))
- if any(hint in nearby for hint in COMMENT_HINTS):
- return None, "Blocked: detected comment composer, not Messenger thread"
- box = textbox.bounding_box()
- if not box or box.get("y", 0) < 400:
- continue
- if dealer_name and dealer_name.casefold() in label.casefold():
- return textbox, ""
- if label or box:
- return textbox, ""
- except Exception:
- continue
- return None, "Messenger thread textbox not found"
- 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]:
- result = {"action": "send_dm_thread", "clicked": False, "error": ""}
- thread_url = facebook_thread_url(page.url)
- if not thread_url:
- result["error"] = "Could not build Messenger thread URL from Facebook Page URL"
- return result
- if not confirm:
- print(f" -> dry-run: Messenger thread fallback available: {thread_url}")
- return result
- thread_page = page.context.new_page()
- thread_page.goto(thread_url, wait_until="domcontentloaded", timeout=60000)
- thread_page.wait_for_timeout(8000)
- thread_page.bring_to_front()
- textbox, error = find_messenger_thread_textbox(thread_page, dealer_name)
- if textbox is None:
- result["error"] = error
- return result
- textbox.click()
- try:
- existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
- except Exception:
- existing_text = ""
- if text[:80] not in existing_text:
- textbox.fill(text)
- print(" -> Message typed into Messenger thread")
- else:
- print(" -> existing drafted message detected; not inserting duplicate text")
- if before_send_wait != (0, 0):
- delay = random.uniform(*before_send_wait)
- print(f" -> pacing before send: {delay:.1f}s")
- time.sleep(delay)
- thread_page.keyboard.press("Enter")
- result["clicked"] = True
- print(" -> Message sent by Enter in Messenger thread")
- return result
- 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]:
- result = {"action": "send_dm", "clicked": False, "error": ""}
- try:
- dialog = find_matching_messenger_dialog(page, dealer_name)
- if dialog is None:
- opened = open_message_dialog(page, confirm=confirm)
- if opened.get("error"):
- print(f" ! Message button issue: {opened['error']}; trying Messenger thread fallback")
- fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
- if fallback.get("clicked"):
- result["clicked"] = True
- return result
- result["error"] = fallback.get("error") or opened["error"]
- return result
- if not confirm:
- print(" -> dry-run: no message sent")
- return result
- if message_open_wait != (0, 0):
- delay = random.uniform(*message_open_wait)
- print(f" -> pacing after message open: {delay:.1f}s")
- time.sleep(delay)
- for _ in range(10):
- time.sleep(0.8)
- dialog = find_matching_messenger_dialog(page, dealer_name)
- if dialog is not None:
- break
- if dialog is None:
- print(f" ! Messenger dialog for {dealer_name} not found; trying Messenger thread fallback")
- fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
- if fallback.get("clicked"):
- result["clicked"] = True
- return result
- result["error"] = fallback.get("error") or f"Messenger dialog for {dealer_name} not found or title mismatch"
- return result
- print(f" -> Messenger dialog matched: {dealer_name}")
- textbox, error = find_dialog_textbox(dialog)
- if textbox is None:
- result["error"] = error
- return result
- if not confirm:
- print(" -> dry-run: Messenger textbox found; no message typed")
- return result
- textbox.click()
- existing_text = ""
- try:
- existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
- except Exception:
- existing_text = ""
- if text[:80] not in existing_text:
- page.keyboard.insert_text(text)
- print(" -> Message typed into Messenger dialog")
- else:
- print(" -> existing drafted message detected; not inserting duplicate text")
- if before_send_wait != (0, 0):
- delay = random.uniform(*before_send_wait)
- print(f" -> pacing before send: {delay:.1f}s")
- time.sleep(delay)
- page.keyboard.press("Enter")
- result["clicked"] = True
- print(" -> Message sent by Enter")
- except Exception as exc: # pragma: no cover - browser dependent
- result["error"] = str(exc)
- return result
- def header_map(ws) -> Dict[str, int]:
- raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1]) if clean(cell.value)}
- mapped: Dict[str, int] = {}
- for key, aliases in HEADER_ALIASES.items():
- for alias in aliases:
- if alias in raw:
- mapped[key] = raw[alias]
- break
- return mapped
- def update_workbook(excel_path: str, sheet_name: str, updates: List[Dict[str, str]]) -> int:
- wb = load_workbook(excel_path)
- if sheet_name not in wb.sheetnames:
- raise KeyError(f"Sheet not found: {sheet_name}")
- ws = wb[sheet_name]
- columns = header_map(ws)
- if "index" not in columns or "status" not in columns:
- raise RuntimeError("Workbook is missing 序号 or 建联状态 columns.")
- update_by_index = {str(item.get("index", "")).strip(): item for item in updates if str(item.get("index", "")).strip()}
- updated = 0
- for row in ws.iter_rows(min_row=2, values_only=False):
- row_index = clean(row[columns["index"]].value)
- if row_index not in update_by_index:
- continue
- update = update_by_index[row_index]
- if update.get("status"):
- row[columns["status"]].value = update["status"]
- if update.get("note_append") and "note" in columns:
- existing = clean(row[columns["note"]].value)
- separator = " | " if existing else ""
- row[columns["note"]].value = existing + separator + update["note_append"]
- updated += 1
- wb.save(excel_path)
- return updated
- def write_audit_log(log_path: Path, entries: List[Dict[str, Any]]) -> None:
- log_path.parent.mkdir(parents=True, exist_ok=True)
- with log_path.open("a", encoding="utf-8") as handle:
- for entry in entries:
- handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
- def select_english_dm(item: Dict[str, Any]) -> str:
- message = item.get("messages", {}).get("dm", {}).get("en", "")
- if not message:
- raise ValueError(f"Missing English DM text for {item.get('dealer_name', '(unknown)')}")
- return message
- 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]:
- friend_ok = bool(friend_result and friend_result.get("clicked"))
- follow_ok = bool(follow_result and (follow_result.get("clicked") or follow_result.get("already_active")))
- dm_ok = bool(dm_result and dm_result.get("clicked"))
- if action == "friend":
- return (STATUS_VALUES["friend_success"], "") if friend_ok else (STATUS_VALUES["friend_failed"], friend_result.get("error", "unknown") if friend_result else "unknown")
- if action == "follow":
- return (STATUS_VALUES["follow_success"], "") if follow_ok else (STATUS_VALUES["follow_failed"], follow_result.get("error", "unknown") if follow_result else "unknown")
- if action == "dm":
- return (STATUS_VALUES["dm_success"], "") if dm_ok else (STATUS_VALUES["dm_failed"], dm_result.get("error", "unknown") if dm_result else "unknown")
- if action == "follow_dm":
- if follow_ok and dm_ok:
- return STATUS_VALUES["both_success"], ""
- if follow_ok:
- return STATUS_VALUES["follow_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
- if dm_ok:
- return STATUS_VALUES["dm_success"], follow_result.get("error", "follow not completed") if follow_result else "follow not completed"
- return STATUS_VALUES["dm_failed"], "follow and DM were not completed"
- if friend_ok and dm_ok:
- return STATUS_VALUES["both_success"], ""
- if friend_ok:
- return STATUS_VALUES["friend_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
- if dm_ok:
- return STATUS_VALUES["dm_success"], friend_result.get("error", "friend request not sent") if friend_result else "friend request not sent"
- return STATUS_VALUES["dm_failed"], "friend request and DM were not completed"
- def should_navigate(page, target_url: str, use_open_page: bool) -> bool:
- if not use_open_page:
- return True
- current = clean(getattr(page, "url", ""))
- if not current:
- return True
- return target_url.rstrip("/") not in current.rstrip("/")
- def find_open_page_for_target(browser, target_url: str):
- """Pick the most recently opened tab that already matches the target URL."""
- target = target_url.rstrip("/")
- fallback = None
- for context in browser.contexts:
- for candidate in context.pages:
- fallback = candidate
- try:
- current = clean(candidate.url).rstrip("/")
- except Exception:
- continue
- if target and target in current:
- fallback = candidate
- return fallback
- def main(argv: Optional[Sequence[str]] = None) -> int:
- parser = argparse.ArgumentParser(description="Run dry-run or confirmed English Facebook outreach in AdsPower.")
- parser.add_argument("--preview", required=True, help="JSON preview generated by prepare_facebook_outreach.py")
- parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
- parser.add_argument("--ads-power-url", default=DEFAULT_ADS_POWER_URL, help="AdsPower local API URL")
- parser.add_argument("--action", choices=["friend", "follow", "dm", "both", "follow_dm"], default="follow_dm", help="Action to perform")
- parser.add_argument("--max-per-run", type=int, default=MAX_PER_RUN, help="Maximum customers per run. Default: 3.")
- parser.add_argument("--confirm", action="store_true", help="Allow real clicks/sends after the whole preview has been shown and approved")
- parser.add_argument("--batch-confirmed", action="store_true", help="Required with --confirm; means the full batch preview was shown in chat and approved once")
- parser.add_argument("--headless", action="store_true", help="Start AdsPower browser in headless mode if supported")
- parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open")
- 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")
- parser.add_argument("--write-workbook", action="store_true", help="Explicitly write outreach status back to the workbook")
- parser.add_argument("--use-open-page", action="store_true", help="Use the first currently open AdsPower page instead of creating a new tab")
- parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
- parser.add_argument("--risk-profile", choices=sorted(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=DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
- parser.add_argument("--daily-dm-limit", type=int, default=DEFAULT_DAILY_DM_LIMIT, help="Per-profile daily DM/customer outreach cap. Default: 20.")
- parser.add_argument("--session-max", type=int, default=DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
- parser.add_argument("--dry-run-schedule", action="store_true", help="Print the pacing schedule, limits, and customer list without opening AdsPower.")
- args = parser.parse_args(argv)
- if args.auto_confirm_each:
- print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation via --batch-confirmed.")
- args.batch_confirmed = True
- if args.session_max < 1:
- raise ValueError("--session-max must be at least 1")
- if args.daily_follow_limit < 0 or args.daily_dm_limit < 0:
- raise ValueError("Daily limits cannot be negative")
- if args.confirm and not args.batch_confirmed:
- raise ValueError("Real Facebook sending requires --confirm --batch-confirmed after the full preview is shown in chat and approved.")
- preview_path = Path(args.preview).expanduser()
- if not preview_path.is_absolute():
- preview_path = Path.cwd() / preview_path
- if not preview_path.exists():
- raise FileNotFoundError(f"Preview file not found: {preview_path}")
- preview = json.loads(preview_path.read_text(encoding="utf-8"))
- language_policy = preview.get("language_policy", {})
- if language_policy.get("customer_facing") and language_policy.get("customer_facing") != "English":
- raise ValueError("Preview is not marked as English customer-facing. Regenerate with the updated prepare script.")
- all_items = preview.get("items", [])
- requested_count = max(0, args.max_per_run)
- pacing_profile = PACING_PROFILES[args.risk_profile]
- ledger = load_ledger(args.profile_id)
- active_cooldown = cooldown_active(ledger)
- if active_cooldown:
- raise RuntimeError(f"Profile {args.profile_id} is cooling down until {active_cooldown}; stop Facebook outreach for this account.")
- if ledger.get("risk_events"):
- raise RuntimeError(f"Profile {args.profile_id} has risk events today; stop Facebook outreach for this account.")
- if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
- raise RuntimeError(f"Profile {args.profile_id} reached today's failure threshold; stop Facebook outreach for this account.")
- allowed_count = allowed_item_count(args.action, requested_count, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
- items = all_items[:allowed_count]
- if requested_count and allowed_count < requested_count:
- print(f"Pacing limit reduced this run from {requested_count} to {allowed_count} customers.")
- schedule_preview = build_schedule_preview(items, args, ledger, pacing_profile)
- print(json.dumps({"schedule_preview": schedule_preview}, ensure_ascii=False, indent=2))
- if args.dry_run_schedule:
- print("Dry-run schedule only: browser execution skipped.")
- return 0
- if requested_count and not items:
- raise RuntimeError("No Facebook outreach capacity remains for this profile today.")
- source = preview.get("source", {})
- excel_path = source.get("excel", "")
- sheet_name = source.get("sheet", "Facebook")
- confirm_mode = bool(args.confirm)
- write_workbook = bool(args.write_workbook and not args.no_write_workbook)
- print(f"Mode: {'CONFIRMED batch execution' if confirm_mode else 'dry-run only'}")
- print("Customer-facing language: English")
- print(f"AdsPower profile: {args.profile_id}")
- print(f"Items: {len(items)} / {len(all_items)}")
- print(f"Workbook write: {'enabled' if write_workbook else 'disabled'}")
- print("-" * 60)
- client = AdsPowerClient(base_url=args.ads_power_url)
- audit_entries: List[Dict[str, Any]] = []
- status_updates: List[Dict[str, str]] = []
- stopped_early = False
- try:
- browser = client.start_browser(args.profile_id, headless=args.headless)
- page = client.get_open_page() if args.use_open_page else None
- page = page or client.new_page()
- for offset, item in enumerate(items, start=1):
- dm_text = select_english_dm(item)
- dealer_name = clean(item.get("dealer_name", ""))
- target_url = item.get("page_url", "")
- print(f"\n[{offset}/{len(items)}] {dealer_name} | {target_url}")
- print(f" Current status: {item.get('status', '未联系')} | Suggested action: {item.get('suggested_action', '')}")
- print(f" English DM preview:\n {dm_text[:500]}{'...' if len(dm_text) > 500 else ''}")
- if args.use_open_page:
- matched_page = find_open_page_for_target(browser, target_url)
- if matched_page is not None:
- page = matched_page
- if should_navigate(page, target_url, args.use_open_page):
- page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
- pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after page open")
- else:
- print(" -> using current open Page tab")
- pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after existing page focus")
- try:
- page.bring_to_front()
- time.sleep(1)
- except Exception:
- pass
- risk = detect_risk(page)
- if risk:
- print(f"\nRisk prompt detected: {risk}. Stopping immediately.")
- ledger_event(ledger, "risk", dealer_name, risk)
- save_ledger(args.profile_id, ledger)
- stopped_early = True
- break
- if confirm_mode:
- print(" -> batch preview already confirmed; executing this customer without per-row prompt")
- else:
- print(" -> dry-run: no click/send and no per-row prompt")
- friend_result = None
- follow_result = None
- dm_result = None
- if args.action in {"friend", "both"}:
- friend_result = click_add_friend(page, confirm=confirm_mode)
- if friend_result.get("error"):
- print(f" ! Add Friend: {friend_result['error']}")
- if args.action in {"follow", "follow_dm"}:
- follow_result = click_follow(page, confirm=confirm_mode)
- if follow_result.get("error"):
- print(f" ! Follow: {follow_result['error']}")
- elif confirm_mode and follow_result.get("clicked"):
- ledger_event(ledger, "follow", dealer_name)
- save_ledger(args.profile_id, ledger)
- pause(pacing_profile, "after_follow", confirm=confirm_mode, reason="after Follow")
- if args.action in {"dm", "both", "follow_dm"}:
- dm_result = send_dm(
- page,
- dealer_name,
- dm_text,
- confirm=confirm_mode,
- message_open_wait=pacing_range(pacing_profile, "after_message_open"),
- before_send_wait=pacing_range(pacing_profile, "before_send"),
- )
- if dm_result.get("error"):
- print(f" ! Message: {dm_result['error']}")
- elif confirm_mode and dm_result.get("clicked"):
- ledger_event(ledger, "dm", dealer_name)
- save_ledger(args.profile_id, ledger)
- pause(pacing_profile, "after_send", confirm=confirm_mode, reason="after DM send")
- new_status = ""
- error_note = ""
- if confirm_mode:
- new_status, error_note = determine_status(args.action, friend_result, follow_result, dm_result)
- status_updates.append({
- "index": str(item.get("index", "")),
- "status": new_status,
- "note_append": f"{now_iso()} Facebook {args.action}->{new_status}" + (f" err={error_note}" if error_note else ""),
- })
- if error_note:
- ledger_event(ledger, "failure", dealer_name, error_note)
- save_ledger(args.profile_id, ledger)
- audit_entries.append({
- "timestamp": now_iso(),
- "profile_id": args.profile_id,
- "target_url": target_url,
- "dealer_name": dealer_name,
- "action": args.action,
- "confirm_mode": confirm_mode,
- "auto_confirm_each": bool(args.auto_confirm_each),
- "language": "English",
- "new_status": new_status,
- "result": "dry_run" if not confirm_mode else ("success" if not error_note else "partial_or_failed"),
- "error": error_note,
- })
- pause(pacing_profile, "between_customers", confirm=confirm_mode, reason="between customers")
- if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
- print("Failure threshold reached. Stopping remaining customers for today.")
- stopped_early = True
- break
- capacity_now = remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit)
- if (action_needs_follow(args.action) and capacity_now["follow"] <= 0) or (action_needs_dm(args.action) and capacity_now["dm"] <= 0):
- print("Daily Follow/DM capacity exhausted. Stopping remaining customers.")
- stopped_early = True
- break
- except KeyboardInterrupt:
- print("\nInterrupted by user.")
- finally:
- print("Browser left open")
- try:
- if client.playwright:
- client.playwright.stop()
- except Exception:
- pass
- log_path = Path("logs") / f"facebook_social_{datetime.now().strftime('%Y%m%d')}.jsonl"
- if audit_entries:
- write_audit_log(log_path, audit_entries)
- print(f"\nAudit log written: {log_path}")
- save_ledger(args.profile_id, ledger)
- if args.confirm:
- cooldown = sample_wait(pacing_profile, "session_cooldown")
- print(f"Session cooldown recommendation: {cooldown / 60:.1f} minutes before the next Facebook outreach run.")
- if confirm_mode and status_updates and write_workbook:
- if not excel_path:
- backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
- backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"Workbook path missing in preview. Status updates saved to: {backup}")
- else:
- try:
- updated = update_workbook(excel_path, sheet_name, status_updates)
- print(f"Workbook updated: {updated} rows ({excel_path})")
- except Exception as exc:
- backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
- backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"Workbook update failed: {exc}")
- print(f"Status updates saved to: {backup}")
- if confirm_mode and status_updates and not write_workbook:
- print("Workbook update skipped")
- print("\nDone.")
- if stopped_early:
- print("Stopped early because a Facebook risk prompt was detected.")
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|