diff --git a/.env.example b/.env.example index 7ecb464..00d9430 100644 --- a/.env.example +++ b/.env.example @@ -7,23 +7,17 @@ TARGET_SIZE=5 # Poll interval for checking new accounts when pool incomplete (seconds) POLL_INTERVAL=30 -# Minimum balance threshold - switch token when balance <= MIN_BALANCE -MIN_BALANCE=0 - # HTTPS proxy URL (used by Firefox and balance API) HTTPS_PROXY=http://user:pass@host:port # Path to emails.txt (email:password per line) EMAILS_FILE=/data/emails.txt -# Browser channel for Patchright (chrome or chromium) -BROWSER_CHANNEL=chrome +# Firefox binary path +FIREFOX_BINARY=firefox-esr -# Run browser headless (1/0) -HEADLESS=0 - -# Optional: override Patchright browser cache path -PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers +# Geckodriver path +GECKODRIVER_PATH=/usr/local/bin/geckodriver # Extensions directory (dark-reader.xpi, ublock_origin.xpi) EXTRAS_DIR=/app/extras diff --git a/Dockerfile b/Dockerfile index b740957..ba2d493 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,17 +4,28 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata \ + xvfb \ + xauth \ ca-certificates \ curl \ + firefox-esr=140.8.0esr-1~deb13u1 \ fonts-noto \ fonts-noto-cjk \ fonts-dejavu \ fonts-liberation \ fonts-noto-color-emoji \ + pulseaudio \ + libgl1-mesa-dri \ + libglu1-mesa \ zip && \ rm -rf /var/lib/apt/lists/* && \ fc-cache -fv +# Install geckodriver +ARG GECKO_VERSION=v0.36.0 +RUN curl -fsSL "https://github.com/mozilla/geckodriver/releases/download/${GECKO_VERSION}/geckodriver-${GECKO_VERSION}-linux64.tar.gz" | \ + tar -xzf - -C /usr/local/bin && chmod +x /usr/local/bin/geckodriver + # Download uBlock Origin (latest) ARG UBLOCK_VERSION=1.69.0 RUN mkdir -p /extras/extensions && \ @@ -25,13 +36,12 @@ COPY pyproject.toml uv.lock . RUN pip install --no-cache-dir uv RUN uv sync --frozen --no-dev -# Install Patchright browsers (Chrome) -RUN PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers \ - /app/.venv/bin/patchright install chrome - # Configure fontconfig for emoji support -# Build Dark Reader extension +COPY extras/patch_firefox.py . +RUN python3 ./patch_firefox.py + +# Build Dark Reader extension (Selenium cleanup) COPY extras/extension /tmp/extension RUN cd /tmp/extension && zip -r /extras/extensions/dark-reader.xpi . && rm -rf /tmp/extension @@ -39,13 +49,11 @@ ENV PYTHONUNBUFFERED=1 ENV PORT=80 ENV TARGET_SIZE=5 ENV POLL_INTERVAL=30 -ENV MIN_BALANCE=0 ENV DATA_DIR=/data ENV EXTRAS_DIR=/extras ENV EMAILS_FILE=/data/emails.txt -ENV BROWSER_CHANNEL=chrome -ENV HEADLESS=0 -ENV PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers +ENV FIREFOX_BINARY=/usr/bin/firefox-esr +ENV GECKODRIVER_PATH=/usr/local/bin/geckodriver VOLUME ["/data"] diff --git a/appimage/AppRun b/appimage/AppRun deleted file mode 100755 index 47d00a6..0000000 --- a/appimage/AppRun +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh -set -e - -APPDIR="$(dirname "$(readlink -f "$0")")" - -export PATH="$APPDIR/usr/bin:$PATH" -export EXTRAS_DIR="${EXTRAS_DIR:-$APPDIR/opt/kilocode/extras}" -export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$APPDIR/opt/patchright-browsers}" -export CHROME_PATH="${CHROME_PATH:-$APPDIR/opt/google/chrome/chrome}" - -if [ -z "${HOME}" ] || [ ! -w "${HOME}" ]; then - export HOME="/tmp/kilocode-home" -fi - -mkdir -p "$HOME" -export XDG_CONFIG_HOME="$HOME/.config" -export XDG_CACHE_HOME="$HOME/.cache" -export XDG_DATA_HOME="$HOME/.local/share" -mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" - -# Ensure DATA_DIR is writable (default to current directory) -if [ -z "${DATA_DIR}" ]; then - DATA_DIR="$PWD/data" -fi - -if [ ! -d "$DATA_DIR" ] || [ ! -w "$DATA_DIR" ]; then - DATA_DIR="$PWD/data" -fi - -mkdir -p "$DATA_DIR" -export DATA_DIR - -VENV_DIR="$APPDIR/opt/kilocode/.venv" -PY_BASE="$APPDIR/root/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu" -PYTHON_BIN="$PY_BASE/bin/python3.14" -export VIRTUAL_ENV="$VENV_DIR" -export PYTHONHOME="$PY_BASE" -export PYTHONPATH="$VENV_DIR/lib/python3.14/site-packages" -SERVER_PY="$APPDIR/opt/kilocode/src/server.py" - -if [ ! -x "$PYTHON_BIN" ]; then - echo "Python runtime not found: $PYTHON_BIN" >&2 - exit 1 -fi - -exec "$PYTHON_BIN" -u "$SERVER_PY" diff --git a/appimage/Dockerfile b/appimage/Dockerfile deleted file mode 100644 index 9a9cc31..0000000 --- a/appimage/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -FROM debian:13-slim - -ENV DEBIAN_FRONTEND=noninteractive - -ARG UBLOCK_VERSION=1.69.0 -ARG PATCHRIGHT_VERSION=1.58.2 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - file \ - python3 \ - python3-venv \ - python3-pip \ - zip \ - xz-utils \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -RUN python3 -m venv /opt/uv-bootstrap && \ - /opt/uv-bootstrap/bin/pip install --no-cache-dir uv - -COPY pyproject.toml uv.lock ./ - -RUN UV_CACHE_DIR=/opt/uv-cache \ - UV_PROJECT_ENVIRONMENT=/opt/build-deps \ - /opt/uv-bootstrap/bin/uv sync --frozen --no-dev - -RUN python3 -m venv /opt/patchright-venv && \ - /opt/patchright-venv/bin/pip install --no-cache-dir "patchright==${PATCHRIGHT_VERSION}" && \ - PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers /opt/patchright-venv/bin/patchright install chrome && \ - ls -la /opt/google/chrome/ && \ - ls -la /opt/patchright-browsers/ - -RUN mkdir -p /opt/assets && \ - curl -fsSL -o /opt/assets/ublock_origin.xpi \ - "https://github.com/gorhill/uBlock/releases/download/${UBLOCK_VERSION}/uBlock0_${UBLOCK_VERSION}.firefox.signed.xpi" - -RUN curl -fsSL -o /opt/appimagetool.AppImage \ - "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" && \ - chmod +x /opt/appimagetool.AppImage - -COPY src/ ./src/ -COPY appimage/ ./appimage/ -COPY extras/extension/ ./extras/extension/ - -RUN cd extras/extension && zip -r /opt/assets/dark-reader.xpi . && cd /build - -RUN chmod +x ./appimage/build_in_container.sh - -CMD ["./appimage/build_in_container.sh"] diff --git a/appimage/build_appimage.sh b/appimage/build_appimage.sh deleted file mode 100755 index 5bcca44..0000000 --- a/appimage/build_appimage.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -set -e - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -OUT_DIR="$ROOT_DIR/dist" - -mkdir -p "$OUT_DIR" - -docker build -f "$ROOT_DIR/appimage/Dockerfile" -t megacode-appimage-builder "$ROOT_DIR" - -docker run --rm \ - -e UBLOCK_VERSION="${UBLOCK_VERSION:-1.69.0}" \ - -v "$OUT_DIR:/out" \ - megacode-appimage-builder - -echo "Done: $OUT_DIR/megacode.appimage" diff --git a/appimage/build_in_container.sh b/appimage/build_in_container.sh deleted file mode 100755 index d3876d3..0000000 --- a/appimage/build_in_container.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh -set -e - -APPDIR="/AppDir" -OUTDIR="/out" - -mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/opt/kilocode" "$OUTDIR" - -cp -a /opt/assets/dark-reader.xpi /tmp/dark-reader.xpi -cp -a /opt/assets/ublock_origin.xpi /tmp/ublock_origin.xpi - -cp -a /build/src "$APPDIR/opt/kilocode/src" -mkdir -p "$APPDIR/opt/kilocode/extras/extensions" -cp -a /tmp/dark-reader.xpi "$APPDIR/opt/kilocode/extras/extensions/dark-reader.xpi" -cp -a /tmp/ublock_origin.xpi "$APPDIR/opt/kilocode/extras/extensions/ublock_origin.xpi" - -export UV_CACHE_DIR="/opt/uv-cache" -export UV_PYTHON_DOWNLOADS=auto -export UV_PYTHON_PREFERENCE=managed - -/opt/uv-bootstrap/bin/uv venv --python 3.14 "$APPDIR/opt/kilocode/.venv" -cd /build -UV_PROJECT_ENVIRONMENT="$APPDIR/opt/kilocode/.venv" /opt/uv-bootstrap/bin/uv sync --frozen --no-dev - -mkdir -p "$APPDIR/opt/patchright-browsers" -cp -a /opt/patchright-browsers/* "$APPDIR/opt/patchright-browsers/" || true - -mkdir -p "$APPDIR/opt/google" -cp -a /opt/google/chrome "$APPDIR/opt/google/" || { echo "ERROR: Chrome not found at /opt/google/chrome"; exit 1; } - -if [ -d "/root/.local/share/uv/python" ]; then - mkdir -p "$APPDIR/root/.local/share/uv" - cp -a /root/.local/share/uv/python "$APPDIR/root/.local/share/uv/" -fi - -cp /build/appimage/AppRun "$APPDIR/AppRun" -cp /build/appimage/kilocode.desktop "$APPDIR/kilocode.desktop" -cp /build/appimage/kilocode.svg "$APPDIR/kilocode.svg" -chmod +x "$APPDIR/AppRun" - -rm -f "$OUTDIR/megacode.appimage" -ARCH=x86_64 /opt/appimagetool.AppImage --appimage-extract-and-run \ - "$APPDIR" "$OUTDIR/megacode.appimage" - -echo "AppImage built at $OUTDIR/megacode.appimage" diff --git a/appimage/kilocode.desktop b/appimage/kilocode.desktop deleted file mode 100644 index f63f6ff..0000000 --- a/appimage/kilocode.desktop +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Kilocode Service -Exec=kilocode -Icon=kilocode -Categories=Utility; -Terminal=true diff --git a/appimage/kilocode.svg b/appimage/kilocode.svg deleted file mode 100644 index d57a7e0..0000000 --- a/appimage/kilocode.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/entrypoint.sh b/entrypoint.sh index 12fdb42..6cb866b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,21 @@ #!/bin/sh set -e +XVFB_PID="" + +cleanup() { + if [ -n "$XVFB_PID" ]; then + kill "$XVFB_PID" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM + +if [ -z "$DISPLAY" ]; then + export DISPLAY=:0 + export LIBGL_ALWAYS_SOFTWARE=1 + Xvfb :0 -screen 0 1920x1080x24+32 -nolisten tcp -ac -dpi 96 >/tmp/xvfb.log 2>&1 & + XVFB_PID=$! +fi + exec /app/.venv/bin/python -u server.py diff --git a/pyproject.toml b/pyproject.toml index be758d2..59b98ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" requires-python = ">=3.14" dependencies = [ "aiohttp==3.13.3", - "patchright>=1.52.5", + "selenium>=4.41.0", ] [project.optional-dependencies] diff --git a/src/emails.py b/src/emails.py index ee9f512..e8e548c 100644 --- a/src/emails.py +++ b/src/emails.py @@ -90,19 +90,19 @@ async def pop_account() -> EmailAccount | None: return account -async def mark_done(account: EmailAccount) -> None: - """Append email:password to done.txt after successful registration.""" +async def mark_done(email: str) -> None: + """Append email to done.txt after successful registration.""" DATA_DIR.mkdir(parents=True, exist_ok=True) async with _file_lock: with open(DONE_FILE, "a") as f: - f.write(f"{account.email}:{account.password}\n") - logger.info("Marked done: %s", account.email) + f.write(email + "\n") + logger.info("Marked done: %s", email) -async def mark_failed(account: EmailAccount) -> None: - """Append email:password to failed.txt after failed registration.""" +async def mark_failed(email: str) -> None: + """Append email to failed.txt after failed registration.""" DATA_DIR.mkdir(parents=True, exist_ok=True) async with _file_lock: with open(FAILED_FILE, "a") as f: - f.write(f"{account.email}:{account.password}\n") - logger.warning("Marked failed: %s", account.email) + f.write(email + "\n") + logger.warning("Marked failed: %s", email) diff --git a/src/proxy.py b/src/proxy.py index bb5309b..84f1955 100644 --- a/src/proxy.py +++ b/src/proxy.py @@ -1,6 +1,5 @@ import logging import os -from urllib.parse import urljoin import aiohttp @@ -14,7 +13,10 @@ async def rotate_proxy_ip() -> bool: logger.warning("No proxy configured, cannot rotate IP") return False - rotate_url = urljoin(HTTPS_PROXY + '/', '/rotate') + parsed = HTTPS_PROXY.replace("http://", "").replace("https://", "").split("@")[-1] + host, port = parsed.split(":")[0], parsed.split(":")[1].split("/")[0] + rotate_url = f"http://{host}:{port}/rotate" + timeout = aiohttp.ClientTimeout(total=15) try: async with aiohttp.ClientSession(timeout=timeout) as session: diff --git a/src/registration.py b/src/registration.py index b5d94e6..31e2ef8 100644 --- a/src/registration.py +++ b/src/registration.py @@ -2,320 +2,410 @@ import asyncio import logging import os import random -import tempfile import time as _time -from dataclasses import dataclass from datetime import datetime from pathlib import Path from urllib.parse import urlparse -from patchright.async_api import ( - async_playwright, - TimeoutError as PlaywrightTimeoutError, +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 emails import pop_account, mark_done, mark_failed from proxy import HTTPS_PROXY, rotate_proxy_ip -from usage import get_balance +from emails import pop_account, mark_done, mark_failed 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 -TIMEOUT = 20 -MIN_BALANCE = float(os.environ.get("MIN_BALANCE", "0")) -BROWSER_CHANNEL = os.environ.get("BROWSER_CHANNEL", "chrome") -HEADLESS = os.environ.get("HEADLESS", "0").lower() in {"1", "true", "yes"} - -CHROME_PATH = os.environ.get("CHROME_PATH", "/opt/google/chrome/chrome") -if not Path(CHROME_PATH).exists(): - CHROME_PATH = None -def _now_ts() -> str: - return datetime.now().strftime("%Y%m%d_%H%M%S") +def human_delay(): + _time.sleep(random.uniform(0.5, 1.35)) -async def human_delay(): - await asyncio.sleep(random.uniform(0.4, 1.1)) +def human_type(element, text): + for char in text: + element.send_keys(char) + _time.sleep(random.uniform(0.05, 0.15)) -async def human_type(locator, text: str): - await locator.click() - for ch in text: - await locator.type(ch, delay=random.randint(30, 120)) - - -async def human_click(locator): - await locator.scroll_into_view_if_needed() - await human_delay() - await locator.click() - await human_delay() +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") -@dataclass -class ProxyConfig: - server: str - username: str | None = None - password: str | None = None +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 _parse_proxy(proxy_url: str | None) -> ProxyConfig | None: - if not proxy_url: - return None - - cleaned = proxy_url.strip() - scheme = "http" - - if cleaned.startswith("http://"): - cleaned = cleaned[len("http://") :] - default_port = 80 - elif cleaned.startswith("https://"): - cleaned = cleaned[len("https://") :] - default_port = 443 - scheme = "https" - else: - default_port = 80 - - cleaned = cleaned.split("/", 1)[0] - - username = None - password = None - if "@" in cleaned: - creds, cleaned = cleaned.split("@", 1) - if ":" in creds: - username, password = creds.split(":", 1) - else: - username = creds - - if ":" in cleaned: - host, port_str = cleaned.split(":", 1) +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: - port = int(port_str) - except ValueError: - port = default_port + 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: - host = cleaned - port = default_port + logger.warning("Dark Reader xpi not found at %s", dark_reader_path) - host = host.strip() - if not host: - return None + # 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) - server = f"{scheme}://{host}:{port}" - return ProxyConfig(server=server, username=username, password=password) + logger.info("Firefox launched (Dark Reader, uBlock, dark theme, strict ETP)") + return driver -async def save_error_screenshot(page, step: str) -> None: - if not page: - return - - screenshots_dir = DATA_DIR / "screenshots" - screenshots_dir.mkdir(parents=True, exist_ok=True) - filename = screenshots_dir / f"error_kilo_{step}_{_now_ts()}.png" +def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool: + """Complete Google OAuth sign-in flow. Returns True on success.""" try: - await page.screenshot(path=str(filename), full_page=True) - logger.error("Screenshot saved: %s", filename) - except Exception as e: - logger.warning("Failed to save screenshot at step %s: %s", step, e) + 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() -async def _google_sign_in(page, email: str, password: str) -> bool: - try: - email_input = page.locator('input[type="email"]') - await email_input.wait_for(state="visible", timeout=TIMEOUT * 1000) - await human_type(email_input, email) + # Click Next + next_btn = driver.find_element(By.CSS_SELECTOR, "#identifierNext") + human_click(driver, next_btn) - await human_click(page.locator("#identifierNext")) - - password_input = page.get_by_role("textbox", name="password") - await password_input.wait_for(state="visible", timeout=TIMEOUT * 1000) + # 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...") - await human_type(password_input, password) + human_delay() + password_input.clear() + human_delay() + human_type(password_input, password) + human_delay() + # Click Next try: - await human_click(page.locator("#passwordNext")) - except Exception: - buttons = page.locator("button") - count = await buttons.count() - for i in range(count): - btn = buttons.nth(i) - text = (await btn.inner_text()).lower() - if "next" in text: - await human_click(btn) + 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() - await asyncio.sleep(3) - - for _ in range(10): - if _is_on_kilo(page.url): + # 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...", page.url[:80]) + logger.info( + "Still on Google (%s), looking for buttons...", driver.current_url[:80] + ) - buttons = page.locator("button") - count = await buttons.count() - if count > 0: - btn = buttons.nth(count - 1) + 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: - await human_click(btn) + ActionChains(driver).move_to_element(btn).pause( + 0.3 + ).click().perform() except Exception: - pass + btn.click() + human_delay() - await asyncio.sleep(0.7) + # Check if URL changed + if _is_on_kilo(driver.current_url): + return True + else: + human_delay() - return _is_on_kilo(page.url) + return _is_on_kilo(driver.current_url) - except PlaywrightTimeoutError: - logger.warning("Google sign-in timeout") - return False except Exception as e: logger.warning("Google sign-in error: %s", e) return False -async def _try_register_once( - email: str, password: str, proxy: ProxyConfig | None +def _try_register_once_sync( + driver: WebDriver, + email: str, + password: str, ) -> str | None: - page = None - + """Attempt one full registration cycle via Google OAuth.""" try: - logger.info( - "Env DISPLAY=%s WAYLAND_DISPLAY=%s XDG_RUNTIME_DIR=%s", - os.environ.get("DISPLAY"), - os.environ.get("WAYLAND_DISPLAY"), - os.environ.get("XDG_RUNTIME_DIR"), + # 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) - proxy_dict = None - if proxy: - proxy_dict = {"server": proxy.server} - if proxy.username: - proxy_dict["username"] = proxy.username - if proxy.password: - proxy_dict["password"] = proxy.password - logger.info("Using proxy: %s", proxy.server) + # 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() - async with async_playwright() as p: - with tempfile.TemporaryDirectory(prefix="kilo_profile_") as tmpdir: - launch_args = { - "user_data_dir": tmpdir, - "headless": HEADLESS, - "args": ["--start-maximized"], - "no_viewport": True, - "proxy": proxy_dict, - } - if CHROME_PATH: - launch_args["executable_path"] = CHROME_PATH - else: - launch_args["channel"] = BROWSER_CHANNEL - context = await p.chromium.launch_persistent_context(**launch_args) - context.set_default_timeout(TIMEOUT * 1000) + # 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) - if context.pages: - page = context.pages[0] - else: - page = await context.new_page() - - logger.info("[1/6] Navigating to Kilo home...") - await page.goto(KILO_HOME, wait_until="load") - await human_delay() - - logger.info("[2/6] Clicking 'Sign up'...") - async with context.expect_page() as new_page_info: - await human_click(page.locator("text=/Sign up/i")) - page = await new_page_info.value - await page.wait_for_load_state("load") - logger.info("[2/6] Switched to new tab: %s", page.url) - - logger.info("[3/6] Clicking 'Sign in or Sign up'...") - await human_click(page.locator("text=/Sign in|Sign up/i")) - await page.wait_for_load_state("load") - logger.info("[3/6] Redirected to: %s", page.url) - - logger.info("[4/6] Clicking 'Sign in with Google'...") - await human_click( - page.locator("text=/Sign in with Google|Continue with Google/i") + # 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() - await page.wait_for_url( - "**accounts.google.com/**", timeout=TIMEOUT * 1000 - ) - logger.info( - "[4/6] Google sign-in page loaded: %s", page.url.split("?")[0] + # 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) - logger.info("[5/6] Signing in with Google (%s)...", email) - success = await _google_sign_in(page, email, password) - if not success and not _is_on_kilo(page.url): - await save_error_screenshot(page, "google_auth") - return None + # 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.split('?', 1)[0]) - logger.info("[5/6] Waiting for Kilo redirect...") - deadline = _time.time() + TIMEOUT - while _time.time() < deadline: - if _is_on_kilo(page.url) and "/users/sign_in" not in page.url: - logger.info("[5/6] On kilo.ai: %s", page.url) - break - await asyncio.sleep(0.5) - else: - await save_error_screenshot(page, "redirect") - return None + # Step 5: Google sign-in + logger.info("[5/6] Signing in with Google (%s)...", email) + success = _google_sign_in(driver, email, password) - try: - confirm_btn = page.locator("input#confirm") - await confirm_btn.wait_for(state="visible", timeout=5000) - logger.info("[5/6] Educational account page, clicking confirm...") - await human_click(confirm_btn) - except PlaywrightTimeoutError: - logger.info("[5/6] No educational account page, continuing...") + if not success and not _is_on_kilo(driver.current_url): + raise AutomationError( + "google_auth", "Google sign-in did not redirect to Kilo", driver + ) - deadline = _time.time() + TIMEOUT - while _time.time() < deadline: - url = page.url - if "/get-started" in url or "/profile" in url: - break - await asyncio.sleep(0.5) - else: - await save_error_screenshot(page, "profile_redirect") - return None + # 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) - logger.info("[6/6] Navigating to profile to get API key...") - await page.goto(PROFILE_URL, wait_until="load") - await human_delay() + # 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...") - api_key_input = page.locator("input#api-key") - await api_key_input.wait_for(state="visible", timeout=TIMEOUT * 1000) - api_key = await api_key_input.get_attribute("value") - if not api_key or not api_key.strip(): - await save_error_screenshot(page, "profile") - return None + # 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() - api_key = api_key.strip() - logger.info("[6/6] API key obtained (length=%d)", len(api_key)) - return api_key + # Step 6: Get API key + logger.info("[6/6] Navigating to profile to get API key...") + driver.get(PROFILE_URL) + human_delay() - except PlaywrightTimeoutError as e: - logger.error("Timeout during registration: %s", e) - await save_error_screenshot(page, "timeout") + 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) - await save_error_screenshot(page, "unexpected") + save_error_screenshot(driver, "unexpected") return None async def register_kilo_account() -> bool: + """Register a new Kilo account via Google OAuth using Selenium Firefox. + + Pops one email account from emails.txt and attempts registration. + On success, appends token to pool, marks email done, returns True. + On failure, marks email failed, returns False. + Rotates proxy IP between attempts if needed. + """ logger.info("=== Starting Kilo account registration (Google OAuth) ===") account = await pop_account() @@ -323,9 +413,11 @@ async def register_kilo_account() -> bool: logger.error("No email accounts available") return False - proxy = _parse_proxy(HTTPS_PROXY) + driver: WebDriver | None = None try: + driver = await asyncio.to_thread(_create_firefox_driver) + for ip_attempt in range(MAX_IP_ROTATIONS): if ip_attempt > 0: logger.info( @@ -344,59 +436,31 @@ async def register_kilo_account() -> bool: MAX_IP_ROTATIONS, ) - api_key = await _try_register_once(account.email, account.password, proxy) + api_key = await asyncio.to_thread( + _try_register_once_sync, driver, account.email, account.password + ) if api_key: - balance_result = await get_balance(api_key) - if balance_result.is_invalid: - logger.warning("Token invalid right after auth, dropping") - await mark_failed(account) - return False - - if balance_result.balance is None: - logger.warning("Balance unavailable after auth, dropping token") - await mark_failed(account) - return False - - if balance_result.balance <= MIN_BALANCE: - logger.warning( - "Token balance %.2f <= %.2f, dropping", - balance_result.balance, - MIN_BALANCE, - ) - await mark_failed(account) - return False - from pool import append_token await append_token(api_key) - await mark_done(account) + await mark_done(account.email) logger.info("Token added to pool: %s...", api_key[:10]) return True - logger.warning( - "Attempt %d/%d failed for %s", - ip_attempt + 1, - MAX_IP_ROTATIONS, - account.email, - ) await asyncio.sleep(2) - await mark_failed(account) - logger.error( - "All %d attempts exhausted for %s", MAX_IP_ROTATIONS, account.email - ) + await mark_failed(account.email) + logger.error("All registration attempts exhausted for %s", account.email) return False except Exception as e: - logger.exception("Fatal registration error: %s", e) - try: - await mark_failed(account) - except Exception: - pass + await mark_failed(account.email) + logger.error("Fatal registration error: %s", e) return False finally: - try: - await rotate_proxy_ip() - except Exception: - logger.warning("Failed to rotate proxy after account") + if driver: + try: + driver.quit() + except Exception: + pass diff --git a/src/server.py b/src/server.py index 7f53ef2..4dd2d0e 100644 --- a/src/server.py +++ b/src/server.py @@ -11,7 +11,6 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) PORT = int(os.environ.get("PORT", 8080)) -MIN_BALANCE = float(os.environ.get("MIN_BALANCE", "0")) async def on_startup(app: web.Application): @@ -44,36 +43,21 @@ async def token_handler(request: web.Request) -> web.Response: logger.info("token: %s pool: %d balance: -", token[:5], current_size) return web.json_response({"token": token}) - result = await get_balance(token) + balance_data = await get_balance(token) - if result.is_invalid: - logger.warning("Token invalid, removing: %s", token[:10]) + if balance_data is None: await pop_token() await trigger_refill() continue - if result.balance is None: - logger.warning( - "Balance unavailable, keeping token: %s (error: %s)", - token[:10], - result.error, - ) - current_size = await pool_size() - logger.info("token: %s pool: %d balance: -", token[:5], current_size) - return web.json_response({"token": token}) - - if result.balance <= MIN_BALANCE: - logger.info( - "Token balance below threshold (%.2f <= %.2f), removing", - result.balance, - MIN_BALANCE, - ) + balance = balance_data.get("balance", balance_data.get("remaining", 0)) + if balance is None or balance <= 0: await pop_token() await trigger_refill() continue current_size = await pool_size() - calculated_balance = result.balance + (current_size - 1) * 5 + calculated_balance = balance + (current_size - 1) * 5 logger.info( "token: %s pool: %d balance: %.2f", token[:5], @@ -113,7 +97,7 @@ def create_app() -> web.Application: def main(): logger.info("Starting server on port %s", PORT) app = create_app() - web.run_app(app, host="0.0.0.0", port=PORT, access_log=None) + web.run_app(app, host="0.0.0.0", port=PORT) if __name__ == "__main__": diff --git a/src/usage.py b/src/usage.py index 56a7bc2..7760532 100644 --- a/src/usage.py +++ b/src/usage.py @@ -1,5 +1,4 @@ import logging -from dataclasses import dataclass from typing import Any import aiohttp @@ -11,24 +10,11 @@ logger = logging.getLogger(__name__) BALANCE_URL = "https://api.kilo.ai/api/profile/balance" -@dataclass -class BalanceResult: - balance: float | None - is_invalid: bool - error: str | None - - async def get_balance( api_key: str, timeout_ms: int = 10000, -) -> BalanceResult: - """Fetch balance from Kilo API. - - Returns BalanceResult with: - - balance: the balance value (None if unavailable) - - is_invalid: True if token is definitively invalid (should be removed) - - error: error message if any - """ +) -> dict[str, Any] | None: + """Fetch balance from Kilo API (routed through proxy if configured).""" headers = { "Authorization": f"Bearer {api_key}", "Accept": "application/json", @@ -36,12 +22,9 @@ async def get_balance( proxy_url = proxy.HTTPS_PROXY timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000) - try: async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get( - BALANCE_URL, headers=headers, proxy=proxy_url - ) as res: + async with session.get(BALANCE_URL, headers=headers, proxy=proxy_url) as res: if not res.ok: body = await res.text() logger.warning( @@ -49,57 +32,17 @@ async def get_balance( res.status, body[:300], ) - return BalanceResult( - balance=None, - is_invalid=False, - error=f"http_{res.status}", - ) - + return None data = await res.json() except (aiohttp.ClientError, TimeoutError) as e: logger.warning("Balance fetch error: %s", e) - return BalanceResult( - balance=None, - is_invalid=False, - error="network_error", - ) + return None except Exception as e: logger.warning("Balance fetch unexpected error: %s", e) - return BalanceResult( - balance=None, - is_invalid=False, - error="unknown_error", - ) + return None if not isinstance(data, dict): logger.warning("Balance response is not a dict") - return BalanceResult( - balance=None, - is_invalid=False, - error="invalid_response", - ) + return None - if data.get("success") is False: - error_msg = data.get("error", "unknown error") - logger.warning("API error: %s", error_msg) - - if "invalid token" in error_msg.lower(): - return BalanceResult( - balance=None, - is_invalid=True, - error=error_msg, - ) - - return BalanceResult( - balance=None, - is_invalid=False, - error=error_msg, - ) - - balance = data.get("balance", data.get("remaining", None)) - - return BalanceResult( - balance=balance, - is_invalid=False, - error=None, - ) + return data diff --git a/tests/test_emails.py b/tests/test_emails.py index a76cf3b..bbfd82a 100644 --- a/tests/test_emails.py +++ b/tests/test_emails.py @@ -93,11 +93,10 @@ async def test_mark_done(tmp_path, monkeypatch): monkeypatch.setattr(em, "DATA_DIR", tmp_path) monkeypatch.setattr(em, "DONE_FILE", done_file) - account = em.EmailAccount(email="test@example.com", password="secret123") - await mark_done(account) + await mark_done("test@example.com") content = done_file.read_text() - assert "test@example.com:secret123" in content + assert "test@example.com" in content @pytest.mark.asyncio @@ -106,11 +105,10 @@ async def test_mark_failed(tmp_path, monkeypatch): monkeypatch.setattr(em, "DATA_DIR", tmp_path) monkeypatch.setattr(em, "FAILED_FILE", failed_file) - account = em.EmailAccount(email="test@example.com", password="secret123") - await mark_failed(account) + await mark_failed("test@example.com") content = failed_file.read_text() - assert "test@example.com:secret123" in content + assert "test@example.com" in content @pytest.mark.asyncio diff --git a/tests/test_server.py b/tests/test_server.py index 25bee20..fbda93d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,7 +4,6 @@ from aiohttp import web from aiohttp.test_utils import AioHTTPTestCase from server import create_app -from usage import BalanceResult class TestServer(AioHTTPTestCase): @@ -48,9 +47,7 @@ class TestServer(AioHTTPTestCase): self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): mock_first.return_value = "test_token_12345" - mock_balance.return_value = BalanceResult( - balance=10.0, is_invalid=False, error=None - ) + mock_balance.return_value = {"balance": 10.0} mock_size.return_value = 3 resp = await self.client.get("/token") @@ -68,10 +65,7 @@ class TestServer(AioHTTPTestCase): self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): mock_first.side_effect = ["bad_token", "good_token"] - mock_balance.side_effect = [ - BalanceResult(balance=0.0, is_invalid=False, error=None), - BalanceResult(balance=15.0, is_invalid=False, error=None), - ] + mock_balance.side_effect = [{"balance": 0}, {"balance": 15.0}] mock_size.return_value = 2 resp = await self.client.get("/token") @@ -85,41 +79,18 @@ class TestServer(AioHTTPTestCase): @patch("server.trigger_refill", new_callable=AsyncMock) @patch("server.pool_size", new_callable=AsyncMock) @patch("server.get_balance", new_callable=AsyncMock) - async def test_token_network_error_kept( + async def test_token_balance_fetch_fails( self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): - mock_first.return_value = "test_token" - mock_balance.return_value = BalanceResult( - balance=None, is_invalid=False, error="network_error" - ) - mock_size.return_value = 2 - - resp = await self.client.get("/token") - assert resp.status == 200 - data = await resp.json() - assert data["token"] == "test_token" - mock_pop.assert_not_called() - - @patch("server.get_first_token", new_callable=AsyncMock) - @patch("server.pop_token", new_callable=AsyncMock) - @patch("server.trigger_refill", new_callable=AsyncMock) - @patch("server.pool_size", new_callable=AsyncMock) - @patch("server.get_balance", new_callable=AsyncMock) - async def test_token_invalid_removed( - self, mock_balance, mock_size, mock_refill, mock_pop, mock_first - ): - mock_first.side_effect = ["invalid_token", "good_token"] - mock_balance.side_effect = [ - BalanceResult(balance=None, is_invalid=True, error="Invalid token (xxx)"), - BalanceResult(balance=10.0, is_invalid=False, error=None), - ] + mock_first.side_effect = ["bad_token", "good_token"] + mock_balance.side_effect = [None, {"balance": 10.0}] mock_size.return_value = 2 resp = await self.client.get("/token") assert resp.status == 200 data = await resp.json() assert data["token"] == "good_token" - assert mock_pop.call_count == 1 + mock_pop.assert_called() @patch("server.get_first_token", new_callable=AsyncMock) @patch("server.pop_token", new_callable=AsyncMock) @@ -130,9 +101,7 @@ class TestServer(AioHTTPTestCase): self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): mock_first.return_value = "test_token" - mock_balance.return_value = BalanceResult( - balance=20.0, is_invalid=False, error=None - ) + mock_balance.return_value = {"remaining": 20.0} mock_size.return_value = 1 resp = await self.client.get("/token") @@ -162,10 +131,7 @@ class TestServer(AioHTTPTestCase): self, mock_balance, mock_size, mock_wait, mock_refill, mock_pop, mock_first ): mock_first.side_effect = ["token1", "token2", None] - mock_balance.side_effect = [ - BalanceResult(balance=0.0, is_invalid=False, error=None), - BalanceResult(balance=-5.0, is_invalid=False, error=None), - ] + mock_balance.side_effect = [{"balance": 0}, {"balance": -5}] mock_wait.return_value = None mock_size.return_value = 0 diff --git a/tests/test_usage.py b/tests/test_usage.py deleted file mode 100644 index dfe6c50..0000000 --- a/tests/test_usage.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - -from usage import BalanceResult - - -def test_balance_result_dataclass(): - result = BalanceResult(balance=10.5, is_invalid=False, error=None) - assert result.balance == 10.5 - assert result.is_invalid is False - assert result.error is None - - -def test_balance_result_invalid(): - result = BalanceResult(balance=None, is_invalid=True, error="Invalid token") - assert result.balance is None - assert result.is_invalid is True - assert result.error == "Invalid token" diff --git a/uv.lock b/uv.lock index 20819a9..56d5fd5 100644 --- a/uv.lock +++ b/uv.lock @@ -83,6 +83,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -134,28 +160,12 @@ wheels = [ ] [[package]] -name = "greenlet" -version = "3.3.2" +name = "h11" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -182,7 +192,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, - { name = "patchright" }, + { name = "selenium" }, ] [package.optional-dependencies] @@ -200,8 +210,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = "==3.13.3" }, - { name = "patchright", specifier = ">=1.52.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "selenium", specifier = ">=4.41.0" }, ] provides-extras = ["dev"] @@ -257,6 +267,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -266,25 +288,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "patchright" -version = "1.58.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/afacd242f1ac8265275531c2e1be387f0c3b87ed14accff118c1e824695e/patchright-1.58.2-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:3930464552e52f4d5283998db5797e1797c1869206bce25c065b2d84a69e6bfb", size = 42237382, upload-time = "2026-03-07T07:42:41.261Z" }, - { url = "https://files.pythonhosted.org/packages/9b/38/e8f173299b05bbf5fd0278fbee5ceaf25eab93fece203bb5b08ae924d604/patchright-1.58.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be76fa83f5b36219375fc0ed52f76de800eb2388844c185bb857a2e107caea13", size = 41025905, upload-time = "2026-03-07T07:42:44.961Z" }, - { url = "https://files.pythonhosted.org/packages/ba/08/5c97f3f3300a93c62b417b5dac86d22ad771e0941cd5b59c6054d7716197/patchright-1.58.2-py3-none-macosx_11_0_universal2.whl", hash = "sha256:8dc1005c5683c8661de461e5ee85f857b43758f1e2599a7d8a44c50c6ad9c5d7", size = 42237381, upload-time = "2026-03-07T07:42:48.156Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2b/cb8b7053f2ede3586d89cb7e45f7b643751f8d97b4dfa9af7f4188aac3f9/patchright-1.58.2-py3-none-manylinux1_x86_64.whl", hash = "sha256:13aef416c59f23f0fb552658281890ef349db2bee2e449c159560867c2e6cb61", size = 46221550, upload-time = "2026-03-07T07:42:51.984Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d9/33f3c4839ddbc3255ab012457220d56d7a910174a0a41424f6424a8b156f/patchright-1.58.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e68d0c538b5bd2bd6ef0b1327e9e766c3919d5aeade8b7bd4b29ecd3adfc0b4", size = 45950498, upload-time = "2026-03-07T07:42:55.814Z" }, - { url = "https://files.pythonhosted.org/packages/bb/63/3b054f25a44721b9a530ec12de33d6b5d94cd9952748c2586b2a64ef62ba/patchright-1.58.2-py3-none-win32.whl", hash = "sha256:7dac724893fde90d726b125f7c35507a2afb5480c23cb57f88a31484d131de98", size = 36802278, upload-time = "2026-03-07T07:42:59.362Z" }, - { url = "https://files.pythonhosted.org/packages/c4/11/f06d2f6ae8e0c1aea4b17b18a105dc2ad28e358217896eb3720e80e2d297/patchright-1.58.2-py3-none-win_amd64.whl", hash = "sha256:9b740c13343a6e412efe052d0c17a65910cc4e3fd0fd6b62c1ac8dc1eec4c158", size = 36802282, upload-time = "2026-03-07T07:43:02.775Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ae/a85dca1ebcdfc63e5838783c0929d82066dacd7448e29911d052bbd286cb/patchright-1.58.2-py3-none-win_arm64.whl", hash = "sha256:958cd884787d140dd464ec2901ea85b9634aad5e8444a267f407ee648de04667", size = 33072202, upload-time = "2026-03-07T07:43:06.344Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -334,15 +337,12 @@ wheels = [ ] [[package]] -name = "pyee" -version = "13.0.1" +name = "pycparser" +version = "3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -354,6 +354,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -396,6 +405,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "selenium" +version = "4.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/7c/133d00d6d013a17d3f39199f27f1a780ec2e95d7b9aa997dc1b8ac2e62a7/selenium-4.41.0.tar.gz", hash = "sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa", size = 937872, upload-time = "2026-02-20T03:42:06.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/d6/e4160989ef6b272779af6f3e5c43c3ba9be6687bdc21c68c3fb220e555b3/selenium-4.41.0-py3-none-any.whl", hash = "sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1", size = 9532858, upload-time = "2026-02-20T03:42:03.218Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -405,6 +480,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "yarl" version = "1.23.0"