File size: 38,807 Bytes
4223796
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
import logging
import httpx
import base64
import asyncio
import time
from app.config import get_settings

logger = logging.getLogger(__name__)
settings = get_settings()

class CaptchaSolver:
    def __init__(self, api_key: str = None):
        self.api_key = api_key or settings.captcha_api_key
        self.base_url = "https://2captcha.com"

    async def solve_image_captcha(self, image_b64: str) -> str | None:
        """
        Solves a standard image captcha using 2Captcha API via HTTPX.
        """
        if not self.api_key:
            logger.warning("CaptchaSolver: No API Key provided.")
            return None
        
        try:
            async with httpx.AsyncClient(timeout=60) as client:
                # 1. Submit the captcha
                response = await client.post(
                    f"{self.base_url}/in.php",
                    data={
                        "key": self.api_key,
                        "method": "base64",
                        "body": image_b64,
                        "json": 1
                    }
                )
                res_data = response.json()
                if res_data.get("status") != 1:
                    logger.error(f"Error submitting captcha: {res_data.get('request')}")
                    return None
                
                captcha_id = res_data.get("request")
                
                # 2. Poll for the result
                for _ in range(20): # Max 100 seconds
                    await asyncio.sleep(5)
                    res_response = await client.get(
                        f"{self.base_url}/res.php",
                        params={
                            "key": self.api_key,
                            "action": "get",
                            "id": captcha_id,
                            "json": 1
                        }
                    )
                    res_data = res_response.json()
                    if res_data.get("status") == 1:
                        return res_data.get("request")
                    if res_data.get("request") != "CAPCHA_NOT_READY":
                        logger.error(f"Error getting captcha result: {res_data.get('request')}")
                        return None
                
                logger.error("Captcha resolution timeout.")
                return None
        except Exception as e:
            logger.error(f"Error in CaptchaSolver: {e}")
            return None

    async def solve_image_captcha_local(self, image_b64: str) -> str | None:
        """
        Solves an image captcha locally using ddddocr (Free, no API key).
        """
        try:
            # We import ddddocr here to avoid heavy initialization on startup if not needed
            import ddddocr
            import base64
            
            # Disable printing of ddddocr branding
            import sys
            import os
            
            # Decode the base64 image
            image_bytes = base64.b64decode(image_b64)
            
            # Instantiate ddddocr (it is fast enough to do on-the-fly, or could be cached)
            ocr = ddddocr.DdddOcr(show_ad=False, beta=True)
            res = ocr.classification(image_bytes)
            return res
        except Exception as e:
            logger.error(f"Error in local OCR CaptchaSolver: {e}")
            return None

    async def solve_image_captcha_preprocessed(self, image_bytes: bytes) -> str | None:
        """
        Pre-procesamiento avanzado específico para captchas Securimage (SSSalud, organismos AR).

        Pipeline de limpieza:
          1. Escala de grises
          2. Blur Gaussiano leve para reducir ruido de alta frecuencia
          3. Threshold adaptativo tipo OTSU (mejor que el fijo 128)
          4. Filtro mediano agresivo para romper líneas de interferencia
          5. Inversión de colores si el fondo es oscuro
          6. Upscale 2x para mejorar OCR
          7. Prueba con ddddocr primero (rápido, gratis)
          8. Si falla → envía imagen limpia a Groq Vision (con preprocessing)
        """
        import io
        import re

        try:
            from PIL import Image, ImageFilter, ImageEnhance, ImageOps
            import numpy as np

            img = Image.open(io.BytesIO(image_bytes)).convert("L")

            # 1. Blur Gaussiano leve — reduce ruido de alta frecuencia
            img = img.filter(ImageFilter.GaussianBlur(radius=1))

            # 2. Threshold adaptativo OTSU via numpy
            arr = np.array(img)
            # Calcula el threshold óptimo (método de Otsu simplificado)
            hist, _ = np.histogram(arr, bins=256, range=(0, 256))
            total = arr.size
            best_thresh, best_var = 0, 0.0
            sumB, wB, total_sum = 0, 0, np.dot(np.arange(256), hist)
            for i, h in enumerate(hist):
                wB += h
                if wB == 0:
                    continue
                wF = total - wB
                if wF == 0:
                    break
                sumB += i * h
                mB = sumB / wB
                mF = (total_sum - sumB) / wF
                var = wB * wF * (mB - mF) ** 2
                if var > best_var:
                    best_var = var
                    best_thresh = i

            binary = arr > best_thresh  # True = blanco (texto), False = negro (fondo)

            # 3. Inferir si texto es claro sobre fondo oscuro (invertir si hace falta)
            white_ratio = binary.mean()
            if white_ratio > 0.5:
                binary = ~binary  # texto oscuro sobre fondo claro: el estándar

            img_bin = Image.fromarray((binary * 255).astype(np.uint8))

            # 4. Filtro mediano agresivo (size=5) para eliminar líneas de ruido
            img_bin = img_bin.filter(ImageFilter.MedianFilter(size=5))

            # 5. Upscale 2x — mejora mucho el OCR en imágenes pequeñas
            w, h = img_bin.size
            img_bin = img_bin.resize((w * 2, h * 2), Image.LANCZOS)

            # 6. Guardar PNG limpio
            buf = io.BytesIO()
            img_bin.save(buf, format="PNG")
            clean_bytes = buf.getvalue()

            # 7. Probar ddddocr con imagen limpia
            import ddddocr
            ocr = ddddocr.DdddOcr(show_ad=False, beta=True)
            result = ocr.classification(clean_bytes)
            cleaned = re.sub(r"[^A-Za-z0-9]", "", result)
            logger.info(f"[CaptchaPreProcess] ddddocr resultado: '{cleaned}'")

            if cleaned and len(cleaned) >= 4:
                return cleaned

            # 8. Fallback: enviar imagen LIMPIA a Groq Vision
            #    (mayor chance de éxito que la imagen original con ruido)
            if settings.groq_api_key:
                logger.info("[CaptchaPreProcess] ddddocr insuficiente, enviando imagen preprocesada a Groq Vision...")
                groq_result = await self.solve_image_captcha_groq(clean_bytes)
                if groq_result and len(groq_result) >= 3:
                    logger.info(f"[CaptchaPreProcess] Groq Vision resultado: '{groq_result}'")
                    return groq_result

            return cleaned if cleaned else None

        except ImportError:
            # numpy no disponible — usar versión básica
            logger.warning("[CaptchaPreProcess] numpy no disponible, usando pipeline básico")
            try:
                from PIL import Image, ImageFilter
                img = Image.open(io.BytesIO(image_bytes)).convert("L")
                img = img.point(lambda x: 255 if x > 128 else 0)
                img = img.filter(ImageFilter.MedianFilter(size=3))
                import ddddocr
                ocr = ddddocr.DdddOcr(show_ad=False, beta=True)
                buf = io.BytesIO()
                img.save(buf, format="PNG")
                result = ocr.classification(buf.getvalue())
                cleaned = re.sub(r"[^A-Za-z0-9]", "", result)
                logger.info(f"[CaptchaPreProcess] ddddocr básico: '{cleaned}'")
                return cleaned if cleaned else None
            except Exception as e2:
                logger.error(f"[CaptchaPreProcess] Error pipeline básico: {e2}")
                return None
        except Exception as e:
            logger.error(f"[CaptchaPreProcess] Error pipeline avanzado: {e}")
            return None

    async def solve_image_captcha_groq(self, image_bytes: bytes, model: str = None) -> str | None:
        """
        Resuelve un captcha de imagen usando Groq Vision (llama-4-scout-17b-16e-instruct).
        """
        if not settings.groq_api_key:
            logger.warning("[CaptchaGroq] GROQ_API_KEY no configurada — fallback a ddddocr")
            return await self.solve_image_captcha_local(
                __import__("base64").b64encode(image_bytes).decode()
            )
        
        groq_model = "meta-llama/llama-4-scout-17b-16e-instruct"

        try:
            import base64
            image_b64 = base64.b64encode(image_bytes).decode("utf-8")
            
            magic = image_bytes[:4]
            if magic[:2] == b'\xff\xd8':
                mime = "image/jpeg"
            elif magic[:4] == b'\x89PNG':
                mime = "image/png"
            else:
                mime = "image/png"
            
            groq_url = "https://api.groq.com/openai/v1/chat/completions"
            payload = {
                "model": groq_model,
                "messages": [
                    {
                        "role": "system",
                        "content": (
                            "You are a CAPTCHA reader. You see distorted text images. "
                            "Your ONLY job is to transcribe the characters exactly as they appear. "
                            "Output ONLY the alphanumeric characters. No explanations, no quotes, no spaces."
                        )
                    },
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:{mime};base64,{image_b64}"
                                }
                            },
                            {
                                "type": "text",
                                "text": "Transcribe the CAPTCHA text from this image. Output ONLY the characters."
                            }
                        ]
                    }
                ],
                "max_tokens": 20,
                "temperature": 0.0
            }
            
            async with httpx.AsyncClient(timeout=15) as client:
                resp = await client.post(
                    groq_url,
                    headers={"Authorization": f"Bearer {settings.groq_api_key}"},
                    json=payload
                )
            
            if resp.status_code != 200:
                logger.warning(f"[CaptchaGroq] Groq Vision error {resp.status_code}: {resp.text[:200]}")
                # Fallback a ddddocr
                return await self.solve_image_captcha_local(
                    __import__("base64").b64encode(image_bytes).decode()
                )
            
            content = resp.json()["choices"][0]["message"]["content"].strip()
            # Limpiar la respuesta — quedarse solo con alfanuméricos
            import re
            clean = re.sub(r"[^A-Za-z0-9]", "", content)
            logger.info(f"[CaptchaGroq] Groq Vision resolvió captcha: '{clean}'")
            return clean if clean else None
            
        except Exception as e:
            logger.error(f"[CaptchaGroq] Error en Groq Vision: {e}")
            # Fallback a ddddocr
            try:
                return await self.solve_image_captcha_local(
                    __import__("base64").b64encode(image_bytes).decode()
                )
            except Exception:
                return None

    async def solve_recaptcha_v2(self, site_key: str, url: str) -> str | None:
        """
        Solves reCAPTCHA v2.
        """
        if not self.api_key: return None
            
        try:
            async with httpx.AsyncClient(timeout=120) as client:
                response = await client.post(
                    f"{self.base_url}/in.php",
                    data={
                        "key": self.api_key,
                        "method": "userrecaptcha",
                        "googlekey": site_key,
                        "pageurl": url,
                        "json": 1
                    }
                )
                res_data = response.json()
                if res_data.get("status") != 1: return None
                
                captcha_id = res_data.get("request")
                
                for _ in range(40): # Max 200 seconds
                    await asyncio.sleep(5)
                    res_response = await client.get(
                        f"{self.base_url}/res.php",
                        params={"key": self.api_key, "action": "get", "id": captcha_id, "json": 1}
                    )
                    res_data = res_response.json()
                    if res_data.get("status") == 1: return res_data.get("request")
                    if res_data.get("request") != "CAPCHA_NOT_READY": return None
                return None
        except Exception as e:
            logger.error(f"Error in reCAPTCHA solver: {e}")
            return None

    async def solve_recaptcha_v2_audio(self, page, iframe_selector: str = None, max_retries: int = 3) -> bool:
        """
        Solves reCAPTCHA v2 VISIBLE on a page for free using browser automation,
        downloading the audio challenge, and transcribing it via Groq Whisper API.
        For visible reCAPTCHA with checkbox (#recaptcha-anchor).
        """
        for attempt in range(max_retries):
            try:
                result = await self._solve_recaptcha_v2_audio_once(page, iframe_selector)
                if result:
                    return True
                logger.warning(f"[reCAPTCHA] Intento {attempt+1} falló, reintentando en {(attempt+1)*3}s...")
                await asyncio.sleep((attempt + 1) * 3)
            except Exception as e:
                logger.warning(f"[reCAPTCHA] Intento {attempt+1} falló con excepción: {e}")
                await asyncio.sleep((attempt + 1) * 3)
        return False

    async def solve_recaptcha_v2_invisible(self, page, max_retries: int = 3) -> bool:
        """
        Solves reCAPTCHA v2 INVISIBLE on a page for free using browser automation.
        Unlike visible reCAPTCHA, invisible reCAPTCHA has NO checkbox.
        It is triggered by grecaptcha.execute() and shows a challenge directly.

        This method:
        1. Finds the anchor iframe (api2/anchor)
        2. Clicks the anchor iframe element directly (no checkbox lookup)
        3. Waits for the challenge iframe (api2/bframe) to appear
        4. Solves the audio challenge using Groq Whisper API
        """
        for attempt in range(max_retries):
            try:
                result = await self._solve_recaptcha_v2_invisible_once(page)
                if result:
                    return True
                logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} falló, reintentando en {(attempt+1)*3}s...")
                await asyncio.sleep((attempt + 1) * 3)
            except Exception as e:
                logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} falló con excepción: {e}")
                await asyncio.sleep((attempt + 1) * 3)
        return False

    async def _solve_recaptcha_v2_invisible_once(self, page) -> bool:
        """
        Single attempt to solve reCAPTCHA v2 INVISIBLE audio challenge.
        Key difference from visible: no checkbox, click the anchor iframe element directly.
        """
        try:
            # Locate anchor iframe
            anchor_iframe = None
            anchor_element = None

            for frame in page.frames:
                if "api2/anchor" in frame.url:
                    try:
                        el = await frame.frame_element()
                        if el and await el.is_visible():
                            anchor_iframe = frame
                            anchor_element = el
                            break
                    except Exception:
                        pass

            if not anchor_iframe:
                for frame in page.frames:
                    if "api2/anchor" in frame.url:
                        anchor_iframe = frame
                        try:
                            anchor_element = await frame.frame_element()
                        except Exception:
                            pass
                        break

            if not anchor_iframe or not anchor_element:
                logger.error("[reCAPTCHA-invisible] Anchor iframe not found.")
                for i, frame in enumerate(page.frames):
                    logger.debug(f"Frame {i}: url={frame.url}")
                return False

            # Click the anchor iframe element directly (no checkbox for invisible)
            try:
                await anchor_element.click(timeout=5000, force=True)
                logger.info("[reCAPTCHA-invisible] Clicked anchor iframe element")
            except Exception as e:
                logger.error(f"[reCAPTCHA-invisible] Failed to click anchor element: {e}")
                return False

            await asyncio.sleep(2)

            # Check if solved instantly
            try:
                checkbox = await anchor_iframe.query_selector("#recaptcha-anchor")
                if checkbox:
                    is_checked = await checkbox.get_attribute("aria-checked")
                    if is_checked == "true":
                        logger.info("[reCAPTCHA-invisible] Solved instantly by reputation.")
                        return True
            except Exception:
                pass

            # Wait for challenge iframe (bframe) to appear
            try:
                await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=8000)
            except Exception:
                pass

            # Locate challenge iframe
            challenge_iframe = None
            for frame in page.frames:
                if "api2/bframe" in frame.url:
                    try:
                        frame_element = await frame.frame_element()
                        if frame_element and await frame_element.is_visible():
                            challenge_iframe = frame
                            break
                    except Exception:
                        pass

            if not challenge_iframe:
                for frame in page.frames:
                    if "api2/bframe" in frame.url:
                        challenge_iframe = frame
                        break

            if not challenge_iframe:
                logger.error("[reCAPTCHA-invisible] Challenge iframe (bframe) not found.")
                return False

            # Click audio challenge button
            audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button")
            if not audio_button or not await audio_button.is_visible():
                logger.warning("[reCAPTCHA-invisible] Audio button not found or invisible.")
                return False

            try:
                await audio_button.click(timeout=5000, force=True)
                logger.info("[reCAPTCHA-invisible] Clicked audio button")
            except Exception as e:
                logger.error(f"[reCAPTCHA-invisible] Failed to click audio button: {e}")
                return False

            await asyncio.sleep(2.5)

            # Check for block message
            block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active")
            if block_msg and await block_msg.is_visible():
                logger.error("[reCAPTCHA-invisible] Blocked: Automated queries detected.")
                return False

            # Get audio download link
            download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link")
            if not download_link:
                logger.error("[reCAPTCHA-invisible] Audio download link not found.")
                return False

            audio_url = await download_link.get_attribute("href")

            # Download audio file
            audio_resp = await page.request.get(audio_url)
            if not audio_resp.ok:
                logger.error(f"[reCAPTCHA-invisible] Failed to download audio (status {audio_resp.status}).")
                return False

            audio_content = await audio_resp.body()
            if len(audio_content) < 1024:
                logger.error(f"[reCAPTCHA-invisible] Audio file too small ({len(audio_content)} bytes) — blocked.")
                return False

            if not settings.groq_api_key:
                logger.error("[reCAPTCHA-invisible] GROQ_API_KEY not configured.")
                return False

            # Transcribe with Groq Whisper
            groq_url = "https://api.groq.com/openai/v1/audio/transcriptions"
            headers = {"Authorization": f"Bearer {settings.groq_api_key}"}
            files = {"file": ("challenge.mp3", audio_content, "audio/mpeg")}
            data = {"model": "whisper-large-v3-turbo", "response_format": "json"}

            async with httpx.AsyncClient(timeout=60) as client:
                transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data)

            if transcription_resp.status_code != 200:
                logger.error(f"[reCAPTCHA-invisible] Groq API Error: {transcription_resp.text}")
                return False

            text = transcription_resp.json().get("text", "").strip()
            if not text:
                logger.error("[reCAPTCHA-invisible] Groq returned empty text.")
                return False

            logger.info(f"[reCAPTCHA-invisible] Transcribed: '{text}'")

            # Fill response input
            input_field = await challenge_iframe.query_selector("#audio-response")
            if not input_field:
                logger.error("[reCAPTCHA-invisible] Audio response input not found.")
                return False

            await input_field.fill(text)
            await asyncio.sleep(0.5)

            # Click verify
            verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button")
            if not verify_button or not await verify_button.is_visible():
                logger.error("[reCAPTCHA-invisible] Verify button not found.")
                return False

            try:
                await verify_button.click(timeout=5000, force=True)
            except Exception as e:
                logger.error(f"[reCAPTCHA-invisible] Failed to click verify: {e}")
                return False

            await asyncio.sleep(2)

            # Check if solved
            try:
                checkbox = await anchor_iframe.query_selector("#recaptcha-anchor")
                if checkbox:
                    is_checked = await checkbox.get_attribute("aria-checked")
                    if is_checked == "true":
                        logger.info("[reCAPTCHA-invisible] Solved successfully via audio.")
                        return True
            except Exception:
                pass

            # For invisible reCAPTCHA, also check if the token was generated
            try:
                token = await page.evaluate("""
                    () => {
                        try { return grecaptcha.getResponse() || null; }
                        catch(e) { return null; }
                    }
                """)
                if token and len(token) > 10:
                    logger.info("[reCAPTCHA-invisible] Token generated successfully.")
                    return True
            except Exception:
                pass

            logger.warning("[reCAPTCHA-invisible] Verify clicked but not confirmed.")
            return False

        except Exception as e:
            logger.error(f"[reCAPTCHA-invisible] Error: {e}")
            return False

    async def _solve_recaptcha_v2_audio_once(self, page, iframe_selector: str = None) -> bool:
        """
        Single attempt to solve reCAPTCHA v2 audio challenge.
        """
        try:
            # We don't need ffmpeg or pydub anymore, we will use Groq API
            import os
            import uuid
            import shutil
            
            # Locate anchor iframe - try multiple strategies
            anchor_iframe = None
            if iframe_selector:
                iframe_el = await page.query_selector(iframe_selector)
                if iframe_el:
                    anchor_iframe = await iframe_el.content_frame()
            
            # Strategy 1: Find by URL pattern
            if not anchor_iframe:
                for frame in page.frames:
                    if "api2/anchor" in frame.url:
                        try:
                            el = await frame.frame_element()
                            if el and await el.is_visible():
                                anchor_iframe = frame
                                break
                        except Exception:
                            pass
                            
            # Strategy 2: Find by title attribute
            if not anchor_iframe:
                for frame in page.frames:
                    try:
                        el = await frame.frame_element()
                        if el:
                            title = await el.get_attribute("title")
                            if title and "recaptcha" in title.lower():
                                anchor_iframe = frame
                                break
                    except Exception:
                        pass
            
            # Strategy 3: Find by frame name
            if not anchor_iframe:
                for frame in page.frames:
                    if frame.name and "recaptcha" in frame.name.lower():
                        anchor_iframe = frame
                        break
            
            # Strategy 4: Fallback - any frame with api2/anchor
            if not anchor_iframe:
                for frame in page.frames:
                    if "api2/anchor" in frame.url:
                        anchor_iframe = frame
                        break
            
            if not anchor_iframe:
                logger.error("reCAPTCHA anchor iframe not found after all strategies.")
                # Log all frames for debugging
                for i, frame in enumerate(page.frames):
                    logger.debug(f"Frame {i}: url={frame.url}, name={frame.name}")
                return False
                
            # Click checkbox
            checkbox = await anchor_iframe.query_selector("#recaptcha-anchor")
            if not checkbox:
                logger.error("reCAPTCHA anchor checkbox not found.")
                return False
                
            try:
                await checkbox.click(timeout=5000, force=True)
            except Exception as e:
                logger.error(f"Failed to click anchor checkbox: {e}")
                return False
                
            await asyncio.sleep(2)
            
            # Check if solved directly (some high-reputation browsers get passed instantly)
            is_checked = await checkbox.get_attribute("aria-checked")
            if is_checked == "true":
                logger.info("reCAPTCHA solved instantly by reputation.")
                return True
                
            # Wait for any challenge iframe to appear and be visible
            try:
                await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=5000)
            except Exception:
                pass
                
            # Locate challenge iframe
            challenge_iframe = None
            for frame in page.frames:
                if "api2/bframe" in frame.url:
                    try:
                        frame_element = await frame.frame_element()
                        if frame_element and await frame_element.is_visible():
                            challenge_iframe = frame
                            break
                    except Exception:
                        pass
            
            if not challenge_iframe:
                for frame in page.frames:
                    if "api2/bframe" in frame.url:
                        challenge_iframe = frame
                        break
                    
            if not challenge_iframe:
                logger.error("reCAPTCHA challenge iframe not found.")
                return False
                
            # Click audio challenge button
            audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button")
            if not audio_button or not await audio_button.is_visible():
                logger.warning("Audio challenge button not found or invisible. reCAPTCHA might be blocked.")
                return False
                
            try:
                await audio_button.click(timeout=5000, force=True)
            except Exception as e:
                logger.error(f"Failed to click audio button: {e}")
                return False
            
            await asyncio.sleep(2.5)
            
            # Look for block message
            block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active")
            if block_msg and await block_msg.is_visible():
                logger.error("reCAPTCHA blocked: Automated queries warning detected.")
                return False
                
            # Get download link
            download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link")
            if not download_link:
                logger.error("Audio download link not found.")
                return False
                
            audio_url = await download_link.get_attribute("href")
            
            # Download MP3 file — usar page.request para heredar cookies/TLS/headers del browser
            audio_resp = await page.request.get(audio_url)
            if not audio_resp.ok:
                logger.error(f"Failed to download audio challenge (status {audio_resp.status}).")
                return False
            
            # Verify the audio file has actual content (Google may block and serve empty file)
            audio_content = await audio_resp.body()
            if len(audio_content) < 1024:  # Less than 1KB means the file is empty/blocked
                logger.error(f"Audio challenge file is too small ({len(audio_content)} bytes) — Google is blocking the audio challenge for this browser session.")
                return False

            if not settings.groq_api_key:
                logger.error("GROQ_API_KEY is not configured in settings. Cannot transcribe audio.")
                return False
                
            # Send the MP3 to Groq Whisper API
            groq_url = "https://api.groq.com/openai/v1/audio/transcriptions"
            headers = {"Authorization": f"Bearer {settings.groq_api_key}"}
            
            # We must use files= parameter for multipart/form-data
            files = {
                "file": ("challenge.mp3", audio_content, "audio/mpeg")
            }
            data = {
                "model": "whisper-large-v3-turbo",
                "response_format": "json"
            }
            
            async with httpx.AsyncClient(timeout=60) as client:
                transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data)
                
            if transcription_resp.status_code != 200:
                logger.error(f"Groq API Error: {transcription_resp.text}")
                return False
                
            transcription_json = transcription_resp.json()
            text = transcription_json.get("text", "").strip()
            
            if not text:
                logger.error("Groq API returned empty text.")
                return False
                
            logger.info(f"reCAPTCHA audio transcribed via Groq successfully: '{text}'")
                
            # Fill the response input
            input_field = await challenge_iframe.query_selector("#audio-response")
            if not input_field:
                logger.error("Audio response input field not found.")
                return False
                
            await input_field.fill(text)
            await asyncio.sleep(0.5)
            
            # Click verify
            verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button")
            if not verify_button or not await verify_button.is_visible():
                logger.error("Verify button not found or invisible.")
                return False
                
            try:
                await verify_button.click(timeout=5000, force=True)
            except Exception as e:
                logger.error(f"Failed to click verify button: {e}")
                return False
                
            await asyncio.sleep(2)
                
            # Final check
            is_checked = await checkbox.get_attribute("aria-checked")
            if is_checked == "true":
                logger.info("reCAPTCHA solved successfully via audio transcription.")
                return True
                
            logger.warning("reCAPTCHA verify button clicked but checkmark not set.")
            return False
            
        except Exception as e:
            logger.error(f"Error solving audio reCAPTCHA: {e}")
            return False
    # ---------------------------------------------------------------------------
    # NopeCHA — reCAPTCHA v3 Solver
    # ---------------------------------------------------------------------------
    # NopeCHA offers a generous free tier with a daily quota.
    # Each reCAPTCHA v3 token costs ~20 credits.
    # Docs: https://nopecha.com/api
    #
    # Flow:
    #   1. POST /token/  →  { id: "job_id" }   (submit the solving job)
    #   2. GET  /token/?key=...&id=...  →  poll until { data: ["token"] } or error
    #
    # The returned token is a standard g-recaptcha-response value that can be
    # injected into the target form directly — no browser interaction needed.
    # ---------------------------------------------------------------------------

    async def solve_recaptcha_v3_nopecha(
        self,
        site_key: str,
        page_url: str,
        action: str = "submit",
        max_wait_seconds: int = 90,
    ) -> str | None:
        """
        Solves reCAPTCHA v3 using the NopeCHA token API.

        Args:
            site_key:         The reCAPTCHA v3 data-sitekey on the target page.
            page_url:         Full URL of the page being solved (RENAPER portal, etc).
            action:           The grecaptcha action name (e.g. 'submit_tramite').
            max_wait_seconds: How long to poll before giving up (default 90s).

        Returns:
            A valid g-recaptcha-response token string, or None on failure.

        Limits (NopeCHA free tier as of 2025):
            - ~100 reCAPTCHA v3 solves per day
            - ~20 credits per token
            - Typical solve time: 10–30 seconds
            - Score range: 0.3 – 0.9 (cannot be forced to a specific score)
        """
        api_key = settings.nopecha_api_key
        if not api_key:
            logger.error(
                "[NopeCHA] NOPECHA_API_KEY is not configured in .env — "
                "cannot solve reCAPTCHA v3. Set NOPECHA_API_KEY=<your_key>."
            )
            return None

        base_url = "https://api.nopecha.com"
        payload = {
            "key": api_key,
            "type": "recaptcha3",
            "sitekey": site_key,
            "url": page_url,
            "data": {"action": action},
        }

        try:
            async with httpx.AsyncClient(timeout=30) as client:
                # Step 1 — Submit the job
                submit_resp = await client.post(f"{base_url}/token/", json=payload)
                submit_data = submit_resp.json()

                if submit_resp.status_code != 200 or "error" in submit_data:
                    logger.error(
                        f"[NopeCHA] Job submission failed "
                        f"(HTTP {submit_resp.status_code}): {submit_data}"
                    )
                    return None

                job_id = submit_data.get("id")
                if not job_id:
                    logger.error(f"[NopeCHA] No job ID in response: {submit_data}")
                    return None

                logger.info(f"[NopeCHA] Job submitted — id={job_id}, polling...")

                # Step 2 — Poll until resolved
                poll_interval = 5  # seconds between polls
                elapsed = 0

                while elapsed < max_wait_seconds:
                    await asyncio.sleep(poll_interval)
                    elapsed += poll_interval

                    poll_resp = await client.get(
                        f"{base_url}/token/",
                        params={"key": api_key, "id": job_id},
                    )
                    poll_data = poll_resp.json()

                    # NopeCHA returns {"data": ["<token>"]} on success
                    if poll_data.get("data"):
                        token = poll_data["data"][0]
                        if token and len(token) > 20:
                            logger.info(
                                f"[NopeCHA] reCAPTCHA v3 solved in ~{elapsed}s — "
                                f"token length: {len(token)}"
                            )
                            return token

                    # Check for terminal errors
                    error_code = poll_data.get("error")
                    if error_code and error_code not in (0, None):
                        logger.error(
                            f"[NopeCHA] Solver returned error after {elapsed}s: "
                            f"{poll_data}"
                        )
                        return None

                    logger.debug(f"[NopeCHA] Still solving... ({elapsed}s elapsed)")

                logger.error(
                    f"[NopeCHA] Timed out after {max_wait_seconds}s without a token."
                )
                return None

        except Exception as e:
            logger.error(f"[NopeCHA] Unexpected error: {e}")
            return None