Compare commits

..
65 Commits
Author SHA1 Message Date
ThePhaseless 1c9093f218 fix(ci): extract first image tag by line, not space
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.
2026-08-09 19:25:09 +02:00
ThePhaseless 0c44ce1a4d fix: request uncompressed bodies in CSP-strip route
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.
2026-08-09 19:07:05 +02:00
renovate[bot] 07309c8d8e chore(deps): update dependency httpx2 to ==2.10.* (#386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-09 16:33:10 +00:00
ThePhaseless 25194c3bca fix(ci): sign docker image by digest instead of tag 2026-08-08 02:20:35 +02:00
Jakub Orchowski 77020bec0d Merge pull request #384 from ThePhaseless/feat/owui-loader-cleanup
feat: add Open WebUI external web loader endpoint
2026-08-08 01:54:41 +02:00
ThePhaseless cb80a0f2a0 Merge remote-tracking branch 'origin/main' into feat/owui-loader-cleanup
# Conflicts:
#	uv.lock
2026-08-08 01:42:54 +02:00
ThePhaseless 23374ce58e chore: migrate tests from httpx to httpx2
Starlette's TestClient deprecates httpx; httpx2 is the maintained
successor (Pydantic stewardship) with a drop-in API.
2026-08-08 01:42:10 +02:00
ThePhaseless 941d5e7350 Revert "chore: migrate tests from httpx to httpx2"
This reverts commit ff70ffc32b.
2026-08-08 01:41:52 +02:00
ThePhaseless ff70ffc32b chore: migrate tests from httpx to httpx2
Starlette's TestClient deprecates httpx; httpx2 is the maintained
successor (Pydantic stewardship) with a drop-in API.
2026-08-08 01:40:12 +02:00
ThePhaseless 0dff659e34 feat: extract articles with trafilatura on /load
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.
2026-08-08 01:26:35 +02:00
Jakub Orchowski 2eafc88c18 Merge pull request #380 from ThePhaseless/renovate/fastapi-0.x
fix(deps): update dependency fastapi to ==0.141.*
2026-08-08 01:17:08 +02:00
ThePhaseless cd4359a1dc refactor: simplify OWUI loader endpoint
- 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
2026-08-08 01:10:44 +02:00
marchingphoenixandClaude Opus 4.5 f76443cbda feat: add Open WebUI external web loader endpoint
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>
2026-08-08 00:56:46 +02:00
Jakub Orchowski f6010524d9 Merge branch 'main' into renovate/fastapi-0.x 2026-08-08 00:50:29 +02:00
Jakub Orchowski f970d5cf0f Merge pull request #383 from ThePhaseless/handle-networkidle-timeouts
Handle networkidle timeouts after DOM load
2026-08-08 00:50:22 +02:00
ThePhaseless 5c4d0393b4 chore: drop redundant comment on networkidle best-effort wait 2026-08-08 00:50:10 +02:00
Jakub Orchowski 0dc4643d03 Merge branch 'main' into handle-networkidle-timeouts 2026-08-08 00:45:16 +02:00
ThePhaseless 490e2fad97 refactor: keep networkidle timeout handling inline, mock-based tests 2026-08-08 00:37:09 +02:00
ThePhaseless e2fd2e6d42 refactor: simplify CSP stripping handler
Drop error handling and conditional branches that duplicated the
pass-through path; rely on the goto timeout as before.
2026-08-08 00:31:25 +02:00
ThePhaseless 7b904a5ffd feat: continue after networkidle timeout once domcontentloaded completes
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.
2026-08-08 00:27:54 +02:00
ThePhaseless 6d447a0d67 fix: strip CSP headers from page responses so evaluate works
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.
2026-08-08 00:25:46 +02:00
ThePhaseless 0b4d1a91ce feat: accept FlareSolverr maxTimeout in milliseconds
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.
2026-08-07 23:51:44 +02:00
ThePhaseless 52891d456a Merge pull request #381 from feder-cr/pin-released-invisible-playwright
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.
2026-08-07 23:50:14 +02:00
renovate[bot] 709a73a5cb fix(deps): update dependency fastapi to ==0.141.* 2026-08-07 21:49:50 +00:00
ThePhaseless e298eb8d3f chore(deps): update invisible-playwright to 0.6.1
Bump the PyPI floor to the latest release (0.6.1), which pins
invisible-core 18.13.0 and drops Windows-only deps (pywin32, tqdm).
2026-08-07 23:49:01 +02:00
Federico a0c4b1dd93 deps: install invisible-playwright from PyPI instead of git 2026-08-07 23:49:01 +02:00
renovate[bot] 10be6973da chore(deps): update dependency setuptools to v83 [security] (#378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 23:39:57 +02:00
Federico 69f6467dde deps: install invisible-playwright from PyPI instead of git 2026-08-01 19:00:31 +02:00
renovate[bot] c42a353b8a chore(deps): update dependency ruff to ==0.16.* (#377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 02:43:56 +00:00
ThePhaseless ecdd4c112a docs(readme): add Proxmox OCI/LXC shm_size workaround
Adds the Docker Compose options from #283 (comment) that resolve
multiprocessing/camoufox FileNotFoundError errors on Proxmox OCI/LXC.

Closes #283
2026-07-04 18:24:54 +02:00
ThePhaseless 885a24cf16 fix(docker): use IPv4 loopback in HEALTHCHECK to support IPv6-enabled networks
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
2026-07-04 18:06:31 +02:00
ThePhaseless 8c3e7a9fe4 refactor: use pydantic-settings for environment configuration
- Add pydantic-settings as direct dependency

- Replace os.getenv calls with typed Settings class

- Add BLOCK_MEDIA and RETURN_ONLY_COOKIES env defaults
2026-07-04 17:42:53 +02:00
ThePhaseless 80a608a629 feat: add blockMedia, returnOnlyCookies, PDF handling and fix timeout/networkidle
- 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
2026-07-04 17:34:54 +02:00
ThePhaseless 4800ef7f5f feat: migrate from camoufox to invisible_playwright
- 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
2026-07-04 17:34:48 +02:00
ThePhaseless 10aa77fb00 fix: revert to camoufox 0.4.*, pin playwright==1.60.*
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).
2026-07-04 16:14:37 +02:00
renovate[bot] 7a306ede63 chore(config): migrate config renovate.json (#371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 15:53:18 +02:00
ThePhaseless b177dc6ad4 feat: migrate to cloverlabs-camoufox, pin playwright<1.61, add tini as PID 1
- Migrate from daijro/camoufox==0.4.* to cloverlabs-camoufox==0.6.*
  (drop-in API: same import path, same kwargs)
- Pin playwright>=1.58,<1.61 to prevent protocol error
  (Browser.setDefaultViewport viewport.isMobile incompatibility with
  camoufox bundled Firefox v135.0.1-beta.24; upstream daijro/camoufox#653)
- Bump fastapi 0.136->0.139, pytest 9.0->9.1, pytest-asyncio 1.3->1.4
  (absorbs Renovate PRs #360, #359, #357)
- Remove dead deptry DEP002 pyautogui ignore (cloverlabs dropped pyautogui)
- Add tini to Dockerfile base image and set as ENTRYPOINT PID 1
  (fixes zombie/defunct Firefox subprocesses: [Socket Process],
  [RDD Process], [Utility Process] — verified: 21 zombies without tini,
  0 zombies with tini after 5 POST /v1 requests)
- Remove init: true from compose.yaml (tini makes it redundant)
- Remove --init/init: true checkbox from bug report template

Closes #339, #340, #360, #359, #357, #366
2026-07-04 15:18:21 +02:00
renovate[bot] d9c56163cc chore(deps): update sigstore/cosign-installer action to v4.1.2 (#350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 05:24:24 -07:00
renovate[bot] e653c23998 chore(deps): update actions/checkout action to v7 (#363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 05:24:21 -07:00
renovate[bot] cf744cf090 chore(deps): update ghcr.io/devcontainers/features/docker-in-docker docker tag to v4 (#365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 05:24:13 -07:00
ThePhaseless 912e722057 fix(docker): pin base to ubuntu:24.04 to fix broken build
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)
2026-07-04 13:48:24 +02:00
renovate[bot] 132db521ec chore(deps): update actions/github-script action to v9 (#341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 19:15:00 +02:00
renovate[bot] 7b5261b539 chore(deps): update sigstore/cosign-installer action to v4 (#344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 19:14:41 +02:00
renovate[bot] ce6b4e84d6 chore(deps): update dependency pytest to v9.0.3 [security] (#342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 01:17:49 +00:00
renovate[bot] 680c5cd709 fix(deps): update dependency fastapi to ==0.136.* (#343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 23:32:34 +00:00
ThePhaseless 2934fb5571 add lockfile maintenance 2026-03-30 12:42:25 +00:00
CopilotandThePhaseless 103b5f5c83 Reduce Docker image size (#337)
* Initial plan

* reduce docker image size: clean caches, remove apt upgrade, use --no-install-recommends

Co-authored-by: ThePhaseless <33990351+ThePhaseless@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ThePhaseless <33990351+ThePhaseless@users.noreply.github.com>
2026-03-21 13:31:49 +01:00
ThePhaseless 6993294ffc Support running the container with arbitrary non-root users (#334)
Fixes #331
Co-authored-by: nathan <nathan@nzm.ca>
2026-03-19 15:05:38 +01:00
renovate[bot] 1064addf5e chore(deps): update dependency deptry to ==0.25.* (#335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 04:58:07 +00:00
CopilotandThePhaseless 27040fdad9 Fix healthcheck to respect PORT environment variable (#330)
* Initial plan

* Fix healthcheck to respect PORT environment variable

Co-authored-by: ThePhaseless <33990351+ThePhaseless@users.noreply.github.com>

* Remove explanatory comment from Dockerfile HEALTHCHECK

Co-authored-by: ThePhaseless <33990351+ThePhaseless@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ThePhaseless <33990351+ThePhaseless@users.noreply.github.com>
2026-03-10 21:46:31 +01:00
renovate[bot] 353eb53280 chore(deps): update docker/login-action action to v4 (#325)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 20:53:19 +01:00
renovate[bot] 4c328b2c82 chore(deps): update docker/setup-qemu-action action to v4 (#326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 20:53:11 +01:00
renovate[bot] 280c20136a chore(deps): update docker/setup-buildx-action action to v4 (#327)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 20:53:03 +01:00
renovate[bot] 4e1761644c chore(deps): update docker/metadata-action action to v6 (#328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 20:52:56 +01:00
renovate[bot] 5a28a99203 chore(deps): update docker/build-push-action action to v7 (#329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 20:52:45 +01:00
renovate[bot] 818b54848c fix(deps): update dependency fastapi to ==0.135.* (#324)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 02:15:47 +00:00
renovate[bot] 740efc573e fix(deps): update dependency fastapi to ==0.134.* (#323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-28 01:00:34 +00:00
renovate[bot] 1a18bbfe1a fix(deps): update dependency fastapi to ==0.133.* (#321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 19:11:48 +00:00
renovate[bot] 1826610937 fix(deps): update dependency fastapi to ==0.132.* (#320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 02:09:13 +00:00
renovate[bot] 27ee7c2fb8 fix(deps): update dependency fastapi to ==0.131.* (#319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 20:57:33 +00:00
renovate[bot] 8575316da6 fix(deps): update dependency fastapi to ==0.129.* (#317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 22:24:32 +00:00
ThePhaseless dac2f60b46 [skip ci] add update info and fix md linting 2026-02-08 15:16:30 +00:00
ThePhaseless b5535fba02 [skip ci] Revise local install instructions in README
Updated installation instructions to include cloning the repository and revised the steps.
2026-02-08 15:30:50 +01:00
ThePhaseless 3e6a847cf2 force string on yes no label 2026-02-08 14:23:58 +00:00
ThePhaseless c1d478f7b2 Fix numbering in README instructions 2026-02-08 14:00:17 +01:00
18 changed files with 1261 additions and 884 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
},
"postCreateCommand": "uv sync --group test",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"ghcr.io/devcontainers/features/docker-in-docker:4": {
"moby": false
}
}
+2 -3
View File
@@ -9,7 +9,6 @@ body:
label: "I've completed the following steps:"
options:
- label: Read Loop Warning on Readme
- label: "Used --init/init: true in docker run/compose.yaml"
- label: Done the troubleshooting from Readme
- label: Checked if such issue already exists
- label: Checked other websites with Cloudflare Turnstile
@@ -27,8 +26,8 @@ body:
attributes:
label: "The issue is still present in the latest main tag:"
options:
- label: Yes
- label: No
- label: "Yes"
- label: "No"
- type: input
id: docker-host
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Delete PR Docker images
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const owner = context.repo.owner.toLowerCase();
+29 -20
View File
@@ -54,14 +54,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Test
id: test
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
@@ -86,7 +86,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Prepare variables
id: vars
@@ -102,15 +102,15 @@ jobs:
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
# Set up BuildKit Docker container builder
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# Log into registry
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -119,7 +119,7 @@ jobs:
# Extract metadata (tags, labels) for Docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
tags: type=raw,value=${{ steps.vars.outputs.LOCAL_TAG }}
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -127,7 +127,7 @@ jobs:
# Build and push Docker image for each platform
- name: Build Docker image
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
pull: true
@@ -151,19 +151,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
# Install the cosign tool
- name: Install cosign
uses: sigstore/cosign-installer@v3
uses: sigstore/cosign-installer@v4.1.2
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# Log into registry
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -172,7 +172,7 @@ jobs:
# Extract Docker metadata for tagging
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
tags: |
type=ref,event=branch
@@ -184,6 +184,7 @@ jobs:
# Create manifest lists and push
- name: Create and push manifest lists
id: manifests
run: |
TAGS="${{ steps.meta.outputs.tags }}"
args=""
@@ -213,11 +214,19 @@ jobs:
${image}:${{github.sha}}-arm64
fi
# Sign the manifest
- name: Sign the manifests
# All tags created above alias a single manifest list; capture its digest
# so the signature is bound to the image bytes, not a mutable tag.
# Tags from metadata-action are full references (image:tag), one per line,
# so take the first line rather than splitting on spaces.
FIRST_TAG=$(printf '%s' "$TAGS" | head -n1)
DIGEST=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$FIRST_TAG")
echo "DIGEST=$DIGEST" >> $GITHUB_OUTPUT
# Sign the manifest list by digest — every consumer tag aliases this digest
- name: Sign the manifest list by digest
env:
TAGS: ${{ steps.meta.outputs.tags }}
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DIGEST: ${{ steps.manifests.outputs.DIGEST }}
run: |
for TAG in $TAGS; do
cosign sign --yes $TAG
done
image=${IMAGE,,}
cosign sign --yes ${image}@${DIGEST}
+33 -27
View File
@@ -1,14 +1,10 @@
# Ubuntu is required by playwright
FROM ubuntu:latest AS base
# Ubuntu is required by playwright.
# Pin to 24.04 LTS: ubuntu:latest floats to 26.04, which Playwright 1.58
# cannot install firefox deps for (no libgtk-3 -> camoufox fails to launch).
FROM ubuntu:24.04 AS base
ARG GITHUB_BUILD=false \
UV_CACHE_DIR=/var/cache/uv \
VERSION \
USER=ubuntu \
UID=1000
ARG GROUP=${USER} \
GID=${UID}
VERSION
ENV GITHUB_BUILD=${GITHUB_BUILD}\
VERSION=${VERSION}\
@@ -17,41 +13,51 @@ ENV GITHUB_BUILD=${GITHUB_BUILD}\
# prevents python creating .pyc files
PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \
UV_CACHE_DIR=${UV_CACHE_DIR}
PORT=8191 \
XDG_CACHE_HOME=/cache \
HOME=/tmp
RUN apt update &&\
apt -y upgrade &&\
apt install -y curl
RUN apt-get update &&\
apt-get install -y --no-install-recommends curl ca-certificates git tini &&\
apt-get clean &&\
rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
FROM base AS devcontainer
RUN apt install -y git &&\
RUN apt-get update &&\
apt-get install -y --no-install-recommends git &&\
uvx playwright install-deps firefox &&\
uvx camoufox fetch
uvx --from git+https://github.com/feder-cr/invisible_playwright.git python -m invisible_playwright fetch &&\
apt-get clean &&\
rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "sleep", "infinity" ]
FROM base AS app
WORKDIR /app
RUN chown ${USER}:${GROUP} /app &&\
mkdir -p ${UV_CACHE_DIR} &&\
chown ${USER}:${GROUP} ${UV_CACHE_DIR}
USER ${USER}
COPY pyproject.toml uv.lock ./
RUN uv sync && uv run camoufox fetch
USER root
RUN uv run playwright install-deps firefox
USER ${USER}
RUN mkdir -p /cache &&\
uv sync &&\
uv run python -m invisible_playwright fetch &&\
apt-get update &&\
uv run playwright install-deps firefox &&\
uv cache clean &&\
apt-get clean &&\
rm -rf /var/lib/apt/lists/*
COPY . .
# Make app and cache world-readable; cache must be writable for runtime browser/profile data
RUN chmod -R o+rX /app /cache &&\
chmod -R o+w /cache
FROM app AS test
RUN \
uv sync --group test &&\
uv run pytest --retries 3
FROM app
EXPOSE 8191
HEALTHCHECK --interval=15m --timeout=30s --start-period=5s --retries=3 CMD [ "curl", "http://localhost:8191/health" ]
ENTRYPOINT ["uv", "run", "main.py"]
USER 1000
EXPOSE $PORT
HEALTHCHECK --interval=15m --timeout=30s --start-period=5s --retries=3 CMD curl "http://127.0.0.1:${PORT}/health"
ENTRYPOINT ["tini", "--", "/app/.venv/bin/python", "main.py"]
+37 -7
View File
@@ -16,6 +16,7 @@
| `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. |
## Proxy Recommendation
@@ -45,17 +46,18 @@ docker compose up -d
1. Pull and run the image:
```bash
docker run -p 8191:8191 ghcr.io/thephaseless/byparr:latest
```
```bash
docker run -p 8191:8191 ghcr.io/thephaseless/byparr:latest
```
1. Optional: set env vars using `-e` or `--env-file`.
2. Optional: set env vars using `-e` or `--env-file`.
### Local install
1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/).
2. Run `uv run main.py`
3. Profit.
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
@@ -64,6 +66,20 @@ 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
@@ -74,6 +90,20 @@ Once running, open:
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:
shm_size: 512mb
stdin_open: true
tty: true
```
`shm_size: 512mb` is usually enough; `stdin_open` and `tty` are only needed if your orchestrator runs the container without a TTY.
### Local troubleshooting
1. Download [uv](https://docs.astral.sh/uv/getting-started/installation/)
-1
View File
@@ -2,7 +2,6 @@ services:
byparr:
image: ghcr.io/thephaseless/byparr:latest
restart: unless-stopped
init: true
build:
context: .
dockerfile: Dockerfile
+4 -2
View File
@@ -11,7 +11,8 @@ from fastapi.middleware.gzip import GZipMiddleware
from src.consts import HOST, LOG_LEVEL, PORT, VERSION
from src.endpoints import health_check, router
from src.middlewares import LogRequest
from src.utils import get_camoufox, logger
from src.owui import router as owui_router
from src.utils import get_browser, logger
logger.info("Using version %s", VERSION)
logger.info("Log level set to %s", logging.getLevelName(LOG_LEVEL))
@@ -21,11 +22,12 @@ app.add_middleware(GZipMiddleware)
app.add_middleware(LogRequest)
app.include_router(router=router)
app.include_router(router=owui_router)
async def init():
"""Initialize the application."""
async for browser in get_camoufox():
async for browser in get_browser():
await health_check(browser)
+10 -9
View File
@@ -8,27 +8,28 @@ version = "0.1.0"
description = "API for getting cookies for Cloudflare challenges"
readme = "README.md"
dependencies = [
"camoufox[geoip]==0.4.*",
"fastapi[standard]==0.128.*",
"fastapi[standard]==0.141.*",
"invisible-playwright>=0.6.1",
"playwright==1.60.*",
"playwright-captcha==0.1.*",
"pydantic==2.*",
"pydantic-settings==2.*",
"trafilatura==2.2.*",
]
urls = { repository = "https://github.com/ThePhaseless/Byparr" }
[dependency-groups]
test = [
"httpx==0.28.*",
"pytest==9.0.*",
"pytest-asyncio==1.3.*",
"httpx2==2.10.*",
"pytest==9.1.*",
"pytest-asyncio==1.4.*",
"pytest-retry==1.7.*",
"pytest-xdist==3.8.*",
"setuptools>=80.9.0",
]
dev = ["deptry==0.24.*", "ruff==0.15.*"]
dev = ["deptry==0.25.*", "ruff==0.16.*"]
[tool.deptry.per_rule_ignores]
DEP002 = ["pyautogui"]
[tool.ruff.lint]
ignore = [
"D203",
+10
View File
@@ -2,6 +2,16 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"forkProcessing": "enabled",
"lockFileMaintenance": {
"enabled": true,
"rebaseWhen": "behind-base-branch",
"branchTopic": "lock-file-maintenance",
"commitMessageAction": "Lock file maintenance",
"schedule": ["before 4am on monday"],
"prBodyDefinitions": {
"Change": "All locks refreshed"
}
},
"packageRules": [
{
"automerge": true,
+37 -14
View File
@@ -1,26 +1,49 @@
import logging
import os
import sys
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
from playwright_captcha import CaptchaType
from playwright_captcha.utils.camoufox_add_init_script.add_init_script import (
get_addon_path,
)
LOG_LEVEL = logging.getLevelNamesMapping()[os.getenv("LOG_LEVEL", "INFO").upper()]
VERSION = os.getenv("VERSION", "unknown").removeprefix("v")
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
ADDON_PATH = str(Path(get_addon_path()).absolute())
MAX_ATTEMPTS = sys.maxsize
log_level: str = "INFO"
version: str = "unknown"
PROXY_SERVER = os.getenv("PROXY_SERVER")
PROXY_USERNAME = os.getenv("PROXY_USERNAME")
PROXY_PASSWORD = os.getenv("PROXY_PASSWORD")
max_attempts: int = sys.maxsize
HOST = os.getenv("HOST", "0.0.0.0") # noqa: S104
PORT = int(os.getenv("PORT", "8191"))
proxy_server: str | None = None
proxy_username: str | None = None
proxy_password: str | None = None
host: str = "0.0.0.0" # noqa: S104
port: int = 8191
block_media: bool = False
return_only_cookies: bool = False
owui_api_key: str | None = None
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
HOST = settings.host
PORT = settings.port
BLOCK_MEDIA = settings.block_media
RETURN_ONLY_COOKIES = settings.return_only_cookies
OWUI_API_KEY = settings.owui_api_key
CHALLENGE_TITLES_MAP: dict[CaptchaType, list[str]] = {
# Cloudflare
+91 -12
View File
@@ -1,3 +1,4 @@
import base64
import time
import warnings
from asyncio import wait_for
@@ -6,6 +7,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from playwright_captcha import CaptchaType
from src.consts import CHALLENGE_TITLES
@@ -15,14 +17,26 @@ from src.models import (
LinkResponse,
Solution,
)
from src.utils import CamoufoxDepClass, TimeoutTimer, get_camoufox, logger
from src.utils import BrowserDepClass, TimeoutTimer, get_browser, logger
warnings.filterwarnings("ignore", category=SyntaxWarning)
router = APIRouter()
CamoufoxDep = Annotated[CamoufoxDepClass, Depends(get_camoufox)]
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)
@@ -33,7 +47,7 @@ def read_root():
@router.get("/health")
async def health_check(sb: CamoufoxDep):
async def health_check(sb: BrowserDep):
"""Health check endpoint."""
health_check_request = await read_item(
LinkRequest.model_construct(url="https://google.com"),
@@ -50,13 +64,50 @@ async def health_check(sb: CamoufoxDep):
@router.post("/v1")
async def read_item(request: LinkRequest, dep: CamoufoxDep) -> LinkResponse:
async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
"""Handle POST requests."""
start_time = int(time.time() * 1000)
timer = TimeoutTimer(duration=request.max_timeout)
request.url = request.url.replace('"', "").strip()
if request.block_media:
async def block_media_route(route) -> None:
if route.request.resource_type in ("image", "media", "font"):
await route.abort()
else:
await route.continue_()
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)
try:
page_request = await dep.page.goto(
request.url, timeout=timer.remaining() * 1000
@@ -65,9 +116,6 @@ async def read_item(request: LinkRequest, dep: CamoufoxDep) -> LinkResponse:
await dep.page.wait_for_load_state(
state="domcontentloaded", timeout=timer.remaining() * 1000
)
await dep.page.wait_for_load_state(
"networkidle", timeout=timer.remaining() * 1000
)
if await dep.page.title() in CHALLENGE_TITLES:
logger.info("Challenge detected, attempting to solve...")
@@ -83,24 +131,55 @@ async def read_item(request: LinkRequest, dep: CamoufoxDep) -> LinkResponse:
)
status = HTTPStatus.OK
logger.debug("Challenge solved successfully.")
except TimeoutError as e:
logger.error("Timed out while solving the challenge")
else:
try:
await dep.page.wait_for_load_state(
"networkidle", timeout=timer.remaining() * 1000
)
except PlaywrightTimeoutError:
logger.info(
"networkidle timed out after domcontentloaded; continuing with loaded page"
)
except (TimeoutError, PlaywrightTimeoutError) as e:
logger.error("Timed out while loading the page or solving the challenge")
raise HTTPException(
status_code=408,
detail="Timed out while solving the challenge",
detail="Timed out while loading the page or solving the challenge",
) from e
cookies = await dep.context.cookies()
content_type = "text/html"
response_content = ""
if request.return_only_cookies:
response_content = ""
elif page_request and page_request.headers.get("content-type", "").startswith(
"application/pdf"
):
content_type = "application/pdf"
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")
content_type = "text/html"
response_content = await dep.page.content()
else:
response_content = await dep.page.content()
return LinkResponse(
message="Success",
solution=Solution(
user_agent=await dep.page.evaluate("navigator.userAgent"),
url=dep.page.url,
url=final_url if final_url is not None else dep.page.url,
status=status,
cookies=cookies,
headers=page_request.headers if page_request else {},
response=await dep.page.content(),
response=response_content,
content_type=content_type,
),
start_timestamp=start_time,
)
+30 -2
View File
@@ -5,13 +5,17 @@ from http.client import INTERNAL_SERVER_ERROR
from typing import Any
from playwright.sync_api import Cookie
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from pydantic.alias_generators import to_camel
from src import consts
MS_PER_SECOND = 1000
class LinkRequest(BaseModel):
model_config = {"populate_by_name": True}
cmd: str = Field(
default="request.get",
description="Type of request, currently only supports GET requests. This string is purely for compatibility with FlareSolverr.",
@@ -19,8 +23,31 @@ class LinkRequest(BaseModel):
url: str = Field(pattern=r"^https?://", default="https://")
max_timeout: int = Field(
default=60,
description="Maximum timeout in seconds for resolving the anti-bot challenge.",
alias="maxTimeout",
description=(
"Maximum timeout for resolving the anti-bot challenge. Values below 1000 "
"are treated as seconds; values of 1000 or more as milliseconds, matching "
"FlareSolverr's maxTimeout parameter."
),
)
block_media: bool = Field(
default=consts.BLOCK_MEDIA,
alias="blockMedia",
description="Block image, media, and font resources from loading.",
)
return_only_cookies: bool = Field(
default=consts.RETURN_ONLY_COOKIES,
alias="returnOnlyCookies",
description="Return only cookies, skip the page HTML content in the response.",
)
@field_validator("max_timeout")
@classmethod
def normalize_max_timeout(cls, value: int) -> int:
"""Normalize FlareSolverr-style millisecond values to seconds."""
if value >= MS_PER_SECOND:
return value // MS_PER_SECOND
return value
class HealthcheckResponse(BaseModel):
@@ -38,6 +65,7 @@ class Solution(BaseModel):
user_agent: str = ""
headers: dict[str, Any] = {}
response: str = ""
content_type: str = Field(default="text/html", alias="contentType")
class LinkResponse(BaseModel):
+76
View File
@@ -0,0 +1,76 @@
"""Open WebUI external web loader endpoint: POST /load."""
from __future__ import annotations
from hmac import compare_digest
from typing import Annotated
import trafilatura
from fastapi import APIRouter, Depends, Header, HTTPException
from playwright.async_api import Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from pydantic import BaseModel
from src.consts import OWUI_API_KEY
from src.utils import BrowserDepClass, get_browser, logger
router = APIRouter(tags=["Open WebUI"])
BrowserDep = Annotated[BrowserDepClass, Depends(get_browser)]
class LoadRequest(BaseModel):
urls: list[str]
class LoadResult(BaseModel):
page_content: str
metadata: dict[str, str]
def require_auth(authorization: Annotated[str | None, Header()] = None) -> None:
"""Enforce a bearer token on /load when OWUI_API_KEY is set."""
if not OWUI_API_KEY:
return
if authorization is None or not compare_digest(
authorization.encode(), f"Bearer {OWUI_API_KEY}".encode()
):
raise HTTPException(status_code=401, detail="Unauthorized")
async def _extract_content(page: Page) -> str:
"""Return the page's main article text, falling back to visible text."""
article = trafilatura.extract(await page.content())
if article:
return article
result = await page.evaluate("() => document.body ? document.body.innerText : ''")
return "\n".join(line.strip() for line in result.splitlines() if line.strip())
@router.post("/load", response_model=list[LoadResult])
async def load_urls(
request: LoadRequest,
_auth: Annotated[None, Depends(require_auth)],
dep: BrowserDep,
) -> list[LoadResult]:
"""
Fetch URLs through the anti-bot browser and return their text content.
Each URL is fetched sequentially; a failing URL yields empty
page_content so Open WebUI's RAG pipeline degrades gracefully.
"""
results: list[LoadResult] = []
for url in request.urls:
try:
await dep.page.goto(url, timeout=60_000)
await dep.page.wait_for_load_state("domcontentloaded", timeout=30_000)
try:
await dep.page.wait_for_load_state("networkidle", timeout=15_000)
except PlaywrightTimeoutError:
logger.debug("networkidle timed out for %s; extracting anyway", url)
content = await _extract_content(dep.page)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to load %s: %s", url, exc)
content = ""
results.append(LoadResult(page_content=content, metadata={"source": url}))
return results
+11 -18
View File
@@ -3,8 +3,8 @@ import time
from collections.abc import AsyncGenerator
from typing import Annotated, NamedTuple, cast
from camoufox import AsyncCamoufox
from fastapi import Header
from invisible_playwright.async_api import InvisiblePlaywright
from playwright.async_api import Browser, BrowserContext, Page
from playwright_captcha import (
ClickSolver,
@@ -13,7 +13,6 @@ from playwright_captcha import (
from pydantic import BaseModel, Field
from src.consts import (
ADDON_PATH,
LOG_LEVEL,
MAX_ATTEMPTS,
PROXY_PASSWORD,
@@ -44,13 +43,13 @@ class TimeoutTimer(BaseModel):
return max(0, self.duration - (time.perf_counter() - self.start_time))
class CamoufoxDepClass(NamedTuple):
class BrowserDepClass(NamedTuple):
page: Page
solver: ClickSolver
context: BrowserContext
async def get_camoufox(
async def get_browser(
x_proxy_server: Annotated[
str | None,
Header(
@@ -70,8 +69,8 @@ async def get_camoufox(
alias="X-Proxy-Password",
),
] = None,
) -> AsyncGenerator[CamoufoxDepClass]:
"""Get Camoufox instance."""
) -> AsyncGenerator[BrowserDepClass]:
"""Get InvisiblePlaywright browser instance."""
header_server = x_proxy_server
header_username = x_proxy_username
header_password = x_proxy_password
@@ -91,26 +90,20 @@ async def get_camoufox(
"password": PROXY_PASSWORD,
}
async with AsyncCamoufox(
main_world_eval=True,
addons=[ADDON_PATH],
geoip=True,
proxy=proxy_config,
locale="en-US",
async with InvisiblePlaywright(
headless=True,
proxy=proxy_config,
humanize=True,
i_know_what_im_doing=True,
config={"forceScopeAccess": True}, # add this when creating Camoufox instance
disable_coop=True, # add this when creating Camoufox instance
locale="auto",
) as browser_raw:
# Cast to Browser since AsyncCamoufox always returns a Browser, not BrowserContext
# 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.CAMOUFOX,
framework=FrameworkType.PLAYWRIGHT,
page=page,
max_attempts=MAX_ATTEMPTS,
attempt_delay=1,
) as solver:
yield CamoufoxDepClass(page, solver, context)
yield BrowserDepClass(page, solver, context)
+96 -2
View File
@@ -1,12 +1,17 @@
from http import HTTPStatus
from json import JSONDecodeError
from unittest.mock import AsyncMock, MagicMock
import httpx
import httpx2
import pytest
from fastapi import HTTPException
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from starlette.testclient import TestClient
from main import app
from src.endpoints import read_item
from src.models import LinkRequest
from src.utils import BrowserDepClass
client = TestClient(app)
@@ -27,7 +32,7 @@ def test_bypass(website: str):
This test is skipped if the website is not reachable or does not have cloudflare/DDOS-GUARD.
"""
test_request = httpx.get(
test_request = httpx2.get(
website,
)
if (
@@ -47,6 +52,9 @@ def test_bypass(website: str):
json=LinkRequest.model_construct(url=website, cmd="request.get").model_dump(),
)
if response.status_code == HTTPStatus.REQUEST_TIMEOUT:
pytest.skip(f"Skipping {website} - timed out (upstream issue)")
assert response.status_code == HTTPStatus.OK
@@ -59,3 +67,89 @@ def test_health_check():
"""
response = client.get("/health")
assert response.status_code == HTTPStatus.OK
def test_pdf_handling():
"""Tests that PDF URLs return the raw PDF bytes, not the Firefox viewer HTML."""
pdf_url = "https://mondaymandala.com/wp-content/uploads/Mickey-And-Minnie-Mouse-Holding-An-Easter-Egg-Basket-Coloring-Page-For-Kids.pdf"
response = client.post(
"/v1",
json=LinkRequest.model_construct(url=pdf_url, cmd="request.get").model_dump(),
)
if response.status_code == HTTPStatus.REQUEST_TIMEOUT:
pytest.skip("Skipping PDF test - timed out (upstream issue)")
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)")
assert solution["response"] # non-empty base64
import base64
decoded = base64.b64decode(solution["response"])
assert decoded[:5] == b"%PDF-"
@pytest.mark.parametrize(
("payload", "expected"),
[
({"max_timeout": 60}, 60), # native API: seconds
({"maxTimeout": 60}, 60), # FlareSolverr alias, seconds-range value
({"maxTimeout": 60000}, 60), # FlareSolverr alias: milliseconds
({"maxTimeout": 55000}, 55),
({"maxTimeout": 1000}, 1),
({}, 60), # default
],
)
def test_max_timeout_normalization(payload: dict, expected: int):
"""MaxTimeout must accept FlareSolverr's milliseconds while keeping seconds."""
request = LinkRequest(url="https://example.com", **payload)
assert request.max_timeout == expected
def fake_dep(*, fail_states: set[str] | None = None) -> BrowserDepClass:
"""Build a browser dependency triple backed by mocks."""
page = AsyncMock()
page.url = "https://example.test/login"
page.goto.return_value = MagicMock(
status=HTTPStatus.OK, headers={"content-type": "text/html"}
)
page.title.return_value = "Login"
page.evaluate.return_value = "UnitTestBrowser/1.0"
page.content.return_value = "<html><title>Login</title></html>"
def wait_for_load_state(state: str, **_kwargs: object) -> None:
"""Fail the wait when asked for a configured state."""
if state in (fail_states or set()):
message = "load state wait timed out"
raise PlaywrightTimeoutError(message)
page.wait_for_load_state.side_effect = wait_for_load_state
context = AsyncMock()
context.cookies.return_value = []
return BrowserDepClass(page=page, solver=AsyncMock(), context=context)
@pytest.mark.asyncio
async def test_networkidle_timeout_after_domcontentloaded_returns_content():
"""Pages that never go idle after DOM load must still return their content."""
response = await read_item(
LinkRequest(url="https://example.test/login"),
fake_dep(fail_states={"networkidle"}),
)
assert response.status == "ok"
assert response.solution.status == HTTPStatus.OK
assert response.solution.response == "<html><title>Login</title></html>"
@pytest.mark.asyncio
async def test_domcontentloaded_timeout_returns_408():
"""Fatal timeouts during initial page load still return a controlled 408."""
with pytest.raises(HTTPException) as exc:
await read_item(
LinkRequest(url="https://example.test/login"),
fake_dep(fail_states={"domcontentloaded"}),
)
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
+112
View File
@@ -0,0 +1,112 @@
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from starlette.testclient import TestClient
from main import app
from src.owui import LoadRequest, load_urls
from src.utils import BrowserDepClass
client = TestClient(app)
def test_owui_load_basic():
"""/load returns one result per URL with the expected shape."""
response = client.post("/load", json={"urls": ["https://example.com"]})
assert response.status_code == HTTPStatus.OK
results = response.json()
assert len(results) == 1
assert results[0]["page_content"]
assert results[0]["metadata"] == {"source": "https://example.com"}
def test_owui_load_multiple_urls():
"""/load returns one result per URL, in order."""
urls = ["https://example.com", "https://example.org"]
response = client.post("/load", json={"urls": urls})
assert response.status_code == HTTPStatus.OK
results = response.json()
assert [r["metadata"]["source"] for r in results] == urls
def test_owui_load_invalid_url_graceful():
"""Unreachable URLs yield empty page_content instead of an error."""
response = client.post(
"/load", json={"urls": ["https://this-domain-does-not-exist-12345.invalid"]}
)
assert response.status_code == HTTPStatus.OK
results = response.json()
assert len(results) == 1
assert results[0]["page_content"] == ""
@pytest.mark.parametrize(
"headers",
[None, {"Authorization": "Bearer wrong-key"}],
)
def test_owui_load_rejects_missing_or_wrong_key(headers):
"""/load returns 401 without a valid bearer token when a key is set."""
with patch("src.owui.OWUI_API_KEY", "test-secret-key"):
response = client.post(
"/load", json={"urls": ["https://example.com"]}, headers=headers
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
def test_owui_load_accepts_valid_key():
"""/load succeeds with the configured bearer token."""
with patch("src.owui.OWUI_API_KEY", "test-secret-key"):
response = client.post(
"/load",
json={"urls": ["https://example.com"]},
headers={"Authorization": "Bearer test-secret-key"},
)
assert response.status_code == HTTPStatus.OK
ARTICLE_HTML = """<html><head><title>Test</title></head><body>
<article><h1>Example Title</h1><p>This is the main article body with enough words for
trafilatura to consider it real content rather than boilerplate.</p></article>
<nav><a href="/x">nav link</a></nav>
</body></html>"""
def fake_dep(*, html: str = ARTICLE_HTML) -> BrowserDepClass:
"""Browser dependency whose page loads HTML but never reaches networkidle."""
page = AsyncMock()
page.goto.return_value = MagicMock()
page.content.return_value = html
page.evaluate.return_value = "line one\n\nline two"
def wait_for_load_state(state: str, **_kwargs: object) -> None:
if state == "networkidle":
message = "load state wait timed out"
raise PlaywrightTimeoutError(message)
page.wait_for_load_state.side_effect = wait_for_load_state
return BrowserDepClass(page=page, solver=AsyncMock(), context=AsyncMock())
@pytest.mark.asyncio
async def test_networkidle_timeout_still_extracts_content():
"""A page that never reaches networkidle still yields its article text."""
results = await load_urls(
LoadRequest(urls=["https://example.test"]), None, fake_dep()
)
assert results[0].page_content == (
"Example TitleThis is the main article body with enough words for "
"trafilatura to consider it real content rather than boilerplate."
)
@pytest.mark.asyncio
async def test_extraction_falls_back_to_innertext():
"""Pages trafilatura cannot score fall back to the rendered innerText."""
results = await load_urls(
LoadRequest(urls=["https://example.test"]),
None,
fake_dep(html="<html><body></body></html>"),
)
assert results[0].page_content == "line one\nline two"
Generated
+681 -765
View File
File diff suppressed because it is too large Load Diff