354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""
|
|
CAPTCHA Solver for PXIL Login using EasyOCR.
|
|
Extracts base64 from input's CSS background-image, runs EasyOCR OCR, fills the input.
|
|
"""
|
|
import re
|
|
import base64
|
|
import time
|
|
from typing import Optional
|
|
|
|
from PIL import Image
|
|
import cv2
|
|
import numpy as np
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
from selenium.webdriver.remote.webelement import WebElement
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
|
|
|
|
|
|
# Initialize EasyOCR once (module level)
|
|
# First run downloads models, then works offline
|
|
try:
|
|
import easyocr
|
|
_EASYOCR_READER = easyocr.Reader(['en'], gpu=False)
|
|
except ImportError:
|
|
_EASYOCR_READER = None
|
|
|
|
|
|
# Character confusion correction map for common OCR errors
|
|
CONFUSION_MAP = {
|
|
'0': 'O', 'O': '0', 'D': '0',
|
|
'1': 'I', 'I': '1', 'L': '1', 'l': '1',
|
|
'5': 'S', 'S': '5',
|
|
'8': 'B', 'B': '8',
|
|
'2': 'Z', 'Z': '2',
|
|
'6': 'G', 'G': '6',
|
|
'9': 'g', 'g': '9',
|
|
'Q': 'O',
|
|
'U': 'V', 'V': 'U',
|
|
'n': 'h', 'h': 'n',
|
|
'r': 'f', 'f': 'r',
|
|
'c': 'e', 'e': 'c',
|
|
'o': '0', '0': 'o',
|
|
's': '5', '5': 's',
|
|
'z': '2', '2': 'z',
|
|
'i': '1', '1': 'i',
|
|
'l': '1', '1': 'l',
|
|
}
|
|
|
|
|
|
def find_visible_captcha_input(driver: WebDriver) -> WebElement:
|
|
"""Find the visible captcha input element (name='captcha')."""
|
|
wait = WebDriverWait(driver, 15)
|
|
inputs = wait.until(EC.presence_of_all_elements_located((By.NAME, "captcha")))
|
|
for inp in inputs:
|
|
if inp.is_displayed():
|
|
return inp
|
|
raise RuntimeError("No visible captcha input found")
|
|
|
|
|
|
def extract_captcha_base64(captcha_input: WebElement) -> bytes:
|
|
"""Extract base64 image data from input's style attribute. Returns raw JPEG bytes."""
|
|
style = captcha_input.get_attribute("style") or ""
|
|
match = re.search(r'background:\s*url\(["\']?data:image/[^;]+;base64,([^"\')]+)["\']?\)', style)
|
|
if not match:
|
|
raise RuntimeError(f"No base64 background-image found in style: {style[:200]}")
|
|
|
|
b64_data = match.group(1)
|
|
if not b64_data:
|
|
raise RuntimeError("Base64 data is empty (captcha not loaded yet)")
|
|
|
|
b64_data += "=" * ((4 - len(b64_data) % 4) % 4) # Fix padding
|
|
return base64.b64decode(b64_data)
|
|
|
|
|
|
def wait_for_captcha_load(driver: WebDriver, captcha_input: WebElement, timeout: int = 15) -> bool:
|
|
"""Wait for captcha base64 to be populated with actual image data."""
|
|
end_time = time.time() + timeout
|
|
while time.time() < end_time:
|
|
try:
|
|
style = captcha_input.get_attribute("style") or ""
|
|
match = re.search(r'background:\s*url\(["\']?data:image/[^;]+;base64,([^"\')]+)["\']?\)', style)
|
|
if match and match.group(1):
|
|
return True
|
|
except StaleElementReferenceException:
|
|
captcha_input = find_visible_captcha_input(driver)
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
|
|
def preprocess_captcha_image(image_bytes: bytes) -> Image.Image:
|
|
"""Preprocess image for better OCR accuracy - gentle preprocessing."""
|
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
|
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # Load as color (BGR)
|
|
|
|
if img is None:
|
|
raise RuntimeError("Failed to decode image bytes")
|
|
|
|
# Convert BGR to RGB
|
|
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
|
|
# Convert to grayscale
|
|
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
|
|
# Simple threshold - keep it simple
|
|
_, gray = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY)
|
|
|
|
# Light denoise
|
|
gray = cv2.fastNlMeansDenoising(gray, h=3)
|
|
|
|
# Very light morphological close - only connect broken parts
|
|
kernel = np.ones((1, 1), np.uint8)
|
|
gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)
|
|
|
|
# Resize 6x for better recognition (larger for 6-char captcha)
|
|
gray = cv2.resize(gray, None, fx=6, fy=6, interpolation=cv2.INTER_CUBIC)
|
|
|
|
# Convert back to RGB (3-channel) for EasyOCR
|
|
rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
|
|
|
|
return Image.fromarray(rgb)
|
|
|
|
|
|
def apply_confusion_corrections(text: str) -> str:
|
|
"""Apply character confusion corrections based on common OCR errors."""
|
|
corrected = []
|
|
for ch in text:
|
|
corrected.append(CONFUSION_MAP.get(ch, ch))
|
|
return ''.join(corrected)
|
|
|
|
|
|
def ocr_captcha(image: Image.Image) -> str:
|
|
"""Run OCR on preprocessed captcha image using EasyOCR."""
|
|
if _EASYOCR_READER is None:
|
|
raise RuntimeError("EasyOCR not installed. Run: pip install easyocr")
|
|
|
|
img_array = np.array(image)
|
|
|
|
# EasyOCR with alphanumeric allowlist for 6-char captcha
|
|
results = _EASYOCR_READER.readtext(
|
|
img_array,
|
|
detail=0,
|
|
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
|
width_ths=0.5,
|
|
paragraph=False
|
|
)
|
|
|
|
# Combine all detected text
|
|
result = ''.join(results)
|
|
|
|
# Clean result - keep only alphanumeric, max 6 chars
|
|
cleaned = ''.join(filter(str.isalnum, result))[:6]
|
|
|
|
print(f" DEBUG OCR: result='{result}', cleaned='{cleaned}', len={len(cleaned)}")
|
|
return cleaned
|
|
|
|
|
|
def find_refresh_button(driver: WebDriver, captcha_input: WebElement) -> Optional[WebElement]:
|
|
"""Find captcha refresh button near the captcha input."""
|
|
parent = captcha_input.find_element(By.XPATH, "..")
|
|
for sibling in parent.find_elements(By.XPATH, "*"):
|
|
onclick = (sibling.get_attribute("onclick") or "").lower()
|
|
class_attr = (sibling.get_attribute("class") or "").lower()
|
|
id_attr = (sibling.get_attribute("id") or "").lower()
|
|
if any(kw in onclick for kw in ["refresh", "captcha", "reload", "newcaptcha"]):
|
|
return sibling
|
|
if any(kw in class_attr for kw in ["refresh", "captcha-refresh", "reload"]):
|
|
return sibling
|
|
if any(kw in id_attr for kw in ["refresh", "captcha", "reload"]):
|
|
return sibling
|
|
|
|
# Fallback: search globally for refresh-like elements
|
|
try:
|
|
candidates = driver.find_elements(By.XPATH, "//*[self::a or self::button or self::img or self::span][contains(translate(@onclick, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh') or contains(translate(@class, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh') or contains(translate(@title, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh')]")
|
|
for cand in candidates:
|
|
if cand.is_displayed():
|
|
return cand
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
|
|
def wait_for_new_captcha(driver: WebDriver, captcha_input: WebElement, timeout: int = 10) -> bool:
|
|
"""Wait for captcha background-image to change (new challenge loaded)."""
|
|
old_style = captcha_input.get_attribute("style") or ""
|
|
end_time = time.time() + timeout
|
|
while time.time() < end_time:
|
|
try:
|
|
new_style = captcha_input.get_attribute("style") or ""
|
|
if new_style != old_style and "data:image" in new_style:
|
|
return True
|
|
except StaleElementReferenceException:
|
|
captcha_input = find_visible_captcha_input(driver)
|
|
old_style = captcha_input.get_attribute("style") or ""
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
|
|
def solve_and_fill_captcha(driver: WebDriver, max_retries: int = 5, attempt_callback=None) -> bool:
|
|
"""
|
|
Main entry point: solve captcha and fill input.
|
|
Returns True if filled successfully, False if max retries exceeded.
|
|
Each retry gets a fresh captcha via refresh button.
|
|
"""
|
|
for attempt in range(1, max_retries + 1):
|
|
captcha_text = ""
|
|
try:
|
|
captcha_input = find_visible_captcha_input(driver)
|
|
|
|
# Wait for captcha base64 to be populated (async load)
|
|
if not wait_for_captcha_load(driver, captcha_input):
|
|
print(f" [Attempt {attempt}] Captcha failed to load in time, retrying...")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
if attempt_callback:
|
|
attempt_callback(attempt, "", "", "not_loaded")
|
|
continue
|
|
|
|
image_bytes = extract_captcha_base64(captcha_input)
|
|
pil_image = preprocess_captcha_image(image_bytes)
|
|
captcha_text = ocr_captcha(pil_image)
|
|
|
|
if not captcha_text or len(captcha_text) < 6:
|
|
print(f" [Attempt {attempt}] OCR weak result: '{captcha_text}' (len={len(captcha_text)}), retrying...")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
if attempt_callback:
|
|
attempt_callback(attempt, captcha_text, "", "ocr_failed")
|
|
continue
|
|
|
|
# Apply confusion corrections to get best variant
|
|
corrected = apply_confusion_corrections(captcha_text)
|
|
|
|
# Generate case variants for case-sensitive captcha (mixed case + numbers)
|
|
variants = [
|
|
captcha_text, # as-is (from OCR)
|
|
corrected, # with confusion corrections
|
|
captcha_text.upper(), # all uppercase
|
|
captcha_text.capitalize(), # first letter uppercase
|
|
captcha_text.title(), # each word capitalized
|
|
captcha_text.swapcase(), # swap case
|
|
]
|
|
|
|
# Add variations with first char variations
|
|
if len(captcha_text) >= 1:
|
|
variants.append(captcha_text[0].upper() + captcha_text[1:])
|
|
variants.append(captcha_text[0].lower() + captcha_text[1:])
|
|
if len(captcha_text) >= 2:
|
|
variants.append(captcha_text[:2].upper() + captcha_text[2:])
|
|
variants.append(captcha_text[:2].lower() + captcha_text[2:])
|
|
if len(captcha_text) >= 3:
|
|
variants.append(captcha_text[:3].upper() + captcha_text[3:])
|
|
variants.append(captcha_text[:3].lower() + captcha_text[3:])
|
|
|
|
# Remove duplicates while preserving order, keep only 6-char variants
|
|
seen = set()
|
|
unique_variants = []
|
|
for v in variants:
|
|
if v not in seen and len(v) == 6:
|
|
seen.add(v)
|
|
unique_variants.append(v)
|
|
variants = unique_variants
|
|
|
|
if attempt_callback:
|
|
screenshot_path = attempt_callback(attempt, captcha_text, "", "before_entry") or ""
|
|
|
|
success = False
|
|
# Try ALL variants before giving up on this attempt
|
|
for variant in variants:
|
|
if len(variant) == 6:
|
|
print(f" [Attempt {attempt}] Trying variant: '{variant}'")
|
|
captcha_input.clear()
|
|
captcha_input.send_keys(variant)
|
|
success = True
|
|
if attempt_callback:
|
|
attempt_callback(attempt, captcha_text, variant, "filled")
|
|
break
|
|
|
|
if not success:
|
|
print(f" [Attempt {attempt}] No valid 6-char variant found for '{captcha_text}', retrying...")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
continue
|
|
|
|
# Click submit
|
|
submit_btn = WebDriverWait(driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, "//button[@value='Login']"))
|
|
)
|
|
submit_btn.click()
|
|
|
|
# Wait a bit for response
|
|
time.sleep(2)
|
|
|
|
# Check for invalid captcha error using the exact xpath
|
|
try:
|
|
# First handle any alerts that might be open
|
|
try:
|
|
alert = driver.switch_to.alert
|
|
alert_text = alert.text
|
|
print(f" DEBUG: Alert detected after submit: {alert_text}")
|
|
if "logout" in alert_text.lower() or "session" in alert_text.lower() or "previous" in alert_text.lower():
|
|
alert.accept()
|
|
print(" DEBUG: Accepted session/logout alert")
|
|
time.sleep(2)
|
|
except:
|
|
pass
|
|
|
|
invalid_captcha_elem = WebDriverWait(driver, 3).until(
|
|
EC.visibility_of_element_located((
|
|
By.XPATH,
|
|
"//div[@id='error']//strong[contains(normalize-space(), 'Invalid Captcha')]",
|
|
))
|
|
)
|
|
print(f" [Attempt {attempt}] Invalid captcha detected: {invalid_captcha_elem.text}")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
continue
|
|
except TimeoutException:
|
|
# No invalid captcha error - likely success
|
|
print(f" [Attempt {attempt}] No invalid captcha error detected, assuming success")
|
|
return True
|
|
except Exception as e:
|
|
# Handle other exceptions like NoSuchElementException
|
|
print(f" [Attempt {attempt}] Error checking invalid captcha: {type(e).__name__}: {e}")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f" [Attempt {attempt}] Error: {e}")
|
|
if attempt < max_retries:
|
|
refresh_btn = find_refresh_button(driver, captcha_input)
|
|
if refresh_btn:
|
|
refresh_btn.click()
|
|
wait_for_new_captcha(driver, captcha_input)
|
|
|
|
return False
|