diff --git a/.env.example b/.env.example index 36b0dfd..7ecb464 100644 --- a/.env.example +++ b/.env.example @@ -16,11 +16,14 @@ HTTPS_PROXY=http://user:pass@host:port # Path to emails.txt (email:password per line) EMAILS_FILE=/data/emails.txt -# Firefox binary path -FIREFOX_BINARY=firefox-esr +# Browser channel for Patchright (chrome or chromium) +BROWSER_CHANNEL=chrome -# Geckodriver path -GECKODRIVER_PATH=/usr/local/bin/geckodriver +# Run browser headless (1/0) +HEADLESS=0 + +# Optional: override Patchright browser cache path +PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers # Extensions directory (dark-reader.xpi, ublock_origin.xpi) EXTRAS_DIR=/app/extras diff --git a/Dockerfile b/Dockerfile index 9930828..b740957 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,28 +4,17 @@ 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 && \ @@ -36,12 +25,13 @@ 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 -COPY extras/patch_firefox.py . -RUN python3 ./patch_firefox.py - -# Build Dark Reader extension (Selenium cleanup) +# Build Dark Reader extension COPY extras/extension /tmp/extension RUN cd /tmp/extension && zip -r /extras/extensions/dark-reader.xpi . && rm -rf /tmp/extension @@ -53,8 +43,9 @@ ENV MIN_BALANCE=0 ENV DATA_DIR=/data ENV EXTRAS_DIR=/extras ENV EMAILS_FILE=/data/emails.txt -ENV FIREFOX_BINARY=/usr/bin/firefox-esr -ENV GECKODRIVER_PATH=/usr/local/bin/geckodriver +ENV BROWSER_CHANNEL=chrome +ENV HEADLESS=0 +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers VOLUME ["/data"] diff --git a/appimage/AppRun b/appimage/AppRun new file mode 100755 index 0000000..47d00a6 --- /dev/null +++ b/appimage/AppRun @@ -0,0 +1,46 @@ +#!/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 new file mode 100644 index 0000000..9a9cc31 --- /dev/null +++ b/appimage/Dockerfile @@ -0,0 +1,52 @@ +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 new file mode 100755 index 0000000..5bcca44 --- /dev/null +++ b/appimage/build_appimage.sh @@ -0,0 +1,16 @@ +#!/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 new file mode 100755 index 0000000..d3876d3 --- /dev/null +++ b/appimage/build_in_container.sh @@ -0,0 +1,45 @@ +#!/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 new file mode 100644 index 0000000..f63f6ff --- /dev/null +++ b/appimage/kilocode.desktop @@ -0,0 +1,7 @@ +[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 new file mode 100644 index 0000000..d57a7e0 --- /dev/null +++ b/appimage/kilocode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/entrypoint.sh b/entrypoint.sh index 6cb866b..12fdb42 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,21 +1,4 @@ #!/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 59b98ae..be758d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" requires-python = ">=3.14" dependencies = [ "aiohttp==3.13.3", - "selenium>=4.41.0", + "patchright>=1.52.5", ] [project.optional-dependencies] diff --git a/src/emails.py b/src/emails.py index e8e548c..ee9f512 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(email: str) -> None: - """Append email to done.txt after successful registration.""" +async def mark_done(account: EmailAccount) -> None: + """Append email:password 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(email + "\n") - logger.info("Marked done: %s", email) + f.write(f"{account.email}:{account.password}\n") + logger.info("Marked done: %s", account.email) -async def mark_failed(email: str) -> None: - """Append email to failed.txt after failed registration.""" +async def mark_failed(account: EmailAccount) -> None: + """Append email:password 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(email + "\n") - logger.warning("Marked failed: %s", email) + f.write(f"{account.email}:{account.password}\n") + logger.warning("Marked failed: %s", account.email) diff --git a/src/proxy.py b/src/proxy.py index 84f1955..bb5309b 100644 --- a/src/proxy.py +++ b/src/proxy.py @@ -1,5 +1,6 @@ import logging import os +from urllib.parse import urljoin import aiohttp @@ -13,10 +14,7 @@ async def rotate_proxy_ip() -> bool: logger.warning("No proxy configured, cannot rotate IP") return False - 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" - + rotate_url = urljoin(HTTPS_PROXY + '/', '/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 31e2ef8..b5d94e6 100644 --- a/src/registration.py +++ b/src/registration.py @@ -2,410 +2,320 @@ 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 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 patchright.async_api import ( + async_playwright, + TimeoutError as PlaywrightTimeoutError, ) -from proxy import HTTPS_PROXY, rotate_proxy_ip from emails import pop_account, mark_done, mark_failed +from proxy import HTTPS_PROXY, rotate_proxy_ip +from usage import get_balance 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 human_delay(): - _time.sleep(random.uniform(0.5, 1.35)) +def _now_ts() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S") -def human_type(element, text): - for char in text: - element.send_keys(char) - _time.sleep(random.uniform(0.05, 0.15)) +async def human_delay(): + await asyncio.sleep(random.uniform(0.4, 1.1)) -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() +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 _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}") +@dataclass +class ProxyConfig: + server: str + username: str | None = None + password: str | None = None -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" +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) 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") + port = int(port_str) + except ValueError: + port = default_port else: - logger.warning("Dark Reader xpi not found at %s", dark_reader_path) + host = cleaned + port = default_port - # 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) + host = host.strip() + if not host: + return None - logger.info("Firefox launched (Dark Reader, uBlock, dark theme, strict ETP)") - return driver + server = f"{scheme}://{host}:{port}" + return ProxyConfig(server=server, username=username, password=password) -def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool: - """Complete Google OAuth sign-in flow. Returns True on success.""" +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" try: - wait = WebDriverWait(driver, 150) + 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) - # 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) +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) - # Enter password - password_input = WebDriverWait(driver, 150).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, 'input[name="Passwd"], input[type="password"]') - ) - ) + 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) logger.info("Password field found, filling...") - human_delay() - password_input.clear() - human_delay() - human_type(password_input, password) - human_delay() + await human_type(password_input, password) - # 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) + 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) 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): + await asyncio.sleep(3) + + for _ in range(10): + if _is_on_kilo(page.url): return True - logger.info( - "Still on Google (%s), looking for buttons...", driver.current_url[:80] - ) + logger.info("Still on Google (%s), looking for buttons...", page.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 + buttons = page.locator("button") + count = await buttons.count() + if count > 0: + btn = buttons.nth(count - 1) try: - ActionChains(driver).move_to_element(btn).pause( - 0.3 - ).click().perform() + await human_click(btn) except Exception: - btn.click() - human_delay() + pass - # Check if URL changed - if _is_on_kilo(driver.current_url): - return True - else: - human_delay() + await asyncio.sleep(0.7) - return _is_on_kilo(driver.current_url) + return _is_on_kilo(page.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 -def _try_register_once_sync( - driver: WebDriver, - email: str, - password: str, +async def _try_register_once( + email: str, password: str, proxy: ProxyConfig | None ) -> str | None: - """Attempt one full registration cycle via Google OAuth.""" + page = None + try: - # Step 1: Navigate to Kilo home - logger.info("[1/6] Navigating to Kilo home...") - driver.get(KILO_HOME) - human_delay() + 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"), + ) - wait = WebDriverWait(driver, 150) + 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) - # 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')]", + 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) + + 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") ) - ) - ) - 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')]", + await page.wait_for_url( + "**accounts.google.com/**", timeout=TIMEOUT * 1000 ) - ) - ) - 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')]", + logger.info( + "[4/6] Google sign-in page loaded: %s", page.url.split("?")[0] ) - ) - ) - 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.split('?', 1)[0]) + 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 - # Step 5: Google sign-in - logger.info("[5/6] Signing in with Google (%s)...", email) - success = _google_sign_in(driver, email, password) + 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 - if not success and not _is_on_kilo(driver.current_url): - raise AutomationError( - "google_auth", "Google sign-in did not redirect to Kilo", driver - ) + 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...") - # 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) + 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 - # 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...") + logger.info("[6/6] Navigating to profile to get API key...") + await page.goto(PROFILE_URL, wait_until="load") + await human_delay() - # 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_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 - # Step 6: Get API key - logger.info("[6/6] Navigating to profile to get API key...") - driver.get(PROFILE_URL) - human_delay() + api_key = api_key.strip() + logger.info("[6/6] API key obtained (length=%d)", len(api_key)) + return api_key - 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) + except PlaywrightTimeoutError as e: + logger.error("Timeout during registration: %s", e) + await save_error_screenshot(page, "timeout") return None except Exception as e: logger.error("Unexpected error during registration: %s", e) - save_error_screenshot(driver, "unexpected") + await save_error_screenshot(page, "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() @@ -413,11 +323,9 @@ async def register_kilo_account() -> bool: logger.error("No email accounts available") return False - driver: WebDriver | None = None + proxy = _parse_proxy(HTTPS_PROXY) try: - driver = await asyncio.to_thread(_create_firefox_driver) - for ip_attempt in range(MAX_IP_ROTATIONS): if ip_attempt > 0: logger.info( @@ -436,31 +344,59 @@ async def register_kilo_account() -> bool: MAX_IP_ROTATIONS, ) - api_key = await asyncio.to_thread( - _try_register_once_sync, driver, account.email, account.password - ) + api_key = await _try_register_once(account.email, account.password, proxy) 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.email) + await mark_done(account) 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.email) - logger.error("All registration attempts exhausted for %s", account.email) + await mark_failed(account) + logger.error( + "All %d attempts exhausted for %s", MAX_IP_ROTATIONS, account.email + ) return False except Exception as e: - await mark_failed(account.email) - logger.error("Fatal registration error: %s", e) + logger.exception("Fatal registration error: %s", e) + try: + await mark_failed(account) + except Exception: + pass return False finally: - if driver: - try: - driver.quit() - except Exception: - pass + try: + await rotate_proxy_ip() + except Exception: + logger.warning("Failed to rotate proxy after account") diff --git a/src/server.py b/src/server.py index 7c253e5..7f53ef2 100644 --- a/src/server.py +++ b/src/server.py @@ -44,21 +44,36 @@ 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}) - balance_data = await get_balance(token) + result = await get_balance(token) - if balance_data is None: + if result.is_invalid: + logger.warning("Token invalid, removing: %s", token[:10]) await pop_token() await trigger_refill() continue - balance = balance_data.get("balance", balance_data.get("remaining", 0)) - if balance is None or balance <= MIN_BALANCE: + 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, + ) await pop_token() await trigger_refill() continue current_size = await pool_size() - calculated_balance = balance + (current_size - 1) * 5 + calculated_balance = result.balance + (current_size - 1) * 5 logger.info( "token: %s pool: %d balance: %.2f", token[:5], @@ -98,7 +113,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) + web.run_app(app, host="0.0.0.0", port=PORT, access_log=None) if __name__ == "__main__": diff --git a/src/usage.py b/src/usage.py index 7760532..56a7bc2 100644 --- a/src/usage.py +++ b/src/usage.py @@ -1,4 +1,5 @@ import logging +from dataclasses import dataclass from typing import Any import aiohttp @@ -10,11 +11,24 @@ 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, -) -> dict[str, Any] | None: - """Fetch balance from Kilo API (routed through proxy if configured).""" +) -> 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 + """ headers = { "Authorization": f"Bearer {api_key}", "Accept": "application/json", @@ -22,9 +36,12 @@ 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( @@ -32,17 +49,57 @@ async def get_balance( res.status, body[:300], ) - return None + return BalanceResult( + balance=None, + is_invalid=False, + error=f"http_{res.status}", + ) + data = await res.json() except (aiohttp.ClientError, TimeoutError) as e: logger.warning("Balance fetch error: %s", e) - return None + return BalanceResult( + balance=None, + is_invalid=False, + error="network_error", + ) except Exception as e: logger.warning("Balance fetch unexpected error: %s", e) - return None + return BalanceResult( + balance=None, + is_invalid=False, + error="unknown_error", + ) if not isinstance(data, dict): logger.warning("Balance response is not a dict") - return None + return BalanceResult( + balance=None, + is_invalid=False, + error="invalid_response", + ) - return data + 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, + ) diff --git a/tests/test_emails.py b/tests/test_emails.py index bbfd82a..a76cf3b 100644 --- a/tests/test_emails.py +++ b/tests/test_emails.py @@ -93,10 +93,11 @@ async def test_mark_done(tmp_path, monkeypatch): monkeypatch.setattr(em, "DATA_DIR", tmp_path) monkeypatch.setattr(em, "DONE_FILE", done_file) - await mark_done("test@example.com") + account = em.EmailAccount(email="test@example.com", password="secret123") + await mark_done(account) content = done_file.read_text() - assert "test@example.com" in content + assert "test@example.com:secret123" in content @pytest.mark.asyncio @@ -105,10 +106,11 @@ async def test_mark_failed(tmp_path, monkeypatch): monkeypatch.setattr(em, "DATA_DIR", tmp_path) monkeypatch.setattr(em, "FAILED_FILE", failed_file) - await mark_failed("test@example.com") + account = em.EmailAccount(email="test@example.com", password="secret123") + await mark_failed(account) content = failed_file.read_text() - assert "test@example.com" in content + assert "test@example.com:secret123" in content @pytest.mark.asyncio diff --git a/tests/test_server.py b/tests/test_server.py index fbda93d..25bee20 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,6 +4,7 @@ from aiohttp import web from aiohttp.test_utils import AioHTTPTestCase from server import create_app +from usage import BalanceResult class TestServer(AioHTTPTestCase): @@ -47,7 +48,9 @@ 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 = {"balance": 10.0} + mock_balance.return_value = BalanceResult( + balance=10.0, is_invalid=False, error=None + ) mock_size.return_value = 3 resp = await self.client.get("/token") @@ -65,7 +68,10 @@ 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 = [{"balance": 0}, {"balance": 15.0}] + mock_balance.side_effect = [ + BalanceResult(balance=0.0, is_invalid=False, error=None), + BalanceResult(balance=15.0, is_invalid=False, error=None), + ] mock_size.return_value = 2 resp = await self.client.get("/token") @@ -79,18 +85,41 @@ 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_balance_fetch_fails( + async def test_token_network_error_kept( self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): - mock_first.side_effect = ["bad_token", "good_token"] - mock_balance.side_effect = [None, {"balance": 10.0}] + 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_size.return_value = 2 resp = await self.client.get("/token") assert resp.status == 200 data = await resp.json() assert data["token"] == "good_token" - mock_pop.assert_called() + assert mock_pop.call_count == 1 @patch("server.get_first_token", new_callable=AsyncMock) @patch("server.pop_token", new_callable=AsyncMock) @@ -101,7 +130,9 @@ class TestServer(AioHTTPTestCase): self, mock_balance, mock_size, mock_refill, mock_pop, mock_first ): mock_first.return_value = "test_token" - mock_balance.return_value = {"remaining": 20.0} + mock_balance.return_value = BalanceResult( + balance=20.0, is_invalid=False, error=None + ) mock_size.return_value = 1 resp = await self.client.get("/token") @@ -131,7 +162,10 @@ 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 = [{"balance": 0}, {"balance": -5}] + mock_balance.side_effect = [ + BalanceResult(balance=0.0, is_invalid=False, error=None), + BalanceResult(balance=-5.0, is_invalid=False, error=None), + ] mock_wait.return_value = None mock_size.return_value = 0 diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..dfe6c50 --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,17 @@ +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 56d5fd5..20819a9 100644 --- a/uv.lock +++ b/uv.lock @@ -83,32 +83,6 @@ 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" @@ -160,12 +134,28 @@ wheels = [ ] [[package]] -name = "h11" -version = "0.16.0" +name = "greenlet" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -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" } +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" } wheels = [ - { 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" }, + { 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" }, ] [[package]] @@ -192,7 +182,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, - { name = "selenium" }, + { name = "patchright" }, ] [package.optional-dependencies] @@ -210,8 +200,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"] @@ -267,18 +257,6 @@ 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" @@ -288,6 +266,25 @@ 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" @@ -337,12 +334,15 @@ wheels = [ ] [[package]] -name = "pycparser" -version = "3.0" +name = "pyee" +version = "13.0.1" source = { registry = "https://pypi.org/simple" } -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" } +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" } wheels = [ - { 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" }, + { 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" }, ] [[package]] @@ -354,15 +354,6 @@ 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" @@ -405,72 +396,6 @@ 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" @@ -480,41 +405,6 @@ 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"