this got @gmail.com banned on kilo.ai
This commit is contained in:
parent
84ad98b4d3
commit
1861b212c2
19 changed files with 669 additions and 571 deletions
11
.env.example
11
.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
|
||||
|
|
|
|||
25
Dockerfile
25
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"]
|
||||
|
||||
|
|
|
|||
46
appimage/AppRun
Executable file
46
appimage/AppRun
Executable 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
52
appimage/Dockerfile
Normal 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
16
appimage/build_appimage.sh
Executable 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
45
appimage/build_in_container.sh
Executable 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"
|
||||
7
appimage/kilocode.desktop
Normal file
7
appimage/kilocode.desktop
Normal 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
6
appimage/kilocode.svg
Normal 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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
73
src/usage.py
73
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
tests/test_usage.py
Normal file
17
tests/test_usage.py
Normal 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
206
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue