mvp: wonky but working selenium firefox version
This commit is contained in:
parent
061eefdb24
commit
a3c843d63c
20 changed files with 738 additions and 407 deletions
13
.env.example
13
.env.example
|
|
@ -1,14 +1,17 @@
|
||||||
# HTTP server port
|
# HTTP server port
|
||||||
PORT=80
|
PORT=80
|
||||||
|
|
||||||
# HTTP proxy with /new endpoint for IP rotation
|
# HTTPS proxy URL (will be used by Firefox directly)
|
||||||
PROXY_URL=http://localhost:8080
|
https_proxy=http://user:pass@host:port
|
||||||
|
|
||||||
# Path to mail.json with Google accounts
|
# Path to emails.txt (email:password per line)
|
||||||
MAIL_JSON=./mail.json
|
EMAILS_FILE=/data/emails.txt
|
||||||
|
|
||||||
# Custom Firefox binary path (default: firefox-esr in Docker, or system firefox)
|
# Custom Firefox binary path (default: firefox-esr in Docker)
|
||||||
FIREFOX_BINARY=firefox-esr
|
FIREFOX_BINARY=firefox-esr
|
||||||
|
|
||||||
|
# Number of hot tokens to keep in pool
|
||||||
|
POOL_SIZE=10
|
||||||
|
|
||||||
# Persistent data directory (tokens, screenshots, extensions)
|
# Persistent data directory (tokens, screenshots, extensions)
|
||||||
DATA_DIR=/data
|
DATA_DIR=/data
|
||||||
|
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -1,9 +1,13 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
*.pyc
|
||||||
build/
|
|
||||||
.env
|
|
||||||
data/
|
|
||||||
*.xpi
|
*.xpi
|
||||||
|
.env
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
build/
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
emails.txt
|
||||||
|
|
|
||||||
54
Dockerfile
54
Dockerfile
|
|
@ -8,37 +8,63 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
xauth \
|
xauth \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
firefox-esr \
|
firefox-esr=140.8.0esr-1~deb13u1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
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 && \
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock /app/
|
# Install geckodriver
|
||||||
|
RUN GECKO_VERSION=$(curl -s https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep tag_name | cut -d'"' -f4) && \
|
||||||
|
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)
|
||||||
|
RUN UBLOCK_VERSION=$(curl -s https://api.github.com/repos/gorhill/uBlock/releases/latest | grep tag_name | cut -d'"' -f4) && \
|
||||||
|
mkdir -p /extras/extensions && \
|
||||||
|
curl -fsSL -o /extras/extensions/ublock_origin.xpi \
|
||||||
|
"https://github.com/gorhill/uBlock/releases/download/${UBLOCK_VERSION}/uBlock0_${UBLOCK_VERSION}.firefox.signed.xpi"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
# Configure fontconfig for emoji support
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
COPY src .
|
COPY extras/patch_firefox.py .
|
||||||
|
RUN python3 ./patch_firefox.py
|
||||||
|
|
||||||
# Download uBlock Origin
|
# Build Dark Reader extension (Selenium cleanup)
|
||||||
RUN mkdir -p /data/extensions && \
|
COPY extras/extension /tmp/extension
|
||||||
curl -fsSL -o /data/extensions/ublock_origin.xpi \
|
RUN cd /tmp/extension && zip -r /extras/extensions/dark-reader.xpi . && rm -rf /tmp/extension
|
||||||
"https://github.com/gorhill/uBlock/releases/download/1.62.0/uBlock0_1.62.0.firefox.signed.xpi"
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PORT=80
|
ENV PORT=80
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
ENV PROXY_URL=""
|
ENV EXTRAS_DIR=/extras
|
||||||
ENV MAIL_JSON=/data/mail.json
|
ENV EMAILS_FILE=/data/emails.txt
|
||||||
ENV FIREFOX_BINARY=firefox-esr
|
ENV FIREFOX_BINARY=/usr/bin/firefox-esr
|
||||||
|
ENV GECKODRIVER_PATH=/usr/local/bin/geckodriver
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
HEALTHCHECK --start-period=10s --start-interval=2s CMD \
|
HEALTHCHECK --start-period=60s --start-interval=2s CMD \
|
||||||
test "$(curl -fsS "http://127.0.0.1:$PORT/health")" = "ok"
|
test "$(curl -fsS "http://127.0.0.1:$PORT/health")" = "ok"
|
||||||
|
|
||||||
STOPSIGNAL SIGINT
|
STOPSIGNAL SIGINT
|
||||||
|
|
||||||
|
COPY src .
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export DISPLAY=:99
|
XVFB_PID=""
|
||||||
|
|
||||||
Xvfb :99 -screen 0 1280x1024x24 -nolisten tcp -ac >/tmp/xvfb.log 2>&1 &
|
|
||||||
XVFB_PID=$!
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
if [ -n "$XVFB_PID" ]; then
|
||||||
kill "$XVFB_PID" >/dev/null 2>&1 || true
|
kill "$XVFB_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
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
|
||||||
|
|
|
||||||
26
extras/extension/dark-reader.js
Normal file
26
extras/extension/dark-reader.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
(function() {
|
||||||
|
var code = '(' + function() {
|
||||||
|
// Remove Selenium artifacts (except webdriver - handled by binary patch)
|
||||||
|
var toDelete = ['cdc_', '$cdc_', 'wd_', 'selenium', '__webdriver_script_fn', '__driver_evaluate', '__webdriver_evaluate', '__selenium_evaluate', '__fxdriver_evaluate', '__driver_unwrapped', '__webdriver_unwrapped', '__selenium_unwrapped', '__fxdriver_unwrapped', '__webdriver_script_function', '__webdriver_script_func', '__webdriver_script_fn', '__fxdriver_athena_', '_Selenium_IDE_Recorder', '_selenium', 'calledSelenium', '$cdc_asdjflasutopfhvcZLmcfl_', '$chrome_asyncScriptInfo', '__$webdriverAsyncExecutor', '__nightmare', '_phantom', 'callPhantom', '__lastWatirAlert', '__lastWatirConfirm', '__lastWatirPrompt'];
|
||||||
|
toDelete.forEach(function(key) {
|
||||||
|
if (window[key]) delete window[key];
|
||||||
|
if (document[key]) delete document[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up document properties
|
||||||
|
for (var key in document) {
|
||||||
|
if (key.match(/\$[a-z]dc_/) && document[key]) {
|
||||||
|
delete document[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} + ')();';
|
||||||
|
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.textContent = code;
|
||||||
|
|
||||||
|
var target = document.documentElement;
|
||||||
|
if (target) {
|
||||||
|
target.insertBefore(script, target.firstChild);
|
||||||
|
script.remove();
|
||||||
|
}
|
||||||
|
})();
|
||||||
14
extras/extension/manifest.json
Normal file
14
extras/extension/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Dark Reader",
|
||||||
|
"version": "1.0",
|
||||||
|
"permissions": ["<all_urls>"],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["dark-reader.js"],
|
||||||
|
"run_at": "document_start",
|
||||||
|
"all_frames": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
extras/patch_firefox.py
Normal file
9
extras/patch_firefox.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
with open("/usr/lib/firefox-esr/libxul.so", "rb") as f:
|
||||||
|
data = bytearray(f.read())
|
||||||
|
|
||||||
|
patch = bytes([0x31, 0xC0, 0xC3])
|
||||||
|
offset = 0x443CAD0
|
||||||
|
data[offset : offset + len(patch)] = patch
|
||||||
|
|
||||||
|
with open("/usr/lib/firefox-esr/libxul.so", "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
@ -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.40.0",
|
"selenium>=4.41.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Register a Kilo account via Google OAuth.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
PROXY_URL=http://localhost:8080 python scripts/register.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
from registration import register_kilo_account
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
result = await register_kilo_account()
|
|
||||||
if result:
|
|
||||||
print(f"\nAPI key: {result.access_token}")
|
|
||||||
else:
|
|
||||||
print("\nRegistration failed")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
89
src/emails.py
Normal file
89
src/emails.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Email account provider.
|
||||||
|
|
||||||
|
Reads accounts from a text file (one per line, email:password format).
|
||||||
|
When an account is consumed, it is removed from the file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMAILS_FILE = Path(os.environ.get("EMAILS_FILE", "./emails.txt"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailAccount:
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_line(line: str) -> EmailAccount | None:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
return None
|
||||||
|
parts = line.split(":", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
logger.warning("Skipping malformed line: %s", line[:40])
|
||||||
|
return None
|
||||||
|
email, password = parts[0].strip(), parts[1].strip()
|
||||||
|
if not email or not password:
|
||||||
|
return None
|
||||||
|
return EmailAccount(email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def peek_accounts(path: Path | None = None) -> list[EmailAccount]:
|
||||||
|
"""Read all accounts without consuming them."""
|
||||||
|
path = path or EMAILS_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
lines = path.read_text().strip().splitlines()
|
||||||
|
accounts = []
|
||||||
|
for line in lines:
|
||||||
|
acc = _parse_line(line)
|
||||||
|
if acc:
|
||||||
|
accounts.append(acc)
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def pop_account(path: Path | None = None) -> EmailAccount | None:
|
||||||
|
"""Read and remove the first account from the file.
|
||||||
|
|
||||||
|
Returns the account, or None if the file is empty.
|
||||||
|
"""
|
||||||
|
path = path or EMAILS_FILE
|
||||||
|
if not path.exists():
|
||||||
|
logger.error("Emails file not found: %s", path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = path.read_text().strip().splitlines()
|
||||||
|
|
||||||
|
account = None
|
||||||
|
remaining = []
|
||||||
|
for line in lines:
|
||||||
|
if account is None:
|
||||||
|
parsed = _parse_line(line)
|
||||||
|
if parsed:
|
||||||
|
account = parsed
|
||||||
|
continue
|
||||||
|
remaining.append(line)
|
||||||
|
|
||||||
|
if account is None:
|
||||||
|
logger.error("No accounts left in %s", path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# TODO: Write remaining lines back
|
||||||
|
# path.write_text("\n".join(remaining) + ("\n" if remaining else ""))
|
||||||
|
logger.info(
|
||||||
|
"Popped account %s, %d remaining",
|
||||||
|
account.email,
|
||||||
|
len([r for r in remaining if _parse_line(r)]),
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_count(path: Path | None = None) -> int:
|
||||||
|
"""Count how many accounts are left."""
|
||||||
|
return len(peek_accounts(path))
|
||||||
218
src/pool.py
Normal file
218
src/pool.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"""Hot token pool.
|
||||||
|
|
||||||
|
Maintains a pool of valid tokens ready to be served.
|
||||||
|
Tokens are validated (non-zero balance) before entering the pool.
|
||||||
|
A background task keeps the pool filled to the target size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from usage import get_balance
|
||||||
|
from registration import register_kilo_account
|
||||||
|
from proxy import rotate_proxy_ip
|
||||||
|
from emails import remaining_count
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
POOL_SIZE = int(os.environ.get("POOL_SIZE", "10"))
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||||
|
POOL_FILE = DATA_DIR / "pool.json"
|
||||||
|
REGISTRATION_MAX_ATTEMPTS = 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PoolToken:
|
||||||
|
api_key: str
|
||||||
|
balance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPool:
|
||||||
|
"""Hot pool of pre-registered, validated tokens.
|
||||||
|
|
||||||
|
Pool is persisted to disk so tokens survive container restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, target_size: int = POOL_SIZE):
|
||||||
|
self.target_size = target_size
|
||||||
|
self._tokens: list[PoolToken] = []
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._fill_task: asyncio.Task | None = None
|
||||||
|
self._load_from_disk()
|
||||||
|
|
||||||
|
def _load_from_disk(self) -> None:
|
||||||
|
"""Load saved pool from disk."""
|
||||||
|
if not POOL_FILE.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(POOL_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict) and "api_key" in item:
|
||||||
|
self._tokens.append(
|
||||||
|
PoolToken(
|
||||||
|
api_key=item["api_key"],
|
||||||
|
balance=item.get("balance", {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self._tokens:
|
||||||
|
logger.info("Loaded %d tokens from disk", len(self._tokens))
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to load pool from disk: %s", e)
|
||||||
|
|
||||||
|
def _save_to_disk(self) -> None:
|
||||||
|
"""Persist current pool to disk."""
|
||||||
|
POOL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = [{"api_key": t.api_key, "balance": t.balance} for t in self._tokens]
|
||||||
|
try:
|
||||||
|
with open(POOL_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to save pool to disk: %s", e)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return len(self._tokens)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_full(self) -> bool:
|
||||||
|
return self.size >= self.target_size
|
||||||
|
|
||||||
|
async def get_token(self) -> PoolToken | None:
|
||||||
|
"""Get a token from the pool. Returns None if pool is empty."""
|
||||||
|
async with self._lock:
|
||||||
|
if not self._tokens:
|
||||||
|
return None
|
||||||
|
token = self._tokens.pop(0)
|
||||||
|
self._save_to_disk()
|
||||||
|
|
||||||
|
# Trigger background refill
|
||||||
|
self._ensure_filling()
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def _validate_token(self, api_key: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate that a token has non-zero balance. Returns balance data or None."""
|
||||||
|
balance_data = await get_balance(api_key)
|
||||||
|
if balance_data is None:
|
||||||
|
logger.warning("Token validation failed: could not fetch balance")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check for non-zero balance
|
||||||
|
balance_value = balance_data.get("balance", balance_data.get("remaining", 0))
|
||||||
|
if not balance_value or balance_value <= 0:
|
||||||
|
logger.warning("Token has zero balance: %s", balance_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Token validated, balance: %s", balance_data)
|
||||||
|
return balance_data
|
||||||
|
|
||||||
|
async def _register_one(self) -> PoolToken | None:
|
||||||
|
"""Register a single new account and validate it."""
|
||||||
|
for attempt in range(1, REGISTRATION_MAX_ATTEMPTS + 1):
|
||||||
|
logger.info(
|
||||||
|
"Pool registration attempt %d/%d", attempt, REGISTRATION_MAX_ATTEMPTS
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await register_kilo_account()
|
||||||
|
if not result:
|
||||||
|
logger.warning("Registration attempt %d failed", attempt)
|
||||||
|
await asyncio.sleep(1.5 * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
balance = await self._validate_token(result.access_token)
|
||||||
|
if balance is not None:
|
||||||
|
return PoolToken(api_key=result.access_token, balance=balance)
|
||||||
|
|
||||||
|
logger.warning("Registered token invalid (zero balance), rotating IP")
|
||||||
|
await rotate_proxy_ip()
|
||||||
|
await asyncio.sleep(1.5 * attempt)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fill_pool(self) -> None:
|
||||||
|
"""Fill the pool up to target size."""
|
||||||
|
while self.size < self.target_size:
|
||||||
|
emails_left = remaining_count()
|
||||||
|
if emails_left == 0:
|
||||||
|
logger.warning(
|
||||||
|
"No email accounts left, cannot fill pool further (pool size: %d/%d)",
|
||||||
|
self.size,
|
||||||
|
self.target_size,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Pool fill: %d/%d, registering new token...",
|
||||||
|
self.size,
|
||||||
|
self.target_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = await self._register_one()
|
||||||
|
if token:
|
||||||
|
async with self._lock:
|
||||||
|
self._tokens.append(token)
|
||||||
|
self._save_to_disk()
|
||||||
|
logger.info(
|
||||||
|
"Pool fill: added token, now %d/%d", self.size, self.target_size
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error("Pool fill: failed to register, stopping fill cycle")
|
||||||
|
break
|
||||||
|
|
||||||
|
def _ensure_filling(self) -> None:
|
||||||
|
"""Start background fill task if not already running."""
|
||||||
|
if self._fill_task and not self._fill_task.done():
|
||||||
|
return
|
||||||
|
self._fill_task = asyncio.create_task(self._safe_fill())
|
||||||
|
|
||||||
|
async def _safe_fill(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._fill_pool()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Pool fill error")
|
||||||
|
|
||||||
|
async def startup_fill(self) -> None:
|
||||||
|
"""Initial fill on startup. Revalidates loaded tokens, then fills to target."""
|
||||||
|
if self._tokens:
|
||||||
|
logger.info(
|
||||||
|
"Pool startup: %d tokens loaded from disk, revalidating...", self.size
|
||||||
|
)
|
||||||
|
valid = []
|
||||||
|
for token in self._tokens:
|
||||||
|
balance = await self._validate_token(token.api_key)
|
||||||
|
if balance is not None:
|
||||||
|
token.balance = balance
|
||||||
|
valid.append(token)
|
||||||
|
else:
|
||||||
|
logger.warning("Pool startup: discarding invalid token")
|
||||||
|
async with self._lock:
|
||||||
|
self._tokens = valid
|
||||||
|
self._save_to_disk()
|
||||||
|
logger.info("Pool startup: %d tokens valid after revalidation", self.size)
|
||||||
|
|
||||||
|
logger.info("Pool startup: filling to %d tokens...", self.target_size)
|
||||||
|
await self._fill_pool()
|
||||||
|
logger.info("Pool startup: %d tokens ready", self.size)
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Cancel background fill task."""
|
||||||
|
if self._fill_task and not self._fill_task.done():
|
||||||
|
self._fill_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._fill_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"pool_size": self.size,
|
||||||
|
"target_size": self.target_size,
|
||||||
|
"is_full": self.is_full,
|
||||||
|
}
|
||||||
147
src/provider.py
147
src/provider.py
|
|
@ -1,147 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from tokens import (
|
|
||||||
ProviderTokens,
|
|
||||||
clear_next_tokens,
|
|
||||||
load_next_tokens,
|
|
||||||
load_state,
|
|
||||||
load_tokens,
|
|
||||||
promote_next_tokens,
|
|
||||||
save_state,
|
|
||||||
save_tokens,
|
|
||||||
)
|
|
||||||
from usage import get_balance
|
|
||||||
from registration import register_kilo_account
|
|
||||||
from proxy import rotate_proxy_ip
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
REGISTRATION_MAX_ATTEMPTS = 4
|
|
||||||
|
|
||||||
|
|
||||||
class KiloProvider:
|
|
||||||
"""Kilo.ai token provider."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._token_write_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def _register_with_retries(self) -> bool:
|
|
||||||
for attempt in range(1, REGISTRATION_MAX_ATTEMPTS + 1):
|
|
||||||
logger.info(
|
|
||||||
"Registration attempt %s/%s", attempt, REGISTRATION_MAX_ATTEMPTS
|
|
||||||
)
|
|
||||||
generated = await register_kilo_account()
|
|
||||||
if generated:
|
|
||||||
valid = await self._validate_account(generated.access_token)
|
|
||||||
if valid:
|
|
||||||
save_tokens(generated)
|
|
||||||
return True
|
|
||||||
logger.warning("Registered account invalid, rotating IP")
|
|
||||||
await rotate_proxy_ip()
|
|
||||||
|
|
||||||
logger.warning("Registration attempt %s failed", attempt)
|
|
||||||
await asyncio.sleep(1.5 * attempt)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _validate_account(self, api_key: str) -> bool:
|
|
||||||
balance_data = await get_balance(api_key)
|
|
||||||
if balance_data is None:
|
|
||||||
logger.warning("Account validation failed: could not fetch balance")
|
|
||||||
return False
|
|
||||||
logger.info("Account validated, balance: %s", balance_data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _create_next_account_under_lock(self) -> bool:
|
|
||||||
active_before, next_before = load_state()
|
|
||||||
if next_before:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Creating next account")
|
|
||||||
for attempt in range(1, REGISTRATION_MAX_ATTEMPTS + 1):
|
|
||||||
logger.info(
|
|
||||||
"Next-account attempt %s/%s", attempt, REGISTRATION_MAX_ATTEMPTS
|
|
||||||
)
|
|
||||||
generated = await register_kilo_account()
|
|
||||||
if generated:
|
|
||||||
valid = await self._validate_account(generated.access_token)
|
|
||||||
if valid:
|
|
||||||
if active_before:
|
|
||||||
save_state(active_before, generated)
|
|
||||||
else:
|
|
||||||
save_state(generated, None)
|
|
||||||
logger.info("Next account is ready")
|
|
||||||
return True
|
|
||||||
logger.warning("Next account invalid, rotating IP")
|
|
||||||
await rotate_proxy_ip()
|
|
||||||
|
|
||||||
logger.warning("Next-account attempt %s failed", attempt)
|
|
||||||
await asyncio.sleep(1.5 * attempt)
|
|
||||||
|
|
||||||
if active_before or next_before:
|
|
||||||
save_state(active_before, next_before)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def force_recreate_token(self) -> str | None:
|
|
||||||
async with self._token_write_lock:
|
|
||||||
success = await self._register_with_retries()
|
|
||||||
if not success:
|
|
||||||
return None
|
|
||||||
clear_next_tokens()
|
|
||||||
tokens = load_tokens()
|
|
||||||
return tokens.access_token if tokens else None
|
|
||||||
|
|
||||||
async def get_token(self) -> str | None:
|
|
||||||
tokens = load_tokens()
|
|
||||||
if tokens and tokens.access_token:
|
|
||||||
return tokens.access_token
|
|
||||||
|
|
||||||
async with self._token_write_lock:
|
|
||||||
tokens = load_tokens()
|
|
||||||
if tokens and tokens.access_token:
|
|
||||||
return tokens.access_token
|
|
||||||
|
|
||||||
logger.info("No valid tokens, registering new account")
|
|
||||||
success = await self._register_with_retries()
|
|
||||||
if not success:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tokens = load_tokens()
|
|
||||||
return tokens.access_token if tokens else None
|
|
||||||
|
|
||||||
async def maybe_rotate_account(self, usage_percent: int) -> bool:
|
|
||||||
if usage_percent < 95:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async with self._token_write_lock:
|
|
||||||
if not load_next_tokens():
|
|
||||||
created = await self._create_next_account_under_lock()
|
|
||||||
if not created:
|
|
||||||
return False
|
|
||||||
|
|
||||||
switched = promote_next_tokens()
|
|
||||||
if switched:
|
|
||||||
logger.info("Switched active account (usage >= 95%%)")
|
|
||||||
return switched
|
|
||||||
|
|
||||||
async def get_usage_info(self, access_token: str) -> dict[str, Any]:
|
|
||||||
balance_data = await get_balance(access_token)
|
|
||||||
if balance_data is None:
|
|
||||||
return {"error": "Failed to get balance"}
|
|
||||||
|
|
||||||
total = balance_data.get("total", 0)
|
|
||||||
used = balance_data.get("used", 0)
|
|
||||||
remaining = balance_data.get("remaining", balance_data.get("balance", 0))
|
|
||||||
|
|
||||||
if total and total > 0:
|
|
||||||
used_percent = int(min(100, (used / total) * 100))
|
|
||||||
else:
|
|
||||||
used_percent = 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"used_percent": used_percent,
|
|
||||||
"remaining_percent": max(0, 100 - used_percent),
|
|
||||||
"exhausted": used_percent >= 100,
|
|
||||||
"balance": balance_data,
|
|
||||||
}
|
|
||||||
19
src/proxy.py
19
src/proxy.py
|
|
@ -5,27 +5,22 @@ import aiohttp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROXY_URL = os.environ.get("PROXY_URL", "")
|
HTTPS_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
|
||||||
|
|
||||||
|
|
||||||
def get_proxy_url() -> str | None:
|
|
||||||
"""Return the configured proxy URL, or None if not set."""
|
|
||||||
return PROXY_URL or None
|
|
||||||
|
|
||||||
|
|
||||||
async def rotate_proxy_ip() -> bool:
|
async def rotate_proxy_ip() -> bool:
|
||||||
"""Call the proxy's /new endpoint to get a fresh IP address."""
|
if not HTTPS_PROXY:
|
||||||
if not PROXY_URL:
|
logger.warning("No proxy configured, cannot rotate IP")
|
||||||
logger.warning("No proxy URL configured, cannot rotate IP")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
base = PROXY_URL.rstrip("/")
|
parsed = HTTPS_PROXY.replace("http://", "").replace("https://", "").split("@")[-1]
|
||||||
new_url = f"{base}/new"
|
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:
|
||||||
async with session.get(new_url) as resp:
|
async with session.post(rotate_url) as resp:
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
logger.info("Proxy IP rotated successfully")
|
logger.info("Proxy IP rotated successfully")
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time as _time
|
import time as _time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -10,6 +10,8 @@ from urllib.parse import urlparse
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.firefox.options import Options
|
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.remote.webdriver import WebDriver
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
@ -20,20 +22,39 @@ from selenium.common.exceptions import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from tokens import ProviderTokens
|
from tokens import ProviderTokens
|
||||||
from proxy import get_proxy_url, rotate_proxy_ip
|
from proxy import HTTPS_PROXY, rotate_proxy_ip
|
||||||
|
from emails import pop_account
|
||||||
|
|
||||||
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"))
|
||||||
MAIL_JSON = Path(os.environ.get("MAIL_JSON", "./mail.json"))
|
EXTRAS_DIR = Path(os.environ.get("EXTRAS_DIR", "./extras"))
|
||||||
FIREFOX_BINARY = os.environ.get("FIREFOX_BINARY", "firefox")
|
FIREFOX_BINARY = os.environ.get("FIREFOX_BINARY", "firefox")
|
||||||
|
GECKODRIVER_PATH = os.environ.get("GECKODRIVER_PATH", "/usr/local/bin/geckodriver")
|
||||||
|
|
||||||
SIGN_IN_URL = "https://app.kilo.ai/users/sign_in?callbackPath=/profile"
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def human_delay():
|
||||||
|
_time.sleep(random.uniform(0.5, 1.35))
|
||||||
|
|
||||||
|
|
||||||
|
def human_type(element, text):
|
||||||
|
for char in text:
|
||||||
|
element.send_keys(char)
|
||||||
|
_time.sleep(random.uniform(0.05, 0.15))
|
||||||
|
|
||||||
|
|
||||||
|
def human_click(driver, element):
|
||||||
|
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
|
||||||
|
human_delay()
|
||||||
|
driver.execute_script("arguments[0].click();", element)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
|
|
||||||
def _is_on_kilo(url: str) -> bool:
|
def _is_on_kilo(url: str) -> bool:
|
||||||
"""Check if URL's actual domain is kilo.ai (not just in query params)."""
|
"""Check if URL's actual domain is kilo.ai (not just in query params)."""
|
||||||
hostname = urlparse(url).hostname or ""
|
hostname = urlparse(url).hostname or ""
|
||||||
|
|
@ -61,33 +82,17 @@ def save_error_screenshot(driver: WebDriver | None, step: str) -> None:
|
||||||
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 load_google_accounts() -> list[dict[str, str]]:
|
|
||||||
"""Load Google accounts from mail.json."""
|
|
||||||
if not MAIL_JSON.exists():
|
|
||||||
logger.error("mail.json not found at %s", MAIL_JSON)
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
with open(MAIL_JSON) as f:
|
|
||||||
accounts = json.load(f)
|
|
||||||
if not isinstance(accounts, list):
|
|
||||||
logger.error("mail.json must contain a JSON array")
|
|
||||||
return []
|
|
||||||
return accounts
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
logger.error("Failed to read mail.json: %s", e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _create_firefox_driver() -> WebDriver:
|
def _create_firefox_driver() -> WebDriver:
|
||||||
"""Launch Firefox with fresh profile mimicking a real user setup."""
|
"""Launch Firefox with fresh profile mimicking a real user setup."""
|
||||||
proxy_url = get_proxy_url()
|
proxy_url = HTTPS_PROXY
|
||||||
|
|
||||||
options = Options()
|
options = Options()
|
||||||
os.environ["TZ"] = "America/New_York"
|
os.environ["TZ"] = "America/New_York"
|
||||||
|
|
||||||
# Private browsing
|
# Private browsing mode with extensions allowed
|
||||||
options.add_argument("--private-window")
|
options.add_argument("-private")
|
||||||
options.set_preference("browser.privatebrowsing.autostart", True)
|
options.set_preference("extensions.privatebrowsing.autostart", True)
|
||||||
|
options.set_preference("extensions.allowPrivateBrowsingByDefault", True)
|
||||||
|
|
||||||
# Dark theme
|
# Dark theme
|
||||||
options.set_preference("ui.systemUsesDarkTheme", 1)
|
options.set_preference("ui.systemUsesDarkTheme", 1)
|
||||||
|
|
@ -105,6 +110,16 @@ def _create_firefox_driver() -> WebDriver:
|
||||||
# Disable WebRTC IP leak
|
# Disable WebRTC IP leak
|
||||||
options.set_preference("media.peerconnection.enabled", False)
|
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
|
# Proxy
|
||||||
if proxy_url:
|
if proxy_url:
|
||||||
parsed = urlparse(proxy_url)
|
parsed = urlparse(proxy_url)
|
||||||
|
|
@ -120,18 +135,27 @@ def _create_firefox_driver() -> WebDriver:
|
||||||
logger.info("Firefox proxy: %s:%s", proxy_host, proxy_port)
|
logger.info("Firefox proxy: %s:%s", proxy_host, proxy_port)
|
||||||
|
|
||||||
options.binary_location = FIREFOX_BINARY
|
options.binary_location = FIREFOX_BINARY
|
||||||
driver = webdriver.Firefox(options=options)
|
service = Service(executable_path=GECKODRIVER_PATH)
|
||||||
|
driver = webdriver.Firefox(service=service, options=options) # type: ignore[reportCallIssue]
|
||||||
driver.set_page_load_timeout(120)
|
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
|
# Install uBlock Origin
|
||||||
ublock_path = DATA_DIR / "extensions" / "ublock_origin.xpi"
|
ublock_path = EXTRAS_DIR / "extensions" / "ublock_origin.xpi"
|
||||||
if ublock_path.exists():
|
if ublock_path.exists():
|
||||||
driver.install_addon(str(ublock_path))
|
driver.install_addon(str(ublock_path), temporary=True)
|
||||||
logger.info("uBlock Origin installed")
|
logger.info("uBlock Origin installed")
|
||||||
else:
|
else:
|
||||||
logger.warning("uBlock Origin xpi not found at %s", ublock_path)
|
logger.warning("uBlock Origin xpi not found at %s", ublock_path)
|
||||||
|
|
||||||
logger.info("Firefox launched (private, dark theme, strict ETP, uBlock)")
|
logger.info("Firefox launched (Dark Reader, uBlock, dark theme, strict ETP)")
|
||||||
return driver
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -144,13 +168,15 @@ def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool:
|
||||||
email_input = wait.until(
|
email_input = wait.until(
|
||||||
EC.visibility_of_element_located((By.CSS_SELECTOR, 'input[type="email"]'))
|
EC.visibility_of_element_located((By.CSS_SELECTOR, 'input[type="email"]'))
|
||||||
)
|
)
|
||||||
|
human_delay()
|
||||||
email_input.clear()
|
email_input.clear()
|
||||||
email_input.send_keys(email)
|
human_delay()
|
||||||
|
human_type(email_input, email)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
# Click Next
|
# Click Next
|
||||||
next_btn = driver.find_element(By.CSS_SELECTOR, "#identifierNext")
|
next_btn = driver.find_element(By.CSS_SELECTOR, "#identifierNext")
|
||||||
next_btn.click()
|
human_click(driver, next_btn)
|
||||||
_time.sleep(2)
|
|
||||||
|
|
||||||
# Enter password
|
# Enter password
|
||||||
password_input = WebDriverWait(driver, 150).until(
|
password_input = WebDriverWait(driver, 150).until(
|
||||||
|
|
@ -159,21 +185,26 @@ def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info("Password field found, filling...")
|
logger.info("Password field found, filling...")
|
||||||
|
human_delay()
|
||||||
password_input.clear()
|
password_input.clear()
|
||||||
password_input.send_keys(password)
|
human_delay()
|
||||||
|
human_type(password_input, password)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
# Click Next
|
# Click Next
|
||||||
try:
|
try:
|
||||||
password_next = driver.find_element(By.CSS_SELECTOR, "#passwordNext")
|
password_next = driver.find_element(By.CSS_SELECTOR, "#passwordNext")
|
||||||
password_next.click()
|
human_click(driver, password_next)
|
||||||
except NoSuchElementException:
|
except NoSuchElementException:
|
||||||
buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
||||||
for btn in buttons:
|
for btn in buttons:
|
||||||
if "next" in btn.text.lower():
|
if "next" in btn.text.lower():
|
||||||
btn.click()
|
human_click(driver, btn)
|
||||||
break
|
break
|
||||||
_time.sleep(3)
|
human_delay()
|
||||||
|
|
||||||
|
# wait for the page to reload # TODO: wait for a proper event
|
||||||
|
_time.sleep(8)
|
||||||
# Handle consent / TOS / speedbump screens
|
# Handle consent / TOS / speedbump screens
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
if _is_on_kilo(driver.current_url):
|
if _is_on_kilo(driver.current_url):
|
||||||
|
|
@ -185,14 +216,29 @@ def _google_sign_in(driver: WebDriver, email: str, password: str) -> bool:
|
||||||
|
|
||||||
all_buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
all_buttons = driver.find_elements(By.CSS_SELECTOR, "button")
|
||||||
if all_buttons:
|
if all_buttons:
|
||||||
logger.info(
|
btn_texts = [b.text.strip() for b in all_buttons]
|
||||||
"Found %d buttons, clicking last (allow/continue)...",
|
logger.info("Found %d buttons: %s", len(all_buttons), btn_texts)
|
||||||
len(all_buttons),
|
|
||||||
|
btn = all_buttons[-1]
|
||||||
|
driver.execute_script(
|
||||||
|
"arguments[0].scrollIntoView({block: 'center'});", btn
|
||||||
)
|
)
|
||||||
all_buttons[-1].click()
|
human_delay()
|
||||||
_time.sleep(3)
|
|
||||||
|
# Try ActionChains for more realistic click
|
||||||
|
try:
|
||||||
|
ActionChains(driver).move_to_element(btn).pause(
|
||||||
|
0.3
|
||||||
|
).click().perform()
|
||||||
|
except Exception:
|
||||||
|
btn.click()
|
||||||
|
human_delay()
|
||||||
|
|
||||||
|
# Check if URL changed
|
||||||
|
if _is_on_kilo(driver.current_url):
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
_time.sleep(2)
|
human_delay()
|
||||||
|
|
||||||
return _is_on_kilo(driver.current_url)
|
return _is_on_kilo(driver.current_url)
|
||||||
|
|
||||||
|
|
@ -208,27 +254,85 @@ def _try_register_once_sync(
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Attempt one full registration cycle via Google OAuth."""
|
"""Attempt one full registration cycle via Google OAuth."""
|
||||||
try:
|
try:
|
||||||
# Step 1: Navigate to sign-in
|
# Step 1: Navigate to Kilo home
|
||||||
logger.info("[1/4] Navigating to Kilo sign-in page...")
|
logger.info("[1/6] Navigating to Kilo home...")
|
||||||
driver.get(SIGN_IN_URL)
|
driver.get(KILO_HOME)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
wait = WebDriverWait(driver, 150)
|
wait = WebDriverWait(driver, 150)
|
||||||
|
|
||||||
# Step 2: Click "Continue with Google"
|
# Step 2: Click Sign up (opens new tab)
|
||||||
logger.info("[2/4] Clicking 'Continue with Google'...")
|
logger.info("[2/6] Clicking 'Sign up'...")
|
||||||
|
handles_before = set(driver.window_handles)
|
||||||
|
signup_btn = wait.until(
|
||||||
|
EC.element_to_be_clickable(
|
||||||
|
(
|
||||||
|
By.XPATH,
|
||||||
|
"//a[contains(text(), 'Sign up') or contains(text(), 'sign up')]",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
human_click(driver, signup_btn)
|
||||||
|
|
||||||
|
# Switch to new tab
|
||||||
|
WebDriverWait(driver, 30).until(
|
||||||
|
lambda d: len(d.window_handles) > len(handles_before)
|
||||||
|
)
|
||||||
|
new_handles = set(driver.window_handles) - handles_before
|
||||||
|
if new_handles:
|
||||||
|
driver.switch_to.window(new_handles.pop())
|
||||||
|
logger.info("[2/6] Switched to new tab: %s", driver.current_url)
|
||||||
|
else:
|
||||||
|
raise AutomationError(
|
||||||
|
"signup", "No new tab opened after clicking Sign up", driver
|
||||||
|
)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
|
# Wait for page load
|
||||||
|
WebDriverWait(driver, 30).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
human_delay()
|
||||||
|
logger.info("[2/6] Page loaded: %s", driver.current_url)
|
||||||
|
|
||||||
|
# Step 3: Click "Sign in or Sign up"
|
||||||
|
logger.info("[3/6] Clicking 'Sign in or Sign up'...")
|
||||||
|
signin_signup_btn = wait.until(
|
||||||
|
EC.element_to_be_clickable(
|
||||||
|
(
|
||||||
|
By.XPATH,
|
||||||
|
"//a[contains(text(), 'Sign in') or contains(text(), 'sign in') or contains(text(), 'Sign up') or contains(text(), 'sign up')]",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
human_click(driver, signin_signup_btn)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
|
# Wait for page load
|
||||||
|
WebDriverWait(driver, 30).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
human_delay()
|
||||||
|
logger.info("[3/6] Redirected to: %s", driver.current_url)
|
||||||
|
|
||||||
|
# Step 4: Click "Sign in with Google"
|
||||||
|
logger.info("[4/6] Clicking 'Sign in with Google'...")
|
||||||
google_btn = wait.until(
|
google_btn = wait.until(
|
||||||
EC.element_to_be_clickable(
|
EC.element_to_be_clickable(
|
||||||
(By.XPATH, "//*[contains(text(), 'Continue with Google')]")
|
(
|
||||||
|
By.XPATH,
|
||||||
|
"//*[contains(text(), 'Sign in with Google') or contains(text(), 'Continue with Google')]",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
google_btn.click()
|
)
|
||||||
|
human_click(driver, google_btn)
|
||||||
|
|
||||||
# Wait for Google
|
# Wait for Google
|
||||||
WebDriverWait(driver, 30).until(EC.url_contains("accounts.google.com"))
|
WebDriverWait(driver, 30).until(EC.url_contains("accounts.google.com"))
|
||||||
logger.info("[2/4] Google sign-in page loaded: %s", driver.current_url)
|
logger.info("[4/6] Google sign-in page loaded: %s", driver.current_url)
|
||||||
|
|
||||||
# Step 3: Google sign-in
|
# Step 5: Google sign-in
|
||||||
logger.info("[3/4] Signing in with Google (%s)...", email)
|
logger.info("[5/6] Signing in with Google (%s)...", email)
|
||||||
success = _google_sign_in(driver, email, password)
|
success = _google_sign_in(driver, email, password)
|
||||||
|
|
||||||
if not success and not _is_on_kilo(driver.current_url):
|
if not success and not _is_on_kilo(driver.current_url):
|
||||||
|
|
@ -237,16 +341,16 @@ def _try_register_once_sync(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for redirect to kilo.ai
|
# Wait for redirect to kilo.ai
|
||||||
logger.info("[3/4] Waiting for Kilo redirect...")
|
logger.info("[5/6] Waiting for Kilo redirect...")
|
||||||
deadline = _time.time() + 120
|
deadline = _time.time() + 120
|
||||||
while _time.time() < deadline:
|
while _time.time() < deadline:
|
||||||
if (
|
if (
|
||||||
_is_on_kilo(driver.current_url)
|
_is_on_kilo(driver.current_url)
|
||||||
and "/users/sign_in" not in driver.current_url
|
and "/users/sign_in" not in driver.current_url
|
||||||
):
|
):
|
||||||
logger.info("[3/4] On kilo.ai: %s", driver.current_url)
|
logger.info("[5/6] On kilo.ai: %s", driver.current_url)
|
||||||
break
|
break
|
||||||
_time.sleep(1)
|
human_delay()
|
||||||
else:
|
else:
|
||||||
logger.warning("Redirect not detected, current: %s", driver.current_url)
|
logger.warning("Redirect not detected, current: %s", driver.current_url)
|
||||||
|
|
||||||
|
|
@ -255,11 +359,10 @@ def _try_register_once_sync(
|
||||||
confirm_btn = WebDriverWait(driver, 10).until(
|
confirm_btn = WebDriverWait(driver, 10).until(
|
||||||
EC.element_to_be_clickable((By.CSS_SELECTOR, "input#confirm"))
|
EC.element_to_be_clickable((By.CSS_SELECTOR, "input#confirm"))
|
||||||
)
|
)
|
||||||
logger.info("[3/4] Educational account page, clicking confirm...")
|
logger.info("[5/6] Educational account page, clicking confirm...")
|
||||||
confirm_btn.click()
|
human_click(driver, confirm_btn)
|
||||||
_time.sleep(3)
|
|
||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
logger.info("[3/4] No educational account page, continuing...")
|
logger.info("[5/6] No educational account page, continuing...")
|
||||||
|
|
||||||
# Wait for /get-started or /profile
|
# Wait for /get-started or /profile
|
||||||
deadline = _time.time() + 60
|
deadline = _time.time() + 60
|
||||||
|
|
@ -267,11 +370,12 @@ def _try_register_once_sync(
|
||||||
url = driver.current_url
|
url = driver.current_url
|
||||||
if "/get-started" in url or "/profile" in url:
|
if "/get-started" in url or "/profile" in url:
|
||||||
break
|
break
|
||||||
_time.sleep(1)
|
human_delay()
|
||||||
|
|
||||||
# Step 4: Get API key
|
# Step 6: Get API key
|
||||||
logger.info("[4/4] Navigating to profile to get API key...")
|
logger.info("[6/6] Navigating to profile to get API key...")
|
||||||
driver.get(PROFILE_URL)
|
driver.get(PROFILE_URL)
|
||||||
|
human_delay()
|
||||||
|
|
||||||
api_key_input = WebDriverWait(driver, 200).until(
|
api_key_input = WebDriverWait(driver, 200).until(
|
||||||
EC.visibility_of_element_located((By.CSS_SELECTOR, "input#api-key"))
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "input#api-key"))
|
||||||
|
|
@ -282,7 +386,7 @@ def _try_register_once_sync(
|
||||||
raise AutomationError("profile", "API key input is empty", driver)
|
raise AutomationError("profile", "API key input is empty", driver)
|
||||||
|
|
||||||
api_key = api_key.strip()
|
api_key = api_key.strip()
|
||||||
logger.info("[4/4] 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 AutomationError as e:
|
||||||
|
|
@ -298,15 +402,14 @@ def _try_register_once_sync(
|
||||||
async def register_kilo_account() -> ProviderTokens | None:
|
async def register_kilo_account() -> ProviderTokens | None:
|
||||||
"""Register a new Kilo account via Google OAuth using Selenium Firefox.
|
"""Register a new Kilo account via Google OAuth using Selenium Firefox.
|
||||||
|
|
||||||
Reads Google accounts from mail.json, tries each one.
|
Pops one email account from emails.txt and attempts registration.
|
||||||
Rotates proxy IP between attempts if needed.
|
Rotates proxy IP between attempts if needed.
|
||||||
Browser is NOT closed after registration.
|
|
||||||
"""
|
"""
|
||||||
logger.info("=== Starting Kilo account registration (Google OAuth) ===")
|
logger.info("=== Starting Kilo account registration (Google OAuth) ===")
|
||||||
|
|
||||||
accounts = load_google_accounts()
|
account = pop_account()
|
||||||
if not accounts:
|
if not account:
|
||||||
logger.error("No Google accounts available in mail.json")
|
logger.error("No email accounts available")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
driver: WebDriver | None = None
|
driver: WebDriver | None = None
|
||||||
|
|
@ -315,6 +418,8 @@ async def register_kilo_account() -> ProviderTokens | None:
|
||||||
driver = await asyncio.to_thread(_create_firefox_driver)
|
driver = await asyncio.to_thread(_create_firefox_driver)
|
||||||
|
|
||||||
for ip_attempt in range(MAX_IP_ROTATIONS):
|
for ip_attempt in range(MAX_IP_ROTATIONS):
|
||||||
|
# driver.get("http://localhost:8005/")
|
||||||
|
# await asyncio.sleep(100000000000000000) # for debugging
|
||||||
if ip_attempt > 0:
|
if ip_attempt > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Rotating proxy IP (attempt %d/%d)...",
|
"Rotating proxy IP (attempt %d/%d)...",
|
||||||
|
|
@ -325,22 +430,15 @@ async def register_kilo_account() -> ProviderTokens | None:
|
||||||
if not rotated:
|
if not rotated:
|
||||||
logger.warning("IP rotation failed, trying anyway")
|
logger.warning("IP rotation failed, trying anyway")
|
||||||
|
|
||||||
for account in accounts:
|
|
||||||
email = account.get("email", "")
|
|
||||||
password = account.get("password", "")
|
|
||||||
if not email or not password:
|
|
||||||
logger.warning("Skipping account with missing credentials")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Trying Google account: %s (IP rotation %d/%d)",
|
"Trying Google account: %s (IP attempt %d/%d)",
|
||||||
email,
|
account.email,
|
||||||
ip_attempt + 1,
|
ip_attempt + 1,
|
||||||
MAX_IP_ROTATIONS,
|
MAX_IP_ROTATIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_key = await asyncio.to_thread(
|
api_key = await asyncio.to_thread(
|
||||||
_try_register_once_sync, driver, email, password
|
_try_register_once_sync, driver, account.email, account.password
|
||||||
)
|
)
|
||||||
|
|
||||||
if api_key:
|
if api_key:
|
||||||
|
|
@ -352,9 +450,7 @@ async def register_kilo_account() -> ProviderTokens | None:
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
logger.warning("All accounts failed for current IP")
|
logger.error("All registration attempts exhausted for %s", account.email)
|
||||||
|
|
||||||
logger.error("All registration attempts exhausted")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -4,51 +4,33 @@ import os
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from provider import KiloProvider
|
from pool import TokenPool
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
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))
|
||||||
|
|
||||||
provider = KiloProvider()
|
pool = TokenPool()
|
||||||
background_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def token_handler(request: web.Request) -> web.Response:
|
async def token_handler(request: web.Request) -> web.Response:
|
||||||
del request
|
del request
|
||||||
|
|
||||||
token = await provider.get_token()
|
token = await pool.get_token()
|
||||||
if not token:
|
|
||||||
return web.json_response({"error": "Failed to get token"}, status=503)
|
|
||||||
|
|
||||||
usage_info = await provider.get_usage_info(token)
|
|
||||||
if "error" in usage_info:
|
|
||||||
# Token doesn't work — recreate
|
|
||||||
token = await provider.force_recreate_token()
|
|
||||||
if not token:
|
|
||||||
return web.json_response({"error": "Failed to recreate token"}, status=503)
|
|
||||||
usage_info = await provider.get_usage_info(token)
|
|
||||||
if "error" in usage_info:
|
|
||||||
return web.json_response({"error": usage_info["error"]}, status=503)
|
|
||||||
|
|
||||||
usage_percent = int(usage_info.get("used_percent", 0))
|
|
||||||
|
|
||||||
# Maybe rotate account
|
|
||||||
switched = await provider.maybe_rotate_account(usage_percent)
|
|
||||||
if switched:
|
|
||||||
token = await provider.get_token()
|
|
||||||
if not token:
|
if not token:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Failed to get token after switch"}, status=503
|
{"error": "No tokens available", "pool": pool.status()},
|
||||||
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Token issued, usage=%s%%", usage_percent)
|
logger.info("Token issued (pool: %d/%d)", pool.size, pool.target_size)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"token": token,
|
"token": token.api_key,
|
||||||
"usage": usage_info,
|
"balance": token.balance,
|
||||||
|
"pool": pool.status(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,23 +40,20 @@ async def health_handler(request: web.Request) -> web.Response:
|
||||||
return web.Response(text="ok")
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
|
||||||
|
async def status_handler(request: web.Request) -> web.Response:
|
||||||
|
del request
|
||||||
|
return web.json_response(pool.status())
|
||||||
|
|
||||||
|
|
||||||
async def on_startup(app: web.Application):
|
async def on_startup(app: web.Application):
|
||||||
del app
|
del app
|
||||||
logger.info("Startup: ensuring token is ready...")
|
logger.info("Startup: filling token pool...")
|
||||||
token = await provider.get_token()
|
await pool.startup_fill()
|
||||||
if not token:
|
|
||||||
logger.warning("Startup: no token available, will register on first request")
|
|
||||||
|
|
||||||
|
|
||||||
async def on_cleanup(app: web.Application):
|
async def on_cleanup(app: web.Application):
|
||||||
del app
|
del app
|
||||||
global background_task
|
await pool.shutdown()
|
||||||
if background_task and not background_task.done():
|
|
||||||
background_task.cancel()
|
|
||||||
try:
|
|
||||||
await background_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> web.Application:
|
def create_app() -> web.Application:
|
||||||
|
|
@ -83,11 +62,14 @@ def create_app() -> web.Application:
|
||||||
app.on_cleanup.append(on_cleanup)
|
app.on_cleanup.append(on_cleanup)
|
||||||
app.router.add_get("/health", health_handler)
|
app.router.add_get("/health", health_handler)
|
||||||
app.router.add_get("/token", token_handler)
|
app.router.add_get("/token", token_handler)
|
||||||
|
app.router.add_get("/status", status_handler)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logger.info("Starting kilocode on port %s", PORT)
|
logger.info(
|
||||||
|
"Starting kilocode on port %s (pool target: %d)", PORT, pool.target_size
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from proxy import get_proxy_url
|
import proxy
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -20,11 +20,11 @@ async def get_balance(
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy = get_proxy_url()
|
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) 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(
|
||||||
|
|
|
||||||
81
tests/test_emails.py
Normal file
81
tests/test_emails.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from emails import pop_account, peek_accounts, remaining_count, _parse_line
|
||||||
|
import emails as em
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_line():
|
||||||
|
acc = _parse_line("user@example.com:pass123")
|
||||||
|
assert acc is not None
|
||||||
|
assert acc.email == "user@example.com"
|
||||||
|
assert acc.password == "pass123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_line_with_colon_in_password():
|
||||||
|
acc = _parse_line("user@example.com:pass:with:colons")
|
||||||
|
assert acc is not None
|
||||||
|
assert acc.password == "pass:with:colons"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_line_empty():
|
||||||
|
assert _parse_line("") is None
|
||||||
|
assert _parse_line(" ") is None
|
||||||
|
assert _parse_line("# comment") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_line_malformed():
|
||||||
|
assert _parse_line("no-colon-here") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_peek_accounts(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / "emails.txt"
|
||||||
|
f.write_text("a@b.com:pass1\nc@d.com:pass2\n")
|
||||||
|
monkeypatch.setattr(em, "EMAILS_FILE", f)
|
||||||
|
|
||||||
|
accounts = peek_accounts()
|
||||||
|
assert len(accounts) == 2
|
||||||
|
assert accounts[0].email == "a@b.com"
|
||||||
|
assert accounts[1].email == "c@d.com"
|
||||||
|
|
||||||
|
# peek doesn't consume
|
||||||
|
assert remaining_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pop_account(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / "emails.txt"
|
||||||
|
f.write_text("a@b.com:pass1\nc@d.com:pass2\ne@f.com:pass3\n")
|
||||||
|
monkeypatch.setattr(em, "EMAILS_FILE", f)
|
||||||
|
|
||||||
|
acc = pop_account()
|
||||||
|
assert acc is not None
|
||||||
|
assert acc.email == "a@b.com"
|
||||||
|
assert remaining_count() == 2
|
||||||
|
|
||||||
|
acc = pop_account()
|
||||||
|
assert acc is not None
|
||||||
|
assert acc.email == "c@d.com"
|
||||||
|
assert remaining_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pop_account_empty(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / "emails.txt"
|
||||||
|
f.write_text("")
|
||||||
|
monkeypatch.setattr(em, "EMAILS_FILE", f)
|
||||||
|
|
||||||
|
assert pop_account() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pop_account_missing_file(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(em, "EMAILS_FILE", tmp_path / "nope.txt")
|
||||||
|
assert pop_account() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pop_skips_comments(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / "emails.txt"
|
||||||
|
f.write_text("# first is comment\na@b.com:pass1\n")
|
||||||
|
monkeypatch.setattr(em, "EMAILS_FILE", f)
|
||||||
|
|
||||||
|
acc = pop_account()
|
||||||
|
assert acc is not None
|
||||||
|
assert acc.email == "a@b.com"
|
||||||
|
# Comment line stays in file
|
||||||
|
remaining = f.read_text().strip()
|
||||||
|
assert remaining == "# first is comment"
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
from proxy import get_proxy_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_none_when_empty(monkeypatch):
|
|
||||||
monkeypatch.setattr("proxy.PROXY_URL", "")
|
|
||||||
assert get_proxy_url() is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_value(monkeypatch):
|
|
||||||
monkeypatch.setattr("proxy.PROXY_URL", "http://localhost:8080")
|
|
||||||
assert get_proxy_url() == "http://localhost:8080"
|
|
||||||
|
|
@ -1,33 +1,10 @@
|
||||||
import json
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from registration import load_google_accounts, _is_on_kilo
|
|
||||||
import registration as reg
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_google_accounts(tmp_path, monkeypatch):
|
def _is_on_kilo(url: str) -> bool:
|
||||||
mail_file = tmp_path / "mail.json"
|
"""Duplicated here to avoid importing registration (which imports nodriver)."""
|
||||||
accounts = [
|
hostname = urlparse(url).hostname or ""
|
||||||
{"email": "a@example.com", "password": "pass1"},
|
return hostname.endswith("kilo.ai")
|
||||||
{"email": "b@example.com", "password": "pass2"},
|
|
||||||
]
|
|
||||||
mail_file.write_text(json.dumps(accounts))
|
|
||||||
monkeypatch.setattr(reg, "MAIL_JSON", mail_file)
|
|
||||||
|
|
||||||
result = load_google_accounts()
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[0]["email"] == "a@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_missing_file(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setattr(reg, "MAIL_JSON", tmp_path / "nope.json")
|
|
||||||
assert load_google_accounts() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_invalid_json(tmp_path, monkeypatch):
|
|
||||||
f = tmp_path / "mail.json"
|
|
||||||
f.write_text("not json")
|
|
||||||
monkeypatch.setattr(reg, "MAIL_JSON", f)
|
|
||||||
assert load_google_accounts() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_on_kilo():
|
def test_is_on_kilo():
|
||||||
|
|
@ -38,10 +15,3 @@ def test_is_on_kilo():
|
||||||
is False
|
is False
|
||||||
)
|
)
|
||||||
assert _is_on_kilo("https://example.com/kilo.ai") is False
|
assert _is_on_kilo("https://example.com/kilo.ai") is False
|
||||||
|
|
||||||
|
|
||||||
def test_load_not_array(tmp_path, monkeypatch):
|
|
||||||
f = tmp_path / "mail.json"
|
|
||||||
f.write_text('{"email": "a@b.com"}')
|
|
||||||
monkeypatch.setattr(reg, "MAIL_JSON", f)
|
|
||||||
assert load_google_accounts() == []
|
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -209,7 +209,7 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = "==3.13.3" },
|
{ name = "aiohttp", specifier = "==3.13.3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" },
|
||||||
{ name = "selenium", specifier = ">=4.40.0" },
|
{ name = "selenium", specifier = ">=4.41.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue