File size: 19,451 Bytes
f26e640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Cloudflare Turnstile + JS-Challenge ("Just a moment...") solver.

Design notes:
  - Single warm Camoufox (stealth Firefox) browser with a persistent
    profile. Camoufox replaces nodriver because CF fingerprints
    patchright/nodriver on current Cloudflare deploys so the Turnstile
    iframe never mounts. Camoufox + its built-in human-like mouse model
    reliably clears the widget.
  - New page per request via `browser.new_page()`, closed after solve.
  - Solves serialised through a lock: concurrent tabs hitting the same
    sitekey make CF escalate difficulty. HTTP callers can still fire in
    parallel - requests queue here.
  - No hardcoded sleeps. Event-driven waits via page.wait_for_function.
  - Two public entry points:
        solve_async(sitekey, siteurl) -> str
        solve_challenge_async(siteurl) -> dict   # cleared cookies + html

  - solve_challenge_async optionally delegates to a challenge proxy
    (Byparr or FlareSolverr) when CHALLENGE_PROXY_URL / FLARESOLVERR_URL
    is set. The in-process browser is the fallback.
"""

import asyncio
import json
import logging
import os
import random
import time
from typing import Optional
from urllib.parse import urlparse

import aiohttp
from camoufox.async_api import AsyncCamoufox


log = logging.getLogger("solver")


def _step(req_id: str, msg: str):
    """One-line stdout progress log, visible between the NEW REQUEST block."""
    print(f"  [{req_id}] {msg}", flush=True)


# ---------- Profile + headless mode ----------

def _get_profile_dir() -> str:
    if os.environ.get("TS_PROFILE_DIR"):
        return os.environ["TS_PROFILE_DIR"]
    return "/tmp/ts_profile"


def _headless_mode():
    # Camoufox accepts False, True, or 'virtual'. 'virtual' spawns its own
    # Xvfb internally so we don't need one in the image.
    mode = os.environ.get("CAMOUFOX_HEADLESS", "virtual").lower()
    if mode in ("true", "1"):
        return True
    if mode in ("false", "0"):
        return False
    return "virtual"


# ---------- Singleton browser ----------

class BrowserSingleton:
    def __init__(self, max_concurrent: int):
        self._camoufox: Optional[AsyncCamoufox] = None
        self.browser = None  # playwright BrowserContext when launched with user_data_dir
        self.sem = asyncio.Semaphore(max_concurrent)
        self.solve_lock = asyncio.Lock()
        self.max_concurrent = max_concurrent
        self._start_lock = asyncio.Lock()
        self.solve_count = 0
        self.stopped = False

    async def ensure(self):
        async with self._start_lock:
            if self.browser is not None and not self.stopped:
                return
            profile = _get_profile_dir()
            os.makedirs(profile, exist_ok=True)
            log.info("launching camoufox profile=%s", profile)
            self._camoufox = AsyncCamoufox(
                headless=_headless_mode(),
                humanize=True,
                persistent_context=True,
                user_data_dir=profile,
                os=["windows", "macos", "linux"],
                locale="en-US",
            )
            # AsyncCamoufox is an async context manager. Enter it
            # manually so the BrowserContext survives beyond a `with`
            # block and can be reused across many HTTP requests.
            self.browser = await self._camoufox.__aenter__()
            self.stopped = False
            log.info("camoufox ready")

    async def new_page(self, url: str):
        await self.ensure()
        page = await self.browser.new_page()
        try:
            await page.goto(url, wait_until="domcontentloaded", timeout=30_000)
        except Exception as e:
            log.warning("initial goto failed: %s", e)
        return page

    async def shutdown(self):
        if self.stopped:
            return
        self.stopped = True
        if self._camoufox is not None:
            try:
                await self._camoufox.__aexit__(None, None, None)
            except Exception:
                pass
        self._camoufox = None
        self.browser = None


_pool: Optional[BrowserSingleton] = None
_pool_lock: Optional[asyncio.Lock] = None


async def get_pool(size: Optional[int] = None) -> BrowserSingleton:
    global _pool, _pool_lock
    if _pool_lock is None:
        _pool_lock = asyncio.Lock()
    async with _pool_lock:
        if _pool is None:
            n = size if size is not None else int(os.environ.get("MAX_WORKERS", 8))
            _pool = BrowserSingleton(n)
            await _pool.ensure()
        return _pool


# ---------- Turnstile injection ----------

_INJECT_JS_TEMPLATE = """
(() => {
    if (document.getElementById('_ts_box')) return;
    window._tsToken = null;
    const wrap = document.createElement('div');
    wrap.id = '_ts_box';
    wrap.style = 'position:fixed;top:20px;left:20px;z-index:2147483647;';
    document.body.appendChild(wrap);
    window._tsLoad = function () {
        turnstile.render('#_ts_box', {
            sitekey: '__SITEKEY__',
            callback: function(t) { window._tsToken = t; }
        });
    };
    if (typeof turnstile !== 'undefined') {
        window._tsLoad();
    } else {
        const s = document.createElement('script');
        s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_tsLoad&render=explicit';
        s.async = true;
        document.head.appendChild(s);
    }
})();
"""

_GET_TOKEN_JS = """
(() => {
    if (window._tsToken) return window._tsToken;
    const inp = document.querySelector('#_ts_box [name="cf-turnstile-response"]');
    return (inp && inp.value) ? inp.value : null;
})()
"""

_GET_IFRAME_RECT_JS = """
(() => {
    for (const f of document.querySelectorAll('iframe')) {
        const src = f.src || f.getAttribute('src') || '';
        if (!src.includes('challenges.cloudflare.com')) continue;
        const r = f.getBoundingClientRect();
        if (r.width > 50 && r.height > 20) return {x:r.x, y:r.y, w:r.width, h:r.height};
    }
    return null;
})()
"""

_IS_CHALLENGE_JS = """
(() => {
    if (document.title.toLowerCase().includes('just a moment')) return true;
    if (document.querySelector('div.challenge-form, #challenge-form, .ray-id')) return true;
    if (document.querySelector('iframe[src*="challenges.cloudflare.com/cdn-cgi"]')) return true;
    return false;
})()
"""


# HTML body served at the intercepted siteurl. The <div class="cf-turnstile">
# is auto-discovered and rendered by api.js without a manual render() call,
# so we never touch the widget's main-world JS - side-steps Camoufox's
# isolated-world sandbox. Based on the Theyka/Turnstile-Solver approach.
_HOST_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>.</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head><body>
<div class="cf-turnstile" data-sitekey="__SITEKEY__"__EXTRA__></div>
</body></html>"""


async def _turnstile_on_page(page, sitekey: str, siteurl: str, req_id: str,
                              timeout: int, action: Optional[str] = None,
                              cdata: Optional[str] = None) -> str:
    """Inject Turnstile on an intercepted siteurl and return the token.

    We route-intercept the exact siteurl and fulfill it with a minimal
    HTML body carrying a cf-turnstile div with the requested sitekey.
    api.js auto-renders the widget, so the main-world JS never needs to
    be reached - sidesteps Camoufox's isolated-world sandbox. Referer
    and origin still match siteurl so CF issues a valid token.
    """
    loop = asyncio.get_event_loop()
    t0 = loop.time()

    extra = ""
    if action:
        extra += f' data-action="{action}"'
    if cdata:
        extra += f' data-cdata="{cdata}"'
    body = _HOST_HTML.replace("__SITEKEY__", sitekey).replace("__EXTRA__", extra)

    target = siteurl if siteurl.endswith("/") else siteurl + "/"

    async def _fulfill(route):
        try:
            await route.fulfill(status=200, content_type="text/html", body=body)
        except Exception:
            try:
                await route.continue_()
            except Exception:
                pass

    await page.route(target, _fulfill)
    _step(req_id, f"route intercepted {target}")

    try:
        await page.goto(target, timeout=15_000)
    except Exception as e:
        _step(req_id, f"goto warn: {e}")

    deadline = t0 + timeout
    clicked = False
    while loop.time() < deadline:
        try:
            val = await page.locator('[name=cf-turnstile-response]').first.get_attribute("value", timeout=500)
        except Exception:
            val = None
        if val:
            _step(req_id, f"token obtained ({loop.time() - t0:.1f}s)")
            return val

        if not clicked:
            try:
                await page.locator(".cf-turnstile").first.click(timeout=2_000)
                _step(req_id, f"clicked widget ({loop.time() - t0:.1f}s)")
                clicked = True
            except Exception:
                pass

        await asyncio.sleep(0.5)

    raise TimeoutError(f"turnstile timeout after {timeout}s")


async def solve_async(sitekey: str, siteurl: str, req_id: str = "-",
                      timeout: int = 45, action: Optional[str] = None,
                      cdata: Optional[str] = None) -> str:
    pool = await get_pool()
    async with pool.sem:
        async with pool.solve_lock:
            _step(req_id, f"opening tab for {siteurl}")
            page = None
            try:
                await pool.ensure()
                page = await pool.browser.new_page()
                return await _turnstile_on_page(
                    page, sitekey, siteurl, req_id, timeout, action, cdata
                )
            finally:
                pool.solve_count += 1
                if page is not None:
                    try:
                        await page.close()
                    except Exception:
                        pass


# ---------- JS Challenge ("Just a moment...") ----------

_CF_WIDGET_RECT_JS = """
(() => {
    for (const f of document.querySelectorAll('iframe')) {
        const src = f.src || f.getAttribute('src') || '';
        if (!src.includes('challenges.cloudflare.com')) continue;
        const r = f.getBoundingClientRect();
        if (r.width > 50 && r.height > 20) return {x:r.x, y:r.y, w:r.width, h:r.height};
    }
    const el = document.querySelector('#hQLfM7, .main-wrapper .ch-title-zone + div');
    if (el) {
        const r = el.getBoundingClientRect();
        if (r.width > 50 && r.height > 20) return {x:r.x, y:r.y, w:r.width, h:r.height};
    }
    return null;
})()
"""


def _match_host(target_host: str, cdomain: str) -> bool:
    d = (cdomain or "").lstrip(".").lower()
    h = (target_host or "").lower()
    return bool(h) and (h == d or h.endswith("." + d))


def _challenge_proxy() -> tuple[Optional[str], str]:
    """Return (base_url, kind) for the configured challenge proxy."""
    url = os.environ.get("CHALLENGE_PROXY_URL") or os.environ.get("FLARESOLVERR_URL") or ""
    url = url.rstrip("/")
    if not url:
        return None, ""
    kind = (os.environ.get("CHALLENGE_PROXY_KIND")
            or ("flaresolverr" if os.environ.get("FLARESOLVERR_URL") else "byparr")).lower()
    return url, kind


async def _solve_via_proxy(siteurl: str, req_id: str, timeout: int) -> Optional[dict]:
    url, kind = _challenge_proxy()
    if not url:
        return None
    _step(req_id, f"delegating to {kind} -> {url}")

    candidates = [siteurl]
    try:
        u = urlparse(siteurl)
        if u.path and not u.path.endswith("/") and "." not in u.path.rsplit("/", 1)[-1] and not u.query:
            fixed = siteurl.rstrip() + "/"
            if fixed != siteurl:
                candidates.append(fixed)
    except Exception:
        pass

    if kind == "byparr":
        payload_base = {"cmd": "request.get", "max_timeout": max(5, timeout)}
    else:
        payload_base = {"cmd": "request.get", "maxTimeout": max(5000, timeout * 1000)}

    loop = asyncio.get_event_loop()
    t0 = loop.time()
    data = None
    last_err = None
    for i, try_url in enumerate(candidates):
        if i:
            _step(req_id, f"retrying with trailing slash -> {try_url}")
        payload = {**payload_base, "url": try_url}
        try:
            conn_timeout = aiohttp.ClientTimeout(total=timeout + 15)
            async with aiohttp.ClientSession(timeout=conn_timeout) as s:
                async with s.post(f"{url}/v1", json=payload) as resp:
                    body_text = await resp.text()
                    if resp.status == 200:
                        data = json.loads(body_text)
                        if (data.get("status") or "").lower() == "ok":
                            break
                        last_err = f"{kind}: {data.get('message') or data}"
                        data = None
                        continue
                    last_err = f"{kind} HTTP {resp.status}: {body_text[:200]}"
        except asyncio.TimeoutError:
            last_err = f"{kind} did not respond within {timeout + 15}s"
        except aiohttp.ClientError as e:
            last_err = f"{kind} connection error: {e}"

    if data is None:
        raise RuntimeError(last_err or f"{kind}: unknown failure")

    sol = data.get("solution") or {}
    final_url = sol.get("url") or siteurl
    target_host = urlparse(final_url).hostname or ""
    raw_cookies = sol.get("cookies") or []
    cookies = []
    for c in raw_cookies:
        if not _match_host(target_host, c.get("domain", "")):
            continue
        cookies.append({
            "name": c.get("name"),
            "value": c.get("value"),
            "domain": c.get("domain"),
            "path": c.get("path", "/"),
            "expires": c.get("expiry") if c.get("expiry") is not None else c.get("expires", -1),
        })

    html = sol.get("response") or ""
    title = ""
    low = html.lower()
    a = low.find("<title")
    if a != -1:
        b = low.find(">", a)
        c_ = low.find("</title>", b)
        if b != -1 and c_ != -1:
            title = html[b + 1:c_].strip()

    user_agent = sol.get("userAgent") or sol.get("user_agent") or ""
    _step(req_id, f"{kind} cleared ({loop.time() - t0:.1f}s, cookies={len(cookies)})")
    return {
        "url": final_url,
        "title": title,
        "user_agent": user_agent,
        "cookies": cookies,
        "html": html,
    }


