universal-autoreg: initial public release (restored, redacted, hardened)
This commit is contained in:
759
providers/conol_register.py
Normal file
759
providers/conol_register.py
Normal file
@@ -0,0 +1,759 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
conol_register.py — Pilot registration of new conol.ai accounts (max 5).
|
||||
|
||||
Strategy: HTTP-only registration (AntiCaptcha reCAPTCHA v3 + IMAP verification).
|
||||
No browser needed. Creates N new accounts via plus-alias.
|
||||
|
||||
Usage:
|
||||
python conol_register.py --count 5 # pilot 5 accounts (default)
|
||||
python conol_register.py --count 1 # single account
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
import imaplib
|
||||
import email as email_module
|
||||
import quopri
|
||||
import ssl
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
import requests
|
||||
|
||||
# Log lines carry ✅/❌. When stdout is a file rather than a tty the encoding comes from
|
||||
# the locale (cp1251/cp866 on this box) and the first emoji raises UnicodeEncodeError,
|
||||
# killing an unattended run. Setting PYTHONIOENCODING from inside the process is too late
|
||||
# — the interpreter reads it at startup — so reconfigure the streams directly. The
|
||||
# supervisor also passes -X utf8 and PYTHONIOENCODING; this covers every other launcher.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("conol_register")
|
||||
|
||||
# ─── constants ──────────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
POOL_FILE = BASE_DIR / "conol_accounts_pool.jsonl"
|
||||
PID_FILE = BASE_DIR / "conol_register_run.pid"
|
||||
|
||||
# credentials load from config.json / env — see conol_secrets.py
|
||||
from conol_secrets import ( # noqa: E402
|
||||
GMAIL, GMAIL_APP, PASSWORD, SITE_KEY, BASE_URL, ANTICAPTCHA_KEYS,
|
||||
)
|
||||
import conol_captcha # noqa: E402 free chrome_cdp -> paid anticaptcha chain
|
||||
import conol_emails # noqa: E402 gmail plus-alias + t-online.de dedicated boxes
|
||||
|
||||
|
||||
def solve_captcha(action: str, skip: "set|None" = None) -> Optional[str]:
|
||||
"""Solve reCAPTCHA v3 through the provider chain (free Chrome CDP first,
|
||||
paid AntiCaptcha fallback). `skip` escalates past a provider whose token
|
||||
the server just rejected. See conol_captcha.py for the order/config."""
|
||||
return conol_captcha.solve(action, skip=skip)
|
||||
|
||||
|
||||
def new_client() -> "requests.Session":
|
||||
"""One HTTP session for the WHOLE registration chain.
|
||||
|
||||
better-auth sets a session cookie on POST /api/invites/register and
|
||||
/api/auth/send-verification-email only succeeds when that cookie is
|
||||
presented. The previous code used a fresh `requests.post` per step, so the
|
||||
cookie was dropped and 3/5 pilot accounts died with
|
||||
"send verification email failed" even though the account had been created.
|
||||
"""
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"Accept": "application/json",
|
||||
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/140.0 Safari/537.36"),
|
||||
"Origin": BASE_URL,
|
||||
})
|
||||
return session
|
||||
|
||||
|
||||
def _log_failure(step: str, resp) -> None:
|
||||
"""Never swallow a non-2xx: the body is the only diagnostic conol gives us."""
|
||||
log.warning("%s failed: HTTP %s %s", step, resp.status_code, resp.text[:220].replace("\n", " "))
|
||||
|
||||
|
||||
def sign_in(email: str, captcha_token: str, client=None) -> Optional[dict]:
|
||||
"""POST /api/auth/sign-in/email. Returns session data dict or None."""
|
||||
http = client or requests
|
||||
resp = http.post(f"{BASE_URL}/api/auth/sign-in/email", json={
|
||||
"email": email, "password": PASSWORD,
|
||||
"callbackURL": "/home", "rememberMe": True,
|
||||
}, headers={
|
||||
"x-captcha-response": captcha_token,
|
||||
"Content-Type": "application/json",
|
||||
"Referer": f"{BASE_URL}/login",
|
||||
}, timeout=20)
|
||||
|
||||
if resp.status_code != 200:
|
||||
_log_failure(f"sign-in {email[:34]}", resp)
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
# The usable credential is the COOKIE value: "<token>.<base64 signature>" (~85
|
||||
# chars). The response body's "token" is the bare 32-char session id and
|
||||
# get-session rejects it, so it must never be treated as a fallback.
|
||||
# With a shared requests.Session the cookie often lands in the session jar
|
||||
# rather than resp.cookies, so check both.
|
||||
session_token = resp.cookies.get("__Secure-better-auth.session_token")
|
||||
if not session_token:
|
||||
jar = getattr(http, "cookies", None)
|
||||
if jar is not None:
|
||||
session_token = jar.get("__Secure-better-auth.session_token")
|
||||
if not session_token:
|
||||
body_token = data.get("token") or ""
|
||||
log.warning("sign-in 200 for %s but no session cookie (body token %d chars is "
|
||||
"unusable for get-session)", email[:34], len(body_token))
|
||||
return None
|
||||
|
||||
return {"session_token": session_token, "user": data.get("user", {})}
|
||||
|
||||
|
||||
def register_account(email: str, name: str, captcha_token: str, client=None) -> Optional[dict]:
|
||||
"""POST /api/invites/register. Returns response dict or {"error": ...}."""
|
||||
http = client or requests
|
||||
resp = http.post(f"{BASE_URL}/api/invites/register", json={
|
||||
"token": None, "email": email, "password": PASSWORD,
|
||||
"name": name, "referrer_share_id": None,
|
||||
}, headers={
|
||||
"x-captcha-response": captcha_token,
|
||||
"Content-Type": "application/json",
|
||||
"Referer": f"{BASE_URL}/sign-up",
|
||||
}, timeout=20)
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {"ok": True, "raw": resp.text[:200]}
|
||||
_log_failure(f"register {email[:34]}", resp)
|
||||
try:
|
||||
err = resp.json()
|
||||
# Include the machine code so callers can classify a CAPTCHA_VERIFICATION_FAILED
|
||||
# rejection (retryable with a fresh token) from a hard validation error. The
|
||||
# human message alone ("Human verification failed") does not contain "CAPTCHA".
|
||||
msg = err.get("error") or resp.text[:200]
|
||||
code = err.get("code") or ""
|
||||
return {"error": f"{msg} [{code}]" if code else msg}
|
||||
except ValueError:
|
||||
return {"error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
|
||||
|
||||
def send_verification_email(email: str, captcha_token: str, client=None) -> bool:
|
||||
"""POST /api/auth/send-verification-email. Requires the register session cookie."""
|
||||
http = client or requests
|
||||
resp = http.post(f"{BASE_URL}/api/auth/send-verification-email", json={
|
||||
"email": email, "callbackURL": "/home",
|
||||
}, headers={
|
||||
"x-captcha-response": captcha_token,
|
||||
"Content-Type": "application/json",
|
||||
"Referer": f"{BASE_URL}/sign-up",
|
||||
}, timeout=20)
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
return True
|
||||
_log_failure(f"send-verification-email {email[:34]}", resp)
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_verify_link(target_alias: str, since_epoch: float, timeout: int = 180) -> Optional[str]:
|
||||
"""Poll Gmail IMAP for verification link. Returns URL or None."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
mail = None
|
||||
try:
|
||||
mail = imaplib.IMAP4_SSL("imap.gmail.com", 993,
|
||||
ssl_context=ssl.create_default_context(), timeout=20)
|
||||
mail.login(GMAIL, GMAIL_APP)
|
||||
mail.select("INBOX")
|
||||
status, data = mail.search(None, 'FROM "Conol"')
|
||||
if status != "OK" or not data or not data[0]:
|
||||
time.sleep(4)
|
||||
continue
|
||||
for message_id in reversed(data[0].split()[-30:]):
|
||||
status, rows = mail.fetch(message_id, "(RFC822)")
|
||||
if status != "OK" or not rows or not isinstance(rows[0], tuple):
|
||||
continue
|
||||
raw = rows[0][1]
|
||||
message = email_module.message_from_bytes(raw)
|
||||
to_header = str(message.get("To", ""))
|
||||
if target_alias.lower() not in to_header.lower():
|
||||
continue
|
||||
message_ts = email_module.utils.mktime_tz(
|
||||
email_module.utils.parsedate_tz(str(message.get("Date", ""))))
|
||||
if message_ts and message_ts < since_epoch - 30:
|
||||
continue
|
||||
decoded = quopri.decodestring(raw).decode("utf-8", errors="replace")
|
||||
match = re.search(
|
||||
r"https://conol\.ai/api/auth/verify-email\?token=([^&\s\"'<>]+)(?:&|&)callbackURL=([^\s\"'<>]+)",
|
||||
decoded, flags=re.IGNORECASE)
|
||||
if match:
|
||||
token = match.group(1).replace("=\r\n", "").replace("=\n", "")
|
||||
callback = match.group(2).replace("=\r\n", "").replace("=\n", "")
|
||||
return f"https://conol.ai/api/auth/verify-email?token={token}&callbackURL={callback}"
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
if mail is not None:
|
||||
try:
|
||||
mail.logout()
|
||||
except:
|
||||
pass
|
||||
time.sleep(4)
|
||||
return None
|
||||
|
||||
|
||||
def verify_email(verify_url: str, client=None) -> bool:
|
||||
"""GET verify-email URL. Returns True if verification succeeded."""
|
||||
http = client or requests
|
||||
resp = http.get(verify_url, headers={"Accept": "application/json"},
|
||||
timeout=20, allow_redirects=False)
|
||||
if resp.status_code in (200, 302, 307):
|
||||
return True
|
||||
_log_failure("verify-email", resp)
|
||||
return False
|
||||
|
||||
|
||||
def verify_session(session_token: str) -> Optional[dict]:
|
||||
"""GET /api/auth/get-session. Returns user or None."""
|
||||
import requests
|
||||
|
||||
resp = requests.get(f"{BASE_URL}/api/auth/get-session",
|
||||
cookies={"__Secure-better-auth.session_token": session_token},
|
||||
headers={"Accept": "application/json"}, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("user")
|
||||
return None
|
||||
|
||||
|
||||
def get_balance(session_token: str) -> Optional[float]:
|
||||
"""GET /api/billing/balance. Returns total or None."""
|
||||
import requests
|
||||
|
||||
resp = requests.get(f"{BASE_URL}/api/billing/balance",
|
||||
cookies={"__Secure-better-auth.session_token": session_token},
|
||||
headers={"Accept": "application/json"}, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("total")
|
||||
return None
|
||||
|
||||
|
||||
def save_cookies(name: str, session_token: str) -> str:
|
||||
"""Save cookies atomically; never truncate an existing file on empty input.
|
||||
|
||||
Same defect that was fixed in conol_refresh.py: opening the target with "w"
|
||||
before validating anything turned a working cookie file into `[]` on a failed
|
||||
refresh (observed: cookies_conol337718.json 629 B -> 2 B).
|
||||
"""
|
||||
cookies_path = BASE_DIR / f"cookies_{name.lower()}.json"
|
||||
if not session_token:
|
||||
log.warning("save_cookies: empty token for %s — leaving %s untouched", name, cookies_path.name)
|
||||
return str(cookies_path)
|
||||
|
||||
cookies = [{
|
||||
"name": "__Secure-better-auth.session_token",
|
||||
"value": session_token,
|
||||
"domain": "conol.ai",
|
||||
"path": "/",
|
||||
"expires": int(time.time() + 604800),
|
||||
"httpOnly": True,
|
||||
"secure": True,
|
||||
"sameSite": "Lax",
|
||||
}]
|
||||
tmp_path = cookies_path.with_name(cookies_path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(cookies, f, indent=2)
|
||||
f.flush()
|
||||
tmp_path.replace(cookies_path)
|
||||
return str(cookies_path)
|
||||
|
||||
|
||||
def append_to_pool(entry: dict):
|
||||
"""Append one account to pool file."""
|
||||
with open(POOL_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def register_one(suffix: int, provider: str = None) -> dict:
|
||||
"""Register one account. Returns result dict.
|
||||
|
||||
provider: 'gmail' | 'tonline' | None (first enabled). The address AND its
|
||||
mailbox credentials come from conol_emails.next_address() so the verify-link
|
||||
poller knows which IMAP server/box to read."""
|
||||
mailres = conol_emails.next_address(f"conol{suffix}", provider=provider)
|
||||
if mailres is None:
|
||||
return {"name": f"Conol{suffix}", "email": None, "started_at": time.time(),
|
||||
"error": f"no email address available (provider={provider})"}
|
||||
email = mailres["address"]
|
||||
# 10 digits, not 6: save_cookies keys the file on this name, so a 6-digit space
|
||||
# is only 10^6 and a ~370-attempt campaign has a ~6-7% birthday chance of two
|
||||
# pool rows pointing at one cookies_conol*.json (the second silently overwrites
|
||||
# the first, and it surfaces later as a token belonging to another account).
|
||||
name = f"Conol{str(suffix).zfill(10)[-10:]}"
|
||||
started = time.time()
|
||||
result = {"name": name, "email": email, "started_at": started,
|
||||
"email_provider": mailres["provider"]}
|
||||
captcha_cost = 0
|
||||
|
||||
log.info("=" * 50)
|
||||
log.info("Registering %s (%s)", name, email)
|
||||
|
||||
client = new_client()
|
||||
|
||||
# Step 1: register. Retry with a FRESH token per attempt: AntiCaptcha v3
|
||||
# tokens are a score lottery, and the same endpoint reliably passes on
|
||||
# attempt 2-3. On EMAIL_ALREADY_REGISTERED (a t-online box used by an earlier
|
||||
# campaign) we hop to a fresh address instead of burning attempts — the pool
|
||||
# of 17k .de boxes has an unknown fraction already taken.
|
||||
reg = None
|
||||
email_hops = 0
|
||||
MAX_EMAIL_HOPS = 12
|
||||
attempt = 0
|
||||
skip_providers = set()
|
||||
while attempt < 3:
|
||||
attempt += 1
|
||||
captcha = solve_captcha("sign_up", skip=skip_providers or None)
|
||||
used_provider = conol_captcha.LAST_PROVIDER
|
||||
if not captcha:
|
||||
log.warning("sign_up captcha solve failed (attempt %d/3)", attempt)
|
||||
time.sleep(3)
|
||||
continue
|
||||
captcha_cost += 1
|
||||
candidate = register_account(email, name, captcha, client)
|
||||
if candidate is not None and "error" not in candidate:
|
||||
reg = candidate
|
||||
break
|
||||
err_text = str((candidate or {}).get("error", ""))
|
||||
upper = err_text.upper()
|
||||
if "ALREADY REGISTERED" in upper or "ALREADY EXISTS" in upper or "EMAIL_ALREADY" in upper:
|
||||
# This mailbox is taken on conol. Mark it used and hop to the next one.
|
||||
email_hops += 1
|
||||
if email_hops > MAX_EMAIL_HOPS:
|
||||
log.error("%d consecutive already-registered addresses — provider pool exhausted", email_hops)
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "all reserved addresses already registered (%d hops)" % email_hops}
|
||||
log.warning("address %s already registered on conol — reserving next (hop %d/%d)",
|
||||
email, email_hops, MAX_EMAIL_HOPS)
|
||||
conol_emails.mark_used(mailres)
|
||||
mailres = conol_emails.next_address("conol%d" % int(time.time() * 1000), provider=mailres["provider"])
|
||||
if mailres is None:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "email provider exhausted while skipping registered addresses"}
|
||||
email = mailres["address"]
|
||||
result["email"] = email
|
||||
result["email_provider"] = mailres["provider"]
|
||||
name = "Conol" + str(int(time.time() * 1000) % 10_000_000_000).zfill(10)[-10:]
|
||||
attempt -= 1 # a hop is not a failed attempt against the new address
|
||||
client = new_client()
|
||||
continue
|
||||
log.warning("register attempt %d/3 rejected: %s", attempt, err_text[:160] or "no response")
|
||||
if err_text and "CAPTCHA" not in upper:
|
||||
# Registration is not idempotent. A 403 CAPTCHA_VERIFICATION_FAILED happens
|
||||
# BEFORE the account exists, so retrying is safe; a 400/409/422 (validation)
|
||||
# or a 429 does not benefit from another solve and retrying it just burns
|
||||
# budget while masking real state.
|
||||
log.error("non-captcha rejection — not retrying: %s", err_text[:160])
|
||||
return {**result, "captcha_solves": captcha_cost, "error": err_text[:200]}
|
||||
# The server rejected THIS token. Escalate: stop using the provider that
|
||||
# minted it (a fresh Chrome profile scores too low for reCAPTCHA v3) and
|
||||
# let the next attempt fall through to the paid AntiCaptcha path.
|
||||
if "CAPTCHA_VERIFICATION_FAILED" in upper and used_provider:
|
||||
skip_providers.add(used_provider)
|
||||
log.warning("token from %s rejected by server — escalating past it (skip=%s)",
|
||||
used_provider, sorted(skip_providers))
|
||||
time.sleep(5)
|
||||
if reg is None:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "register failed after 3 attempts"}
|
||||
|
||||
log.info("✅ Register OK")
|
||||
|
||||
# Step 2: send verification email — same retry discipline, fresh token each time.
|
||||
sent = False
|
||||
for attempt in range(1, 4):
|
||||
captcha = solve_captcha("send_verification_email")
|
||||
if not captcha:
|
||||
log.warning("send_verification_email captcha solve failed (attempt %d/3)", attempt)
|
||||
time.sleep(3)
|
||||
continue
|
||||
captcha_cost += 1
|
||||
if send_verification_email(email, captcha, client):
|
||||
sent = True
|
||||
break
|
||||
log.warning("send-verification attempt %d/3 rejected", attempt)
|
||||
time.sleep(5)
|
||||
if not sent:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "send verification email failed after 3 attempts"}
|
||||
|
||||
log.info("✅ Verification email sent")
|
||||
|
||||
# Step 3: Wait for verify link in the provider mailbox (up to 3 min)
|
||||
verify_url = conol_emails.wait_for_verify_link(mailres, started, 180)
|
||||
if not verify_url:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "no verify email found in %s mailbox (3 min timeout)"
|
||||
% mailres["provider"]}
|
||||
|
||||
log.info("✅ Got verify link: %s...", verify_url[:80])
|
||||
|
||||
# Step 4: Verify email via HTTP
|
||||
if not verify_email(verify_url, client):
|
||||
return {**result, "captcha_solves": captcha_cost, "error": "email verification failed"}
|
||||
|
||||
# Not "Email verified": verify_email() accepts any 200/302/307 without inspecting
|
||||
# Location, and better-auth 302s on failure too. The authoritative gate is
|
||||
# user.emailVerified from get-session at Step 6 — in a 14-hour unattended log a
|
||||
# premature success line is how a batch of unusable accounts goes unnoticed.
|
||||
log.info("✅ verify-email responded (authoritative emailVerified check follows at sign-in)")
|
||||
|
||||
# Step 5: Sign in (with retry)
|
||||
session_data = None
|
||||
for retry in range(3):
|
||||
captcha = solve_captcha("sign_in")
|
||||
if not captcha:
|
||||
continue
|
||||
captcha_cost += 1
|
||||
session_data = sign_in(email, captcha, client)
|
||||
if session_data:
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
if not session_data:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "sign-in failed after 3 retries"}
|
||||
|
||||
session_token = session_data["session_token"]
|
||||
|
||||
# Step 6: Verify session. `verify_email()` accepts any 302 without checking
|
||||
# Location, so emailVerified here is the only real proof the account is usable
|
||||
# rather than merely able to sign in.
|
||||
user = verify_session(session_token)
|
||||
if not user:
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "session invalid after sign-in"}
|
||||
|
||||
if user.get("email", "").lower() != email.lower():
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": f"email mismatch: {user.get('email')}"}
|
||||
|
||||
if not user.get("emailVerified"):
|
||||
# Registration AND sign-in both worked, so this alias is real and
|
||||
# recoverable — dropping it would manufacture another orphan on conol.ai
|
||||
# that nobody can find later. Record it as a work-queue row instead:
|
||||
# no session_token and no cookie file, so the importer's
|
||||
# is_live = bool(session_token and expires > now) keeps it inactive and it
|
||||
# never reaches ExtremeRouter as a usable account. A later pass can re-issue
|
||||
# send-verification-email for exactly these rows and promote them.
|
||||
append_to_pool({
|
||||
"name": name, "email": email, "password": PASSWORD,
|
||||
"cookies_path": "", "created_at": started,
|
||||
"session_token": None, "token_expires": None,
|
||||
"last_login": time.time(), "status": "unverified",
|
||||
"credits": 0,
|
||||
"email_provider": mailres["provider"],
|
||||
"mailbox_password": mailres.get("password"),
|
||||
})
|
||||
log.warning("%s: session ok but emailVerified=false — queued as 'unverified'", name)
|
||||
return {**result, "captcha_solves": captcha_cost,
|
||||
"error": "emailVerified=false — queued as unverified for a later pass"}
|
||||
|
||||
# Step 7: Get balance
|
||||
credits = get_balance(session_token)
|
||||
elapsed = time.time() - started
|
||||
|
||||
# Step 8: Save cookies + pool
|
||||
cookies_path = save_cookies(name, session_token)
|
||||
pool_entry = {
|
||||
"name": name, "email": email, "password": PASSWORD,
|
||||
"cookies_path": cookies_path, "created_at": started,
|
||||
"session_token": session_token, "token_expires": time.time() + 604800,
|
||||
"last_login": time.time(), "status": "live",
|
||||
"credits": credits or 0,
|
||||
"email_provider": mailres["provider"],
|
||||
"mailbox_password": mailres.get("password"),
|
||||
}
|
||||
append_to_pool(pool_entry)
|
||||
|
||||
log.info("✅ %s: email=%s, credits=%s, time=%.0fs, captcha_solves=%d",
|
||||
name, email, credits, elapsed, captcha_cost)
|
||||
|
||||
return {
|
||||
**result, "ok": True, "credits": credits,
|
||||
"elapsed_s": round(elapsed, 1), "captcha_solves": captcha_cost,
|
||||
}
|
||||
|
||||
# ─── unattended-run guards ────────────────────────────────────────────────────
|
||||
# Budget exhaustion, a dead solver key, Gmail IMAP throttling and an IP block all
|
||||
# present identically: solve_captcha returns None or the endpoint 403s, per
|
||||
# account, forever. Without these guards a multi-hour run produces nothing while
|
||||
# still looking alive.
|
||||
MAX_CONSECUTIVE_FAILURES = 10
|
||||
MIN_BUDGET_USD = 1.0
|
||||
RESULTS_FILE = BASE_DIR / "conol_register_results.jsonl"
|
||||
|
||||
|
||||
def solver_balances() -> dict:
|
||||
"""Live AntiCaptcha balances — the log must report what can actually be spent,
|
||||
not a hardcoded figure from whenever the script was written."""
|
||||
out = {}
|
||||
for idx, key in enumerate(ANTICAPTCHA_KEYS, start=1):
|
||||
try:
|
||||
resp = requests.post("https://api.anti-captcha.com/getBalance",
|
||||
json={"clientKey": key}, timeout=20).json()
|
||||
out[f"key{idx}"] = resp.get("balance") if resp.get("errorId") == 0 \
|
||||
else f"error:{resp.get('errorCode')}"
|
||||
except Exception as exc: # noqa: BLE001 - a probe must not kill the run
|
||||
out[f"key{idx}"] = f"unreachable:{type(exc).__name__}"
|
||||
return out
|
||||
|
||||
|
||||
def append_result_line(result: dict):
|
||||
"""One JSON line per attempt, written as it happens.
|
||||
|
||||
The in-memory `results` list is only summarised after the loop, so a death at
|
||||
account 90 used to leave no record of which attempts failed or why.
|
||||
"""
|
||||
with open(RESULTS_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({**result, "ts": round(time.time(), 1)}, ensure_ascii=False) + "\n")
|
||||
|
||||
# A slot claim is authoritative only while the file is younger than this. The window
|
||||
# must exceed the LONGEST legitimate hold: a 200-attempt round at ~70-140 s per attempt
|
||||
# runs up to ~8 h, and conol_refresh rewrites the whole pool over a similar span. A
|
||||
# window shorter than that would read a legitimate multi-hour holder as stale and
|
||||
# green-light a second writer — the exact threat the slot exists to stop. A window this
|
||||
# long is also what caps the worst case of a wedged claim (a pid Windows recycled onto a
|
||||
# long-lived unrelated process, plus the deliberately fail-closed probe): refusal can
|
||||
# never outlast the window, so an unattended campaign cannot be bricked by its own guard.
|
||||
# Project-wide source of truth: conol_scale.slot_holder imports it lazily inside the
|
||||
# function — a module-level import would let this module's logging.basicConfig win the
|
||||
# root-logger configuration of whoever imports it.
|
||||
SLOT_MAX_HOLD_SEC = 12 * 3600
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""True if `pid` exists. Fail-closed: any probe error means "assume alive".
|
||||
|
||||
Do NOT reach for os.kill(pid, 0) here — on Windows CPython implements that as
|
||||
OpenProcess(PROCESS_TERMINATE) + TerminateProcess(handle, sig), so signal 0 would
|
||||
KILL the process it was meant to probe. tasklist is the only safe read-only check.
|
||||
Its output is decoded defensively because this box emits non-UTF-8 bytes (0xff) in
|
||||
some locales, which previously dropped a reader thread and yielded stdout=None; a
|
||||
broken probe that reads as "dead" would let a second registrar start next to a
|
||||
live one, which is the exact failure this guard exists to prevent.
|
||||
"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
proc = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
||||
capture_output=True, timeout=20)
|
||||
except Exception as exc: # noqa: BLE001 — probe failure must not look like "dead"
|
||||
log.warning("pid probe for %s failed (%s) — assuming alive", pid, exc)
|
||||
return True
|
||||
out = (proc.stdout or b"").decode("utf-8", "replace")
|
||||
# Whole-token match: the Mem Usage column can contain bare digits ("516 K") that a
|
||||
# substring search would mistake for a pid.
|
||||
return str(pid) in out.split()
|
||||
|
||||
|
||||
def _write_pid_atomic(pid: int) -> None:
|
||||
tmp = PID_FILE.with_suffix(".pid.tmp")
|
||||
tmp.write_text(f"{pid}\n", encoding="utf-8")
|
||||
os.replace(tmp, PID_FILE)
|
||||
|
||||
|
||||
def acquire_pid_slot(owner: str = "registrar") -> bool:
|
||||
"""Claim conol_register_run.pid; refuse to start if a live foreign pid holds it.
|
||||
|
||||
This file used to be written only by whoever launched the registrar, so a manual
|
||||
launch left a stale pid behind and the supervisor's pid-oracle probed a long-dead
|
||||
process forever. That left a SINGLE oracle — the run log's mtime — guarding against
|
||||
two registrars appending to one JSONL pool and reading one Gmail mailbox over IMAP.
|
||||
Owning the slot here makes the second writer impossible no matter who launches the
|
||||
process, which the supervisor-side write cannot do. A pid left behind by taskkill /F
|
||||
(no job object, so the child survives its parent, and atexit never runs) is detected
|
||||
as stale on the next start and taken over.
|
||||
|
||||
Refusal also requires the claim to be FRESH (mtime < SLOT_MAX_HOLD_SEC): _pid_alive
|
||||
fails closed on purpose and Windows recycles pids, so without the age term one stale
|
||||
file whose pid got reused by a long-lived unrelated process would refuse every round
|
||||
forever — an unattended campaign bricked by its own guard. The holder keeps its claim
|
||||
fresh by rewriting the file once per account (see main()).
|
||||
"""
|
||||
me = os.getpid()
|
||||
if PID_FILE.exists():
|
||||
try:
|
||||
other = int(PID_FILE.read_text(encoding="utf-8").split("\n", 1)[0].strip())
|
||||
except (ValueError, OSError):
|
||||
other = 0
|
||||
try:
|
||||
age = time.time() - PID_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
age = float("inf") # cannot prove freshness -> treat as abandoned
|
||||
if other == me:
|
||||
log.info("re-acquiring own pid slot (pid %s)", me)
|
||||
elif other and age < SLOT_MAX_HOLD_SEC and _pid_alive(other):
|
||||
log.error("another %s (pid %s) already holds the slot (%.0fs old) — refusing "
|
||||
"to start a second writer on %s", owner, other, age, POOL_FILE.name)
|
||||
return False
|
||||
else:
|
||||
reason = (f"held longer than the {SLOT_MAX_HOLD_SEC}s max-hold window"
|
||||
if other and age >= SLOT_MAX_HOLD_SEC else "pid not alive")
|
||||
log.warning("taking over pid slot from pid %s (%s)", other, reason)
|
||||
_write_pid_atomic(me)
|
||||
return True
|
||||
|
||||
|
||||
def release_pid_slot() -> None:
|
||||
"""Drop the pid file, but only while it still holds OUR pid."""
|
||||
me = os.getpid()
|
||||
try:
|
||||
if (PID_FILE.exists()
|
||||
and PID_FILE.read_text(encoding="utf-8").split("\n", 1)[0].strip() == str(me)):
|
||||
PID_FILE.unlink()
|
||||
except OSError as exc:
|
||||
log.warning("could not clear pid file: %s", exc)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Pilot conol.ai registration")
|
||||
parser.add_argument("--count", type=int, default=5, help="Number of accounts to register")
|
||||
parser.add_argument("--provider", choices=["gmail", "tonline"], default=None,
|
||||
help="Email provider (default: first enabled — gmail, then tonline)")
|
||||
parser.add_argument("--free-captcha", action="store_true",
|
||||
help="Force the free Chrome CDP solver only (no paid AntiCaptcha)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.free_captcha:
|
||||
conol_captcha.PROVIDERS = ["chrome_cdp"]
|
||||
# Pre-launch Chrome + park it on conol.ai so the first solve is ~1 s, not ~30.
|
||||
if "chrome_cdp" in conol_captcha.PROVIDERS:
|
||||
conol_captcha.warmup()
|
||||
enabled = conol_emails.providers()
|
||||
if args.provider and args.provider not in enabled:
|
||||
log.error("provider %s not enabled (available: %s)", args.provider, enabled)
|
||||
return 4
|
||||
log.info("email providers enabled: %s (requested: %s)", enabled, args.provider or "auto")
|
||||
|
||||
# Claim the slot before anything touches the pool. atexit (not try/finally around
|
||||
# the whole body) keeps the diff to two lines and still covers every clean exit
|
||||
# path, including the early `return 2` budget refusal below.
|
||||
if not acquire_pid_slot():
|
||||
return 3
|
||||
atexit.register(release_pid_slot)
|
||||
|
||||
# Cap raised from 5 (pilot) to 200 after the retry fix was measured end-to-end:
|
||||
# 1/2 pilot accounts registered fully, 6 solves = $0.06, ~83 s per attempt.
|
||||
if args.count > 200:
|
||||
log.warning("Capping requested %d accounts to 200 per run.", args.count)
|
||||
args.count = 200
|
||||
|
||||
log.info("Pilot registration: %d accounts", args.count)
|
||||
paid_in_chain = "anticaptcha" in conol_captcha.PROVIDERS
|
||||
budgets = solver_balances() if paid_in_chain else {}
|
||||
log.info("Captcha budget (live getBalance): %s", budgets or "n/a — free solver chain")
|
||||
spendable = sum(v for v in budgets.values() if isinstance(v, (int, float)))
|
||||
# Scale the pre-flight floor with the round size: at the measured ~$0.03/attempt
|
||||
# (worst case ~$0.09 with every retry firing) a flat $1 floor lets a 200-attempt
|
||||
# round start, create aliases, and die on the consecutive-failure breaker — which
|
||||
# is exactly the wasted work the floor exists to prevent.
|
||||
floor = max(MIN_BUDGET_USD, 0.09 * args.count) if paid_in_chain else 0.0
|
||||
if paid_in_chain and spendable < floor:
|
||||
log.error("Solver budget $%.4f is below the $%.2f floor for %d attempts — refusing to start",
|
||||
spendable, floor, args.count)
|
||||
return 2
|
||||
|
||||
results = []
|
||||
consecutive_failures = 0
|
||||
t0 = time.time()
|
||||
|
||||
for i in range(args.count):
|
||||
# Millisecond resolution (no randomness): collision-free unless two attempts
|
||||
# land in the same millisecond, which the 30 s inter-account gap rules out.
|
||||
# `name` above keeps 10 digits so the cookie filename space matches.
|
||||
suffix = int(time.time() * 1000) % 10_000_000_000
|
||||
result = register_one(suffix, provider=args.provider)
|
||||
result["batch_index"] = i
|
||||
results.append(result)
|
||||
append_result_line(result)
|
||||
# Keep the slot claim fresh: acquire_pid_slot's refusal is age-gated, so a long
|
||||
# round that never touches this file would eventually read as abandoned and let a
|
||||
# second writer in. Refresh the MTIME only, and only while we still own the file:
|
||||
# os.utime opens with FILE_WRITE_ATTRIBUTES (no replace), so it does not race the
|
||||
# concurrent slot_holder() reader the way an os.replace would raise PermissionError
|
||||
# on Windows, and it does not stomp a new holder's claim if we were aged out and
|
||||
# someone legitimately took over. A missed refresh is harmless — the age window
|
||||
# is 2 orders of magnitude wider than one attempt.
|
||||
try:
|
||||
if (PID_FILE.exists()
|
||||
and PID_FILE.read_text(encoding="utf-8").split("\n", 1)[0].strip() == str(os.getpid())):
|
||||
os.utime(PID_FILE, None)
|
||||
except OSError as exc:
|
||||
log.warning("could not refresh the slot claim: %s", exc)
|
||||
|
||||
if result.get("ok"):
|
||||
consecutive_failures = 0
|
||||
log.info(" ✅ Account %d/%d registered", i+1, args.count)
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
log.warning(" ❌ Account %d/%d failed: %s", i+1, args.count, result.get("error", "?"))
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
log.error("CIRCUIT BREAKER: %d consecutive failures — aborting instead of "
|
||||
"burning budget for hours. Last error: %s",
|
||||
consecutive_failures, result.get("error", "?"))
|
||||
break
|
||||
|
||||
if i < args.count - 1:
|
||||
delay = 30
|
||||
log.info(" Waiting %ds before next account...", delay)
|
||||
time.sleep(delay)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
ok_count = sum(1 for r in results if r.get("ok"))
|
||||
captcha_total = sum(r.get("captcha_solves", 0) for r in results)
|
||||
# Divide by what was actually attempted: after a circuit-breaker abort that is
|
||||
# fewer than args.count, and the per-account cost matters most in that run.
|
||||
avg_time = round(elapsed / len(results), 1) if results else 0
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"PILOT COMPLETE: {ok_count}/{args.count} accounts registered")
|
||||
print(f"Total time: {elapsed:.0f}s (avg {avg_time}s per account)")
|
||||
print(f"Total captcha solves: {captcha_total}")
|
||||
|
||||
if ok_count > 0:
|
||||
live_with_credits = [r for r in results if r.get("ok")]
|
||||
total_credits = sum(r.get("credits", 0) or 0 for r in live_with_credits)
|
||||
# These are conol credit units, not dollars — the old label rendered a
|
||||
# 22k-credit pool as "$22268.00".
|
||||
print(f"Total credits earned: {total_credits:.2f} conol credits")
|
||||
print(f"Captcha solves: {captcha_total} (estimate at $0.01/solve: ~${captcha_total * 0.01:.2f})")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
# Detailed failures
|
||||
failed = [r for r in results if not r.get("ok")]
|
||||
if failed:
|
||||
print("\nFAILURES:")
|
||||
for r in failed:
|
||||
print(f" ❌ {r['name']} ({r['email']}): {r.get('error', '?')}")
|
||||
print()
|
||||
|
||||
return 0 if ok_count > 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user