pryscraper/account_manager.py
cryptorugmunch 7e0b93dd84 feat(pry): wire captcha_solver.CaptchaSolver into /v1/auth/captcha endpoint
The big captcha_solver.py (6 providers: capsolver/2captcha/anti-captcha/
capmonster/deathbycaptcha/nextcaptcha, supports reCAPTCHA v2/v3, hCaptcha,
Turnstile, image) was an orphan module — never imported. The /v1/auth/captcha
endpoint was using the smaller auth_connector.solve_captcha (2 providers,
2captcha stubbed out).

This commit:
- Replaces the /v1/auth/captcha handler to delegate to CaptchaSolver
- Adds support for captcha_type: recaptcha_v2, recaptcha_v3, hcaptcha, image
- Adds per-provider api_keys dict for the 6-provider chain
- Adds tests/test_captcha_solver_wiring.py
- Annotates account_manager.py and signup_automator.py (BSL-licensed) with
  notes about their unwired status and where they belong in the architecture

The two remaining orphan modules are kept (not deleted) because they are
BSL 1.1 stealth code that may be wired in the future as anti-fingerprint
or identity-evasion routes. See FEATURES.md and ARCHITECTURE.md.

Audit item 10. Tests: 623 passed, 1 skipped (test_ready_returns_200 fails
pre-existing because Ollama is unreachable from test env).
2026-07-06 10:37:55 +02:00

147 lines
5 KiB
Python

# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
"""Pry — Account pool management, session persistence, proxy scoring."""
# NOTE: This module is part of the BSL-licensed stealth/anti-detection
# surface (see LICENSE-BSL-STEALTH). The class AccountPool and
# ProxyScorer are NOT currently wired into any router. They are kept
# for future use by the Account/Identity/Anti-fingerprint workflows.
# See ARCHITECTURE.md#module-map and FEATURES.md "Account pool".
import json
import logging
import os
import time
from datetime import UTC, datetime
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
ACCOUNTS_DIR = PRY_DATA_DIR / "accounts"
ACCOUNTS_DIR.mkdir(parents=True, exist_ok=True)
class AccountPool:
"""Manage pool of registered accounts with session persistence."""
def store(
self,
site: str,
credentials: dict[str, Any],
profile_id: str = "",
metadata: dict | None = None,
) -> str:
import uuid
account_id = uuid.uuid4().hex[:12]
account: dict[str, Any] = {
"id": account_id,
"site": site,
"credentials": credentials,
"profile_id": profile_id,
"metadata": metadata or {},
"status": "active",
"created_at": datetime.now(UTC).isoformat(),
"last_used": None,
"use_count": 0,
"errors": [],
}
path = ACCOUNTS_DIR / f"{site}_{account_id}.json"
path.write_text(json.dumps(account, indent=2))
logger.info("account_stored", extra={"site": site, "account_id": account_id})
return account_id
def get_active(self, site: str) -> dict[str, Any] | None:
"""Get a random active account for a site."""
accounts = []
for path in ACCOUNTS_DIR.glob(f"{site}_*.json"):
try:
acct = json.loads(path.read_text())
if acct.get("status") == "active":
accounts.append(acct)
except (json.JSONDecodeError, OSError):
continue
if not accounts:
return None
import random
return random.choice(accounts)
def mark_error(self, account_id: str, error: str) -> None:
for path in ACCOUNTS_DIR.glob(f"*_{account_id}.json"):
try:
acct = json.loads(path.read_text())
acct["errors"].append({"time": datetime.now(UTC).isoformat(), "error": error[:200]})
if len(acct["errors"]) > 5:
acct["status"] = "suspended"
path.write_text(json.dumps(acct, indent=2))
except (json.JSONDecodeError, ValueError):
pass
def record_use(self, account_id: str) -> None:
for path in ACCOUNTS_DIR.glob(f"*_{account_id}.json"):
try:
acct = json.loads(path.read_text())
acct["last_used"] = datetime.now(UTC).isoformat()
acct["use_count"] = acct.get("use_count", 0) + 1
path.write_text(json.dumps(acct, indent=2))
except (json.JSONDecodeError, ValueError):
pass
def list_accounts(self, site: str = "") -> list[dict[str, Any]]:
accounts = []
pattern = f"{site}_*.json" if site else "*_*.json"
for path in sorted(ACCOUNTS_DIR.glob(pattern), key=os.path.getmtime, reverse=True)[:50]:
try:
data = json.loads(path.read_text())
data.pop("credentials", None) # Don't expose passwords
accounts.append(data)
except (json.JSONDecodeError, ValueError):
continue
return accounts
class ProxyScorer:
"""Score and rank proxies for reliability."""
async def test_proxy(
self, proxy_url: str, test_url: str = "https://httpbin.org/ip", timeout: int = 10
) -> dict[str, Any]:
from client import get_client
client = await get_client()
start = time.time()
try:
resp = await client.get(test_url, timeout=timeout)
elapsed = time.time() - start
return {
"proxy": proxy_url,
"working": resp.is_success,
"latency": round(elapsed, 2),
"status": resp.status_code,
"ip": resp.text[:50] if resp.is_success else "",
}
except (httpx.HTTPError, httpx.RequestError) as e:
return {"proxy": proxy_url, "working": False, "error": str(e)[:80]}
def score(self, test_result: dict[str, Any]) -> int:
"""Score proxy 0-100."""
if not test_result.get("working"):
return 0
latency = test_result.get("latency", 99)
if latency < 1:
return 100
if latency < 2:
return 90
if latency < 5:
return 75
if latency < 10:
return 50
return 25