Commit Graph
693 Commits
Author SHA1 Message Date
renovate[bot] 4e4e76ffed chore(deps): update dependency ruff to ==0.17.* (#431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 04:58:49 +00:00
Jakub Orchowski 9d522ee299 Merge pull request #426 from ThePhaseless/fix/stale-snapshot-js-redirect
fix: read the page after the idle wait, not before it
2026-10-09 00:06:41 +02:00
ThePhaselessandClaude Opus 5.5 80dc9fc272 refactor: read the settled page once, without a retry
The retry in read_settled_html covered page.content() racing a
navigation. #418 only showed that race on the early read taken right
after domcontentloaded, and this branch already removes that read. The
late read now matches the Cloudflare path, which reads after the idle
wait and has never retried.

When the retry fires at the end of the budget, remaining_ms() is 1 ms.
The second idle wait then times out at once, and the second read lands
in the same 60-150 ms window that failed. So the retry adds a helper and
threads timer into content.py for a case it does not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:03:10 +02:00
ThePhaseless 9e6762b053 Merge remote-tracking branch 'origin/main' into fix/stale-snapshot-js-redirect
# Conflicts:
#	tests/main_test.py
2026-10-09 00:02:28 +02:00
Jakub Orchowski e1eaa19166 Merge pull request #427 from ThePhaseless/fix/vendored-playwright-errors
fix: catch invisible_playwright's own error classes
2026-10-09 00:00:33 +02:00
Jakub Orchowski 9246d3a732 Merge pull request #428 from ThePhaseless/fix/per-request-geo-lookup
fix: resolve the browser timezone once per process instead of per request
2026-10-08 23:56:35 +02:00
ThePhaselessandClaude Opus 5.5 804c776a80 refactor: take the host timezone from tzlocal
The fallback zone used after a failed egress lookup came from hand-rolled
TZ and /etc/localtime parsing. That parsing passed values such as
TZ=UTC0, TZ=CET-1CEST,M3.5.0,M10.5.0/3 or TZ=:/usr/share/zoneinfo/... to
the browser unchanged, and Playwright rejects them as an invalid
timezone ID. tzlocal.get_localzone_name() handles those forms and returns
only names it could load, or None. A None result still falls back to UTC.

tzlocal already came in through trafilatura (htmldate -> dateparser).
It is now a direct dependency because Byparr imports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:52:44 +02:00
ThePhaseless 34181005d5 Merge remote-tracking branch 'origin/main' into fix/vendored-playwright-errors
# Conflicts:
#	pyproject.toml
#	uv.lock
2026-10-08 23:42:23 +02:00
renovate[bot] 06b67c498e fix(deps): update dependency fastapi to ==0.143.* (#430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 19:37:48 +00:00
ThePhaselessandClaude Opus 5.5 70d6ced2cb fix: resolve the browser timezone once per process instead of per request
Every request builds a new InvisiblePlaywright, and its __aenter__ calls
prepare_session_geo(): with the default timezone="" and no proxy that
discovers the egress IP through api.ipify.org, icanhazip.com and
checkip.amazonaws.com, then calls ensure_geoip_mmdb(), which HEADs GitHub
for a newer GeoIP release, three times per session. On a network where
those hosts are slow or blocked every request paid for it before the
browser even started. Measured in the app image: ipify black-holed adds
~5s per request, all three echo hosts black-holed ~12.5s, GitHub
black-holed ~75s. Setting BROWSER_LOCALE alone did not help, because
the timezone was still "auto".

Without a proxy the egress is the host's own, so the answer does not
change between requests. Resolve it on the first request, off the event
loop and under a lock so concurrent first requests share one lookup,
then hand the zone and the locale's primary tag to InvisiblePlaywright
explicitly; an explicit zone and tag make prepare_session_geo touch no
network. The resulting browser is the same as with "auto": passing the
primary tag back reproduces the decided language list for every Firefox
build the locale table can pick, and without a proxy the egress IP,
coordinates and srflx are not used by InvisiblePlaywright anyway.

When the lookup fails upstream falls back to the host timezone by
passing "", but passing "" again would repeat the lookup on every
request, so the host zone is named explicitly (TZ, else the
/etc/localtime link, else UTC) and the lookup is retried after ten
minutes.

Sessions behind a proxy (PROXY_SERVER or X-Proxy-Server) still resolve
per request through the proxy: each proxy has its own egress, and
upstream deliberately fails the launch when it cannot be resolved.

BROWSER_TIMEZONE mirrors BROWSER_LOCALE and wins also behind a proxy;
with both set the lookup is skipped entirely.

Fixes #416

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:05:13 +02:00
ThePhaselessandClaude Opus 5.5 39bdeedecd fix: catch invisible_playwright's own error classes
invisible_playwright drives Firefox through a vendored copy of Playwright
(invisible_playwright._pw), and its Error/TimeoutError are not subclasses
of playwright.async_api's classes or of the builtin TimeoutError. Every
except and suppress in Byparr named the playwright.async_api classes, so
in production none of them matched:

- a Turnstile widget read that outlasted its 1s timeout escaped the
  suppress() in the solve loop and turned the whole request into a 500
  (the Locator.bounding_box traceback in #419), even with budget left
- an unreachable host was a 500 instead of a 502
- a goto/domcontentloaded timeout was a 500 instead of a 408
- a page that never reaches networkidle was a 500 instead of returning
  the loaded page, and /load returned empty content for it

Nothing in Byparr runs the real playwright package at runtime any more;
it is only used for type hints. So the handlers now import the classes
invisible_playwright re-exports from its public async_api, and nothing
else. That module exports them since 0.15.0, hence the raised floor.

The unit-test fakes raised the playwright.async_api classes too, which
is why the suite stayed green. They now raise the vendored classes, and
a new test covers a widget read timing out mid-solve.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:51:21 +02:00
ThePhaselessandClaude Opus 5.5 c653b10533 fix: read the page after the idle wait, not before it
When no Cloudflare interstitial was detected, _navigate_and_solve()
snapshotted page.content() right after domcontentloaded and
build_response_content() returned that snapshot, even though
_wait_for_networkidle() ran afterwards. A site with its own JS guard
(com-x.life: 302 to a proof-of-work page that sets cookies and calls
location.replace) finishes its redirect during that wait, so Byparr
returned the guard page while solution.url already named the target.

The snapshot came in with the localized-detection change (#388) only to
reuse HTML that had already been read; before it the body was always
read after the idle wait. Drop it, along with the challenge_detected and
page_html plumbing, and read the settled page on both branches.

The early read was also a liability of its own: a guard redirecting
within a few hundred milliseconds of domcontentloaded makes that read
fail with "Failed to find execution context", which escaped read_item
as a 500. The late read can still race a navigation that lands right at
the budget's edge, so if it fails, wait for network idle again and read
once more. The error is caught as invisible_playwright's Error, which is
what the browser actually raises.

Fixes #418. Thanks to c4uran for the analysis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:38:30 +02:00
ThePhaselessandClaude Opus 5.5 838704ea23 test: expect newline between heading and body in trafilatura output
trafilatura 2.3 (bumped in #421) separates an <h1> from the following
paragraph with a newline; 2.2 glued them together ("Example TitleThis is
..."). test_networkidle_timeout_still_extracts_content pinned the old
glued string, so the Docker test stage has failed on every main run since
#421 merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:55:36 +02:00
Jakub Orchowski 07a6d43a72 Merge pull request #409 from nfvelten/fix/scope-cookies-to-requested-url
fix: scope returned cookies to the requested page
2026-10-07 22:19:43 +02:00
Jakub Orchowski c5b8d370c0 Merge pull request #423 from ThePhaseless/fix/http-urls-on-challenged-sites
fix: load plain-http URLs behind a Cloudflare challenge
2026-10-07 00:24:13 +02:00
Jakub Orchowski 35f373a44c Merge pull request #415 from zkhcohen/add-disk-io-reduction-to-docs
Add Disk Wear Reduction Recommendations
2026-10-07 00:23:31 +02:00
Jakub Orchowski ee1cd6a2d9 Revise warning about disk operations and temp storage
Updated warning message to simplify and clarify recommendations for reducing disk wear.
2026-10-07 00:22:39 +02:00
Jakub Orchowski 633ae677f7 Enable shared memory and tmpfs in compose.yaml 2026-10-07 00:21:09 +02:00
Jakub Orchowski c6b97e60bb Merge pull request #421 from ThePhaseless/renovate/trafilatura-2.x
fix(deps): update dependency trafilatura to ==2.3.*
2026-10-07 00:18:44 +02:00
Jakub Orchowski 6aae5ed83d Merge pull request #370 from ThePhaseless/renovate/ubuntu-26.x
chore(deps): update ubuntu docker tag to v26
2026-10-07 00:18:32 +02:00
Jakub Orchowski 2de7002a4e Merge pull request #373 from ThePhaseless/renovate/lock-file-maintenance
chore(deps): lock file maintenance
2026-10-07 00:18:27 +02:00
ThePhaselessandClaude Opus 5.5 4e4340dccd fix: load plain-http URLs behind a Cloudflare challenge
Firefox's HTTPS-First mode upgrades an http:// navigation, sees the
challenge's 403 and falls back to http as a fresh navigation. That
aborts page.goto() with "interrupted by another navigation", so any
http:// URL to a challenged site failed before the solver could run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:00:12 +02:00
ThePhaselessandClaude Opus 5.5 a5e903e60a chore: drop explanatory comment from cookie scoping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:49:56 +02:00
renovate[bot] 16b3bd2a9c chore(deps): lock file maintenance 2026-10-06 21:19:36 +00:00
renovate[bot] b87a07439c fix(deps): update dependency trafilatura to ==2.3.* 2026-10-06 21:19:26 +00:00
Jakub Orchowski ac7876d0d5 Merge pull request #410 from DatGuy1/patch-1
pass log level to uvicorn
2026-10-06 23:18:03 +02:00
renovate[bot] 15180849fd chore(deps): update ubuntu docker tag to v26 2026-10-01 05:03:52 +00:00
renovate[bot] 8bcc5e89c4 fix(deps): update dependency fastapi to ==0.142.* (#420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-30 03:46:54 +00:00
zkhcohen 73535a0f7e formatting 2026-09-21 10:23:24 -07:00
zkhcohen 4571c5b422 Add Disk Wear Reduction Recommendations 2026-09-21 10:17:20 -07:00
Jakub Orchowski 98e1668721 Add sponsor badge to README
Added a sponsor badge to the README.
2026-09-18 21:26:26 +02:00
ThePhaseless beb281267a feat(github): add GitHub Sponsors funding configuration 2026-09-18 21:06:05 +02:00
renovate[bot] b46614fb55 fix(deps): update dependency playwright to ==1.63.* (#413)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 02:11:38 +00:00
renovate[bot] cdfd73785a chore(deps): update dependency httpx2 to ==2.13.* (#412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 21:29:44 +00:00
DatGuy1 e65d809438 pass log level to uvicorn
logger is currently hooking into uvicorn.error https://github.com/ThePhaseless/Byparr/blob/main/src/utils.py#L27 but uvicorn doesn't know that and in run() sets the log level back to INFO.

Repro: none of Byparr's (invisible_playwright is a different logger so it does work) logger.debug calls ever actually show, even when LOG_LEVEL is set to debug

Separately, logger should probably be Dispatcharr's own instance instead of using uvicorn.error.
2026-09-02 08:01:20 +03:00
Nicholas Velten bfa03a4a36 fix: scope returned cookies to the requested page
context.cookies() with no argument returns every cookie in the browser
context, so a page that loads adverts hands the client cookies for dozens
of unrelated domains. One malformed third-party value is enough to make a
client discard the whole jar, cf_clearance with it, and the solve looks
like it failed.

Scope the call to the requested URL, plus the final page URL when a
redirect moved it. That matches what Selenium gave FlareSolverr, which is
the shape its clients are written against.
2026-08-31 21:39:16 -03:00
renovate[bot] 080b0f9141 chore(deps): update dependency httpx2 to ==2.12.* (#406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 05:27:30 +00:00
renovate[bot] c611afb865 fix(deps): update dependency playwright to ==1.62.* (#369)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 01:34:31 +00:00
Jakub Orchowski cb2a862386 Merge pull request #400 from ThePhaseless/fix/tls-handshake-398
fix: match a real browser's TLS and solve Cloudflare challenges again
v3.0.4
2026-08-18 12:45:53 +02:00
ThePhaseless 2852dc2527 fix: report an unreachable target as a gateway failure, not a crash
A host that will not resolve arrived as a plain PlaywrightError, which
nothing caught, so Byparr answered 500 and read as its own defect. It is
the upstream that failed, and the failure is usually transient, so 502
also lets clients retry where a 4xx would tell them not to. The timeout
branch stays ahead of it, since PlaywrightTimeoutError subclasses Error.
2026-08-18 12:29:39 +02:00
ThePhaseless 12ef77177a fix: stop waiting for the challenge widget to hold still
scroll_into_view_if_needed waits for actionability, and the Cloudflare
widget animates without pause, so the call timed out at every ancestor
depth and aborted the measurement before the box was ever read. The
container is already in the viewport; drop the scroll and read the box.
2026-08-18 12:02:20 +02:00
ThePhaselessandClaude Opus 5 11d7e59263 test: assert what the challenge tests claim to assert
Three tests checked solution.status against HTTPStatus.OK, which the
endpoint now hardcodes, so the check could never fail. Drop it everywhere
except the test that exists to pin that behaviour.

That left the self-clearing case asserting only that nothing raised, which
the click test already covers. Give it a measurable widget and assert we
never press it: a challenge that clears on its own is the one case where
touching the checkbox would be wrong, and nothing tested it. Verified by
moving the click ahead of the exit check, which the assertion now catches.

Drop the fake page.evaluate return as well, since /v1 stopped calling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:00:17 +02:00
ThePhaselessandClaude Opus 5 78b3f314c3 refactor: report 200 like FlareSolverr and split the challenge out
FlareSolverr hardcodes the solution status ("todo: fix, selenium not
provides this info"), so clients built against it never see anything else.
Byparr handed back the real navigation code on the branch without a
challenge and 200 on the branch with one, which is neither honest nor
compatible. Always report 200.

Move the challenge handling into src/challenge.py and the response bodies
into src/content.py, leaving endpoints.py with the routes and navigation.
That also confines the import of playwright_captcha's private detection
module to a single file, so a patch release can only break one import
instead of the app and the test module at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:51:34 +02:00
ThePhaselessandClaude Opus 5 d0b013029c fix: load the page the challenge was hiding, and stop trusting a stray box
Nothing waited for the destination once the interstitial let go: page_html
stayed unset and the networkidle wait only ran on the branch that never saw
a challenge, so a challenge clearing on its own returned whatever had loaded
by then. Wait for it on both branches.

An exhausted budget produced timeout=0, which Playwright reads as no timeout
at all, turning every remaining wait unbounded exactly when it should fail
fast. Floor it instead.

Scroll the widget into view before measuring it, since bounding_box reports
viewport coordinates and an off-screen widget was clicked at a point that hit
nothing, and reject containers taller than a checkbox row so a full-page
wrapper cannot pass for one - both reported success while clicking blank
space.

Drop max_attempts, which nothing reads now that the solver is gone, and
refresh AGENTS.md, which still described camoufox and a solver in the
dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:41:58 +02:00
ThePhaselessandClaude Opus 5 426aae4310 fix: address review findings on the challenge solver
nowsecure.nl carries no interstitial, so the turnstile detector only widened
the entry condition without ever being able to satisfy the exit one, leaving
a turnstile-only page reported as solved on the first poll. Detect on the
interstitial alone.

Restore the user-agent guard that went missing: an absent header made
Solution reject None and turned into an unhandled 500, where it used to
degrade to an empty string. Fall back to "" directly rather than reaching
for evaluate(), which CDP refuses.

Bound the widget measurement, which inherited Playwright's 30s default at
each of four depths and so could run far past the request budget, throttle
the probe when no click lands so the scan no longer repeats every tick, and
release the mouse button through a finally so a failed press cannot leave it
held down.

Cover the click path: the fixture pinned bounding_box to None, so nothing
exercised the code this branch exists to add.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:30:49 +02:00
ThePhaselessandClaude Opus 5 3dcf529609 refactor: drop the solver and prove the challenge page is gone
Nothing calls ClickSolver any more now that the checkbox is clicked through
the mouse, so take it out of the browser dependency instead of leaving it
constructed but unused.

Assert on the returned body as well: a 200 alone passed even when the
interstitial itself was handed back, which is the defect this branch fixes.
Judge on _cf_chl_opt, since Cloudflare keeps serving cdn-cgi/challenge-platform
as a beacon on pages that are already cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:45:19 +02:00
ThePhaselessandClaude Opus 5 61db8d82ee fix: click the challenge checkbox without tripping Cloudflare's detector
Measuring the widget with page.evaluate() made Cloudflare reissue the
challenge every few seconds, so the interstitial never cleared. Locate the
container with locators instead and click it through the mouse, which leaves
its closed shadow root untouched.

Keep the solver lazy as well: ClickSolver.prepare() patches attachShadow,
which is what escalated a self-clearing challenge into a checkbox in the
first place.

A click can land while the widget is still self-verifying, so retry on a
cooldown for as long as the request budget lasts rather than stopping after
the first one, and confirm the marker is gone twice before reporting success
since it drops out between challenge rounds.

The bypass tests now pass on their own, so drop the xfail marks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:45:15 +02:00
ThePhaselessandClaude Opus 5 9067ca37df test: simplify the xfail reason
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
2026-08-17 12:38:14 +02:00
ThePhaselessandClaude Opus 5 cb8fb58fa8 refactor: use the library's detector instead of hand-rolled selectors
detect_cloudflare_challenge(page, 'interstitial') matches the challenge on
the page and stops matching once it clears, measured on ext.to (True then
False), and on yggtorrent, nowsecure.nl and google.com, none of which carry
a /cdn-cgi/challenge-platform/ script when cleared. So the custom marker set
was unnecessary.

The turnstile variant is not usable for this: nowsecure.nl embeds turnstile
scripts on its normal page, so it reports a challenge even when cleared.

Also adds PlaywrightTimeoutError to the retryable set. It is a different
class from the builtin TimeoutError -- playwright's derives from its own
Error -- so a Playwright timeout inside the solver escaped the loop and
returned 408 without retrying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
2026-08-17 11:35:49 +02:00
ThePhaselessandClaude Opus 5 fc64fe05d5 refactor: drop what is not part of the fix
Replaces the try/except-plus-log around the solve attempt with
contextlib.suppress, and inlines the one-use attempt cap. Type annotations
are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
2026-08-17 11:30:29 +02:00