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.
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.
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>
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>
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>
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>
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>
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>
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
solve_captcha decides it failed by waiting for networkidle, which returns
while Cloudflare is still verifying, so it reported failure on challenges
that had already passed and retried until the whole max_timeout burned into
a 408. Cap each solve attempt and let the challenge markup decide instead.
CHALLENGE_MARKERS has to match the orchestrator specifically: the bare
/cdn-cgi/challenge-platform/ path also matches the jsd beacon Cloudflare
serves on cleared pages, so detect_cloudflare_challenge never reports
success on its own.
Also switches the solver back to FrameworkType.PLAYWRIGHT, since PATCHRIGHT
injects the shadow-root unlock over CDP and Firefox has no CDP session, and
turns off COOP/COEP so the widget's iframe appears in page.frames at all.
The four sites Cloudflare refuses on this browser are xfail rather than
skip, so a regression still shows and a pass records as xpass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
sys.maxsize was the original bug, but only because solve_captcha was called
in a retry loop. That call is gone, so MAX_ATTEMPTS now only reaches
ClickSolver at construction and never bounds anything; the solve loop is
bounded by timer.remaining(). Reverting a change that no longer does
anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
Removes every explanatory comment added by this branch, inlines the browser
prefs rather than holding them in a module constant, folds _cloudflare_frame
into its only caller, and cuts the added docstrings to one line each.
No behaviour change: 13 unit tests pass, and removing the checked-box guard
still fails its test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
Cuts ~100 lines of commentary that restated the diff or recorded dead
investigation, and merges _press_point back into _press_checkbox now that
the checked guard is one condition rather than the extra return that
tripped the too-many-returns lint.
Corrects the COOP/COEP note, which claimed the pair changed no outcome.
Without those prefs the widget's iframe never appears in page.frames at
all: measured on ext.to, eight presses land with them and none without.
No behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
The reason said Cloudflare refuses the checkbox click from datacenter IPs
and that the visitor's address was being judged rather than our code. That
is false. Measured from one datacenter IP within a single hour, byparr
v2.1.0 clears ext.to in 18s, speed.cd/login in 20s and extratorrent.st in
19s, each returning cf_clearance, while no configuration of the current
stack clears any of them.
Two candidate causes were measured and eliminated rather than assumed. The
JS-visible fingerprint is not it: camoufox is the less coherent of the two
browsers -- no WebGL at all, oscpu leaking Linux beneath a Windows UA -- and
passes regardless. The TLS handshake is not it either: re-enabling cipher
0xC009 reproduces camoufox's JA4 byte for byte
(t13d1717h2_5b57614c22b0_3cbfd9057e0d) and the challenge is still refused.
Also records that devtools.jsonview.enabled is load-bearing for #394, and
that the COOP/COEP pair changed no outcome on any site or network measured.
No behaviour change; comments and the xfail reason only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
playwright-captcha's ClickSolver clicks the challenge's input element
directly. That input sits under a styled overlay, so Playwright reports a
successful click while `checked` never flips -- which is why the
interactive challenge has never been solved here. The solver also judged
its own click by waiting for networkidle, which returned 9ms later while
Cloudflare was still verifying, so it reported failure on challenges that
were about to pass.
Replace it with a poll loop that watches for the challenge markup to go
away and presses the widget's visible pixels whenever an unchecked box is
on offer. A box that is already checked is left alone: pressing over the
top of Cloudflare's verification restarts it, and ext.to and speed.cd sat
on "performing security verification" for a full 300s budget while being
pressed a dozen times.
Measured on a residential connection, driving the real /v1 handler:
nowsecure.nl passes in 3s, extratorrent.st in 116s and 1337x.to in 198s,
all three returning cf_clearance. extratorrent.st had never cleared
before, on any network or solver. ext.to and speed.cd still refuse -- the
press registers and the widget re-serves a fresh unchecked box -- so they
stay in the xfail list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
v2.1.0 launched camoufox with disable_coop=True. v3 dropped it, and without it
Cloudflare's challenge iframe -- which carries allow="cross-origin-isolated" --
lands in an isolated content process where Juggler sees no docShell:
content_frame() raises "Permission denied to access property docShell on
cross-origin object" and the solver never reaches the checkbox to click it.
Not a demonstrated win. From a datacenter IP Cloudflare rejects the click
however it is delivered -- measured across eight sites, nine consecutive
clicks, a humanized cursor, four fresh navigations, and a shadow-root patch
made undetectable (no global flag, toString reporting native code). The same
browser and IP clear nowsecure.nl and come back with a cf_clearance cookie, so
what is being judged is the address, not the client.
Restored for parity with the version users report working, because reaching the
checkbox is a precondition for ever passing an interactive challenge and Byparr
mostly runs from residential addresses that Cloudflare treats far better than a
CI runner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the earlier CI fix, after A/B-ing every change against main and
against this branch's original commit.
What measurably changed, and what did not:
- The solver's retry loop was unbounded (max_attempts = sys.maxsize). On a
challenge it cannot clear it retried ~1300 times per request and the caller
waited out the entire max_timeout for a 408 it was always going to get.
_solve_challenge now clicks, waits for the challenge markup to actually
disappear, and gives up when the budget does.
- That wait exists because the solver's own verdict is worthless here: it
judges its click with wait_for_load_state("networkidle"), which returned 9ms
after the click while Cloudflare was still showing "verifying you are
human", and then reported failure.
- The "is it still up?" check cannot use detect_cloudflare_challenge alone.
That matches any script under /cdn-cgi/challenge-platform/, and Cloudflare
serves its jsd bot-scoring beacon from the same path on cleared pages. Nor
can it use the widget iframe: a cleared nowsecure.nl carries two of those
with no challenge present. CHALLENGE_MARKERS matches the challenge
orchestrator script and the interstitial's own markup.
- test_tls_handshake_looks_like_firefox pins what this branch is actually for.
Measured through /v1 on the same host: main offers 52 cipher suites, this
branch 16, and real Firefox offers 16. route.fetch() was re-issuing
navigations through Playwright's HTTP client, and that is a fingerprint no
header spoofing hides. Unlike a Cloudflare verdict the count is
deterministic, so it is the one assertion here that cannot flake.
- Disabling COOP/COEP does let the solver reach and click the checkbox for the
first time (Cloudflare advances to "verifying you are human"), but it changed
no outcome across eight sites, and real Firefox ships those policies on.
Recorded in a comment rather than shipped.
test_bypass keeps a hard assertion against targets that clear from any network.
The four Cloudflare guards hardest move to xfail rather than skip: they still
run and still report, but Cloudflare's opinion of the runner's IP cannot turn
the build red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bypass tests were failing on CI with 408s after 78 minutes. Neither the
runner's speed nor this branch's TLS change was responsible.
On the sites that fail, Cloudflare serves its interactive checkbox challenge.
playwright-captcha locates the widget iframe inside the shadow root and then
calls ElementHandle.content_frame(), which this Firefox build refuses:
Protocol error (Page.describeNode): Permission denied to access property
"docShell" on cross-origin object
Its fallback -- matching page.frames by URL -- cannot help either, because the
challenge frame exposes an empty URL to the parent. Every attempt therefore
ends in CaptchaDetectionError: Cloudflare iframes not found.
MAX_ATTEMPTS was sys.maxsize, so that repeated until the request budget ran
out: 432 docShell errors and 1326 retry iterations in a single request on the
runner, and with max_timeout raised to 360 and --retries 3, a 1h18m job.
Three changes:
- max_attempts defaults to 5. An unreachable widget stays unreachable, so the
retries were not buying anything; the caller now hears about it in seconds.
- _solve_challenge translates the solver's own give-up exceptions into the 408
read_item already reports for timeouts. Without this, bounding max_attempts
would have turned the hang into an unhandled 500.
- The solver framework goes back to PLAYWRIGHT. PATCHRIGHT skips the
unlockShadowRoot init script and injects over CDP instead, which Firefox has
no session for ("CDP session is only available in Chromium"). Cloudflare
builds its widget in a closed shadow root, so on this branch the challenge
iframe was invisible even to page.locator: 1 -> 0 against the same sites on
the same runner.
test_bypass drops the max_timeout=360 override and skips again on 408.
Whether Cloudflare shows the interactive challenge depends on the visitor, so
the runner's luck should not decide whether a regression of ours is reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
page.goto() returns None only for about:blank or a same-URL-different-hash
navigation, so page_request is always present for a real request and its
headers always carry the UA. The evaluate call was therefore unreachable
as a fallback and, once moved before navigation, silently became the
primary source instead.
Request headers are also the correct source: consumers replay them with
the clearance cookies, so the UA the server saw is the one to report.
This restores the ordering d3a828e established.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
page.evaluate runs eval() in the page's main world, which fails with 'call to eval() blocked by CSP' under any CSP that disallows unsafe-eval - HTTP headers (already stripped), meta tags (not strippable), or internal viewer documents (#394).
The navigation request already carries the UA the site actually saw, so take user_agent from page_request.request.headers and keep evaluate only as a best-effort fallback whose failure can no longer 500 the request.
Firefox renders application/json documents in a built-in viewer whose own
CSP (<script-src resource:>) blocks Playwright's eval-based page.evaluate,
crashing /v1 with a 500 on JSON APIs (closes#394). Setting
devtools.jsonview.enabled=false renders JSON as plain text, which also
returns the raw JSON body instead of the viewer's syntax-highlighted HTML.
Resolved conflicts in src/consts.py and src/endpoints.py:
- consts.py: take theirs (CHALLENGE_TITLES removed, browser_locale added,
CaptchaType import no longer needed — detection is now library-based)
- endpoints.py: merge both refactors — keep theirs' detect_cloudflare_challenge
+ page_html capture, reapply my helper extraction (setup_routes,
_navigate_and_solve, _solve_challenge, _wait_for_networkidle,
build_response_content, _fetch_pdf_content) on top
fake_dep's AsyncMock page made page.locator() return an un-awaited
coroutine, so detect_cloudflare_challenge swallowed an AttributeError
and reported a challenge. The networkidle-timeout test silently ran the
solver branch and never exercised its intended path, plus emitted a
'coroutine ... was never awaited' RuntimeWarning in CI.
Make page.locator() sync-returning (as in real Playwright) with an
awaitable count() that finds no elements, and assert the solver is never
invoked.
HOME=/tmp put the uv-managed Python at /tmp/.local/share/uv, so a
tmpfs mount on /tmp (e.g. compose tmpfs: /tmp) wiped the interpreter at
container start, leaving the /app/.venv/bin/python symlink dangling and
startup failing with 'exec /app/.venv/bin/python failed: No such file
or directory' (#389).
Move HOME to /home/byparr and apply the OpenShift permission pattern
(owner uid 1000, group 0, group=user) so both the default user and
arbitrary-UID runtimes (docker run --user, OpenShift) can write to it.
Apply the same pattern to /cache, where invisible_playwright keeps
runtime browser/profile data and which arbitrary UIDs previously could
not write.
Fixes#389
Cloudflare localizes its interstitial page title per visitor language
(e.g. Polish "Cierpliwości..." served by 1337x.to), so the hard-coded
["Just a moment..."] title check missed every non-English visitor:
Byparr returned the raw challenge page (HTTP 403, no cf_clearance
cookie, no "Challenge detected" log) and Prowlarr reported "Unable to
access 1337x.to, blocked by CloudFlare Protection." (issue #385, still
open on 3.0.1 after the compression fix).
Replace the title-based gate with the playwright-captcha library's own
language-independent DOM detection (detect_cloudflare_challenge), which
matches Cloudflare's challenge scripts directly:
- interstitial: script[src*="/cdn-cgi/challenge-platform/"]
- turnstile: input[name="cf-turnstile-response"],
script[src*="challenges.cloudflare.com/turnstile/v0"]
Both selectors match the live 1337x "Cierpliwości..." interstitial.
The navigation/detect/solve flow lives in _navigate_and_solve(); the
timeout-to-408 translation is inlined at the call site in read_item.
The now-unused title map is removed from src/consts.py.
Verified live (built image): "Challenge detected" now fires on 1337x
(0 -> 1 in logs) where the title check never fired; example.com negative
control returns 200 with no challenge path entered. End-to-end clearing
still depends on the requester's public IP (README caveat).
Root cause of remaining cache misses: the base stage declared
ARG VERSION, and the build job passed VERSION=${{ github.sha }}.
Since VERSION changes every commit, every base/app layer cache key
changed with it — so layers rebuilt every run regardless of scope.
Additionally the test job passed no build-args while the build job
passed GITHUB_BUILD=true + VERSION, so test's cached base/app layers
had different keys from build's — cross-job reuse never hit either.
Fix:
- Dockerfile: move ARG VERSION / ENV VERSION from base to the final
runtime stage (FROM app). VERSION is only read at runtime by
src.consts via Pydantic settings; base/app layers don't use it.
base/app now cache without per-commit VERSION variation.
- workflow: pass --build-arg GITHUB_BUILD=true in the test step so
test and build share identical base/app cache keys (cross-job reuse).
VERSION is intentionally NOT passed to the test job: the test stage
(FROM app AS test) doesn't read VERSION, and omitting it keeps the
base/app cache keys identical between test and build.
push: branches: ["*"] matched feature branches, so every push to a
branch with an open PR fired both a 'push' and a 'pull_request' event.
Their concurrency groups differ (refs/heads/<branch> vs refs/pull/<n>/merge),
so cancel-in-progress could not dedup them — the full multi-arch build
ran twice on each push, doubling CI minutes.
Scope push to branches: ["main"]; pull_request remains the validator for
feature branches. Tag pushes (v*.*.*), schedule, and workflow_dispatch
are under separate filters and are unaffected.
- test job: scope x64 -> amd64 to match build matrix amd64 leg
- build job: scope ${{ matrix.platform }} -> ${{ steps.vars.outputs.SURFIX }}
(yields amd64/arm64), avoiding the gha backend's / path-separator
bug that mangled scope=linux/arm64 and broke arm64 cache reuse
test (amd64) and build-amd64 now share scope=amd64 so build reuses
the app/base layers the test job cached earlier in the same run.
build-arm64 gets a working scope=arm64 that persists across runs.
metadata-action emits tags newline-separated, so FIRST_TAG=${TAGS%% *}
kept the entire multi-line value and expanded to 4 args on tag releases,
making `imagetools inspect` fail before the manifest could be signed.
Split on the first line instead.
route.fulfill(response=...) re-serves the raw bytes fetched by
route.fetch(), so compressed (gzip/brotli/zstd) documents arrive
at the browser still compressed while the forwarded headers claim
otherwise - page.content() then returns garbled binary, breaking
indexers like uindex.org and 1337x.to (issue #385).
Fetch with accept-encoding: identity so the re-served body is plain
text, and drop content-encoding/content-length alongside the CSP
headers since they are stale after the rewrite.
Run trafilatura server-side on the rendered DOM (page.content()), so
JS-rendered pages stay fully visible to the extractor; fall back to
innerText when trafilatura cannot score any main content.
- Move OWUI_API_KEY into pydantic settings (src/consts.py); drop the
Dockerfile ENV entry so the key is only ever set at runtime
- Enforce auth before the browser is launched via dependency ordering
- Compare bearer tokens in constant time (hmac.compare_digest)
- Keep extracting when networkidle times out, matching /v1 behavior
- Type page as Page, drop redundant comments and docstrings
Add /load endpoint for Open WebUI's WEB_LOADER_ENGINE=external integration.
Uses document.body.innerText for content extraction.
Configure in Open WebUI:
WEB_LOADER_ENGINE=external
EXTERNAL_WEB_LOADER_URL=http://byparr:8191/load
EXTERNAL_WEB_LOADER_API_KEY=<OWUI_API_KEY env var>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
A page whose network never goes idle (background analytics, websockets)
used to fail the whole request with a 408 once the networkidle wait
expired. Since the DOM is fully usable after domcontentloaded, treat a
networkidle timeout as non-fatal and return the loaded page instead.
Fatal timeouts during initial load or challenge solving still return 408.
Adds unit coverage for both paths using a fake page that fails
configured load-state waits.
The Firefox engine evaluates JS via eval(), which pages whose CSP
lacks 'unsafe-eval' block - every page.evaluate() then fails with
"call to eval() blocked by CSP". yggtorrent's search URL redirects to
a page with such a CSP, crashing the user-agent read and 500ing /v1.
Rewrite document responses without CSP headers via route.fetch +
fulfill. Juggler only routes the first request of a redirect chain,
so follow redirects inside the fetch and record the final URL
ourselves instead of relying on page.url.
Add a maxTimeout alias to LinkRequest.max_timeout for FlareSolverr
drop-in compatibility. Values of 1000 or more are treated as
milliseconds and normalized to seconds; smaller values keep the
native seconds semantics. Closes#382.
Install invisible-playwright from PyPI rather than by git URL
Conflict resolution: keep the >=0.6.1 floor set by the follow-up
version bump; uv.lock already resolves to 0.6.1.
On IPv6-enabled Docker networks, 'localhost' resolves to ::1 first,
but uvicorn binds to 0.0.0.0 by default, so the healthcheck can fail.
Using 127.0.0.1 avoids the IPv6/IPv4 mismatch.
Fixes#346
- Add pydantic-settings as direct dependency
- Replace os.getenv calls with typed Settings class
- Add BLOCK_MEDIA and RETURN_ONLY_COOKIES env defaults
- Catch both builtins.TimeoutError and playwright TimeoutError as 408
- Check challenge title before networkidle to avoid timeout on Cloudflare interstitial
- Add blockMedia and returnOnlyCookies request options
- Return raw PDF bytes as base64 with contentType application/pdf
- Skip tests on 408 timeouts; add PDF handling test
- Replace camoufox[geoip] with invisible_playwright git dependency
- Switch playwright-captcha framework from CAMOUFOX to PLAYWRIGHT
- Remove camoufox addon path from consts
- Add git to Docker base image; fetch invisible_playwright binary
- Make /cache writable for runtime USER 1000
cloverlabs-camoufox 0.6.0 was a confirmed regression (3/6 tests failed
with 'Cloudflare iframes not found' vs 6/6 passing on camoufox 0.4.11).
Revert to camoufox[geoip]==0.4.* and pin playwright==1.60.* (exact pin
to avoid the 1.61 protocol error).
ubuntu:latest rolled to 26.04 LTS on 2026-05-06, breaking the Docker
build for 50+ consecutive CI runs. Playwright 1.58.0 (pinned in uv.lock)
cannot install firefox deps for ubuntu26.04-x64 -- it prints 'Cannot
install dependencies for ubuntu26.04-x64 with Playwright 1.58.0!' and
installs nothing, leaving libgtk-3.so.0 absent. Camoufox's bundled
Firefox then fails to load XPCOM at runtime:
libgtk-3.so.0: cannot open shared object file: No such file or directory
Couldn't load XPCOM.
Pinning to 24.04 (the last-known-good base, supported by Playwright 1.58)
restores libgtk-3-0t64 and the rest of the GTK runtime. Adopted from PR #362
which independently diagnosed the same issue.
Verified locally:
- app stage: ldconfig shows libgtk-3.so.0 present (was absent)
- test target: 6/6 tests pass (was BrowserType.launch failure)
- runtime: POST /v1 returns 200 status:ok (was 500 libgtk-3 traceback)
* Skip build and review for Renovate PRs, only run tests
Modified CI workflows to optimize Renovate PRs:
- Skip Docker build and merge-and-push jobs for Renovate PRs
- Skip Claude code review for Renovate PRs
- Tests still run for all PRs including Renovate
This reduces CI time and resource usage for dependency update PRs.
* Remove redundant condition from merge-and-push job
The merge-and-push job depends on build job, so it won't run if build is skipped.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: Build Docker images for PRs with branch name labels
- Remove condition preventing PR builds
- Add branch name extraction and sanitization
- Add branch labels to Docker images (org.opencontainers.image.branch and branch)
- Enable pushing of PR Docker images
- Sanitize branch names for Docker tags (replace / with -)
This allows PR images to be built and tagged with their branch names,
making it easier to test specific PR builds.
* feat: Add path filters to Docker workflow
Only build Docker images when relevant files change:
- Source code (src/**, main.py, tests/**)
- Docker configuration (Dockerfile, compose.yaml)
- Dependencies (pyproject.toml, uv.lock)
- Workflow file itself
This prevents unnecessary builds when only documentation or
other non-functional files are changed.
* feat: Add automatic cleanup of PR Docker images
Create a new workflow that automatically deletes Docker images
when a PR is closed or merged. This prevents accumulation of
old PR images in the container registry.
Features:
- Triggers on PR close/merge events
- Deletes images tagged with PR number and SHA
- Handles both architecture variants (amd64, arm64)
- Supports both organization and user repositories
- Provides detailed logging of cleanup operations
* refactor: Remove redundant branch label preparation
Remove duplicate branch name preparation step in merge-and-push job.
Branch labels are already added during the build step, so no need
to add them again when creating the manifest.
* refactor: Remove redundant label configurations
Remove custom label configurations from docker-publish workflow.
The docker/metadata-action already sets standard OCI labels by
default, so explicit label configuration is unnecessary.
Also removed unused BRANCH_TAG variable preparation.
* refactor: Use PR number for Docker image tags instead of SHA
Changed Docker image tagging strategy for pull requests:
- Use pr-{number}-{arch} for individual platform builds
- Use pr-{number} for final manifest
- Non-PR builds still use SHA-based tags
Benefits:
- Simpler, more readable tags for PRs
- Easier to identify which PR an image belongs to
- Cleanup script simplified to match only PR number tags
Updated cleanup workflow to match new tag pattern.
* docs: Add PR Docker image tags to README
Document the pr-{number} tag pattern used for pull request images.
These images are automatically built for PRs and cleaned up when
the PR is closed.
---------
Co-authored-by: Claude <noreply@anthropic.com>
label:"Before submitting, I've done the following:"
options:
- label:Checked the existing feature requests to avoid duplicates
- type:textarea
attributes:
label:Describe the problem this feature would solve
description:Clearly explain the issue or need that this feature addresses.
placeholder:"Example: It's difficult to manage multiple configurations because..."
- type:textarea
attributes:
label:Alternatives considered
description:Have you considered any alternative solutions or workarounds? If so, please describe them.
placeholder:"Example: As a workaround, I've been manually editing the config files, but this is error-prone."
- type:textarea
attributes:
label:Additional context
description:Add any other context or information that might be helpful in understanding your request. This could include use cases, mockups, or links to relevant discussions.
- 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() -> 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.
## Key modules and patterns
- Models use Pydantic v2 with camelCase aliasing for responses (see src/models.py).
- LinkResponse.invalid() is the standard error response shape; keep fields consistent with FlareSolverr style.
- get_camoufox() constructs AsyncCamoufox with addons and optional proxy config from env vars.
## Config and environment
- Core env vars in src/consts.py: HOST, PORT, PROXY_SERVER, PROXY_USERNAME, PROXY_PASSWORD, LOG_LEVEL, VERSION.
- VERSION strips leading "v" for tag-style values.
## Developer workflows
- 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 5
- Docker troubleshooting: docker build --target test .
## Tests and external dependencies
- tests/main_test.py calls real websites; tests are network-dependent and may be skipped based on upstream status.
- HTTP client tests use starlette.testclient + httpx; avoid mocking unless needed for local-only changes.
An alternative to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) as a drop-in replacement, build with [seleniumbase](https://seleniumbase.io/) and [FastAPI](https://fastapi.tiangolo.com).
> Due to recent challenge changes, this software does not guarantee that the Cloudflare challenge will be bypassed. Cloudflare likely requires valid network traffic originating from the user’s public IP address to mark a connection as legitimate. While this tool may bypass the initial browser check, it does not ensure that requests will consistently pass Cloudflare's validation. More testing and data are required to understand how Cloudflare identifies connections and requests as valid. Invalid requests will result in Byparr's looping and eventually time-outing.
> [!IMPORTANT]
> This software does not **guarantee** (only greatly increases the chance) that any challenge will be bypassed. While this tool passes the initial browser check, Cloudflare and other captcha providers likely require valid network traffic originating from the user’s public IP address to mark a connection as legitimate. If any website does not pass the challenge, please run troubleshooting steps and check if other websites work before you create an GitHub issue.
> [!WARNING]
> Support for NAS devices (like Synology) is minimal. Please report issues, but do not expect it to be fixed quickly. The only ARM device I have is a free Ampere Oracle VM, so I can only test ARM support on that.
## Options
> [!NOTE]
> Thanks to FastAPI implementation, now you can also see the API documentation at `/docs` or `/` (redirect to `/docs`) endpoints.
| `HOST` | `0.0.0.0` | Host address to bind the server to. Use `0.0.0.0` to bind to all IPv4 interfaces, `::` for all IPv6 interfaces, or `127.0.0.1`/`localhost` for local access only. |
| `PORT` | `8191` | Port to bind the server to. |
| `PROXY_SERVER` | None | Proxy to use in format: `protocol://host:port`. |
| `PROXY_USERNAME` | None | Username for proxy authentication. |
| `PROXY_PASSWORD` | None | Password for proxy authentication. |
| `OWUI_API_KEY` | None | Bearer token for `/load` endpoint authentication. Must match `EXTERNAL_WEB_LOADER_API_KEY` in Open WebUI. |
| `BROWSER_LOCALE` | None | Override the browser's language with a [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) tag, e.g. `en-US`, `de-DE`, `fr-FR`. When unset, the locale is derived from the egress country. |
## Troubleshooting
#### Browser language
1. Clone repo to the host that has the container has issues on.
2. Using vscode and `SSH extention`, connect to the host and open repo in it.
3. Download `devcontainers` extention and reopen repo in container (with `CTRL + SHIFT + P` -> `Reopen in devcontainer`)
4. Forward port 6080 from devcontainer (port of noVNC server) to the host.
5. Open `http://localhost:6080` and connect to the virtual desktop.
6. Check if `chromium` works by running in VNC's terminal command `chromium --no-sandbox`.
7. If chromium works, run (or debug) tests from VS Code.
1. If code works, congrats! (/s) You are on your own.
2. If it does not, try another host or network, try again and create issue about the problem.
Set `BROWSER_LOCALE` to a [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language tag like `en-US`, `de-DE`, `fr-FR`, `pl-PL`, or `zh-CN` to fix the browser's language and `Accept-Language` header. When unset, Byparr derives the locale from the egress country (e.g. a French proxy → `fr-FR`), keeping the browser language consistent with the exit IP.
Valid tags are maintained in the [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry). For a friendlier list, see [List of ISO 639-1 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (language) combined with an [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) region code for the full tag, e.g. `pt-BR`.
## Proxy Recommendation
Recently I've partnered with a _new in town_ proxy service - ProxyBase - to offer affordable proxy services that seems to work seamlessly with Byparr! Using my affiliate code `byparr` (case sensitive!) when signing up will not only get you access to their cost-effective (**$0.69/GB with occasional promotions** _at the time of writing_) proxy network but will also help support the continued development of this project. ProxyBase's proxies can significantly improve your success rate when bypassing anti-bot challenges. [Check out ProxyBase](https://client.proxybase.org/signup?ref=byparr) and enhance your Byparr experience!
## Tags
-`v*.*.*`/`latest` - Releases considered stable
-`main` - Latest release from main branch (untested)
-`pr-{number}` - Pull request images for testing (automatically cleaned up when PR closes)
## Usage
### Docker Compose
> [!IMPORTANT]
> Support for NAS devices (like Synology) is minimal. Please report issues, but do not expect it to be fixed quickly. The only ARM device I have is a free Ampere Oracle VM, so I can only test ARM support on that. See [#22](https://github.com/ThePhaseless/Byparr/issues/22) and [#3](https://github.com/ThePhaseless/Byparr/issues/3)
### Docker Compose setup
1. Review settings in `compose.yaml`.
2. Start the service:
```bash
docker compose up -d
```
### Docker install
1. Pull and run the image:
```bash
docker run -p 8191:8191 ghcr.io/thephaseless/byparr:latest
```
2. Optional: set env vars using `-e` or `--env-file`.
### Local install
1. Install ([or update when Python version changes](https://github.com/astral-sh/uv/issues/17887)) [uv](https://docs.astral.sh/uv/getting-started/installation/).
2. Clone this repo - `git clone https://github.com/ThePhaseless/Byparr`
3. Run `uv run main.py`
4. Enjoy!
### API Docs
Once running, open:
- `http://localhost:8191/docs`
- `http://localhost:8191/` (redirects to `/docs`)
### Open WebUI Integration
Byparr can serve as an external web loader for [Open WebUI](https://github.com/open-webui/open-webui), allowing it to fetch web content through Byparr's anti-bot bypassing capabilities.
Configure Open WebUI with these environment variables:
```bash
WEB_LOADER_ENGINE=external
EXTERNAL_WEB_LOADER_URL=http://byparr:8191/load
EXTERNAL_WEB_LOADER_API_KEY=your-secret-key # Optional, must match OWUI_API_KEY
```
The `/load` endpoint accepts `POST` requests with `{"urls": ["https://..."]}` and returns extracted text content for RAG pipelines.
## Troubleshooting
### Docker troubleshooting
1. Clone repo to the host that has issues with Byparr.
2. Run `docker build --target test .`
3. Depending of the build success:
1. If run successfully, try updating container or if already on newest stable release create an issue for creating new release with new dependencies
2. If build fails, try troubleshooting on another host/using other method
#### Proxmox OCI / LXC browser launch errors
If you are running Byparr as an OCI container in Proxmox (or another LXC-based setup) and see a `FileNotFoundError` from `multiprocessing.synchronize`/`camoufox` when processing requests, increase the service's shared memory in `compose.yaml`:
```yaml
services:
byparr:
image:ghcr.io/thephaseless/byparr:latest
environment:
- LOG_LEVEL=INFO# optional
ports:
- "8191:8191"# Optional if needed to make make requests/check docs on host
shm_size: 512mb
stdin_open: true
tty: true
```
## Need help with / TODO
`shm_size: 512mb` is usually enough; `stdin_open` and `tty` are only needed if your orchestrator runs the container without a TTY.
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.