# Back-compat alias
_solve_via_flaresolverr = _solve_via_proxy


async def solve_challenge_async(siteurl: str, req_id: str = "-",
                                 timeout: int = 45) -> dict:
    """Open page, wait for CF challenge to clear, return cookies + final html."""
    proxy_url, proxy_kind = _challenge_proxy()
    if proxy_url:
        try:
            result = await _solve_via_proxy(siteurl, req_id, timeout)
            if result is not None:
                return result
        except Exception as e:
            _step(req_id, f"{proxy_kind or 'proxy'} failed, falling back to camoufox: {e}")

    pool = await get_pool()
    async with pool.sem:
        async with pool.solve_lock:
            _step(req_id, f"opening tab -> {siteurl}")
            page = None
            try:
                page = await pool.new_page(siteurl)
                loop = asyncio.get_event_loop()
                t0 = loop.time()
                _step(req_id, "waiting for navigation...")
                try:
                    await page.wait_for_load_state("domcontentloaded", timeout=15_000)
                except Exception:
                    pass
                _step(req_id, f"page loaded ({loop.time() - t0:.1f}s)")

                deadline = t0 + timeout
                cleared = False
                attempts = 0
                clicks = 0
                last_click = 0.0

                while loop.time() < deadline:
                    is_challenge = await page.evaluate(_IS_CHALLENGE_JS)
                    if not is_challenge:
                        cleared = True
                        break
                    attempts += 1
                    if attempts == 1:
                        _step(req_id, "CF challenge detected, waiting for clear...")

                    now = loop.time()
                    if clicks < 3 and (clicks == 0 or now - last_click > 6):
                        rect = await page.evaluate(_CF_WIDGET_RECT_JS)
                        if rect:
                            cx = rect["x"] + 28 + random.uniform(-3, 3)
                            cy = rect["y"] + rect["h"] / 2 + random.uniform(-3, 3)
                            _step(req_id, f"interactive click #{clicks + 1} at ({cx:.0f},{cy:.0f})")
                            try:
                                await page.mouse.move(cx - 60, cy - 20)
                                await asyncio.sleep(0.05)
                                await page.mouse.move(cx, cy)
                                await asyncio.sleep(0.03)
                                await page.mouse.click(cx, cy)
                            except Exception as e:
                                _step(req_id, f"click error: {e}")
                            last_click = now
                            clicks += 1
                    await asyncio.sleep(0.3)

                if not cleared:
                    raise TimeoutError(f"challenge did not clear within {timeout}s")

                final_url = page.url
                title = await page.title()
                user_agent = await page.evaluate("navigator.userAgent")
                html = await page.content()
                target_host = urlparse(final_url or siteurl).hostname or ""
                try:
                    raw_cookies = await pool.browser.cookies()
                    cookies = [
                        {"name": c["name"], "value": c["value"], "domain": c["domain"],
                         "path": c["path"], "expires": c.get("expires", -1)}
                        for c in raw_cookies
                        if _match_host(target_host, c.get("domain", ""))
                    ]
                except Exception as e:
                    _step(req_id, f"cookie fetch failed: {e}")
                    cookies = []

                _step(req_id, f"challenge cleared ({loop.time() - t0:.1f}s, attempts={attempts})")
                return {
                    "url": final_url,
                    "title": title,
                    "user_agent": user_agent,
                    "cookies": cookies,
                    "html": html,
                }
            finally:
                pool.solve_count += 1
                if page is not None:
                    try:
                        await page.close()
                    except Exception:
                        pass


def solve(sitekey: str, siteurl: str, timeout: int = 45) -> str:
    """Legacy sync wrapper."""
    import warnings
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        return asyncio.run(solve_async(sitekey, siteurl, timeout=timeout))


if __name__ == "__main__":
    import sys
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", stream=sys.stdout)
    if len(sys.argv) < 3:
        print("Usage: python solver.py <sitekey> <siteurl>")
        sys.exit(1)
    t0 = time.time()
    tok = solve(sys.argv[1], sys.argv[2])
    print(f"{tok}\nelapsed: {time.time()-t0:.2f}s")