diff --git a/README.md b/README.md index aa9e64a..b6eaa6b 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,24 @@ requirements.txt`, then you can just run `python -m deletefb.deletefb.py` in the * It is recommended that you disable Two-Factor Authentication tempoprarily while you are running the script, in order to get the best experience. +* If you run into issues with Facebook complaining about your browser, + currently the only workaround is to manually click through them. + * If you do have 2-Factor Auth configured then the script will pause for 20 seconds to allow you to enter your code and log in. * You may also pass in a code by using the `-F` argument, e.g. `-F 111111`. +## Delete by year +* The tool supports passing the `--year` flag in order to delete wall posts by + year. It is incompatible with any mode other than `wall`. + +## Archival +* The tool will archive everything being deleted by default in `.log` files. + Currently they are simply stored as JSON objects for each line in the log. It + will archive the content, and a timestamp if it is available. You may disable + this feature by using `--no-archive`. + ## Headless mode * The tool supports running Chrome in headless mode with the `--headless` option, which may be preferable if you plan on running it in the background. diff --git a/deletefb/deletefb.py b/deletefb/deletefb.py index b52f956..23e275d 100755 --- a/deletefb/deletefb.py +++ b/deletefb/deletefb.py @@ -3,11 +3,26 @@ import argparse import getpass +from sys import exit +from os import environ from .tools.login import login from .tools.wall import delete_posts +from .tools.likes import unlike_pages def run_delete(): parser = argparse.ArgumentParser() + + parser.add_argument( + "-M", + "--mode", + required=False, + default="wall", + dest="mode", + type=str, + choices=["wall", "unlike_pages"], + help="The mode you want to run in. Default is `wall' which deletes wall posts" + ) + parser.add_argument( "-E", "--email", @@ -53,19 +68,49 @@ def run_delete(): help="Run browser in headless mode (no gui)" ) + parser.add_argument( + "--no-archive", + action="store_true", + dest="archive_off", + default=True, + help="Turn off archiving (on by default)" + ) + + parser.add_argument( + "-Y", + "--year", + required=False, + dest="year", + type=str, + help="The year(s) you want posts deleted." + ) + args = parser.parse_args() + if args.archive_off: + environ["DELETEFB_ARCHIVE"] = "false" if args.archive_off else "true" + + if args.year and args.mode != "wall": + parser.error("The --year option is only supported in wall mode") + args_user_password = args.password or getpass.getpass('Enter your password: ') driver = login( user_email_address=args.email, user_password=args_user_password, - user_profile_url=args.profile_url, is_headless=args.is_headless, two_factor_token=args.two_factor_token ) - delete_posts(driver) + if args.mode == "wall": + delete_posts(driver, + args.profile_url, + year=args.year) + elif args.mode == "unlike_pages": + unlike_pages(driver) + else: + print("Please enter a valid mode") + exit(1) if __name__ == "__main__": run_delete() diff --git a/deletefb/tools/common.py b/deletefb/tools/common.py index 92405c4..9dc9e38 100644 --- a/deletefb/tools/common.py +++ b/deletefb/tools/common.py @@ -1,3 +1,50 @@ -from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException +from selenium.common.exceptions import (NoSuchElementException, + StaleElementReferenceException, + TimeoutException) +from time import sleep +from json import dumps +from os.path import abspath, relpath, split +from os import environ -SELENIUM_EXCEPTIONS = (NoSuchElementException, StaleElementReferenceException) +SELENIUM_EXCEPTIONS = (NoSuchElementException, + StaleElementReferenceException, + TimeoutException) + +def try_move(actions, el): + for _ in range(10): + try: + actions.move_to_element(el).perform() + except StaleElementReferenceException: + sleep(5) + continue + + +def archiver(category): + """ + category: the category of logs you want to log + return values: (log_file_handle, archiver) + + call archiver like archive("some content") + """ + log_path = "{0}.log".format(abspath(relpath(split(category)[-1], "."))) + + log_file = open(log_path, mode="wt", buffering=1) + + def log(content, timestamp=False): + if environ.get("DELETEFB_ARCHIVE", "true") == "false": + return + structured_content = { + "category" : category, + "content" : content, + "timestamp" : timestamp + } + + log_file.write("{0}\n".format(dumps(structured_content))) + + return (log_file, log) + + +no_chrome_driver = """ +You need to install the chromedriver for Selenium\n +Please see this link https://github.com/weskerfoot/DeleteFB#how-to-use-it\n +""" diff --git a/deletefb/tools/likes.py b/deletefb/tools/likes.py index 80c6b0a..6914145 100644 --- a/deletefb/tools/likes.py +++ b/deletefb/tools/likes.py @@ -1,8 +1,69 @@ +from selenium.webdriver.common.by import By from selenium.webdriver.common.action_chains import ActionChains -from .common import SELENIUM_EXCEPTIONS +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from .common import SELENIUM_EXCEPTIONS, archiver + +def load_likes(driver): + """ + Loads the page that lists all pages you like + """ + driver.get("https://www.facebook.com/pages/?category=liked") + + wait = WebDriverWait(driver, 20) + + try: + wait.until( + EC.presence_of_element_located((By.XPATH, "//button/div/div[text()='Liked']")) + ) + + wait.until( + EC.presence_of_element_located((By.XPATH, "//button/div/i[@aria-hidden=\"true\"]")) + ) + except SELENIUM_EXCEPTIONS: + return def unlike_pages(driver): """ Unlike all pages """ - return + + like_log, archive_likes = archiver("likes") + + actions = ActionChains(driver) + + load_likes(driver) + + pages_list = driver.find_element_by_css_selector("#all_liked_pages") + + actions.move_to_element(pages_list).perform() + + unlike_buttons = pages_list.find_elements_by_xpath("//button/div/div[text()='Liked']/../..") + + while unlike_buttons: + for button in unlike_buttons: + try: + if "Liked" in button.text: + page_name = button.find_element_by_xpath("./../..").text.split("\n")[0] + + driver.execute_script("arguments[0].click();", button) + + archive_likes(page_name) + + print("{0} was unliked".format(page_name)) + + except SELENIUM_EXCEPTIONS as e: + continue + + load_likes(driver) + try: + pages_list = driver.find_element_by_css_selector("#all_liked_pages") + actions.move_to_element(pages_list).perform() + unlike_buttons = pages_list.find_elements_by_xpath("//button") + if not unlike_buttons: + break + except SELENIUM_EXCEPTIONS: + break + + # Explicitly close the log file when we're done with it + like_log.close() diff --git a/deletefb/tools/login.py b/deletefb/tools/login.py index 38aa5bf..0a1ddd7 100644 --- a/deletefb/tools/login.py +++ b/deletefb/tools/login.py @@ -1,11 +1,13 @@ import time +from sys import stderr, exit from selenium.webdriver.chrome.options import Options from seleniumrequests import Chrome +from selenium.common.exceptions import NoSuchElementException +from .common import no_chrome_driver def login(user_email_address, user_password, - user_profile_url, is_headless, two_factor_token): """ @@ -28,7 +30,15 @@ def login(user_email_address, chrome_options.add_argument('--disable-gpu') chrome_options.add_argument('log-level=2') - driver = Chrome(options=chrome_options) + try: + driver = Chrome(options=chrome_options) + except Exception as e: + # The user does not have chromedriver installed + # Tell them to install it + stderr.write(str(e)) + stderr.write(no_chrome_driver) + exit(1) + driver.implicitly_wait(10) driver.get("https://facebook.com") @@ -47,24 +57,40 @@ def login(user_email_address, loginelement = driver.find_element_by_id(login) loginelement.click() - if "two-factor authentication" in driver.page_source.lower(): - if two_factor_token: - - twofactorelement = driver.find_element_by_name(approvals_code) - twofactorelement.send_keys(two_factor_token) - - # Submits after the code is passed into the form, does not validate 2FA code. - contelement = driver.find_element_by_id("checkpointSubmitButton") - contelement.click() - - # Defaults to saving this new browser, this occurs on each new automated login. - save_browser = driver.find_element_by_id("checkpointSubmitButton") - save_browser.click() - else: - # Allow time to enter 2FA code - print("Pausing to enter 2FA code") - time.sleep(20) - print("Continuing execution") + # Defaults to no 2fa + has_2fa = False + + try: + # If this element exists, we've reached a 2FA page + driver.find_element_by_xpath("//form[@class=\"checkpoint\"]") + driver.find_element_by_xpath("//input[@name=\"approvals_code\"]") + has_2fa = True + except NoSuchElementException: + has_2fa = "two-factor authentication" in driver.page_source.lower() or has_2fa + + if has_2fa: + print(""" + Two-Factor Auth is enabled. + Please file an issue at https://github.com/weskerfoot/DeleteFB/issues if you run into any problems + """) + + if two_factor_token and has_2fa: + twofactorelement = driver.find_element_by_name(approvals_code) + twofactorelement.send_keys(two_factor_token) + + # Submits after the code is passed into the form, does not validate 2FA code. + contelement = driver.find_element_by_id("checkpointSubmitButton") + contelement.click() + + # Defaults to saving this new browser, this occurs on each new automated login. + save_browser = driver.find_element_by_id("checkpointSubmitButton") + save_browser.click() + elif has_2fa: + # Allow time to enter 2FA code + print("Pausing to enter 2FA code") + time.sleep(35) + print("Continuing execution") + else: + pass - driver.get(user_profile_url) return driver diff --git a/deletefb/tools/wall.py b/deletefb/tools/wall.py index 3005dd1..a318c54 100644 --- a/deletefb/tools/wall.py +++ b/deletefb/tools/wall.py @@ -1,20 +1,40 @@ import time from selenium.webdriver.common.action_chains import ActionChains -from .common import SELENIUM_EXCEPTIONS +from .common import SELENIUM_EXCEPTIONS, archiver -MAX_POSTS = 5000 +# Used as a threshold to avoid running forever +MAX_POSTS = 15000 -def delete_posts(driver): +def delete_posts(driver, + user_profile_url, + year=None): """ Deletes or hides all posts from the wall """ + + if not year is None: + user_profile_url = "{0}/timeline?year={1}".format(user_profile_url, year) + + driver.get(user_profile_url) + + wall_log, archive_wall_post = archiver("wall") + for _ in range(MAX_POSTS): post_button_sel = "_4xev" + post_content_sel = "_5_jv" + + post_timestamp_sel = "timestamp" while True: try: timeline_element = driver.find_element_by_class_name(post_button_sel) + + post_content_element = driver.find_element_by_class_name(post_content_sel) + post_content_ts = driver.find_element_by_class_name(post_timestamp_sel) + + archive_wall_post(post_content_element.text, timestamp=post_content_ts.text) + actions = ActionChains(driver) actions.move_to_element(timeline_element).click().perform()