1
0
Fork 0

Compare commits

...

2 commits

Author SHA1 Message Date
1861b212c2
this got @gmail.com banned on kilo.ai 2026-03-12 04:46:48 +03:00
84ad98b4d3
feat: min balance 2026-03-08 10:23:44 +03:00
19 changed files with 674 additions and 571 deletions

View file

@ -7,17 +7,23 @@ TARGET_SIZE=5
# Poll interval for checking new accounts when pool incomplete (seconds) # Poll interval for checking new accounts when pool incomplete (seconds)
POLL_INTERVAL=30 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 URL (used by Firefox and balance API)
HTTPS_PROXY=http://user:pass@host:port HTTPS_PROXY=http://user:pass@host:port
# Path to emails.txt (email:password per line) # Path to emails.txt (email:password per line)
EMAILS_FILE=/data/emails.txt EMAILS_FILE=/data/emails.txt
# Firefox binary path # Browser channel for Patchright (chrome or chromium)
FIREFOX_BINARY=firefox-esr BROWSER_CHANNEL=chrome
# Geckodriver path # Run browser headless (1/0)
GECKODRIVER_PATH=/usr/local/bin/geckodriver HEADLESS=0
# Optional: override Patchright browser cache path
PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers
# Extensions directory (dark-reader.xpi, ublock_origin.xpi) # Extensions directory (dark-reader.xpi, ublock_origin.xpi)
EXTRAS_DIR=/app/extras EXTRAS_DIR=/app/extras

View file

@ -4,28 +4,17 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata \ tzdata \
xvfb \
xauth \
ca-certificates \ ca-certificates \
curl \ curl \
firefox-esr=140.8.0esr-1~deb13u1 \
fonts-noto \ fonts-noto \
fonts-noto-cjk \ fonts-noto-cjk \
fonts-dejavu \ fonts-dejavu \
fonts-liberation \ fonts-liberation \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
pulseaudio \
libgl1-mesa-dri \
libglu1-mesa \
zip && \ zip && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
fc-cache -fv 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) # Download uBlock Origin (latest)
ARG UBLOCK_VERSION=1.69.0 ARG UBLOCK_VERSION=1.69.0
RUN mkdir -p /extras/extensions && \ RUN mkdir -p /extras/extensions && \
@ -36,12 +25,13 @@ COPY pyproject.toml uv.lock .
RUN pip install --no-cache-dir uv RUN pip install --no-cache-dir uv
RUN uv sync --frozen --no-dev 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 # Configure fontconfig for emoji support
COPY extras/patch_firefox.py . # Build Dark Reader extension
RUN python3 ./patch_firefox.py
# Build Dark Reader extension (Selenium cleanup)
COPY extras/extension /tmp/extension COPY extras/extension /tmp/extension
RUN cd /tmp/extension && zip -r /extras/extensions/dark-reader.xpi . && rm -rf /tmp/extension RUN cd /tmp/extension && zip -r /extras/extensions/dark-reader.xpi . && rm -rf /tmp/extension
@ -49,11 +39,13 @@ ENV PYTHONUNBUFFERED=1
ENV PORT=80 ENV PORT=80
ENV TARGET_SIZE=5 ENV TARGET_SIZE=5
ENV POLL_INTERVAL=30 ENV POLL_INTERVAL=30
ENV MIN_BALANCE=0
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV EXTRAS_DIR=/extras ENV EXTRAS_DIR=/extras
ENV EMAILS_FILE=/data/emails.txt ENV EMAILS_FILE=/data/emails.txt
ENV FIREFOX_BINARY=/usr/bin/firefox-esr ENV BROWSER_CHANNEL=chrome
ENV GECKODRIVER_PATH=/usr/local/bin/geckodriver ENV HEADLESS=0
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/patchright-browsers
VOLUME ["/data"] VOLUME ["/data"]

46
appimage/AppRun Executable file
View file

@ -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"

52
appimage/Dockerfile Normal file
View file

@ -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"]

16
appimage/build_appimage.sh Executable file
View file

@ -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"

45
appimage/build_in_container.sh Executable file
View file

