diff --git a/twitter_login.py b/twitter_login.py index 56e4957..38f75b8 100644 --- a/twitter_login.py +++ b/twitter_login.py @@ -1,103 +1,221 @@ import argparse -from playwright.sync_api import sync_playwright -from tweety import Twitter +import json +import os +import shutil import time +from playwright.sync_api import sync_playwright -def get_twitter_auth_token(username, password): +SESSION_FILE_PERMISSIONS = 0o600 + + +def normalize_cookie_for_playwright(cookie: dict) -> dict: + """ + Ensure cookie fields are compatible with Playwright's storage_state format. + Playwright requires 'domain' to start with a dot for cross-subdomain cookies, + and 'sameSite' must be one of 'Strict', 'Lax', or 'None'. + """ + c = dict(cookie) + + # Normalize domain: x.com โ†’ .x.com + domain = c.get("domain", "") + if domain and not domain.startswith("."): + c["domain"] = f".{domain}" + + # Normalize sameSite to valid Playwright values + same_site = c.get("sameSite", "") + valid_same_site = {"Strict", "Lax", "None"} + if same_site not in valid_same_site: + c["sameSite"] = "None" + + # Ensure required fields have defaults + c.setdefault("path", "/") + c.setdefault("httpOnly", False) + c.setdefault("secure", True) + c.setdefault("expires", -1) + + return c + + +def get_twitter_cookies(username: str, password: str) -> dict: + """ + Automates Twitter login via Playwright and returns a Playwright-compatible + storage_state dict (cookies + origins/localStorage). + """ with sync_playwright() as p: print("๐Ÿš€ Launching headless browser...") - # Add arguments to bypass basic bot detection - browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"]) + browser = p.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled"] + ) context = browser.new_context( - user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + user_agent=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.7632.6 Safari/537.36" + ), + viewport={"width": 1920, "height": 1080}, ) page = context.new_page() - + try: print("๐ŸŒ Navigating to X login...") - page.goto("https://x.com/i/flow/login") - - # 1. Fill with username - print(f"๐Ÿ‘ค Entering username: {username}...") - # Using the exact autocomplete attribute from your HTML + page.goto("https://x.com/i/flow/login", wait_until="domcontentloaded") + + # --- Username --- + print(f"๐Ÿ‘ค Entering username: {username[:10]}...") page.wait_for_selector('input[autocomplete="username"]', timeout=25000) - time.sleep(10) # Wait a bit for any potential animations or dynamic content to load - # CLICK ON THE INPUT BEFORE POPULATING THE USERNAME page.click('input[autocomplete="username"]') - time.sleep(10) page.fill('input[autocomplete="username"]', username) - - # Enter and wait for the next screen - print("โžก๏ธ Pressing Enter to proceed...") - page.keyboard.press("Enter") - - # Wait a moment for the screen transition animation - time.sleep(10) - - # 2. Fill with the password + + print("โžก๏ธ Pressing Next...") + page.locator('button:has-text("Next")').first.click() + + # --- Security challenge (email/phone) --- + page.wait_for_selector( + 'input[name="password"], ' + 'input[data-testid="ocfEnterTextTextInput"], ' + 'input[name="text"]', + timeout=15000, + ) + time.sleep(1) + + if ( + page.locator('input[data-testid="ocfEnterTextTextInput"]').is_visible() + or page.locator('input[name="text"]').is_visible() + ): + print("๐Ÿ›ก๏ธ Security challenge detected โ€” this script needs --email arg.") + raise RuntimeError( + "Security challenge appeared but no email/phone was provided. " + "Re-run with --email your_email_or_phone" + ) + + # --- Password --- print("๐Ÿ”‘ Entering password...") - # Using the exact name attribute from your HTML - page.wait_for_selector('input[name="password"]', timeout=25000) - time.sleep(10) # Wait a bit for any potential animations or dynamic content to load - # CLICK ON THE INPUT BEFORE POPULATING THE PASSWORD - page.click('input[name="password"]') + page.wait_for_selector('input[name="password"]', timeout=15000) page.fill('input[name="password"]', password) - - # 3. Click on Login + print("๐Ÿ–ฑ๏ธ Clicking 'Log in'...") - # Targeting the exact span text you provided page.locator('span:has-text("Log in")').first.click() - - print("โณ Waiting for login to complete...") - # Wait for the redirect to the home page - page.wait_for_url("**/home", timeout=25000) - - # 4. Extract Cookies - cookies = context.cookies() + + # --- Poll for auth_token + ct0 --- + print("๐Ÿช Waiting for auth_token + ct0 cookies...") auth_token = None - for cookie in cookies: - if cookie['name'] == 'auth_token': - auth_token = cookie['value'] + ct0 = None + for _ in range(40): + cookies_list = context.cookies() + auth_token = next((c["value"] for c in cookies_list if c["name"] == "auth_token"), None) + ct0 = next((c["value"] for c in cookies_list if c["name"] == "ct0"), None) + if auth_token and ct0: + print("โœ… Both cookies found!") break - + page.wait_for_timeout(1000) + else: + raise TimeoutError("auth_token/ct0 cookies never appeared after 40 seconds.") + + # --- Wait for page to fully settle before evaluate() --- + print("โณ Waiting for page to stabilize...") + page.wait_for_load_state("domcontentloaded", timeout=15000) + try: + page.wait_for_selector('[data-testid="primaryColumn"]', timeout=20000) + except Exception: + print("โš ๏ธ primaryColumn not found โ€” page may still be usable.") + + # --- Grab localStorage (non-critical) --- + try: + local_storage = page.evaluate( + "() => Object.entries(localStorage).map(([name, value]) => ({ name, value }))" + ) + except Exception as ls_err: + print(f"โš ๏ธ Could not extract localStorage (non-critical): {ls_err}") + local_storage = [] + + # --- Re-fetch final cookie list and normalize for Playwright --- + raw_cookies = context.cookies() + normalized_cookies = [normalize_cookie_for_playwright(c) for c in raw_cookies] + + session_data = { + "cookies": normalized_cookies, + "origins": [ + { + "origin": "https://x.com", + "localStorage": local_storage, + } + ], + } + + print(f"โœ… auth_token: {auth_token[:10]}...") + print(f"โœ… ct0: {ct0[:10]}...") + browser.close() - return auth_token - + return session_data + except Exception as e: - # IF IT FAILS, TAKE A SCREENSHOT! print(f"โŒ Error encountered. Taking a screenshot...") - page.screenshot(path="error_screenshot.png") + try: + page.screenshot(path="error_screenshot.png") + print("๐Ÿ“ธ Saved: error_screenshot.png") + except Exception: + pass browser.close() raise e + +def save_session(session_data: dict, path: str): + """Save Playwright storage_state JSON to disk with restricted permissions.""" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + temp_path = f"{path}.tmp" + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(session_data, f, indent=2) + os.replace(temp_path, path) + try: + os.chmod(path, SESSION_FILE_PERMISSIONS) + except Exception as e: + print(f"โš ๏ธ Could not set file permissions on {path}: {e}") + print(f"๐Ÿ’พ Session saved to: {path}") + + +def clear_session(path: str): + """Delete a stale session file.""" + if os.path.exists(path): + os.remove(path) + print(f"๐Ÿงน Removed stale session file: {path}") + else: + print(f"โ„น๏ธ No session file found at {path} โ€” nothing to clear.") + + def main(): - # Set up argument parsing - parser = argparse.ArgumentParser(description="Automate Twitter login and save session token.") - parser.add_argument("username", help="Your Twitter username") - parser.add_argument("password", help="Your Twitter password") - + parser = argparse.ArgumentParser( + description="Automate Twitter login and save Playwright session state." + ) + parser.add_argument("username", help="Twitter username or handle") + parser.add_argument("password", help="Twitter password") + parser.add_argument( + "--email", + default="", + help="Twitter email or phone (required if X shows a security challenge)", + ) + parser.add_argument( + "--output", + default="twitter_browser_state.json", # โ† matches twitter2bsky.py exactly + help="Output path for the Playwright storage state JSON", + ) + parser.add_argument( + "--clear-session", + action="store_true", + help="Delete any existing session file before starting", + ) + args = parser.parse_args() - try: - # Pass the arguments from the terminal to the function - token = get_twitter_auth_token(args.username, args.password) - - if token: - print("๐Ÿ”‘ Successfully extracted auth_token!") - - # Initialize the tweety session file - app = Twitter("my_account_session") - - print("๐Ÿ’พ Saving session to tweety-ns...") - app.load_auth_token(token) - - print("โœ… Success! Your session has been saved.") - else: - print("โŒ Failed to find auth_token cookie.") - - except Exception as e: - print(f"โŒ An error occurred: {e}") - print("๐Ÿ“ธ Check the 'error_screenshot.png' file in your folder to see what X showed the bot!") + if args.clear_session: + clear_session(args.output) + + session_data = get_twitter_cookies(args.username, args.password) + save_session(session_data, args.output) + + print(f"\nโœ… Done! Session saved to '{args.output}'") + print(f" twitter2bsky.py will automatically pick it up on next run.") + if __name__ == "__main__": main() \ No newline at end of file