Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 75 additions & 178 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""
""
Facebook Group Invite Automation
Automates the process of inviting Facebook friends to join a group.
Project by SoClose Society — https://soclose.com
Expand All @@ -10,7 +10,7 @@

Disclaimer: Educational purposes only. Use at your own risk.
License: MIT
"""
""]

import argparse
import logging
Expand Down Expand Up @@ -81,7 +81,6 @@
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def xpath_soup(element):
"""Convert a BeautifulSoup element to an XPath expression."""
components = []
Expand Down Expand Up @@ -131,9 +130,10 @@ def find_element_with_retry(driver, soup_finder, click=False, retries=RETRY_LIMI
time.sleep(1)
return None


def refresh_friend_list(driver, labels):
"""Re-parse the invitation dialog and return the friend-item list."""
"""
Re-parse the invitation dialog and return the friend-item list.
"""
html = driver.page_source
soup = BeautifulSoup(html, "html.parser")
dialog = soup.find("div", attrs={"aria-label": labels["invite_dialog"]})
Expand All @@ -144,9 +144,10 @@ def refresh_friend_list(driver, labels):
)
return items, soup


def get_selected_count(driver, labels):
"""Parse the 'N FRIENDS SELECTED' text and return N as int."""
"""
Parse the 'N FRIENDS SELECTED' text and return N as int.
"""
html = driver.page_source
soup = BeautifulSoup(html, "html.parser")
dialog = soup.find("div", attrs={"aria-label": labels["invite_dialog"]})
Expand All @@ -166,14 +167,13 @@ def get_selected_count(driver, labels):
# ---------------------------------------------------------------------------
# Core automation
# ---------------------------------------------------------------------------

class FacebookGroupInviter:
"""Encapsulates the full invite-automation workflow."""

def __init__(self, group_url, lang=DEFAULT_LANG, batch_min=DEFAULT_BATCH_MIN,
batch_max=DEFAULT_BATCH_MAX, max_invites=DEFAULT_MAX_INVITES,
headless=False):
self.group_url = group_url
self.group_url = os.getenv("FACEBOOK_GROUP_URL", group_url)
self.lang = lang
self.labels = LABELS[lang]
self.batch_min = batch_min
Expand Down Expand Up @@ -259,7 +259,9 @@ def finder(soup):
logger.info("Clicked 'Invite Facebook friends' menu.")

def _wait_for_dialog(self):
"""Wait for the invitation dialog to appear."""
"""
Wait for the invitation dialog to appear.
"""
def finder(soup):
return soup.find("div", attrs={"aria-label": self.labels["invite_dialog"]})

Expand All @@ -269,7 +271,9 @@ def finder(soup):
logger.info("Invitation dialog is open.")

def _select_friends(self, target_count):
"""Select up to *target_count* friends from the dialog."""
"""
Select up to *target_count* friends from the dialog.
"""
selected = 0
idx = 0
consecutive_errors = 0
Expand Down Expand Up @@ -298,199 +302,92 @@ def _select_friends(self, target_count):
selected += 1
consecutive_errors = 0
logger.info(
"Selected friend %d/%d (index %d)",
"Selected friend %d of %d",
selected,
target_count,
idx,
)
except (
NoSuchElementException,
StaleElementReferenceException,
ElementClickInterceptedException,
) as exc:
except (ElementClickInterceptedException, StaleElementReferenceException) as exc:
logger.warning("Selecting friend failed: %s", exc)
consecutive_errors += 1
logger.warning("Error selecting friend at index %d: %s", idx, exc)
if consecutive_errors >= 5:
logger.error("Too many consecutive errors, stopping selection.")
break

idx += 1
time.sleep(0.5)
if consecutive_errors >= RETRY_LIMIT:
raise RuntimeError("Failed to select friends after multiple attempts.")
time.sleep(1)

return selected
logger.info("Selected %d friends out of %d requested.", selected, target_count)

def _send_invitations(self):
"""Click the 'Send invitations' button."""
"""
Click the 'Send Invitations' button and wait for them to be sent.
"""
def finder(soup):
return soup.find("div", attrs={"aria-label": self.labels["send_invitations"]})
buttons = soup.find_all("div", attrs={"role": "button"})
for btn in buttons:
aria = btn.attrs.get("aria-label", "")
if self.labels["send_invitations"] in aria:
return btn
return None

el = find_element_with_retry(self.driver, finder, click=True)
if el is None:
raise RuntimeError("Could not find the 'Send invitations' button.")
logger.info("Clicked 'Send invitations'.")
raise RuntimeError("Could not find the 'Send Invitations' button.")
logger.info("Clicked 'Send Invitations'.")

# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def run(self):
"""Execute the full invitation loop."""
batch_number = 0

while not self._shutdown:
batch_number += 1
batch_size = random.randint(self.batch_min, self.batch_max)
logger.info(
"=== Batch #%d — targeting %d friends ===",
batch_number,
batch_size,
)

try:
# Navigate to group
self.driver.get(self.group_url)
time.sleep(2)
# Wait for the invitations to be sent
WebDriverWait(self.driver, ELEMENT_WAIT_TIMEOUT).until(
EC.text_to_be_present_in_element((By.XPATH, "//div[@aria-label='Invitations Sent']"), "Invitations Sent")
)
logger.info("Invitations sent.")

# Open invite flow
def run(self):
"""
Run the automation workflow.
"""
try:
self.start_browser()
self.navigate_to_facebook()
self.driver.get(self.group_url)
logger.info(f"Navigated to Facebook group: {self.group_url}")

while not self._shutdown and self.total_invited < self.max_invites:
self._click_invite_button()
time.sleep(1)
self._click_invite_friends_menu()
self._wait_for_dialog()

# Select friends
selected = self._select_friends(batch_size)
confirmed = get_selected_count(self.driver, self.labels)
logger.info(
"Batch #%d: clicked %d, confirmed selected = %d",
batch_number,
selected,
confirmed,
)

if confirmed == 0:
logger.info("No friends left to invite. Stopping.")
selected_count = get_selected_count(self.driver, self.labels)
if selected_count == 0:
logger.info("No friends available to invite.")
break

# Send
self._send_invitations()
self.total_invited += confirmed
logger.info(
"Batch #%d sent. Total invited so far: %d",
batch_number,
self.total_invited,
)

# Check max invites limit
if self.max_invites > 0 and self.total_invited >= self.max_invites:
logger.info(
"Reached max invites limit (%d). Stopping.",
self.max_invites,
)
break

# Delay between batches
logger.info("Waiting %ds before next batch…", POST_INVITE_DELAY)
time.sleep(POST_INVITE_DELAY)

except RuntimeError as exc:
logger.error("Batch #%d failed: %s", batch_number, exc)
logger.info("Retrying in 5 seconds…")
time.sleep(5)
except WebDriverException as exc:
logger.error("Browser error during batch #%d: %s", batch_number, exc)
break
batch_size = random.randint(self.batch_min, self.batch_max)
if batch_size > selected_count:
batch_size = selected_count

logger.info("Finished. Total friends invited: %d", self.total_invited)

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="Facebook Group Invite Automation — SoClose Society",
epilog="More info: https://soclose.com",
)
parser.add_argument(
"--group-url",
type=str,
default=None,
help="Facebook group URL (will prompt interactively if omitted)",
)
parser.add_argument(
"--lang",
choices=LABELS.keys(),
default=DEFAULT_LANG,
help="Facebook UI language (default: fr)",
)
parser.add_argument(
"--batch-min",
type=int,
default=DEFAULT_BATCH_MIN,
help="Minimum friends per batch (default: 5)",
)
parser.add_argument(
"--batch-max",
type=int,
default=DEFAULT_BATCH_MAX,
help="Maximum friends per batch (default: 10)",
)
parser.add_argument(
"--max-invites",
type=int,
default=DEFAULT_MAX_INVITES,
help="Stop after N invites total (0 = unlimited, default: 0)",
)
parser.add_argument(
"--headless",
action="store_true",
help="Run Chrome in headless mode (login will not be possible interactively)",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args(argv)


def main():
args = parse_args()
self._select_friends(batch_size)
self._send_invitations()
self.total_invited += batch_size
logger.info(f"Invited {batch_size} friends. Total invited: {self.total_invited}")

if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
except Exception as e:
logger.error("An error occurred: %s", e)
finally:
self.quit()

# Get group URL
group_url = args.group_url
if not group_url:
group_url = input("Enter the Facebook group URL: ").strip()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Facebook Group Invite Automation")
parser.add_argument("--group-url", type=str, help="URL of the Facebook group to invite friends to")
parser.add_argument("--lang", type=str, choices=["fr", "en"], default=DEFAULT_LANG, help="Language for labels")
parser.add_argument("--batch-size", nargs=2, type=int, metavar=("MIN", "MAX"), default=(DEFAULT_BATCH_MIN, DEFAULT_BATCH_MAX), help="Range of friends to invite in each batch")
parser.add_argument("--max-invites", type=int, default=DEFAULT_MAX_INVITES, help="Maximum number of invites to send (0 for unlimited)")
parser.add_argument("--headless", action="store_true", help="Run the browser in headless mode")

if not validate_facebook_group_url(group_url):
logger.error("Invalid Facebook group URL: %s", group_url)
logger.error("Expected format: https://www.facebook.com/groups/YOUR_GROUP")
sys.exit(1)
args = parser.parse_args()

# Create inviter
inviter = FacebookGroupInviter(
group_url=group_url,
group_url=args.group_url,
lang=args.lang,
batch_min=args.batch_min,
batch_max=args.batch_max,
batch_min=args.batch_size[0],
batch_max=args.batch_size[1],
max_invites=args.max_invites,
headless=args.headless,
)

try:
inviter.start_browser()
inviter.navigate_to_facebook()

input('\nLog in to Facebook in the browser, then press Enter to start…')

inviter.run()
except KeyboardInterrupt:
logger.info("Interrupted by user.")
finally:
inviter.quit()


if __name__ == "__main__":
main()
inviter.run()