@ -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"

View file

@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Kilocode Service
Exec=kilocode
Icon=kilocode
Categories=Utility;
Terminal=true

6
appimage/kilocode.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="40" ry="40" fill="#1b1f24"/>
<rect x="48" y="56" width="160" height="144" rx="18" ry="18" fill="#2d333b"/>
<path d="M88 96h80v16H88zM88 128h80v16H88zM88 160h48v16H88z" fill="#8fb0ff"/>
<circle cx="176" cy="168" r="16" fill="#66d9a8"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View file

@ -1,21 +1,4 @@
#!/bin/sh #!/bin/sh
set -e 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 exec /app/.venv/bin/python -u server.py

View file

@ -4,7 +4,7 @@ version = "0.1.0"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"aiohttp==3.13.3", "aiohttp==3.13.3",
"selenium>=4.41.0", "patchright>=1.52.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -90,19 +90,19 @@ async def pop_account() -> EmailAccount | None:
return account return account
async def mark_done(email: str) -> None: async def mark_done(account: EmailAccount) -> None:
"""Append email to done.txt after successful registration.""" """Append email:password to done.txt after successful registration."""
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
async with _file_lock: async with _file_lock:
with open(DONE_FILE, "a") as f: with open(DONE_FILE, "a") as f:
f.write(email + "\n") f.write(f"{account.email}:{account.password}\n")
logger.info("Marked done: %s", email) logger.info("Marked done: %s", account.email)
async def mark_failed(email: str) -> None: async def mark_failed(account: EmailAccount) -> None:
"""Append email to failed.txt after failed registration.""" """Append email:password to failed.txt after failed registration."""
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
async with _file_lock: async with _file_lock:
with open(FAILED_FILE, "a") as f: with open(FAILED_FILE, "a") as f:
f.write(email + "\n") f.write(f"{account.email}:{account.password}\n")
logger.warning("Marked failed: %s", email) logger.warning("Marked failed: %s", account.email)

View file

@ -1,5 +1,6 @@
import logging import logging
import os import os
from urllib.parse import urljoin
import aiohttp import aiohttp
@ -13,10 +14,7 @@ async def rotate_proxy_ip() -> bool:
logger.warning("No proxy configured, cannot rotate IP") logger.warning("No proxy configured, cannot rotate IP")
return False return False
parsed = HTTPS_PROXY.replace("http://", "").replace("https://", "").split("@")[-1] rotate_url = urljoin(HTTPS_PROXY + '/', '/rotate')
host, port = parsed.split(":")[0], parsed.split(":")[1].split("/")[0]
rotate_url = f"http://{host}:{port}/rotate"
timeout = aiohttp.ClientTimeout(total=15) timeout = aiohttp.ClientTimeout(total=15)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:

View file

