458 lines
16 KiB
Python
458 lines
16 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import random
|
|
import time as _time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from selenium import webdriver
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.firefox.options import Options
|
|
from selenium.webdriver.firefox.service import Service
|
|
from selenium.webdriver.common.action_chains import ActionChains
|
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.common.exceptions import (
|
|
TimeoutException,
|
|
NoSuchElementException,
|
|
WebDriverException,
|
|
)
|
|
|
|
from tokens import ProviderTokens
|
|
from proxy import HTTPS_PROXY, rotate_proxy_ip
|
|
from emails import pop_account
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
|
EXTRAS_DIR = Path(os.environ.get("EXTRAS_DIR", "./extras"))
|
|
FIREFOX_BINARY = os.environ.get("FIREFOX_BINARY", "firefox")
|
|
GECKODRIVER_PATH = os.environ.get("GECKODRIVER_PATH", "/usr/local/bin/geckodriver")
|
|
|
|
KILO_HOME = "https://kilo.ai/"
|
|
PROFILE_URL = "https://app.kilo.ai/profile"
|
|
|
|
MAX_IP_ROTATIONS = 3
|
|
|
|
|
|
def human_delay():
|
|
_time.sleep(random.uniform(0.5, 1.35))
|
|
|
|
|
|
def human_type(element, text):
|
|
for char in text:
|
|
element.send_keys(char)
|
|
_time.sleep(random.uniform(0.05, 0.15))
|
|
|
|
|
|
def human_click(driver, element):
|
|
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
|
|
human_delay()
|
|
driver.execute_script("arguments[0].click();", element)
|
|
human_delay()
|
|
|
|
|
|
def _is_on_kilo(url: str) -> bool:
|
|
"""Check if URL's actual domain is kilo.ai (not just in query params)."""
|
|
hostname = urlparse(url).hostname or ""
|
|
return hostname.endswith("kilo.ai")
|
|
|
|
|
|
class AutomationError(Exception):
|
|
def __init__(self, step: str, message: str, driver: WebDriver | None = None):
|
|
self.step = step
|
|
self.message = message
|
|
self.driver = driver
|
|
super().__init__(f"[{step}] {message}")
|
|
|
|
|
|
def save_error_screenshot(driver: WebDriver | None, step: str) -> None:
|
|
if driver:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
screenshots_dir = DATA_DIR / "screenshots"
|
|
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
filename = screenshots_dir / f"error_kilo_{step}_{timestamp}.png"
|
|
try:
|
|
driver.save_screenshot(str(filename))
|
|
logger.error("Screenshot saved: %s", filename)
|
|
except WebDriverException as e:
|
|
logger.warning("Failed to save screenshot at step %s: %s", step, e)
|
|
|
|
|
|
def _create_firefox_driver() -> WebDriver:
|
|
"""Launch Firefox with fresh profile mimicking a real user setup."""
|
|
proxy_url = HTTPS_PROXY
|
|
|
|
options = Options()
|
|
os.environ["TZ"] = "America/New_York"
|
|
|
|
# Private browsing mode with extensions allowed
|
|
options.add_argument("-private")
|
|
options.set_preference("extensions.privatebrowsing.autostart", True)
|
|
options.set_preference("extensions.allowPrivateBrowsingByDefault", True)
|
|
|
|
# Dark theme
|
|
options.set_preference("ui.systemUsesDarkTheme", 1)
|
|
options.set_preference("browser.theme.content-theme", 0)
|
|
options.set_preference("browser.theme.toolbar-theme", 0)
|
|
|
|
# Enhanced Tracking Protection: Strict
|
|
options.set_preference("browser.contentblocking.category", "strict")
|
|
options.set_preference("privacy.trackingprotection.enabled", True)
|
|
options.set_preference("privacy.trackingprotection.socialtracking.enabled", True)
|
|
options.set_preference("privacy.trackingprotection.cryptomining.enabled", True)
|
|
options.set_preference("privacy.trackingprotection.fingerprinting.enabled", True)
|
|
options.set_preference("network.cookie.cookieBehavior", 5)
|
|
|
|
# Disable WebRTC IP leak
|
|
options.set_preference("media.peerconnection.enabled", False)
|
|
|
|
# Anti-detection: hide webdriver
|
|
options.set_preference("dom.webdriver.enabled", False)
|
|
options.set_preference("useAutomationExtension", False)
|
|
|
|
# Enable WebGL (software rendering via Mesa)
|
|
options.set_preference("webgl.disabled", False)
|
|
options.set_preference("webgl.force-enabled", True)
|
|
options.set_preference("webgl.msaa-force", True)
|
|
options.set_preference("webgl.max-warnings-per-context", 0)
|
|
|
|
# Proxy
|
|
if proxy_url:
|
|
parsed = urlparse(proxy_url)
|
|
proxy_host = parsed.hostname or "localhost"
|
|
proxy_port = parsed.port or 80
|
|
|
|
options.set_preference("network.proxy.type", 1)
|
|
options.set_preference("network.proxy.http", proxy_host)
|
|
options.set_preference("network.proxy.http_port", proxy_port)
|
|
options.set_preference("network.proxy.ssl", proxy_host)
|
|
options.set_preference("network.proxy.ssl_port", proxy_port)
|
|
options.set_preference("network.proxy.no_proxies_on", "")
|
|
logger.info("Firefox proxy: %s:%s", proxy_host, proxy_port)
|
|
|
|
options.binary_location = FIREFOX_BINARY
|
|
service = Service(executable_path=GECKODRIVER_PATH)
|
|
driver = webdriver.Firefox(service=service, options=options) # type: ignore[reportCallIssue]
|
|
driver.set_page_load_timeout(120)
|
|
|
|
# Install Dark Reader extension (Selenium cleanup)
|
|
dark_reader_path = EXTRAS_DIR / "extensions" / "dark-reader.xpi"
|
|
if dark_reader_path.exists():
|
|
driver.install_addon(str(dark_reader_path), temporary=True)
|
|
logger.info("Dark Reader extension installed")
|
|
else:
|
|
logger.warning("Dark Reader xpi not found at %s", dark_reader_path)
|
|
|
|
# Install uBlock Origin
|
|
ublock_path = EXTRAS_DIR / "extensions" / "ublock_origin.xpi"
|
|
if ublock_path.exists():
|
|
driver.install_addon(str(ublock_path), temporary=True)
|
|
logger.info("uBlock Origin installed")
|
|
else:
|
|
logger.warning("uBlock Origin xpi not found at %s", ublock_path)
|
|
|
|
logger.info("Firefox launched (Dark Reader, uBlock, dark theme, strict ETP)")
|
|
return driver
|
|
|
|
|
|
def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool:
|
|
"""Complete Google OAuth sign-in flow. Returns True on success."""
|
|
try:
|
|
wait = WebDriverWait(driver, 150)
|
|
|
|
# Enter email
|
|
email_input = wait.until(
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR, 'input[type="email"]'))
|
|
)
|
|
human_delay()
|
|
email_input.clear()
|
|
human_delay()
|
|
human_type(email_input, email)
|
|
human_delay()
|
|
|
|
# Click Next
|
|
next_btn = driver.find_element(By.CSS_SELECTOR, "#identifierNext")
|
|
human_click(driver, next_btn)
|
|
|
|
# Enter password
|
|
password_input = WebDriverWait(driver, 150).until(
|
|
EC.visibility_of_element_located(
|
|
(By.CSS_SELECTOR, 'input[name="Passwd"], input[type="password"]')
|
|
)
|
|
)
|
|
logger.info("Password field found, filling...")
|
|
human_delay()
|
|
password_input.clear()
|
|
human_delay()
|
|
human_type(password_input, password)
|
|
human_delay()
|
|
|
|
# Click Next
|
|
try:
|
|
password_next = driver.find_element(By.CSS_SELECTOR, "#passwordNext")
|
|
human_click(driver, password_next)
|
|
except NoSuchElementException:
|
|
buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
|
for btn in buttons:
|
|
if "next" in btn.text.lower():
|
|
human_click(driver, btn)
|
|
break
|
|
human_delay()
|
|
|
|
# wait for the page to reload # TODO: wait for a proper event
|
|
_time.sleep(8)
|
|
# Handle consent / TOS / speedbump screens
|
|
for _ in range(15):
|
|
if _is_on_kilo(driver.current_url):
|
|
return True
|
|
|
|
logger.info(
|
|
"Still on Google (%s), looking for buttons...", driver.current_url[:80]
|
|
)
|
|
|
|
all_buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
|
if all_buttons:
|
|
btn_texts = [b.text.strip() for b in all_buttons]
|
|
logger.info("Found %d buttons: %s", len(all_buttons), btn_texts)
|
|
|
|
btn = all_buttons[-1]
|
|
driver.execute_script(
|
|
"arguments[0].scrollIntoView({block: 'center'});", btn
|
|
)
|
|
human_delay()
|
|
|
|
# Try ActionChains for more realistic click
|
|
try:
|
|
ActionChains(driver).move_to_element(btn).pause(
|
|
0.3
|
|
).click().perform()
|
|
except Exception:
|
|
btn.click()
|
|
human_delay()
|
|
|
|
# Check if URL changed
|
|
if _is_on_kilo(driver.current_url):
|
|
return True
|
|
else:
|
|
human_delay()
|
|
|
|
return _is_on_kilo(driver.current_url)
|
|
|
|
except Exception as e:
|
|
logger.warning("Google sign-in error: %s", e)
|
|
return False
|
|
|
|
|
|
def _try_register_once_sync(
|
|
driver: WebDriver,
|
|
email: str,
|
|
password: str,
|
|
) -> str | None:
|
|
"""Attempt one full registration cycle via Google OAuth."""
|
|
try:
|
|
# Step 1: Navigate to Kilo home
|
|
logger.info("[1/6] Navigating to Kilo home...")
|
|
driver.get(KILO_HOME)
|
|
human_delay()
|
|
|
|
wait = WebDriverWait(driver, 150)
|
|
|
|
# Step 2: Click Sign up (opens new tab)
|
|
logger.info("[2/6] Clicking 'Sign up'...")
|
|
handles_before = set(driver.window_handles)
|
|
signup_btn = wait.until(
|
|
EC.element_to_be_clickable(
|
|
(
|
|
By.XPATH,
|
|
"//a[contains(text(), 'Sign up') or contains(text(), 'sign up')]",
|
|
)
|
|
)
|
|
)
|
|
human_click(driver, signup_btn)
|
|
|
|
# Switch to new tab
|
|
WebDriverWait(driver, 30).until(
|
|
lambda d: len(d.window_handles) > len(handles_before)
|
|
)
|
|
new_handles = set(driver.window_handles) - handles_before
|
|
if new_handles:
|
|
driver.switch_to.window(new_handles.pop())
|
|
logger.info("[2/6] Switched to new tab: %s", driver.current_url)
|
|
else:
|
|
raise AutomationError(
|
|
"signup", "No new tab opened after clicking Sign up", driver
|
|
)
|
|
human_delay()
|
|
|
|
# Wait for page load
|
|
WebDriverWait(driver, 30).until(
|
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
)
|
|
human_delay()
|
|
logger.info("[2/6] Page loaded: %s", driver.current_url)
|
|
|
|
# Step 3: Click "Sign in or Sign up"
|
|
logger.info("[3/6] Clicking 'Sign in or Sign up'...")
|
|
signin_signup_btn = wait.until(
|
|
EC.element_to_be_clickable(
|
|
(
|
|
By.XPATH,
|
|
"//a[contains(text(), 'Sign in') or contains(text(), 'sign in') or contains(text(), 'Sign up') or contains(text(), 'sign up')]",
|
|
)
|
|
)
|
|
)
|
|
human_click(driver, signin_signup_btn)
|
|
human_delay()
|
|
|
|
# Wait for page load
|
|
WebDriverWait(driver, 30).until(
|
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
)
|
|
human_delay()
|
|
logger.info("[3/6] Redirected to: %s", driver.current_url)
|
|
|
|
# Step 4: Click "Sign in with Google"
|
|
logger.info("[4/6] Clicking 'Sign in with Google'...")
|
|
google_btn = wait.until(
|
|
EC.element_to_be_clickable(
|
|
(
|
|
By.XPATH,
|
|
"//*[contains(text(), 'Sign in with Google') or contains(text(), 'Continue with Google')]",
|
|
)
|
|
)
|
|
)
|
|
human_click(driver, google_btn)
|
|
|
|
# Wait for Google
|
|
WebDriverWait(driver, 30).until(EC.url_contains("accounts.google.com"))
|
|
logger.info("[4/6] Google sign-in page loaded: %s", driver.current_url)
|
|
|
|
# Step 5: Google sign-in
|
|
logger.info("[5/6] Signing in with Google (%s)...", email)
|
|
success = _google_sign_in(driver, email, password)
|
|
|
|
if not success and not _is_on_kilo(driver.current_url):
|
|
raise AutomationError(
|
|
"google_auth", "Google sign-in did not redirect to Kilo", driver
|
|
)
|
|
|
|
# Wait for redirect to kilo.ai
|
|
logger.info("[5/6] Waiting for Kilo redirect...")
|
|
deadline = _time.time() + 120
|
|
while _time.time() < deadline:
|
|
if (
|
|
_is_on_kilo(driver.current_url)
|
|
and "/users/sign_in" not in driver.current_url
|
|
):
|
|
logger.info("[5/6] On kilo.ai: %s", driver.current_url)
|
|
break
|
|
human_delay()
|
|
else:
|
|
logger.warning("Redirect not detected, current: %s", driver.current_url)
|
|
|
|
# Handle educational account confirmation
|
|
try:
|
|
confirm_btn = WebDriverWait(driver, 10).until(
|
|
EC.element_to_be_clickable((By.CSS_SELECTOR, "input#confirm"))
|
|
)
|
|
logger.info("[5/6] Educational account page, clicking confirm...")
|
|
human_click(driver, confirm_btn)
|
|
except TimeoutException:
|
|
logger.info("[5/6] No educational account page, continuing...")
|
|
|
|
# Wait for /get-started or /profile
|
|
deadline = _time.time() + 60
|
|
while _time.time() < deadline:
|
|
url = driver.current_url
|
|
if "/get-started" in url or "/profile" in url:
|
|
break
|
|
human_delay()
|
|
|
|
# Step 6: Get API key
|
|
logger.info("[6/6] Navigating to profile to get API key...")
|
|
driver.get(PROFILE_URL)
|
|
human_delay()
|
|
|
|
api_key_input = WebDriverWait(driver, 200).until(
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "input#api-key"))
|
|
)
|
|
api_key = api_key_input.get_attribute("value")
|
|
|
|
if not api_key or not api_key.strip():
|
|
raise AutomationError("profile", "API key input is empty", driver)
|
|
|
|
api_key = api_key.strip()
|
|
logger.info("[6/6] API key obtained (length=%d)", len(api_key))
|
|
return api_key
|
|
|
|
except AutomationError as e:
|
|
logger.error("Error at step [%s]: %s", e.step, e.message)
|
|
save_error_screenshot(e.driver or driver, e.step)
|
|
return None
|
|
except Exception as e:
|
|
logger.error("Unexpected error during registration: %s", e)
|
|
save_error_screenshot(driver, "unexpected")
|
|
return None
|
|
|
|
|
|
async def register_kilo_account() -> ProviderTokens | None:
|
|
"""Register a new Kilo account via Google OAuth using Selenium Firefox.
|
|
|
|
Pops one email account from emails.txt and attempts registration.
|
|
Rotates proxy IP between attempts if needed.
|
|
"""
|
|
logger.info("=== Starting Kilo account registration (Google OAuth) ===")
|
|
|
|
account = pop_account()
|
|
if not account:
|
|
logger.error("No email accounts available")
|
|
return None
|
|
|
|
driver: WebDriver | None = None
|
|
|
|
try:
|
|
driver = await asyncio.to_thread(_create_firefox_driver)
|
|
|
|
for ip_attempt in range(MAX_IP_ROTATIONS):
|
|
# driver.get("http://localhost:8005/")
|
|
# await asyncio.sleep(100000000000000000) # for debugging
|
|
if ip_attempt > 0:
|
|
logger.info(
|
|
"Rotating proxy IP (attempt %d/%d)...",
|
|
ip_attempt + 1,
|
|
MAX_IP_ROTATIONS,
|
|
)
|
|
rotated = await rotate_proxy_ip()
|
|
if not rotated:
|
|
logger.warning("IP rotation failed, trying anyway")
|
|
|
|
logger.info(
|
|
"Trying Google account: %s (IP attempt %d/%d)",
|
|
account.email,
|
|
ip_attempt + 1,
|
|
MAX_IP_ROTATIONS,
|
|
)
|
|
|
|
api_key = await asyncio.to_thread(
|
|
_try_register_once_sync, driver, account.email, account.password
|
|
)
|
|
|
|
if api_key:
|
|
return ProviderTokens(
|
|
access_token=api_key,
|
|
refresh_token=None,
|
|
expires_at=0,
|
|
)
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
logger.error("All registration attempts exhausted for %s", account.email)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error("Fatal registration error: %s", e)
|
|
return None
|