""" 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