@ -2,410 +2,320 @@ import asyncio
import logging import logging
import os import os
import random import random
import tempfile
import time as _time import time as _time
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from selenium import webdriver from patchright.async_api import (
from selenium.webdriver.common.by import By async_playwright,
from selenium.webdriver.firefox.options import Options TimeoutError as PlaywrightTimeoutError,
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 proxy import HTTPS_PROXY, rotate_proxy_ip
from emails import pop_account, mark_done, mark_failed 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__) logger = logging.getLogger(__name__)
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
EXTRAS_DIR = Path(os.environ.get("EXTRAS_DIR", "./extras")) 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/" KILO_HOME = "https://kilo.ai/"
PROFILE_URL = "https://app.kilo.ai/profile" PROFILE_URL = "https://app.kilo.ai/profile"
MAX_IP_ROTATIONS = 3 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(): def _now_ts() -> str:
_time.sleep(random.uniform(0.5, 1.35)) return datetime.now().strftime("%Y%m%d_%H%M%S")
def human_type(element, text): async def human_delay():
for char in text: await asyncio.sleep(random.uniform(0.4, 1.1))
element.send_keys(char)
_time.sleep(random.uniform(0.05, 0.15))
def human_click(driver, element): async def human_type(locator, text: str):
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) await locator.click()
human_delay() for ch in text:
driver.execute_script("arguments[0].click();", element) await locator.type(ch, delay=random.randint(30, 120))
human_delay()
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: 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 "" hostname = urlparse(url).hostname or ""
return hostname.endswith("kilo.ai") return hostname.endswith("kilo.ai")
class AutomationError(Exception): @dataclass
def __init__(self, step: str, message: str, driver: WebDriver | None = None): class ProxyConfig:
self.step = step server: str
self.message = message username: str | None = None
self.driver = driver password: str | None = None
super().__init__(f"[{step}] {message}")
def save_error_screenshot(driver: WebDriver | None, step: str) -> None: def _parse_proxy(proxy_url: str | None) -> ProxyConfig | None:
if driver: if not proxy_url:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 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:
port = int(port_str)
except ValueError:
port = default_port
else:
host = cleaned
port = default_port
host = host.strip()
if not host:
return None
server = f"{scheme}://{host}:{port}"
return ProxyConfig(server=server, username=username, password=password)
async def save_error_screenshot(page, step: str) -> None:
if not page:
return
screenshots_dir = DATA_DIR / "screenshots" screenshots_dir = DATA_DIR / "screenshots"
screenshots_dir.mkdir(parents=True, exist_ok=True) screenshots_dir.mkdir(parents=True, exist_ok=True)
filename = screenshots_dir / f"error_kilo_{step}_{timestamp}.png" filename = screenshots_dir / f"error_kilo_{step}_{_now_ts()}.png"
try: try:
driver.save_screenshot(str(filename)) await page.screenshot(path=str(filename), full_page=True)
logger.error("Screenshot saved: %s", filename) logger.error("Screenshot saved: %s", filename)
except WebDriverException as e: except Exception as e:
logger.warning("Failed to save screenshot at step %s: %s", step, e) logger.warning("Failed to save screenshot at step %s: %s", step, e)
def _create_firefox_driver() -> WebDriver: async def _google_sign_in(page, email: str, password: str) -> bool:
"""Launch Firefox with fresh profile mimicking a real user setup."""
proxy_url = HTTPS_PROXY
options = Options()
os.environ["TZ"] = "America/New_York"
# Private browsing mode with extensions allowed
options.add_argument("-private")
options.set_preference("extensions.privatebrowsing.autostart", True)
options.set_preference("extensions.allowPrivateBrowsingByDefault", True)
# Dark theme
options.set_preference("ui.systemUsesDarkTheme", 1)
options.set_preference("browser.theme.content-theme", 0)
options.set_preference("browser.theme.toolbar-theme", 0)
# Enhanced Tracking Protection: Strict
options.set_preference("browser.contentblocking.category", "strict")
options.set_preference("privacy.trackingprotection.enabled", True)
options.set_preference("privacy.trackingprotection.socialtracking.enabled", True)
options.set_preference("privacy.trackingprotection.cryptomining.enabled", True)
options.set_preference("privacy.trackingprotection.fingerprinting.enabled", True)
options.set_preference("network.cookie.cookieBehavior", 5)
# Disable WebRTC IP leak
options.set_preference("media.peerconnection.enabled", False)
# Anti-detection: hide webdriver
options.set_preference("dom.webdriver.enabled", False)
options.set_preference("useAutomationExtension", False)
# Enable WebGL (software rendering via Mesa)
options.set_preference("webgl.disabled", False)
options.set_preference("webgl.force-enabled", True)
options.set_preference("webgl.msaa-force", True)
options.set_preference("webgl.max-warnings-per-context", 0)
# Proxy
if proxy_url:
parsed = urlparse(proxy_url)
proxy_host = parsed.hostname or "localhost"
proxy_port = parsed.port or 80
options.set_preference("network.proxy.type", 1)
options.set_preference("network.proxy.http", proxy_host)
options.set_preference("network.proxy.http_port", proxy_port)
options.set_preference("network.proxy.ssl", proxy_host)
options.set_preference("network.proxy.ssl_port", proxy_port)
options.set_preference("network.proxy.no_proxies_on", "")
logger.info("Firefox proxy: %s:%s", proxy_host, proxy_port)
options.binary_location = FIREFOX_BINARY
service = Service(executable_path=GECKODRIVER_PATH)
driver = webdriver.Firefox(service=service, options=options) # type: ignore[reportCallIssue]
driver.set_page_load_timeout(120)
# Install Dark Reader extension (Selenium cleanup)
dark_reader_path = EXTRAS_DIR / "extensions" / "dark-reader.xpi"
if dark_reader_path.exists():
driver.install_addon(str(dark_reader_path), temporary=True)
logger.info("Dark Reader extension installed")
else:
logger.warning("Dark Reader xpi not found at %s", dark_reader_path)
# Install uBlock Origin
ublock_path = EXTRAS_DIR / "extensions" / "ublock_origin.xpi"
if ublock_path.exists():
driver.install_addon(str(ublock_path), temporary=True)
logger.info("uBlock Origin installed")
else:
logger.warning("uBlock Origin xpi not found at %s", ublock_path)
logger.info("Firefox launched (Dark Reader, uBlock, dark theme, strict ETP)")
return driver
def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool:
"""Complete Google OAuth sign-in flow. Returns True on success."""
try: try:
wait = WebDriverWait(driver, 150) email_input = page.locator('input[type="email"]')
await email_input.wait_for(state="visible", timeout=TIMEOUT * 1000)
await human_type(email_input, email)
# Enter email await human_click(page.locator("#identifierNext"))
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 password_input = page.get_by_role("textbox", name="password")
next_btn = driver.find_element(By.CSS_SELECTOR, "#identifierNext") await password_input.wait_for(state="visible", timeout=TIMEOUT * 1000)
human_click(driver, next_btn)
# Enter password
password_input = WebDriverWait(driver, 150).until(
EC.visibility_of_element_located(
(By.CSS_SELECTOR, 'input[name="Passwd"], input[type="password"]')
)
)
logger.info("Password field found, filling...") logger.info("Password field found, filling...")
human_delay() await human_type(password_input, password)
password_input.clear()
human_delay()
human_type(password_input, password)
human_delay()
# Click Next
try: try:
password_next = driver.find_element(By.CSS_SELECTOR, "#passwordNext") await human_click(page.locator("#passwordNext"))
human_click(driver, password_next)
except NoSuchElementException:
buttons = driver.find_elements(By.CSS_SELECTOR, "button")
for btn in buttons:
if "next" in btn.text.lower():
human_click(driver, btn)
break
human_delay()
# wait for the page to reload # TODO: wait for a proper event
_time.sleep(8)
# Handle consent / TOS / speedbump screens
for _ in range(15):
if _is_on_kilo(driver.current_url):
return True
logger.info(
"Still on Google (%s), looking for buttons...", driver.current_url[:80]
)
all_buttons = driver.find_elements(By.CSS_SELECTOR, "button")
if all_buttons:
btn_texts = [b.text.strip() for b in all_buttons]
logger.info("Found %d buttons: %s", len(all_buttons), btn_texts)
btn = all_buttons[-1]
driver.execute_script(
"arguments[0].scrollIntoView({block: 'center'});", btn
)
human_delay()
# Try ActionChains for more realistic click
try:
ActionChains(driver).move_to_element(btn).pause(
0.3
).click().perform()
except Exception: except Exception:
btn.click() buttons = page.locator("button")
human_delay() 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
# Check if URL changed await asyncio.sleep(3)
if _is_on_kilo(driver.current_url):
for _ in range(10):
if _is_on_kilo(page.url):
return True return True
else:
human_delay()
return _is_on_kilo(driver.current_url) logger.info("Still on Google (%s), looking for buttons...", page.url[:80])
buttons = page.locator("button")
count = await buttons.count()
if count > 0:
btn = buttons.nth(count - 1)
try:
await human_click(btn)
except Exception:
pass
await asyncio.sleep(0.7)
return _is_on_kilo(page.url)
except PlaywrightTimeoutError:
logger.warning("Google sign-in timeout")
return False
except Exception as e: except Exception as e:
logger.warning("Google sign-in error: %s", e) logger.warning("Google sign-in error: %s", e)
return False return False
def _try_register_once_sync( async def _try_register_once(
driver: WebDriver, email: str, password: str, proxy: ProxyConfig | None
email: str,
password: str,
) -> str | None: ) -> str | None:
"""Attempt one full registration cycle via Google OAuth.""" page = None
try: try:
# Step 1: Navigate to Kilo home 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"),
)
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)
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...") logger.info("[1/6] Navigating to Kilo home...")
driver.get(KILO_HOME) await page.goto(KILO_HOME, wait_until="load")
human_delay() await human_delay()
wait = WebDriverWait(driver, 150)
# Step 2: Click Sign up (opens new tab)
logger.info("[2/6] Clicking 'Sign up'...") logger.info("[2/6] Clicking 'Sign up'...")
handles_before = set(driver.window_handles) async with context.expect_page() as new_page_info:
signup_btn = wait.until( await human_click(page.locator("text=/Sign up/i"))
EC.element_to_be_clickable( page = await new_page_info.value
( await page.wait_for_load_state("load")
By.XPATH, logger.info("[2/6] Switched to new tab: %s", page.url)
"//a[contains(text(), 'Sign up') or contains(text(), 'sign up')]",
)
)
)
human_click(driver, signup_btn)
# Switch to new tab
WebDriverWait(driver, 30).until(
lambda d: len(d.window_handles) > len(handles_before)
)
new_handles = set(driver.window_handles) - handles_before
if new_handles:
driver.switch_to.window(new_handles.pop())
logger.info("[2/6] Switched to new tab: %s", driver.current_url)
else:
raise AutomationError(
"signup", "No new tab opened after clicking Sign up", driver
)
human_delay()
# Wait for page load
WebDriverWait(driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
human_delay()
logger.info("[2/6] Page loaded: %s", driver.current_url)
# Step 3: Click "Sign in or Sign up"
logger.info("[3/6] Clicking 'Sign in or Sign up'...") logger.info("[3/6] Clicking 'Sign in or Sign up'...")
signin_signup_btn = wait.until( await human_click(page.locator("text=/Sign in|Sign up/i"))
EC.element_to_be_clickable( await page.wait_for_load_state("load")
( logger.info("[3/6] Redirected to: %s", page.url)
By.XPATH,
"//a[contains(text(), 'Sign in') or contains(text(), 'sign in') or contains(text(), 'Sign up') or contains(text(), 'sign up')]",
)
)
)
human_click(driver, signin_signup_btn)
human_delay()
# Wait for page load
WebDriverWait(driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
human_delay()
logger.info("[3/6] Redirected to: %s", driver.current_url)
# Step 4: Click "Sign in with Google"
logger.info("[4/6] Clicking 'Sign in with Google'...") logger.info("[4/6] Clicking 'Sign in with Google'...")
google_btn = wait.until( await human_click(
EC.element_to_be_clickable( page.locator("text=/Sign in with Google|Continue with Google/i")
(
By.XPATH,
"//*[contains(text(), 'Sign in with Google') or contains(text(), 'Continue with Google')]",
) )
)
)
human_click(driver, google_btn)
# Wait for Google await page.wait_for_url(
WebDriverWait(driver, 30).until(EC.url_contains("accounts.google.com")) "**accounts.google.com/**", timeout=TIMEOUT * 1000
logger.info("[4/6] Google sign-in page loaded: %s", driver.current_url.split('?', 1)[0]) )
logger.info(
"[4/6] Google sign-in page loaded: %s", page.url.split("?")[0]
)
# Step 5: Google sign-in
logger.info("[5/6] Signing in with Google (%s)...", email) logger.info("[5/6] Signing in with Google (%s)...", email)
success = _google_sign_in(driver, email, password) 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
if not success and not _is_on_kilo(driver.current_url):
raise AutomationError(
"google_auth", "Google sign-in did not redirect to Kilo", driver
)
# Wait for redirect to kilo.ai
logger.info("[5/6] Waiting for Kilo redirect...") logger.info("[5/6] Waiting for Kilo redirect...")
deadline = _time.time() + 120 deadline = _time.time() + TIMEOUT
while _time.time() < deadline: while _time.time() < deadline:
if ( if _is_on_kilo(page.url) and "/users/sign_in" not in page.url:
_is_on_kilo(driver.current_url) logger.info("[5/6] On kilo.ai: %s", page.url)
and "/users/sign_in" not in driver.current_url
):
logger.info("[5/6] On kilo.ai: %s", driver.current_url)
break break
human_delay() await asyncio.sleep(0.5)
else: else:
logger.warning("Redirect not detected, current: %s", driver.current_url) await save_error_screenshot(page, "redirect")
return None
# Handle educational account confirmation
try: try:
confirm_btn = WebDriverWait(driver, 10).until( confirm_btn = page.locator("input#confirm")
EC.element_to_be_clickable((By.CSS_SELECTOR, "input#confirm")) await confirm_btn.wait_for(state="visible", timeout=5000)
)
logger.info("[5/6] Educational account page, clicking confirm...") logger.info("[5/6] Educational account page, clicking confirm...")
human_click(driver, confirm_btn) await human_click(confirm_btn)
except TimeoutException: except PlaywrightTimeoutError:
logger.info("[5/6] No educational account page, continuing...") logger.info("[5/6] No educational account page, continuing...")
# Wait for /get-started or /profile deadline = _time.time() + TIMEOUT
deadline = _time.time() + 60
while _time.time() < deadline: while _time.time() < deadline:
url = driver.current_url url = page.url
if "/get-started" in url or "/profile" in url: if "/get-started" in url or "/profile" in url:
break break
human_delay() await asyncio.sleep(0.5)
else:
await save_error_screenshot(page, "profile_redirect")
return None
# Step 6: Get API key
logger.info("[6/6] Navigating to profile to get API key...") logger.info("[6/6] Navigating to profile to get API key...")
driver.get(PROFILE_URL) await page.goto(PROFILE_URL, wait_until="load")
human_delay() await human_delay()
api_key_input = WebDriverWait(driver, 200).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "input#api-key"))
)
api_key = api_key_input.get_attribute("value")
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(): if not api_key or not api_key.strip():
raise AutomationError("profile", "API key input is empty", driver) await save_error_screenshot(page, "profile")
return None
api_key = api_key.strip() api_key = api_key.strip()
logger.info("[6/6] API key obtained (length=%d)", len(api_key)) logger.info("[6/6] API key obtained (length=%d)", len(api_key))
return api_key return api_key
except AutomationError as e: except PlaywrightTimeoutError as e:
logger.error("Error at step [%s]: %s", e.step, e.message) logger.error("Timeout during registration: %s", e)
save_error_screenshot(e.driver or driver, e.step) await save_error_screenshot(page, "timeout")
return None return None
except Exception as e: except Exception as e:
logger.error("Unexpected error during registration: %s", e) logger.error("Unexpected error during registration: %s", e)
save_error_screenshot(driver, "unexpected") await save_error_screenshot(page, "unexpected")
return None return None
async def register_kilo_account() -> bool: 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) ===") logger.info("=== Starting Kilo account registration (Google OAuth) ===")
account = await pop_account() account = await pop_account()
@ -413,11 +323,9 @@ async def register_kilo_account() -> bool:
logger.error("No email accounts available") logger.error("No email accounts available")
return False return False
driver: WebDriver | None = None proxy = _parse_proxy(HTTPS_PROXY)
try: try:
driver = await asyncio.to_thread(_create_firefox_driver)
for ip_attempt in range(MAX_IP_ROTATIONS): for ip_attempt in range(MAX_IP_ROTATIONS):
if ip_attempt > 0: if ip_attempt > 0:
logger.info( logger.info(
@ -436,31 +344,59 @@ async def register_kilo_account() -> bool:
MAX_IP_ROTATIONS, MAX_IP_ROTATIONS,
) )
api_key = await asyncio.to_thread( api_key = await _try_register_once(account.email, account.password, proxy)
_try_register_once_sync, driver, account.email, account.password
)
if api_key: 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 from pool import append_token
await append_token(api_key) await append_token(api_key)
await mark_done(account.email) await mark_done(account)
logger.info("Token added to pool: %s...", api_key[:10]) logger.info("Token added to pool: %s...", api_key[:10])
return True return True
logger.warning(
"Attempt %d/%d failed for %s",
ip_attempt + 1,
MAX_IP_ROTATIONS,
account.email,
)
await asyncio.sleep(2) await asyncio.sleep(2)
await mark_failed(account.email) await mark_failed(account)
logger.error("All registration attempts exhausted for %s", account.email) logger.error(
"All %d attempts exhausted for %s", MAX_IP_ROTATIONS, account.email
)
return False return False
except Exception as e: except Exception as e:
await mark_failed(account.email) logger.exception("Fatal registration error: %s", e)
logger.error("Fatal registration error: %s", e)
return False
finally:
if driver:
try: try:
driver.quit() await mark_failed(account)
except Exception: except Exception:
pass pass
return False
finally:
try:
await rotate_proxy_ip()
except Exception:
logger.warning("Failed to rotate proxy after account")

