feature!: добавлен обход claudflare
не кликает!
This commit is contained in:
1 parent
40e5d046a9
commit
f1ed6edc7a
12 files changed
+159
-52
No files matched your search
@@ -31,10 +31,28 @@ page.click()) телепортирует курсор мгновенно и та
|
|||||||
"""
|
"""
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from playwright.async_api import TimeoutError
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_networkidle(page: Page, timeout: Optional[int] = None):
|
||||||
|
"""Wait for the page to become idle (no network activity) even if there are never-ending requests."""
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_page_stability(page: Page, load_dom: bool, network_idle: bool):
|
||||||
|
await page.wait_for_load_state(state="load")
|
||||||
|
if load_dom:
|
||||||
|
await page.wait_for_load_state(state="domcontentloaded")
|
||||||
|
if network_idle:
|
||||||
|
await wait_for_networkidle(page)
|
||||||
|
|
||||||
|
|
||||||
async def typing(page: Page, locator: str, text: str):
|
async def typing(page: Page, locator: str, text: str):
|
||||||
element = page.locator(locator)
|
element = page.locator(locator)
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,16 @@ iFrame with checkbox
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from random import randint
|
||||||
|
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
from ..base import Detector, ChallengeInfo, AntiBotProvider, Solver
|
from ..base import Detector, ChallengeInfo, AntiBotProvider, Solver
|
||||||
from ..base import Stage
|
from ..base import Stage
|
||||||
from ..registry import register
|
from ..registry import register
|
||||||
|
from ..humanize import wait_for_page_stability, wait_for_networkidle
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
@@ -74,15 +77,23 @@ log.addHandler(logging.StreamHandler())
|
|||||||
|
|
||||||
class CloudflareDetector(Detector):
|
class CloudflareDetector(Detector):
|
||||||
|
|
||||||
|
# степени риска, которые Cloudflare сам проставляет в конфиге своего
|
||||||
|
# челлендж-скрипта на странице (см. `cType: '...'` в html/js) —
|
||||||
|
# по нему и определяем стадию, а не по факту наличия виджета на глаз
|
||||||
|
_CTYPES = ("non-interactive", "managed", "interactive")
|
||||||
|
|
||||||
async def detect(self, page: Page, info: ChallengeInfo):
|
async def detect(self, page: Page, info: ChallengeInfo):
|
||||||
log.debug("CloudflareDetector run")
|
log.debug("CloudflareDetector run")
|
||||||
|
await wait_for_page_stability(page, True, True)
|
||||||
|
|
||||||
info.meta = {}
|
info.meta = {}
|
||||||
checkbox_selector = "иногда надо тыкнуть, сейчас этого нет"
|
widget_selector = "div.cf-turnstile[data-sitekey], div[data-sitekey].cf-turnstile"
|
||||||
marker_selector = info.detect_locator or "//input[@name='cf-turnstile-response']"
|
marker_selector = info.detect_locator or "//input[@name='cf-turnstile-response']"
|
||||||
info.detect_locator = marker_selector
|
info.detect_locator = marker_selector
|
||||||
|
|
||||||
log.debug(f"ищем {marker_selector} успех если есть {info.success_locator}")
|
log.debug(f"ищем {marker_selector} успех если есть {info.success_locator}")
|
||||||
if await page.locator(info.detect_locator).count() == 0:
|
|
||||||
|
if not await page.query_selector(marker_selector):
|
||||||
log.debug("на странице нет маркера преграды")
|
log.debug("на странице нет маркера преграды")
|
||||||
if info.success_locator:
|
if info.success_locator:
|
||||||
info.success = bool(await page.query_selector(info.success_locator))
|
info.success = bool(await page.query_selector(info.success_locator))
|
||||||
@@ -96,19 +107,67 @@ class CloudflareDetector(Detector):
|
|||||||
log.debug("Решение не требуется")
|
log.debug("Решение не требуется")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
content = await page.content()
|
||||||
|
cf_type = next((t for t in self._CTYPES if f"cType: '{t}'" in content), None)
|
||||||
|
info.meta["cf_type"] = cf_type
|
||||||
|
log.debug(f"cType со страницы: {cf_type!r}")
|
||||||
|
|
||||||
|
widget = await page.query_selector(widget_selector)
|
||||||
|
|
||||||
|
if cf_type == "non-interactive":
|
||||||
|
# фулл-пейдж интерстишл без чекбокса — просто ждать, пока пропадёт
|
||||||
|
# или эскалирует в managed/interactive (тогда cf_type сменится на
|
||||||
|
# следующем detect())
|
||||||
info.stage = Stage.INTERSTITIAL
|
info.stage = Stage.INTERSTITIAL
|
||||||
return
|
elif cf_type in ("managed", "interactive"):
|
||||||
if await page.query_selector(checkbox_selector):
|
# CF уже решил, что нужен клик по виджету внутри своего iframe
|
||||||
|
# (challenges.cloudflare.com) — сам клик этот проект не решает
|
||||||
info.stage = Stage.CHECKBOX
|
info.stage = Stage.CHECKBOX
|
||||||
|
info.meta["requires_click"] = True
|
||||||
|
elif widget:
|
||||||
|
# обычный встроенный виджет на странице (без cType, просто
|
||||||
|
# <div class="cf-turnstile" data-sitekey="...">) — решаем через
|
||||||
|
# captcha_solver по токену, см. CloudflareSolver
|
||||||
|
info.stage = Stage.CHECKBOX
|
||||||
|
info.meta["container_selector"] = widget_selector
|
||||||
else:
|
else:
|
||||||
info.stage = Stage.INTERSTITIAL
|
info.stage = Stage.INTERSTITIAL
|
||||||
|
|
||||||
|
|
||||||
|
__CF_PATTERN__ = re.compile(r"^https://challenges\.cloudflare\.com/cdn-cgi/challenge-platform/.*")
|
||||||
|
|
||||||
|
|
||||||
class CloudflareSolver(Solver):
|
class CloudflareSolver(Solver):
|
||||||
|
|
||||||
async def solve(self, page: Page, info: ChallengeInfo):
|
async def solve(self, page: Page, info: ChallengeInfo):
|
||||||
if info.stage == Stage.CHECKBOX:
|
if info.stage == Stage.CHECKBOX:
|
||||||
raise NotImplementedError
|
iframe = page.frame(url=__CF_PATTERN__)
|
||||||
|
if iframe:
|
||||||
|
frame_el = await iframe.frame_element()
|
||||||
|
box = await frame_el.bounding_box()
|
||||||
|
else:
|
||||||
|
box = await page.locator("//input[@name='cf-turnstile-response]").bounding_box()
|
||||||
|
# Calculate the Captcha coordinates for any viewport
|
||||||
|
captcha_x, captcha_y = box["x"] + randint(26, 28), box["y"] + randint(25, 27)
|
||||||
|
# Move the mouse to the center of the window, then press and hold the left mouse button
|
||||||
|
|
||||||
|
# TODO: не кликает!
|
||||||
|
await page.mouse.click(captcha_x, captcha_y, delay=randint(100, 200), button="left")
|
||||||
|
await wait_for_networkidle(page)
|
||||||
|
|
||||||
|
if info.success_locator:
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector(info.success_locator)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
if not await page.query_selector(info.detect_locator):
|
||||||
|
return
|
||||||
|
return
|
||||||
elif info.stage == Stage.INTERSTITIAL:
|
elif info.stage == Stage.INTERSTITIAL:
|
||||||
if info.success_locator:
|
if info.success_locator:
|
||||||
try:
|
try:
|
||||||
@@ -118,13 +177,17 @@ class CloudflareSolver(Solver):
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
deadline = time.time() + 30
|
deadline = time.time() + 10
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if not await page.query_selector(info.detect_locator):
|
if not await page.query_selector(info.detect_locator):
|
||||||
return
|
return
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError
|
captcha_solver = self._opt.get("captcha_solver")
|
||||||
|
if not captcha_solver:
|
||||||
|
raise RuntimeError("Нужен captcha_solver (2captcha) чтобы решить Turnstile")
|
||||||
|
await captcha_solver.solve(info.meta["container_selector"], page)
|
||||||
|
# TODO: возбудить js
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
|
|||||||
@@ -34,19 +34,17 @@ async def pass_challenges(
|
|||||||
deadline = time.monotonic() + timeout_ms / 1000
|
deadline = time.monotonic() + timeout_ms / 1000
|
||||||
|
|
||||||
# 1. определить кто блокирует
|
# 1. определить кто блокирует
|
||||||
try:
|
|
||||||
if captcha:
|
if captcha:
|
||||||
captcha_solver = load_captcha(captcha, antibot_name)
|
captcha_solver = load_captcha(captcha, antibot_name)
|
||||||
else:
|
else:
|
||||||
captcha_solver = None
|
captcha_solver = None
|
||||||
|
|
||||||
provider = await select_one(
|
provider = await select_one(
|
||||||
page, antibot_name,
|
page, antibot_name,
|
||||||
captcha_solver=captcha_solver,
|
captcha_solver=captcha_solver,
|
||||||
success_locator=success_locator,
|
success_locator=success_locator,
|
||||||
detect_locator=detect_locator,
|
detect_locator=detect_locator,
|
||||||
)
|
)
|
||||||
except ProviderNotDetected:
|
|
||||||
return ChallengeOutcome(False, None, None, "Provider not detected")
|
|
||||||
|
|
||||||
info = None
|
info = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
|||||||
Whitespace-only changes.
@@ -1,20 +0,0 @@
|
|||||||
"""
|
|
||||||
стадия B: универсальный click-solver + сигнатуры провайдеров
|
|
||||||
"""
|
|
||||||
|
|
||||||
from ..registry import detect_any
|
|
||||||
from ..base import Stage
|
|
||||||
|
|
||||||
|
|
||||||
async def solve_checkbox(page, info, max_attempts=3):
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
frame = page.locator(page, info.frame_locator)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# после клика — либо чисто, либо эскалация в картинки (стадия C)
|
|
||||||
if await detect_any(page, Stage.CHECKBOX) is None:
|
|
||||||
return True
|
|
||||||
if await detect_any(page, Stage.INTERACTIVE):
|
|
||||||
return False # передаём выше, пусть оркестратор переключит стадию
|
|
||||||
return False
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# стадия C: мост к src/captcha (hcaptcha/recaptcha/...)
|
|
||||||
async def solve_interactive(page, info, captcha):
|
|
||||||
await captcha.solve(info.frame_locator or info.meta["container_selector"], page)
|
|
||||||
return True
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""
|
|
||||||
стадия A: универсальный wait-solver + сигнатуры провайдеров
|
|
||||||
"""
|
|
||||||
from ..base import Stage
|
|
||||||
from ..registry import detect_any
|
|
||||||
|
|
||||||
|
|
||||||
async def solve_interstitial(page, info, timeout_ms=15000, poll_ms=500):
|
|
||||||
elapsed = 0
|
|
||||||
while elapsed < timeout_ms:
|
|
||||||
if await detect_any(page, Stage.INTERSTITIAL) is None:
|
|
||||||
return True
|
|
||||||
await page.wait_for_timeout(poll_ms)
|
|
||||||
elapsed += poll_ms
|
|
||||||
return False
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from .hcaptcha import SolverHCaptcha
|
from .hcaptcha import SolverHCaptcha
|
||||||
from .recaptcha import SolverReCaptcha
|
from .recaptcha import SolverReCaptcha
|
||||||
|
from .turnstile import SolverTurnstile
|
||||||
from .base import load_captcha
|
from .base import load_captcha
|
||||||
@@ -68,6 +68,10 @@ class Captcha:
|
|||||||
resp = await self._run(self._solver.recaptcha, site_key, url)
|
resp = await self._run(self._solver.recaptcha, site_key, url)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
async def turnstile(self, url, site_key):
|
||||||
|
resp = await self._run(self._solver.turnstile, site_key, url)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def load_captcha(key, captcha_type) -> Captcha:
|
def load_captcha(key, captcha_type) -> Captcha:
|
||||||
for cls in Captcha.__subclasses__():
|
for cls in Captcha.__subclasses__():
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
<div id="cf-turnstile" class="cf-turnstile" data-sitekey="3x00000000000000000000FF">
|
||||||
|
<div>
|
||||||
|
<div></div>
|
||||||
|
<input type="hidden" name="cf-turnstile-response" id="cf-chl-widget-a3ptc_response">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Кликабельный чекбокс Cloudflare рендерит в закрытом shadow DOM — туда лезть
|
||||||
|
не нужно и бессмысленно. sitekey и поле для ответа лежат в обычном DOM
|
||||||
|
контейнера, поэтому решаем через API 2captcha и просто подставляем токен.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .base import Captcha, Page
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
|
||||||
|
class SolverTurnstile(Captcha):
|
||||||
|
ctype = "cloudflare"
|
||||||
|
|
||||||
|
async def solve(self, selector, page: Page):
|
||||||
|
container = page.locator(selector)
|
||||||
|
await container.wait_for()
|
||||||
|
|
||||||
|
site_key = await container.get_attribute("data-sitekey")
|
||||||
|
|
||||||
|
response_input = container.locator('input[name="cf-turnstile-response"]')
|
||||||
|
await response_input.wait_for(state="attached")
|
||||||
|
|
||||||
|
code = await self.turnstile(page.url, site_key)
|
||||||
|
|
||||||
|
await response_input.evaluate((
|
||||||
|
"(el, value) => {"
|
||||||
|
"el.value = value;"
|
||||||
|
"el.dispatchEvent(new Event('input', {bubbles: true}));"
|
||||||
|
"el.dispatchEvent(new Event('change', {bubbles: true}));"
|
||||||
|
"}"
|
||||||
|
), code)
|
||||||
|
return code
|
||||||
+19
-1
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
|
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from antibot.base import click_and_race
|
||||||
from antibot.orchestrator import pass_challenges
|
from antibot.orchestrator import pass_challenges
|
||||||
from captcha import load_captcha
|
from captcha import load_captcha
|
||||||
from engine.schemas import ActionRequest
|
from engine.schemas import ActionRequest
|
||||||
@@ -50,6 +51,18 @@ async def click(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
|||||||
await element.click()
|
await element.click()
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||||
|
assert act.selector
|
||||||
|
element = page.locator(act.selector)
|
||||||
|
|
||||||
|
async with page.expect_event(
|
||||||
|
"framenavigated",
|
||||||
|
predicate=lambda frame: frame == page.main_frame,
|
||||||
|
):
|
||||||
|
await element.click()
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||||
assert act.selector
|
assert act.selector
|
||||||
@@ -61,7 +74,10 @@ async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
|||||||
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||||
assert act.selector
|
assert act.selector
|
||||||
assert 1 <= act.timeout <= 7200
|
assert 1 <= act.timeout <= 7200
|
||||||
|
try:
|
||||||
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
||||||
|
except Exception as e:
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
@@ -70,12 +86,13 @@ async def pass_challenge(
|
|||||||
antibot_name, success_locator=None, captcha_key=None, detect_locator=None,
|
antibot_name, success_locator=None, captcha_key=None, detect_locator=None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
await pass_challenges(
|
result = await pass_challenges(
|
||||||
antibot_name, page,
|
antibot_name, page,
|
||||||
captcha=captcha_key,
|
captcha=captcha_key,
|
||||||
success_locator=act.selector or success_locator,
|
success_locator=act.selector or success_locator,
|
||||||
detect_locator=detect_locator
|
detect_locator=detect_locator
|
||||||
)
|
)
|
||||||
|
return result.reason
|
||||||
|
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -102,6 +119,7 @@ async def execute(
|
|||||||
"type": act.type,
|
"type": act.type,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
# TODO: добавить время выполнения
|
||||||
result = await _actions[act.type](page, act, **ctx)
|
result = await _actions[act.type](page, act, **ctx)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
new_line["ok"] = False
|
new_line["ok"] = False
|
||||||
|
|||||||
+2
-2
@@ -82,7 +82,7 @@ async def solve(
|
|||||||
):
|
):
|
||||||
api_key = request.headers.get('X-API-Key')
|
api_key = request.headers.get('X-API-Key')
|
||||||
|
|
||||||
logger.info("Incoming payload: [%s] %s", session_id, payload)
|
logger.info("Incoming payload: [%s]\n%s", session_id, payload.model_dump_json(indent=2, ensure_ascii=True))
|
||||||
global BROWSER_SLOTS
|
global BROWSER_SLOTS
|
||||||
|
|
||||||
if BROWSER_SLOTS <= 0:
|
if BROWSER_SLOTS <= 0:
|
||||||
@@ -159,5 +159,5 @@ async def solve(
|
|||||||
result["html"] = html
|
result["html"] = html
|
||||||
if error:
|
if error:
|
||||||
result["error"] = error
|
result["error"] = error
|
||||||
logger.info("Outgoing payload: %s", result)
|
logger.info("Outgoing payload:\n%s", json.dumps(result, indent=2, sort_keys=True, ensure_ascii=True))
|
||||||
return result
|
return result
|
||||||
Reference in new issue
Block a user