mirror of
https://github.com/ThePhaseless/Byparr.git
synced 2026-09-24 22:03:30 +01:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f943a3cf | ||
|
|
98e1668721 | ||
|
|
beb281267a | ||
|
|
b46614fb55 | ||
|
|
cdfd73785a | ||
|
|
080b0f9141 | ||
|
|
c611afb865 | ||
|
|
cb2a862386 | ||
|
|
2852dc2527 | ||
|
|
12ef77177a | ||
|
|
11d7e59263 | ||
|
|
78b3f314c3 | ||
|
|
d0b013029c | ||
|
|
426aae4310 | ||
|
|
3dcf529609 | ||
|
|
61db8d82ee | ||
|
|
9067ca37df | ||
|
|
cb8fb58fa8 | ||
|
|
fc64fe05d5 | ||
|
|
24c339b085 | ||
|
|
c7633b4525 | ||
|
|
691aaa6f3f | ||
|
|
e0b1efa560 | ||
|
|
362b07e55a | ||
|
|
1a2cf32e1e | ||
|
|
f97e3d325d | ||
|
|
4e70c8b208 | ||
|
|
5130400571 | ||
|
|
14834c23b2 | ||
|
|
aa9a331064 | ||
|
|
c4dcee3e2b | ||
|
|
652c234782 | ||
|
|
1c2b2df90d | ||
|
|
c9d4cd4a4e | ||
|
|
ecf7c03d7c | ||
|
|
c96db89d03 | ||
|
|
742cafc64a | ||
|
|
9afb3e0903 | ||
|
|
d7792b8fcd | ||
|
|
bb526d73b0 | ||
|
|
9b933ea70c | ||
|
|
d3a828e814 | ||
|
|
46a3c68eb0 |
@@ -0,0 +1,3 @@
|
||||
# Supported funding model platforms
|
||||
|
||||
github: [ThePhaseless]
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
- FastAPI service that mimics FlareSolverr-style API for bypassing anti-bot pages using Camoufox + Playwright captcha solver.
|
||||
- Entry point: main app in main.py; routes and request flow defined in src/endpoints.py and src/models.py.
|
||||
- Browser lifecycle is owned by get_camoufox() in src/utils.py, which yields a page, solver, and context for each request.
|
||||
- FastAPI service that mimics FlareSolverr-style API for bypassing anti-bot pages using invisible_playwright.
|
||||
- Entry point: main app in main.py; routes and request flow in src/endpoints.py, challenge handling in src/challenge.py, response bodies in src/content.py, models in src/models.py.
|
||||
- Browser lifecycle is owned by get_browser() in src/utils.py, which yields a page and context for each request.
|
||||
|
||||
## Architecture and data flow
|
||||
|
||||
- Request flow: POST /v1 -> read_item() -> browser.goto() -> wait for load states -> detect challenge title -> solve captcha -> return LinkResponse.
|
||||
- Challenge detection uses CHALLENGE_TITLES from src/consts.py; update this map when adding providers.
|
||||
- Request flow: POST /v1 -> read_item() -> page.goto() -> wait for load states -> detect the interstitial -> click its checkbox until it clears -> return LinkResponse.
|
||||
- Challenge detection uses detect_cloudflare_challenge() from playwright_captcha; the challenge is over when its markup goes, not when a solver says so.
|
||||
- Health check hits /v1 internally with <https://google.com> and fails if status is not OK.
|
||||
- Logging: LogRequest middleware logs only POST /v1 timing and outcome; other paths pass through.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
- Local run: uv sync && uv run main.py
|
||||
- Init mode: uv run main.py --init (pre-warms health check via browser setup)
|
||||
- Tests: uv sync --group test && uv run pytest --retries 3
|
||||
- Tests: uv sync --group test && uv run pytest --retries 5
|
||||
- Docker troubleshooting: docker build --target test .
|
||||
|
||||
## Tests and external dependencies
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ RUN mkdir -p /home/byparr &&\
|
||||
FROM app AS test
|
||||
RUN \
|
||||
uv sync --group test &&\
|
||||
uv run pytest --retries 3
|
||||
uv run pytest -rs --retries 5
|
||||
|
||||
FROM app
|
||||
ARG VERSION
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Byparr
|
||||
# Byparr [](https://github.com/sponsors/ThePhaseless)
|
||||
|
||||
<p align="center">
|
||||
<img src="icon/logo-byparr.svg" alt="Byparr logo" width="120" />
|
||||
|
||||
@@ -2,6 +2,8 @@ services:
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
restart: unless-stopped
|
||||
# environment:
|
||||
# LOG_LEVEL: debug
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ readme = "README.md"
|
||||
dependencies = [
|
||||
"fastapi[standard]==0.141.*",
|
||||
"invisible-playwright>=0.6.1",
|
||||
"playwright==1.60.*",
|
||||
"playwright==1.63.*",
|
||||
"playwright-captcha==0.1.*",
|
||||
"pydantic==2.*",
|
||||
"pydantic-settings==2.*",
|
||||
@@ -20,7 +20,7 @@ urls = { repository = "https://github.com/ThePhaseless/Byparr" }
|
||||
[dependency-groups]
|
||||
|
||||
test = [
|
||||
"httpx2==2.10.*",
|
||||
"httpx2==2.13.*",
|
||||
"pytest==9.1.*",
|
||||
"pytest-asyncio==1.4.*",
|
||||
"pytest-retry==1.7.*",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from asyncio import sleep
|
||||
from contextlib import suppress
|
||||
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import FloatRect, Page
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
CF_INTERSTITIAL_INDICATORS_SELECTORS,
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.utils import TimeoutTimer, logger
|
||||
|
||||
__all__ = [
|
||||
"CF_INTERSTITIAL_INDICATORS_SELECTORS",
|
||||
"challenge_present",
|
||||
"solve_challenge",
|
||||
]
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
CLICK_SETTLE = 1.5
|
||||
CLICK_COOLDOWN = 4
|
||||
PROBE_INTERVAL = 0.5
|
||||
TOKEN_READ_TIMEOUT = 1000
|
||||
BOX_READ_TIMEOUT = 1000
|
||||
CHECKBOX_INSET = 25
|
||||
TURNSTILE_INPUT = 'input[name="cf-turnstile-response"]'
|
||||
WIDGET_ANCESTOR_DEPTHS = (1, 2, 3, 4)
|
||||
WIDGET_MIN_WIDTH = 40
|
||||
WIDGET_MIN_HEIGHT = 20
|
||||
WIDGET_MAX_HEIGHT = 120
|
||||
|
||||
|
||||
async def challenge_present(page: Page) -> bool:
|
||||
"""Report whether the Cloudflare interstitial is up."""
|
||||
return await detect_cloudflare_challenge(page, "interstitial")
|
||||
|
||||
|
||||
async def widget_box(page: Page) -> FloatRect | None:
|
||||
"""Measure the widget container with locators; running page scripts resets the challenge."""
|
||||
for depth in WIDGET_ANCESTOR_DEPTHS:
|
||||
widget = page.locator(f"{TURNSTILE_INPUT} >> xpath=ancestor::div[{depth}]")
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
if await widget.count() == 0:
|
||||
continue
|
||||
box = await widget.first.bounding_box(timeout=BOX_READ_TIMEOUT)
|
||||
if (
|
||||
box
|
||||
and box["width"] > WIDGET_MIN_WIDTH
|
||||
and WIDGET_MIN_HEIGHT < box["height"] < WIDGET_MAX_HEIGHT
|
||||
):
|
||||
return box
|
||||
return None
|
||||
|
||||
|
||||
async def click_checkbox(page: Page) -> bool:
|
||||
"""Click the checkbox through its container, leaving its closed shadow root alone."""
|
||||
box = await widget_box(page)
|
||||
if box is None:
|
||||
return False
|
||||
await page.mouse.move(box["x"] + CHECKBOX_INSET, box["y"] + box["height"] / 2)
|
||||
try:
|
||||
await page.mouse.down()
|
||||
finally:
|
||||
await page.mouse.up()
|
||||
return True
|
||||
|
||||
|
||||
async def checkbox_already_answered(page: Page) -> bool:
|
||||
"""Report whether Turnstile has already filled in its response token."""
|
||||
token = page.locator(TURNSTILE_INPUT)
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
if await token.count() > 0:
|
||||
return bool(await token.first.input_value(timeout=TOKEN_READ_TIMEOUT))
|
||||
return False
|
||||
|
||||
|
||||
async def challenge_is_gone(page: Page) -> bool:
|
||||
"""Confirm the interstitial is really gone and not just between navigations."""
|
||||
if await challenge_present(page):
|
||||
return False
|
||||
await sleep(POLL_INTERVAL)
|
||||
return not await challenge_present(page)
|
||||
|
||||
|
||||
async def solve_challenge(page: Page, timer: TimeoutTimer) -> None:
|
||||
"""Wait out the interstitial, clicking its checkbox whenever one is offered."""
|
||||
logger.info("Challenge detected, waiting for it to clear...")
|
||||
clicks = 0
|
||||
next_click = 0.0
|
||||
while True:
|
||||
if await challenge_is_gone(page):
|
||||
logger.debug("Challenge cleared.")
|
||||
if clicks:
|
||||
await sleep(min(CLICK_SETTLE, timer.remaining()))
|
||||
return
|
||||
|
||||
if time.perf_counter() >= next_click:
|
||||
landed = False
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
landed = not await checkbox_already_answered(
|
||||
page
|
||||
) and await click_checkbox(page)
|
||||
if landed:
|
||||
clicks += 1
|
||||
logger.info("Clicked the challenge checkbox (attempt %d).", clicks)
|
||||
next_click = time.perf_counter() + (
|
||||
CLICK_COOLDOWN if landed else PROBE_INTERVAL
|
||||
)
|
||||
|
||||
if timer.remaining() <= 0:
|
||||
break
|
||||
await sleep(POLL_INTERVAL)
|
||||
|
||||
message = "Challenge still present when the request budget ran out"
|
||||
raise TimeoutError(message)
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
@@ -10,8 +9,6 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
version: str = "unknown"
|
||||
|
||||
max_attempts: int = sys.maxsize
|
||||
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
@@ -30,8 +27,6 @@ settings = Settings()
|
||||
LOG_LEVEL = logging.getLevelNamesMapping()[settings.log_level.upper()]
|
||||
VERSION = settings.version.removeprefix("v")
|
||||
|
||||
MAX_ATTEMPTS = settings.max_attempts
|
||||
|
||||
PROXY_SERVER = settings.proxy_server
|
||||
PROXY_USERNAME = settings.proxy_username
|
||||
PROXY_PASSWORD = settings.proxy_password
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import base64
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from src.models import LinkRequest
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
async def build_response_content(
|
||||
page: Page,
|
||||
request: LinkRequest,
|
||||
page_request: object,
|
||||
*,
|
||||
challenge_detected: bool,
|
||||
page_html: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build (content_type, response_content) from the settled page."""
|
||||
if request.return_only_cookies:
|
||||
return "text/html", ""
|
||||
|
||||
if page_request and page_request.headers.get("content-type", "").startswith(
|
||||
"application/pdf"
|
||||
):
|
||||
return await fetch_pdf_content(page)
|
||||
|
||||
response_content = (
|
||||
page_html
|
||||
if page_html is not None and not challenge_detected
|
||||
else await page.content()
|
||||
)
|
||||
return "text/html", response_content
|
||||
|
||||
|
||||
async def fetch_pdf_content(page: Page) -> tuple[str, str]:
|
||||
"""Fetch raw PDF bytes as base64, falling back to viewer HTML on failure."""
|
||||
try:
|
||||
fetch_response = await page.request.fetch(page.url)
|
||||
response_content = base64.b64encode(await fetch_response.body()).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch PDF bytes, falling back to viewer HTML")
|
||||
return "text/html", await page.content()
|
||||
return "application/pdf", response_content
|
||||
+41
-134
@@ -1,25 +1,28 @@
|
||||
import base64
|
||||
import time
|
||||
import warnings
|
||||
from asyncio import wait_for
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha import CaptchaType
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.challenge import challenge_present, solve_challenge
|
||||
from src.content import build_response_content
|
||||
from src.models import (
|
||||
HealthcheckResponse,
|
||||
LinkRequest,
|
||||
LinkResponse,
|
||||
Solution,
|
||||
)
|
||||
from src.utils import BrowserDepClass, TimeoutTimer, get_browser, logger
|
||||
from src.utils import (
|
||||
BrowserDepClass,
|
||||
TimeoutTimer,
|
||||
get_browser,
|
||||
logger,
|
||||
remaining_ms,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||
|
||||
@@ -28,18 +31,6 @@ router = APIRouter()
|
||||
|
||||
BrowserDep = Annotated[BrowserDepClass, Depends(get_browser)]
|
||||
|
||||
# Headers to strip from the fulfilled response: CSP is removed for navigation
|
||||
# freedom, and content-encoding/content-length are stale once we request an
|
||||
# uncompressed body via accept-encoding: identity below.
|
||||
DROP_HEADERS = frozenset(
|
||||
{
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
def read_root():
|
||||
@@ -72,11 +63,11 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
timer = TimeoutTimer(duration=request.max_timeout)
|
||||
request.url = request.url.replace('"', "").strip()
|
||||
|
||||
final_url = await setup_routes(request, dep)
|
||||
await setup_routes(request, dep)
|
||||
|
||||
try:
|
||||
challenge_detected, page_html, page_request, status = (
|
||||
await _navigate_and_solve(dep, request, timer)
|
||||
challenge_detected, page_html, page_request = await _navigate_and_solve(
|
||||
dep, request, timer
|
||||
)
|
||||
except (TimeoutError, PlaywrightTimeoutError) as e:
|
||||
logger.error("Timed out while loading the page or solving the challenge")
|
||||
@@ -84,20 +75,32 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
status_code=408,
|
||||
detail="Timed out while loading the page or solving the challenge",
|
||||
) from e
|
||||
except PlaywrightError as e:
|
||||
logger.error("Could not reach the target: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Could not reach the target: {e}",
|
||||
) from e
|
||||
|
||||
cookies = await dep.context.cookies()
|
||||
content_type, response_content = await build_response_content(
|
||||
dep, request, page_request,
|
||||
dep.page,
|
||||
request,
|
||||
page_request,
|
||||
challenge_detected=challenge_detected,
|
||||
page_html=page_html,
|
||||
)
|
||||
|
||||
user_agent = (
|
||||
page_request.request.headers.get("user-agent") or "" if page_request else ""
|
||||
)
|
||||
|
||||
return LinkResponse(
|
||||
message="Success",
|
||||
solution=Solution(
|
||||
user_agent=await dep.page.evaluate("navigator.userAgent"),
|
||||
url=final_url if final_url is not None else dep.page.url,
|
||||
status=status,
|
||||
user_agent=user_agent,
|
||||
url=dep.page.url,
|
||||
status=HTTPStatus.OK,
|
||||
cookies=cookies,
|
||||
headers=page_request.headers if page_request else {},
|
||||
response=response_content,
|
||||
@@ -107,13 +110,8 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
)
|
||||
|
||||
|
||||
async def setup_routes(request: LinkRequest, dep: BrowserDep) -> str | None:
|
||||
"""
|
||||
Install request routes for media blocking and CSP stripping.
|
||||
|
||||
Returns the final URL captured during navigation; callers read it after
|
||||
the page settles.
|
||||
"""
|
||||
async def setup_routes(request: LinkRequest, dep: BrowserDep) -> None:
|
||||
"""Install request routes for media blocking."""
|
||||
if request.block_media:
|
||||
|
||||
async def block_media_route(route) -> None:
|
||||
@@ -124,125 +122,34 @@ async def setup_routes(request: LinkRequest, dep: BrowserDep) -> str | None:
|
||||
|
||||
await dep.page.route("**/*", block_media_route)
|
||||
|
||||
final_url: str | None = None
|
||||
|
||||
async def strip_csp_route(route) -> None:
|
||||
nonlocal final_url
|
||||
if route.request.resource_type != "document":
|
||||
await route.continue_()
|
||||
return
|
||||
# Request an uncompressed body via accept-encoding: identity. When
|
||||
# route.fulfill re-serves the fetched response (by uid), it forwards
|
||||
# the original compressed bytes; stripping content-encoding below
|
||||
# would leave the browser reading compressed bytes as plain text.
|
||||
response = await route.fetch(
|
||||
headers={**route.request.headers, "accept-encoding": "identity"}
|
||||
)
|
||||
if route.request.frame == dep.page.main_frame:
|
||||
final_url = response.url
|
||||
await route.fulfill(
|
||||
response=response,
|
||||
headers={
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() not in DROP_HEADERS
|
||||
},
|
||||
)
|
||||
|
||||
await dep.page.route("**/*", strip_csp_route)
|
||||
return final_url
|
||||
|
||||
|
||||
async def _navigate_and_solve(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
timer: TimeoutTimer,
|
||||
) -> tuple[bool, str | None, object, HTTPStatus]:
|
||||
) -> tuple[bool, str | None, object]:
|
||||
"""Navigate to the URL, then solve a challenge or wait for network idle."""
|
||||
page_html: str | None = None
|
||||
page_request = await dep.page.goto(
|
||||
request.url, timeout=timer.remaining() * 1000
|
||||
)
|
||||
status = page_request.status if page_request else HTTPStatus.OK
|
||||
page_request = await dep.page.goto(request.url, timeout=remaining_ms(timer))
|
||||
await dep.page.wait_for_load_state(
|
||||
state="domcontentloaded", timeout=timer.remaining() * 1000
|
||||
state="domcontentloaded", timeout=remaining_ms(timer)
|
||||
)
|
||||
|
||||
challenge_active = (
|
||||
await detect_cloudflare_challenge(dep.page, "interstitial")
|
||||
or await detect_cloudflare_challenge(dep.page, "turnstile")
|
||||
)
|
||||
if not challenge_active:
|
||||
if not await challenge_present(dep.page):
|
||||
page_html = await dep.page.content()
|
||||
await _wait_for_networkidle(dep, timer)
|
||||
return False, page_html, page_request, status
|
||||
return False, page_html, page_request
|
||||
|
||||
await _solve_challenge(dep, timer)
|
||||
status = HTTPStatus.OK
|
||||
return True, page_html, page_request, status
|
||||
|
||||
|
||||
async def _solve_challenge(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""Attempt to solve a detected Cloudflare interstitial challenge."""
|
||||
logger.info("Challenge detected, attempting to solve...")
|
||||
await wait_for(
|
||||
dep.solver.solve_captcha( # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
captcha_container=dep.page,
|
||||
captcha_type=CaptchaType.CLOUDFLARE_INTERSTITIAL,
|
||||
wait_checkbox_attempts=1,
|
||||
wait_checkbox_delay=0.5,
|
||||
),
|
||||
timeout=timer.remaining(),
|
||||
)
|
||||
logger.debug("Challenge solved successfully.")
|
||||
await solve_challenge(dep.page, timer)
|
||||
await _wait_for_networkidle(dep, timer)
|
||||
return True, page_html, page_request
|
||||
|
||||
|
||||
async def _wait_for_networkidle(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""Wait for network idle, tolerating post-DOM-load stalls."""
|
||||
try:
|
||||
await dep.page.wait_for_load_state(
|
||||
"networkidle", timeout=timer.remaining() * 1000
|
||||
)
|
||||
await dep.page.wait_for_load_state("networkidle", timeout=remaining_ms(timer))
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info(
|
||||
"networkidle timed out after domcontentloaded; "
|
||||
"continuing with loaded page"
|
||||
"networkidle timed out after domcontentloaded; continuing with loaded page"
|
||||
)
|
||||
|
||||
|
||||
async def build_response_content(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
page_request: object,
|
||||
*,
|
||||
challenge_detected: bool,
|
||||
page_html: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build (content_type, response_content) from the settled page."""
|
||||
if request.return_only_cookies:
|
||||
return "text/html", ""
|
||||
|
||||
if page_request and page_request.headers.get("content-type", "").startswith(
|
||||
"application/pdf"
|
||||
):
|
||||
return await _fetch_pdf_content(dep)
|
||||
|
||||
response_content = (
|
||||
page_html
|
||||
if page_html is not None and not challenge_detected
|
||||
else await dep.page.content()
|
||||
)
|
||||
return "text/html", response_content
|
||||
|
||||
|
||||
async def _fetch_pdf_content(dep: BrowserDep) -> tuple[str, str]:
|
||||
"""Fetch raw PDF bytes as base64, falling back to viewer HTML on failure."""
|
||||
try:
|
||||
fetch_response = await dep.page.request.fetch(dep.page.url)
|
||||
response_content = base64.b64encode(
|
||||
await fetch_response.body()
|
||||
).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch PDF bytes, falling back to viewer HTML")
|
||||
return "text/html", await dep.page.content()
|
||||
return "application/pdf", response_content
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ async def _extract_content(page: Page) -> str:
|
||||
article = trafilatura.extract(await page.content())
|
||||
if article:
|
||||
return article
|
||||
result = await page.evaluate("() => document.body ? document.body.innerText : ''")
|
||||
result = await page.locator("body").inner_text()
|
||||
return "\n".join(line.strip() for line in result.splitlines() if line.strip())
|
||||
|
||||
|
||||
|
||||
+14
-13
@@ -6,16 +6,11 @@ from typing import Annotated, NamedTuple, cast
|
||||
from fastapi import Header
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
from playwright_captcha import (
|
||||
ClickSolver,
|
||||
FrameworkType,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.consts import (
|
||||
BROWSER_LOCALE,
|
||||
LOG_LEVEL,
|
||||
MAX_ATTEMPTS,
|
||||
PROXY_PASSWORD,
|
||||
PROXY_SERVER,
|
||||
PROXY_USERNAME,
|
||||
@@ -44,9 +39,16 @@ class TimeoutTimer(BaseModel):
|
||||
return max(0, self.duration - (time.perf_counter() - self.start_time))
|
||||
|
||||
|
||||
MIN_WAIT_MS = 1.0
|
||||
|
||||
|
||||
def remaining_ms(timer: TimeoutTimer) -> float:
|
||||
"""Milliseconds left, never 0 - Playwright reads that as no timeout at all."""
|
||||
return max(MIN_WAIT_MS, timer.remaining() * 1000)
|
||||
|
||||
|
||||
class BrowserDepClass(NamedTuple):
|
||||
page: Page
|
||||
solver: ClickSolver
|
||||
context: BrowserContext
|
||||
|
||||
|
||||
@@ -96,15 +98,14 @@ async def get_browser(
|
||||
proxy=proxy_config,
|
||||
humanize=True,
|
||||
locale=BROWSER_LOCALE or "auto",
|
||||
extra_prefs={
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
},
|
||||
) as browser_raw:
|
||||
# InvisiblePlaywright yields a Browser instance
|
||||
browser = cast("Browser", browser_raw)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
async with ClickSolver(
|
||||
framework=FrameworkType.PLAYWRIGHT,
|
||||
page=page,
|
||||
max_attempts=MAX_ATTEMPTS,
|
||||
attempt_delay=1,
|
||||
) as solver:
|
||||
yield BrowserDepClass(page, solver, context)
|
||||
yield BrowserDepClass(page, context)
|
||||
|
||||
+177
-14
@@ -6,13 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import httpx2
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.challenge import CF_INTERSTITIAL_INDICATORS_SELECTORS
|
||||
from src.endpoints import read_item
|
||||
from src.models import LinkRequest
|
||||
from src.utils import BrowserDepClass
|
||||
from src.utils import BrowserDepClass, TimeoutTimer, remaining_ms
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -50,13 +52,44 @@ def test_bypass(website: str):
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=website, cmd="request.get").model_dump(),
|
||||
json=LinkRequest.model_construct(
|
||||
url=website, cmd="request.get", max_timeout=60
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
solution = response.json()["solution"]
|
||||
assert "_cf_chl_opt" not in solution["response"]
|
||||
assert "__cf_chl" not in solution["url"]
|
||||
|
||||
|
||||
def test_json_api():
|
||||
"""
|
||||
JSON APIs must return 200, not crash on the UA evaluate.
|
||||
|
||||
Firefox renders application/json in a built-in viewer whose CSP blocks
|
||||
Playwright's eval-based evaluate() (issue #394). The browser must be
|
||||
launched with the viewer disabled so /v1 works and returns the raw JSON.
|
||||
"""
|
||||
url = "https://api.ipify.org?format=json"
|
||||
test_request = httpx2.get(url)
|
||||
if test_request.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
pytest.skip(
|
||||
f"Skipping JSON API test - upstream error ({test_request.status_code})"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=url, cmd="request.get").model_dump(),
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.REQUEST_TIMEOUT:
|
||||
pytest.skip(f"Skipping {website} - timed out (upstream issue)")
|
||||
pytest.skip("Skipping JSON API test - timed out (upstream issue)")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
solution = response.json()["solution"]
|
||||
assert solution["userAgent"]
|
||||
assert '"ip"' in solution["response"]
|
||||
|
||||
|
||||
def test_health_check():
|
||||
@@ -82,7 +115,9 @@ def test_pdf_handling():
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
solution = response.json()["solution"]
|
||||
if solution.get("contentType") != "application/pdf":
|
||||
pytest.skip("Skipping PDF test - PDF bytes could not be fetched (upstream issue)")
|
||||
pytest.skip(
|
||||
"Skipping PDF test - PDF bytes could not be fetched (upstream issue)"
|
||||
)
|
||||
assert solution["response"] # non-empty base64
|
||||
|
||||
decoded = base64.b64decode(solution["response"])
|
||||
@@ -106,19 +141,40 @@ def test_max_timeout_normalization(payload: dict, expected: int):
|
||||
assert request.max_timeout == expected
|
||||
|
||||
|
||||
def fake_dep(*, fail_states: set[str] | None = None) -> BrowserDepClass:
|
||||
"""Build a browser dependency triple backed by mocks."""
|
||||
def fake_dep(
|
||||
*,
|
||||
fail_states: set[str] | None = None,
|
||||
challenged: bool = False,
|
||||
marker_counts: list[int] | None = None,
|
||||
widget_box: dict[str, float] | None = None,
|
||||
user_agent: str | None = "UnitTestBrowser/1.0",
|
||||
) -> BrowserDepClass:
|
||||
"""Build a browser dependency pair backed by mocks."""
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.test/login"
|
||||
page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.OK, headers={"content-type": "text/html"}
|
||||
status=HTTPStatus.OK,
|
||||
headers={"content-type": "text/html"},
|
||||
request=MagicMock(headers={"user-agent": user_agent} if user_agent else {}),
|
||||
)
|
||||
page.title.return_value = "Login"
|
||||
page.evaluate.return_value = "UnitTestBrowser/1.0"
|
||||
page.content.return_value = "<html><title>Login</title></html>"
|
||||
locator = MagicMock()
|
||||
locator.count = AsyncMock(return_value=0)
|
||||
page.locator = MagicMock(return_value=locator)
|
||||
remaining = list(marker_counts or [])
|
||||
|
||||
def count_for(selector: str) -> int:
|
||||
"""Answer the marker check from the script, else from `challenged`."""
|
||||
if selector not in CF_INTERSTITIAL_INDICATORS_SELECTORS or not remaining:
|
||||
return 1 if challenged else 0
|
||||
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
||||
|
||||
def locator(selector: str) -> MagicMock:
|
||||
handle = MagicMock()
|
||||
handle.count = AsyncMock(side_effect=lambda: count_for(selector))
|
||||
handle.first.bounding_box = AsyncMock(return_value=widget_box)
|
||||
handle.first.input_value = AsyncMock(return_value="")
|
||||
return handle
|
||||
|
||||
page.locator = MagicMock(side_effect=locator)
|
||||
|
||||
def wait_for_load_state(state: str, **_kwargs: object) -> None:
|
||||
"""Fail the wait when asked for a configured state."""
|
||||
@@ -130,7 +186,7 @@ def fake_dep(*, fail_states: set[str] | None = None) -> BrowserDepClass:
|
||||
|
||||
context = AsyncMock()
|
||||
context.cookies.return_value = []
|
||||
return BrowserDepClass(page=page, solver=AsyncMock(), context=context)
|
||||
return BrowserDepClass(page=page, context=context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -143,9 +199,7 @@ async def test_networkidle_timeout_after_domcontentloaded_returns_content():
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
assert response.solution.response == "<html><title>Login</title></html>"
|
||||
dep.solver.solve_captcha.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -158,3 +212,112 @@ async def test_domcontentloaded_timeout_returns_408():
|
||||
)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_host_is_a_502_not_a_500():
|
||||
"""An upstream we cannot reach is a gateway failure, never a Byparr crash."""
|
||||
dep = fake_dep()
|
||||
dep.page.goto.side_effect = PlaywrightError("Page.goto: NS_ERROR_UNKNOWN_HOST")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(LinkRequest(url="https://nope.invalid/"), dep)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_is_always_ok_like_flaresolverr():
|
||||
"""FlareSolverr hardcodes 200 because Selenium cannot report the real code."""
|
||||
dep = fake_dep()
|
||||
dep.page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.FORBIDDEN,
|
||||
headers={"content-type": "text/html"},
|
||||
request=MagicMock(headers={"user-agent": "UnitTestBrowser/1.0"}),
|
||||
)
|
||||
|
||||
response = await read_item(LinkRequest(url="https://example.test/login"), dep)
|
||||
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_exhausted_budget_never_disables_playwright_timeouts():
|
||||
"""Playwright reads timeout=0 as no timeout at all, so the floor must hold."""
|
||||
spent = TimeoutTimer(duration=0)
|
||||
|
||||
assert spent.remaining() == 0
|
||||
assert remaining_ms(spent) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_user_agent_header_is_not_a_500():
|
||||
"""A request without a user-agent header degrades to empty, never a 500 (#394)."""
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login"),
|
||||
fake_dep(user_agent=None),
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.user_agent == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkbox_is_clicked_while_the_challenge_is_up():
|
||||
"""A measurable widget gets a humanised press, not a raw synthetic click."""
|
||||
dep = fake_dep(
|
||||
challenged=True,
|
||||
marker_counts=[1, 1, 0],
|
||||
widget_box={"x": 100.0, "y": 200.0, "width": 300.0, "height": 60.0},
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=5), dep
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
dep.page.mouse.move.assert_awaited_once_with(125.0, 230.0)
|
||||
dep.page.mouse.down.assert_awaited_once()
|
||||
dep.page.mouse.up.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_clears_on_its_own_is_never_clicked():
|
||||
"""A challenge is over when its markup goes, and until then we keep our hands off."""
|
||||
dep = fake_dep(
|
||||
challenged=True,
|
||||
marker_counts=[1, 0],
|
||||
widget_box={"x": 100.0, "y": 200.0, "width": 300.0, "height": 60.0},
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=5), dep
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
dep.page.mouse.down.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_never_clears_returns_408():
|
||||
"""A challenge still up when the budget runs out is a timeout, not a 500."""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1])
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=2), dep
|
||||
)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marker_vanishing_mid_navigation_is_not_a_solved_challenge():
|
||||
"""The marker drops out between challenge rounds; one clear read proves nothing."""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1, 0, 1])
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=2), dep
|
||||
)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
|
||||
|
||||
+5
-2
@@ -78,7 +78,10 @@ def fake_dep(*, html: str = ARTICLE_HTML) -> BrowserDepClass:
|
||||
page = AsyncMock()
|
||||
page.goto.return_value = MagicMock()
|
||||
page.content.return_value = html
|
||||
page.evaluate.return_value = "line one\n\nline two"
|
||||
page.locator = MagicMock()
|
||||
page.locator.return_value.inner_text = AsyncMock(
|
||||
return_value="line one\n\nline two"
|
||||
)
|
||||
|
||||
def wait_for_load_state(state: str, **_kwargs: object) -> None:
|
||||
if state == "networkidle":
|
||||
@@ -86,7 +89,7 @@ def fake_dep(*, html: str = ARTICLE_HTML) -> BrowserDepClass:
|
||||
raise PlaywrightTimeoutError(message)
|
||||
|
||||
page.wait_for_load_state.side_effect = wait_for_load_state
|
||||
return BrowserDepClass(page=page, solver=AsyncMock(), context=AsyncMock())
|
||||
return BrowserDepClass(page=page, context=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user