View file

@ -11,6 +11,7 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PORT = int(os.environ.get("PORT", 8080)) PORT = int(os.environ.get("PORT", 8080))
MIN_BALANCE = float(os.environ.get("MIN_BALANCE", "0"))
async def on_startup(app: web.Application): async def on_startup(app: web.Application):
@ -43,21 +44,36 @@ async def token_handler(request: web.Request) -> web.Response:
logger.info("token: %s pool: %d balance: -", token[:5], current_size) logger.info("token: %s pool: %d balance: -", token[:5], current_size)
return web.json_response({"token": token}) 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 pop_token()
await trigger_refill() await trigger_refill()
continue continue
balance = balance_data.get("balance", balance_data.get("remaining", 0)) if result.balance is None:
if balance is None or balance <= 0: 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 pop_token()
await trigger_refill() await trigger_refill()
continue continue
current_size = await pool_size() current_size = await pool_size()
calculated_balance = balance + (current_size - 1) * 5 calculated_balance = result.balance + (current_size - 1) * 5
logger.info( logger.info(
"token: %s pool: %d balance: %.2f", "token: %s pool: %d balance: %.2f",
token[:5], token[:5],
@ -97,7 +113,7 @@ def create_app() -> web.Application:
def main(): def main():
logger.info("Starting server on port %s", PORT) logger.info("Starting server on port %s", PORT)
app = create_app() 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__": if __name__ == "__main__":

View file

@ -1,4 +1,5 @@
import logging import logging
from dataclasses import dataclass
from typing import Any from typing import Any
import aiohttp import aiohttp
@ -10,11 +11,24 @@ logger = logging.getLogger(__name__)
BALANCE_URL = "https://api.kilo.ai/api/profile/balance" 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( async def get_balance(
api_key: str, api_key: str,
timeout_ms: int = 10000, timeout_ms: int = 10000,
) -> dict[str, Any] | None: ) -> BalanceResult:
"""Fetch balance from Kilo API (routed through proxy if configured).""" """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 = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Accept": "application/json", "Accept": "application/json",
@ -22,9 +36,12 @@ async def get_balance(
proxy_url = proxy.HTTPS_PROXY proxy_url = proxy.HTTPS_PROXY
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000) timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: 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: if not res.ok:
body = await res.text() body = await res.text()
logger.warning( logger.warning(
@ -32,17 +49,57 @@ async def get_balance(
res.status, res.status,
body[:300], body[:300],
) )
return None return BalanceResult(
balance=None,
is_invalid=False,
error=f"http_{res.status}",
)
data = await res.json() data = await res.json()
except (aiohttp.ClientError, TimeoutError) as e: except (aiohttp.ClientError, TimeoutError) as e:
logger.warning("Balance fetch error: %s", e) logger.warning("Balance fetch error: %s", e)
return None return BalanceResult(
balance=None,
is_invalid=False,
error="network_error",
)
except Exception as e: except Exception as e:
logger.warning("Balance fetch unexpected error: %s", 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): if not isinstance(data, dict):
logger.warning("Balance response is not a 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,
)

View file

@ -93,10 +93,11 @@ async def test_mark_done(tmp_path, monkeypatch):
monkeypatch.setattr(em, "DATA_DIR", tmp_path) monkeypatch.setattr(em, "DATA_DIR", tmp_path)
monkeypatch.setattr(em, "DONE_FILE", done_file) 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() content = done_file.read_text()
assert "test@example.com" in content assert "test@example.com:secret123" in content
@pytest.mark.asyncio @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, "DATA_DIR", tmp_path)
monkeypatch.setattr(em, "FAILED_FILE", failed_file) 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() content = failed_file.read_text()
assert "test@example.com" in content assert "test@example.com:secret123" in content
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -4,6 +4,7 @@ from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase from aiohttp.test_utils import AioHTTPTestCase
from server import create_app from server import create_app
from usage import BalanceResult
class TestServer(AioHTTPTestCase): class TestServer(AioHTTPTestCase):
@ -47,7 +48,9 @@ class TestServer(AioHTTPTestCase):
self, mock_balance, mock_size, mock_refill, mock_pop, mock_first self, mock_balance, mock_size, mock_refill, mock_pop, mock_first
): ):
mock_first.return_value = "test_token_12345" 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 mock_size.return_value = 3
resp = await self.client.get("/token") resp = await self.client.get("/token")
@ -65,7 +68,10 @@ class TestServer(AioHTTPTestCase):
self, mock_balance, mock_size, mock_refill, mock_pop, mock_first self, mock_balance, mock_size, mock_refill, mock_pop, mock_first
): ):
mock_first.side_effect = ["bad_token", "good_token"] 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 mock_size.return_value = 2
resp = await self.client.get("/token") resp = await self.client.get("/token")
@ -79,18 +85,41 @@ class TestServer(AioHTTPTestCase):
@patch("server.trigger_refill", new_callable=AsyncMock) @patch("server.trigger_refill", new_callable=AsyncMock)
@patch("server.pool_size", new_callable=AsyncMock) @patch("server.pool_size", new_callable=AsyncMock)
@patch("server.get_balance", 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 self, mock_balance, mock_size, mock_refill, mock_pop, mock_first
): ):
mock_first.side_effect = ["bad_token", "good_token"] mock_first.return_value = "test_token"
mock_balance.side_effect = [None, {"balance": 10.0}] 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 mock_size.return_value = 2
resp = await self.client.get("/token") resp = await self.client.get("/token")
assert resp.status == 200 assert resp.status == 200
data = await resp.json() data = await resp.json()
assert data["token"] == "good_token" 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.get_first_token", new_callable=AsyncMock)
@patch("server.pop_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 self, mock_balance, mock_size, mock_refill, mock_pop, mock_first
): ):
mock_first.return_value = "test_token" 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 mock_size.return_value = 1
resp = await self.client.get("/token") 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 self, mock_balance, mock_size, mock_wait, mock_refill, mock_pop, mock_first
): ):
mock_first.side_effect = ["token1", "token2", None] 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_wait.return_value = None
mock_size.return_value = 0 mock_size.return_value = 0

17
tests/test_usage.py Normal file
View file

@ -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"

206
uv.lock generated
View file

@ -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" }, { 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@ -160,12 +134,28 @@ wheels = [
] ]
[[package]] [[package]]
name = "h11" name = "greenlet"
version = "0.16.0" version = "3.3.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -192,7 +182,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "selenium" }, { name = "patchright" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -210,8 +200,8 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = "==3.13.3" }, { name = "aiohttp", specifier = "==3.13.3" },
{ name = "patchright", specifier = ">=1.52.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" },
{ name = "selenium", specifier = ">=4.41.0" },
] ]
provides-extras = ["dev"] 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -337,12 +334,15 @@ wheels = [
] ]
[[package]] [[package]]
name = "pycparser" name = "pyee"
version = "3.0" version = "13.0.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" 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" }, { 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]] [[package]]
name = "yarl" name = "yarl"
version = "1.23.0" version = "1.23.0"