mirror of
https://github.com/ThePhaseless/Byparr.git
synced 2026-09-24 14:20:08 +01:00
Compare commits
276
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a0ba50b4 | ||
|
|
1173fe0b7d | ||
|
|
57e7f26be4 | ||
|
|
8aa5f921e0 | ||
|
|
3eeba1616b | ||
|
|
f97e3d325d | ||
|
|
4e70c8b208 | ||
|
|
5130400571 | ||
|
|
14834c23b2 | ||
|
|
aa9a331064 | ||
|
|
c4dcee3e2b | ||
|
|
652c234782 | ||
|
|
1c2b2df90d | ||
|
|
c9d4cd4a4e | ||
|
|
ecf7c03d7c | ||
|
|
c96db89d03 | ||
|
|
742cafc64a | ||
|
|
9afb3e0903 | ||
|
|
d7792b8fcd | ||
|
|
bb526d73b0 | ||
|
|
9b933ea70c | ||
|
|
d3a828e814 | ||
|
|
46a3c68eb0 | ||
|
|
f821290bac | ||
|
|
5de9266d7e | ||
|
|
89dcf5e16c | ||
|
|
3c45ef9691 | ||
|
|
bd4c62de38 | ||
|
|
92043725f5 | ||
|
|
f8d087bab8 | ||
|
|
c38a6f4e85 | ||
|
|
aa7bfee7bb | ||
|
|
336773d7da | ||
|
|
8cb5770b84 | ||
|
|
ae28c7098f | ||
|
|
1c4b377613 | ||
|
|
8ef4c62249 | ||
|
|
baad431605 | ||
|
|
7e1a5d4329 | ||
|
|
221f27acca | ||
|
|
bdd59d7e60 | ||
|
|
1801eaa40c | ||
|
|
bc529e5915 | ||
|
|
1c9093f218 | ||
|
|
0c44ce1a4d | ||
|
|
07309c8d8e | ||
|
|
25194c3bca | ||
|
|
77020bec0d | ||
|
|
cb80a0f2a0 | ||
|
|
23374ce58e | ||
|
|
941d5e7350 | ||
|
|
ff70ffc32b | ||
|
|
0dff659e34 | ||
|
|
2eafc88c18 | ||
|
|
cd4359a1dc | ||
|
|
f76443cbda | ||
|
|
f6010524d9 | ||
|
|
f970d5cf0f | ||
|
|
5c4d0393b4 | ||
|
|
0dc4643d03 | ||
|
|
490e2fad97 | ||
|
|
e2fd2e6d42 | ||
|
|
7b904a5ffd | ||
|
|
6d447a0d67 | ||
|
|
0b4d1a91ce | ||
|
|
52891d456a | ||
|
|
709a73a5cb | ||
|
|
e298eb8d3f | ||
|
|
a0c4b1dd93 | ||
|
|
10be6973da | ||
|
|
69f6467dde | ||
|
|
c42a353b8a | ||
|
|
ecdd4c112a | ||
|
|
885a24cf16 | ||
|
|
8c3e7a9fe4 | ||
|
|
80a608a629 | ||
|
|
4800ef7f5f | ||
|
|
10aa77fb00 | ||
|
|
7a306ede63 | ||
|
|
b177dc6ad4 | ||
|
|
d9c56163cc | ||
|
|
e653c23998 | ||
|
|
cf744cf090 | ||
|
|
912e722057 | ||
|
|
132db521ec | ||
|
|
7b5261b539 | ||
|
|
ce6b4e84d6 | ||
|
|
680c5cd709 | ||
|
|
2934fb5571 | ||
|
|
103b5f5c83 | ||
|
|
6993294ffc | ||
|
|
1064addf5e | ||
|
|
27040fdad9 | ||
|
|
353eb53280 | ||
|
|
4c328b2c82 | ||
|
|
280c20136a | ||
|
|
4e1761644c | ||
|
|
5a28a99203 | ||
|
|
818b54848c | ||
|
|
740efc573e | ||
|
|
1a18bbfe1a | ||
|
|
1826610937 | ||
|
|
27ee7c2fb8 | ||
|
|
8575316da6 | ||
|
|
dac2f60b46 | ||
|
|
b5535fba02 | ||
|
|
3e6a847cf2 | ||
|
|
c1d478f7b2 | ||
|
|
1e726296a3 | ||
|
|
1ba0bb3c5e | ||
|
|
a909098b43 | ||
|
|
fadcef5fc2 | ||
|
|
5af383ac3e | ||
|
|
d15707c684 | ||
|
|
4be9674f82 | ||
|
|
a35732596e | ||
|
|
f5d4b02d64 | ||
|
|
d9517de15f | ||
|
|
c4c924ead0 | ||
|
|
8bf82a081a | ||
|
|
98de158d00 | ||
|
|
e894e0bf73 | ||
|
|
da620267b8 | ||
|
|
3a45795db3 | ||
|
|
f91bc1aae7 | ||
|
|
d223bb058a | ||
|
|
463e1ae83c | ||
|
|
4a21ba6906 | ||
|
|
fcbc782564 | ||
|
|
dd72488a44 | ||
|
|
a8f0d95488 | ||
|
|
0cd4401955 | ||
|
|
3a99ff1fbb | ||
|
|
2db12eb329 | ||
|
|
277ae38264 | ||
|
|
da6d90fabc | ||
|
|
c45a464462 | ||
|
|
fd256a3ab6 | ||
|
|
f3c7153edc | ||
|
|
f1696fe0fd | ||
|
|
12e5a1646b | ||
|
|
a8d1157b26 | ||
|
|
a4d1777db0 | ||
|
|
b9af1e5693 | ||
|
|
0977a0daf9 | ||
|
|
d8cd7cc571 | ||
|
|
a5bdd2c144 | ||
|
|
afd360cc95 | ||
|
|
7054136320 | ||
|
|
82f0e4ca01 | ||
|
|
560da0daf0 | ||
|
|
442254a0e8 | ||
|
|
f8187deda1 | ||
|
|
39b804da55 | ||
|
|
f4daffca61 | ||
|
|
1d5f35bd3e | ||
|
|
2089cfe717 | ||
|
|
9b5f2282db | ||
|
|
849e33dff3 | ||
|
|
d25ce2ad94 | ||
|
|
d78b1df041 | ||
|
|
c66d4e7084 | ||
|
|
9183a74fc3 | ||
|
|
d29666151d | ||
|
|
2216d28e62 | ||
|
|
9ffc2bb5b3 | ||
|
|
5f053ecd29 | ||
|
|
91e5279818 | ||
|
|
258f0bdbad | ||
|
|
f98e823991 | ||
|
|
ff49ccb08b | ||
|
|
7cae16ff63 | ||
|
|
34e74b1f7b | ||
|
|
d4c058e7dc | ||
|
|
efcd8f0798 | ||
|
|
4ab174243c | ||
|
|
d4e314e6f9 | ||
|
|
b29e4c826e | ||
|
|
4ab01abbf0 | ||
|
|
834cb76241 | ||
|
|
858bba598f | ||
|
|
27beceaf4d | ||
|
|
eb07de7421 | ||
|
|
7348c0f19b | ||
|
|
6e1c073a6f | ||
|
|
1e443c2dbb | ||
|
|
b153d4ae34 | ||
|
|
a3fec4c584 | ||
|
|
bf8e8ae20f | ||
|
|
ddd325ae29 | ||
|
|
b1704589ae | ||
|
|
6fa5fceff1 | ||
|
|
9ef7a3d3ae | ||
|
|
9eac1f872d | ||
|
|
d5c250c647 | ||
|
|
e150b1a122 | ||
|
|
117c9bdd95 | ||
|
|
a3cd9815bd | ||
|
|
73752f89c8 | ||
|
|
c1dfec8c4d | ||
|
|
64cefb7396 | ||
|
|
ab887df97f | ||
|
|
c5fbc9c363 | ||
|
|
8178f646c0 | ||
|
|
707a9c7a83 | ||
|
|
183f4f7f0f | ||
|
|
23d606a701 | ||
|
|
6b0da3cc5e | ||
|
|
39f758859d | ||
|
|
6fa0658f4c | ||
|
|
d13ab99344 | ||
|
|
c9bcdd6099 | ||
|
|
2263886efe | ||
|
|
5d3ddd9486 | ||
|
|
a2daf5a1e0 | ||
|
|
96d1bdd30f | ||
|
|
d275c56a8d | ||
|
|
09a23ddeb3 | ||
|
|
3c99483a49 | ||
|
|
2223bf722d | ||
|
|
066a6d5f09 | ||
|
|
128a0301f6 | ||
|
|
9ead42863d | ||
|
|
89e9a176c7 | ||
|
|
d5c163f621 | ||
|
|
6694d45e74 | ||
|
|
b23b814e43 | ||
|
|
9353daaf1a | ||
|
|
5a35117299 | ||
|
|
ee8aeed9b1 | ||
|
|
c188d4fe85 | ||
|
|
4ecfc98c81 | ||
|
|
38f344a71c | ||
|
|
815faddcf2 | ||
|
|
999ca88a19 | ||
|
|
dd35795ffa | ||
|
|
147902be2c | ||
|
|
7e9c2ba97d | ||
|
|
9fc9af3ba9 | ||
|
|
55526308f6 | ||
|
|
cdb3223a87 | ||
|
|
329235ba5e | ||
|
|
5031d1e554 | ||
|
|
090b5487e4 | ||
|
|
c608bbc46e | ||
|
|
46eff073b7 | ||
|
|
a347b7046b | ||
|
|
4658d04d2e | ||
|
|
b6aa1a8c58 | ||
|
|
5bff9e1bc1 | ||
|
|
dbd16882de | ||
|
|
adc98e8cea | ||
|
|
8e7d007c6f | ||
|
|
51c1577d7c | ||
|
|
98f9480157 | ||
|
|
2be9fa55a9 | ||
|
|
6520444d4e | ||
|
|
693d67bcfd | ||
|
|
b217778a79 | ||
|
|
3b18dfe522 | ||
|
|
ee5b7fe027 | ||
|
|
d2678643c0 | ||
|
|
343fdca493 | ||
|
|
5a8edf13ab | ||
|
|
1fa6b66f3a | ||
|
|
fe797ba2ac | ||
|
|
409c0f9d8c | ||
|
|
d22e780219 | ||
|
|
d4f722d121 | ||
|
|
38b3407b15 | ||
|
|
81959df037 | ||
|
|
05f9f44c79 | ||
|
|
08cfd95d6a | ||
|
|
f60ecbf2ee | ||
|
|
630389aebe | ||
|
|
603cd0f9a7 |
@@ -1,4 +0,0 @@
|
||||
FROM ghcr.io/astral-sh/uv:debian
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN apt update && apt upgrade -y && apt install -y chromium chromium-driver xvfb git
|
||||
@@ -3,36 +3,26 @@
|
||||
{
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
"dockerfile": "../Dockerfile",
|
||||
"target": "devcontainer"
|
||||
},
|
||||
"runArgs": ["--security-opt", "label=disable", "--privileged"],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"-ms-python.autopep8",
|
||||
"-ms-python.vscode-pylance"
|
||||
"ms-python.python",
|
||||
"tamasfe.even-better-toml"
|
||||
],
|
||||
"settings": {
|
||||
"python.venvPath": "./venv"
|
||||
}
|
||||
}
|
||||
},
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "pip3 install --user -r requirements.txt",
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
"name": "Byparr Dev Container",
|
||||
"runArgs": [
|
||||
"-p",
|
||||
"8181:8191"
|
||||
],
|
||||
"postCreateCommand": "uv sync --group test",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/act:1": {},
|
||||
"ghcr.io/devcontainers/features/desktop-lite:1": {}
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:4": {
|
||||
"moby": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
.extentions/
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
@@ -11,13 +11,29 @@ body:
|
||||
- label: Read Loop Warning on Readme
|
||||
- label: Done the troubleshooting from Readme
|
||||
- label: Checked if such issue already exists
|
||||
- label: Checked other websites with Cloudflare Turnstile
|
||||
- label: Checked other websites with Cloudflare Turnstile
|
||||
- label: I am able to access the webpage from my browser
|
||||
- label: (Docker only) Checked that the problem does also occur on main tag
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "If checked other websites with Cloudflare Turnstile:"
|
||||
options:
|
||||
- label: Other websites do work.
|
||||
- label: Other websites do not work.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "The issue is still present in the latest main tag:"
|
||||
options:
|
||||
- label: "Yes"
|
||||
- label: "No"
|
||||
|
||||
- type: input
|
||||
id: docker-host
|
||||
attributes:
|
||||
label: OS
|
||||
placeholder: e.g. Docker, WSL2, Ubuntu, Windows
|
||||
label: OS/Docker Host
|
||||
placeholder: e.g. Docker, WSL2, Ubuntu, Windows
|
||||
|
||||
- type: input
|
||||
id: platform-model
|
||||
@@ -34,7 +50,7 @@ body:
|
||||
- type: input
|
||||
id: website
|
||||
attributes:
|
||||
label: Website
|
||||
label: Example website/URL that does not work
|
||||
placeholder: e.g. https://nowsecure.nl
|
||||
|
||||
- type: textarea
|
||||
@@ -42,7 +58,7 @@ body:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: "Example: The application crashes when trying to load a new file"
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Cleanup PR Docker Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Delete PR Docker images
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner.toLowerCase();
|
||||
const repo = context.repo.repo.toLowerCase();
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Get the package
|
||||
const packageName = `${repo}`;
|
||||
|
||||
console.log(`Cleaning up images for PR #${prNumber}`);
|
||||
|
||||
// Check if owner is an org or user
|
||||
let isOrg = false;
|
||||
try {
|
||||
await github.rest.orgs.get({ org: owner });
|
||||
isOrg = true;
|
||||
console.log(`Repository owner is an organization`);
|
||||
} catch (error) {
|
||||
console.log(`Repository owner is a user account`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all versions of the package
|
||||
let versions;
|
||||
if (isOrg) {
|
||||
versions = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
org: owner,
|
||||
per_page: 100
|
||||
});
|
||||
} else {
|
||||
versions = await github.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
username: owner,
|
||||
per_page: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Filter versions that match this PR (pr-123, pr-123-amd64, pr-123-arm64)
|
||||
const prVersions = versions.data.filter(version => {
|
||||
const tags = version.metadata?.container?.tags || [];
|
||||
// Match tags like: pr-123, pr-123-amd64, pr-123-arm64
|
||||
return tags.some(tag =>
|
||||
tag === `pr-${prNumber}` ||
|
||||
tag === `pr-${prNumber}-amd64` ||
|
||||
tag === `pr-${prNumber}-arm64`
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Found ${prVersions.length} image version(s) to delete`);
|
||||
|
||||
// Delete each version
|
||||
for (const version of prVersions) {
|
||||
console.log(`Deleting version ${version.id} with tags: ${version.metadata?.container?.tags?.join(', ')}`);
|
||||
try {
|
||||
if (isOrg) {
|
||||
await github.rest.packages.deletePackageVersionForOrg({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
org: owner,
|
||||
package_version_id: version.id
|
||||
});
|
||||
} else {
|
||||
await github.rest.packages.deletePackageVersionForUser({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
username: owner,
|
||||
package_version_id: version.id
|
||||
});
|
||||
}
|
||||
console.log(`✓ Deleted version ${version.id}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete version ${version.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleanup completed');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log('No package found - nothing to clean up');
|
||||
} else {
|
||||
console.error('Error during cleanup:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,26 @@ on:
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "main.py"
|
||||
- "tests/**"
|
||||
- "Dockerfile"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "compose.yaml"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "main.py"
|
||||
- "tests/**"
|
||||
- "Dockerfile"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "compose.yaml"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -22,6 +40,10 @@ env:
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,24 +54,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
cache-from: type=gha,scope=x64
|
||||
cache-from: type=gha,scope=amd64
|
||||
pull: true
|
||||
cache-to: type=gha,mode=max,scope=x64
|
||||
cache-to: type=gha,mode=max,scope=amd64
|
||||
target: test
|
||||
build-args: |
|
||||
GITHUB_BUILD=true
|
||||
|
||||
build:
|
||||
needs: test
|
||||
# Skip build for Renovate PRs
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -62,26 +88,31 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Prepare variables
|
||||
id: vars
|
||||
run: |
|
||||
SURFIX=$(echo ${{ matrix.platform }} | cut -d'/' -f2)
|
||||
echo "SURFIX=$SURFIX" >> $GITHUB_OUTPUT
|
||||
# Generate a unique local tag for the image
|
||||
echo "LOCAL_TAG=${{ github.sha }}-$SURFIX" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use PR number for PRs, SHA for everything else
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
echo "LOCAL_TAG=pr-${{ github.event.pull_request.number }}-$SURFIX" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "LOCAL_TAG=${{ github.sha }}-$SURFIX" >> $GITHUB_OUTPUT
|
||||
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 }}
|
||||
@@ -90,30 +121,31 @@ 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 }}
|
||||
|
||||
# Build and export Docker image for each platform (without pushing)
|
||||
# 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
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
build-args: GITHUB_BUILD=true,VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }}
|
||||
cache-from: type=gha,scope=${{ steps.vars.outputs.SURFIX }}
|
||||
cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.SURFIX }}
|
||||
build-args: |
|
||||
GITHUB_BUILD=true
|
||||
VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }}
|
||||
|
||||
merge-and-push:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -121,19 +153,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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 }}
|
||||
@@ -142,7 +174,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
|
||||
@@ -154,6 +186,7 @@ jobs:
|
||||
|
||||
# Create manifest lists and push
|
||||
- name: Create and push manifest lists
|
||||
id: manifests
|
||||
run: |
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
args=""
|
||||
@@ -172,15 +205,30 @@ jobs:
|
||||
|
||||
echo $args
|
||||
|
||||
docker buildx imagetools create $args \
|
||||
${image}:${{github.sha}}-amd64 \
|
||||
${image}:${{github.sha}}-arm64
|
||||
# Use PR-based tags for PRs, SHA-based tags for everything else
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
docker buildx imagetools create $args \
|
||||
${image}:pr-${{ github.event.pull_request.number }}-amd64 \
|
||||
${image}:pr-${{ github.event.pull_request.number }}-arm64
|
||||
else
|
||||
docker buildx imagetools create $args \
|
||||
${image}:${{github.sha}}-amd64 \
|
||||
${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}
|
||||
|
||||
+4
-1
@@ -168,4 +168,7 @@ core
|
||||
screenshots/
|
||||
|
||||
# Downloaded files
|
||||
downloaded_files/
|
||||
downloaded_files/
|
||||
|
||||
# Browser Data
|
||||
browser_data/
|
||||
|
||||
Vendored
+16
-18
@@ -1,19 +1,17 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"LOG_LEVEL": "DEBUG"
|
||||
},
|
||||
"justMyCode": false,
|
||||
"name": "Python Debugger: Main",
|
||||
"program": "main.py",
|
||||
"request": "launch",
|
||||
"type": "debugpy"
|
||||
},
|
||||
],
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0"
|
||||
}
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"LOG_LEVEL": "DEBUG"
|
||||
},
|
||||
"purpose": ["debug-test", "debug-in-terminal"],
|
||||
"justMyCode": false,
|
||||
"name": "Python Debugger: Main",
|
||||
"program": "main.py",
|
||||
"request": "launch",
|
||||
"type": "debugpy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+17
-22
@@ -1,23 +1,18 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.packageIndexDepths": [
|
||||
{
|
||||
"depth": 5,
|
||||
"includeAllSymbols": true,
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"python.analysis.typeCheckingMode": "standard",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests",
|
||||
"-n",
|
||||
"auto",
|
||||
"--retries=3",
|
||||
],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false
|
||||
}
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.packageIndexDepths": [
|
||||
{
|
||||
"depth": 5,
|
||||
"includeAllSymbols": true,
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.testing.pytestArgs": ["tests", "-n", "auto", "--retries=3"],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"cSpell.words": ["camoufox", "domcontentloaded", "networkidle"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Copilot Instructions
|
||||
|
||||
## Project overview
|
||||
|
||||
- FastAPI service that mimics FlareSolverr-style API for bypassing anti-bot pages using Camoufox + Playwright captcha solver.
|
||||
- Entry point: main app in main.py; routes and request flow defined in src/endpoints.py and src/models.py.
|
||||
- Browser lifecycle is owned by get_camoufox() in src/utils.py, which yields a page, solver, and context for each request.
|
||||
|
||||
## Architecture and data flow
|
||||
|
||||
- Request flow: POST /v1 -> read_item() -> browser.goto() -> wait for load states -> detect challenge title -> solve captcha -> return LinkResponse.
|
||||
- Challenge detection uses CHALLENGE_TITLES from src/consts.py; update this map when adding providers.
|
||||
- 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 3
|
||||
- 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.
|
||||
+48
-26
@@ -1,40 +1,62 @@
|
||||
FROM debian:bullseye-slim 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 \
|
||||
VERSION
|
||||
ARG GITHUB_BUILD=false
|
||||
|
||||
ENV HOME=/root \
|
||||
GITHUB_BUILD=${GITHUB_BUILD}\
|
||||
VERSION=${VERSION}\
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
ENV GITHUB_BUILD=${GITHUB_BUILD}\
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# prevents python creating .pyc files
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
DISPLAY=:0
|
||||
ENV PATH="${HOME}/.local/bin:$PATH"
|
||||
UV_LINK_MODE=copy \
|
||||
PORT=8191 \
|
||||
XDG_CACHE_HOME=/cache \
|
||||
HOME=/home/byparr
|
||||
|
||||
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-get update &&\
|
||||
apt-get install -y --no-install-recommends git &&\
|
||||
uvx playwright install-deps firefox &&\
|
||||
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 apt-get update && \
|
||||
apt-get install -y --no-install-recommends --no-install-suggests xauth xvfb scrot curl chromium chromium-driver ca-certificates
|
||||
|
||||
ADD https://astral.sh/uv/install.sh install.sh
|
||||
RUN sh install.sh
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=${HOME}/.cache/uv uv sync
|
||||
|
||||
# SeleniumBase does not come with an arm64 chromedriver binary
|
||||
RUN cd .venv/lib/*/site-packages/seleniumbase/drivers && ln -s /usr/bin/chromedriver uc_driver
|
||||
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 . .
|
||||
|
||||
FROM base AS test
|
||||
RUN mkdir -p /home/byparr &&\
|
||||
chmod -R o+rX /app &&\
|
||||
chmod -R a+rwX /cache /home/byparr
|
||||
|
||||
RUN --mount=type=cache,target=${HOME}/.cache/uv uv sync --group test
|
||||
RUN ./test.sh
|
||||
FROM app AS test
|
||||
RUN \
|
||||
uv sync --group test &&\
|
||||
uv run pytest -rs --retries 3
|
||||
|
||||
FROM base
|
||||
|
||||
EXPOSE 8191
|
||||
HEALTHCHECK --interval=15m --timeout=30s --start-period=5s --retries=3 CMD [ "curl", "http://localhost:8191/health" ]
|
||||
CMD ["./cmd.sh"]
|
||||
FROM app
|
||||
ARG VERSION
|
||||
ENV VERSION=${VERSION}
|
||||
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"]
|
||||
|
||||
@@ -1,19 +1,95 @@
|
||||
# Byparr
|
||||
|
||||
An alternative to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) as a drop-in replacement, built with [seleniumbase](https://seleniumbase.io/) and [FastAPI](https://fastapi.tiangolo.com).
|
||||
<p align="center">
|
||||
<img src="icon/logo-byparr.svg" alt="Byparr logo" width="120" />
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
## Options
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
| -------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
|
||||
#### Browser language
|
||||
|
||||
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
|
||||
|
||||
> [!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)
|
||||
|
||||
> [!NOTE]
|
||||
> Thanks to FastAPI implementation, now you can also see the API documentation at `/docs` or `/` (redirect to `/docs`) endpoints.
|
||||
### 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
|
||||
### Docker troubleshooting
|
||||
|
||||
1. Clone repo to the host that has issues with Byparr.
|
||||
2. Run `docker build --target test .`
|
||||
@@ -21,7 +97,21 @@ An alternative to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) a
|
||||
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
|
||||
|
||||
### Local
|
||||
#### 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/)
|
||||
2. Download dependencies using `uv sync --group test`
|
||||
@@ -30,43 +120,3 @@ An alternative to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) a
|
||||
5. Depending of the test 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 test fails, try troubleshooting on another host/using other method
|
||||
|
||||
## Options
|
||||
|
||||
| Env | Default | Description |
|
||||
| -------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `USE_XVFB` | `SeleniumBase default` | Use virtual desktop with Xvfb. (Linux only) (Can cause performance hog [#14](https://github.com/ThePhaseless/Byparr/issues/14)) |
|
||||
| `USE_HEADLESS` | `SeleniumBase default` | Use headless chromium. |
|
||||
| `PROXY` | None | Proxy to use. (format: `username:password@host:port`) |
|
||||
|
||||
## Tags
|
||||
|
||||
- `v*.*.*`/`latest` - Releases considered stable
|
||||
- `main` - Latest release from main branch (untested)
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker Compose
|
||||
|
||||
See `compose.yaml`
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8191:8191 ghcr.io/thephaseless/byparr:latest
|
||||
```
|
||||
|
||||
### Local
|
||||
|
||||
```bash
|
||||
uv sync && ./cmd.sh
|
||||
```
|
||||
|
||||
## Need help with / TODO
|
||||
|
||||
- [x] Slimming container (only ~650 MB now!)
|
||||
- [x] Add more anti-bot challenges
|
||||
- [x] Add doc strings
|
||||
- [x] Implement versioning
|
||||
- [x] Proxy support
|
||||
- [x] Add more architectures support
|
||||
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
services:
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
restart: unless-stopped
|
||||
# environment:
|
||||
# LOG_LEVEL: debug
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
volumes:
|
||||
- ./screenshots:/app/screenshots # For screenshots when exception occurs
|
||||
ports:
|
||||
- "8191:8191"
|
||||
# Uncomment below to use byparr outside of internal network
|
||||
# ports:
|
||||
# - "8191:8191"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Find a click Cloudflare's checkbox actually accepts.
|
||||
|
||||
The widget is reachable now, but `Checkbox clicked successfully` leaves it
|
||||
`checked=False`, so the click is not registering as a user gesture. This tries
|
||||
each candidate in turn against a fresh page and reports which one ticks the box
|
||||
and which one clears the challenge.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e TRACE_URL=https://extratorrent.st/ \\
|
||||
-v "$PWD/out:/out" byparr-test uv run python debug/click_modes.py
|
||||
|
||||
MODES defaults to every strategy; set MODES=force,label to narrow it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
from src.utils import get_browser
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
MODES = os.environ.get(
|
||||
"MODES", "locator,force,check,label,mouse,widget_centre"
|
||||
).split(",")
|
||||
SETTLE = int(os.environ.get("SETTLE", "45"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def wait_for_widget(page, seconds: int = 30):
|
||||
"""Wait until the checkbox is visible, returning (frame, locator)."""
|
||||
for _ in range(seconds * 2):
|
||||
frame = cf_frame(page)
|
||||
if frame is not None:
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
if await box.count() and await box.first.is_visible():
|
||||
return frame, box.first
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
return None, None
|
||||
|
||||
|
||||
async def do_click(page, frame, box, mode: str) -> str:
|
||||
"""Perform one click strategy."""
|
||||
if mode == "locator":
|
||||
await box.click(timeout=10_000)
|
||||
return "locator.click()"
|
||||
if mode == "force":
|
||||
await box.click(timeout=10_000, force=True)
|
||||
return "locator.click(force=True)"
|
||||
if mode == "check":
|
||||
await box.check(timeout=10_000)
|
||||
return "locator.check()"
|
||||
if mode == "label":
|
||||
label = frame.locator("label")
|
||||
if await label.count():
|
||||
await label.first.click(timeout=10_000)
|
||||
return "label.click()"
|
||||
return "no label present"
|
||||
if mode in {"mouse", "widget_centre"}:
|
||||
rect = await box.bounding_box()
|
||||
if rect is None:
|
||||
return "no bounding box"
|
||||
x = rect["x"] + rect["width"] / 2
|
||||
y = rect["y"] + rect["height"] / 2
|
||||
# Approach first: a cursor that teleports is itself a signal.
|
||||
await page.mouse.move(x - 180, y - 120)
|
||||
await asyncio.sleep(0.3)
|
||||
await page.mouse.move(x - 40, y - 20, steps=18)
|
||||
await asyncio.sleep(0.2)
|
||||
await page.mouse.move(x, y, steps=10)
|
||||
await asyncio.sleep(0.35)
|
||||
await page.mouse.down()
|
||||
await asyncio.sleep(0.07)
|
||||
await page.mouse.up()
|
||||
return f"page.mouse at ({x:.0f}, {y:.0f})"
|
||||
return f"unknown mode {mode}"
|
||||
|
||||
|
||||
async def try_mode(mode: str) -> None:
|
||||
"""One fresh browser, one strategy, one verdict."""
|
||||
log(f"\n===== mode={mode} =====")
|
||||
async for dep in get_browser():
|
||||
page = dep.page
|
||||
await page.goto(URL, timeout=60_000)
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=30_000)
|
||||
|
||||
# Is our shadow-root patch even surviving the page's CSP?
|
||||
try:
|
||||
src = await page.evaluate("() => Element.prototype.attachShadow.toString()")
|
||||
flag = await page.evaluate("() => '_shadowRootPatched' in window")
|
||||
log(f" on challenge page: native={'[native code]' in src} flag={flag}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" tamper probe blocked: {str(exc)[:70]}")
|
||||
|
||||
frame, box = await wait_for_widget(page)
|
||||
if box is None:
|
||||
log(" checkbox never became visible")
|
||||
return
|
||||
|
||||
log(f" before: checked={await box.is_checked()}")
|
||||
try:
|
||||
what = await do_click(page, frame, box, mode)
|
||||
log(f" clicked via {what}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" click raised: {str(exc)[:120]}")
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(SETTLE // 3):
|
||||
await asyncio.sleep(3)
|
||||
elapsed = time.perf_counter() - start
|
||||
title = await page.title()
|
||||
checked = None
|
||||
frame_now = cf_frame(page)
|
||||
if frame_now is not None:
|
||||
try:
|
||||
b = frame_now.locator('input[type="checkbox"]')
|
||||
checked = await b.first.is_checked() if await b.count() else None
|
||||
except Exception: # noqa: BLE001
|
||||
checked = "unreadable"
|
||||
log(f" +{elapsed:3.0f}s checked={checked} title={title!r}")
|
||||
if "oment" not in title and "ierpliwo" not in title:
|
||||
log(f" >>> CLEARED by {mode}")
|
||||
await page.screenshot(path=str(OUT / f"cleared-{mode}.png"))
|
||||
return
|
||||
await page.screenshot(path=str(OUT / f"stuck-{mode}.png"))
|
||||
log(f" {mode}: still challenged")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Try each strategy on its own fresh browser."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### {URL} modes={MODES}")
|
||||
for mode in MODES:
|
||||
try:
|
||||
await try_mode(mode.strip())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" mode {mode} blew up: {str(exc)[:150]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""Watch Byparr work a Cloudflare challenge, with screenshots.
|
||||
|
||||
Runs the real /v1 handler against one URL and narrates the page every few
|
||||
seconds: what Cloudflare is showing, whether the challenge markers are still
|
||||
there, whether the widget frame is reachable, and whether the checkbox is
|
||||
clickable. Screenshots land in /out.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e TRACE_URL=https://extratorrent.st/ \\
|
||||
-e BUDGET=240 -v "$PWD/out:/out" byparr-test \\
|
||||
uv run python debug/probe.py
|
||||
|
||||
Set FRAMEWORK=patchright to launch the solver the other way for comparison.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(relativeCreated)8.0fms %(name)s %(levelname)s %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
for noisy in ("asyncio", "httpx", "httpcore", "urllib3"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
from src.endpoints import CHALLENGE_MARKERS, read_item # noqa: E402
|
||||
from src.models import LinkRequest # noqa: E402
|
||||
from src.utils import get_browser # noqa: E402
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
BUDGET = int(os.environ.get("BUDGET", "240"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately so a hung step is still visible."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame, if the browser exposes it."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def watch(page, seconds: int) -> None:
|
||||
"""Narrate the page while the handler works."""
|
||||
start = time.perf_counter()
|
||||
for i in range(seconds // 5):
|
||||
await asyncio.sleep(5)
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
title = await page.title()
|
||||
markers = await page.locator(CHALLENGE_MARKERS).count()
|
||||
body = (await page.locator("body").inner_text())[:70]
|
||||
body = body.replace("\n", " | ")
|
||||
|
||||
frame = cf_frame(page)
|
||||
widget = "no frame"
|
||||
if frame is not None:
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
count = await box.count()
|
||||
visible = count and await box.first.is_visible()
|
||||
checked = await box.first.is_checked() if count else None
|
||||
widget = f"checkbox={count} visible={bool(visible)} checked={checked}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
widget = f"frame unreadable: {str(exc)[:50]}"
|
||||
|
||||
log(f" +{elapsed:5.0f}s markers={markers} {widget} | {title!r} {body!r}")
|
||||
if i % 3 == 0:
|
||||
await page.screenshot(path=str(OUT / f"probe-{elapsed:04.0f}s.png"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" +{elapsed:5.0f}s <{str(exc)[:80]}>")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Call read_item and report what came back."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### {URL} budget={BUDGET}s")
|
||||
async for dep in get_browser():
|
||||
try:
|
||||
native = await dep.page.evaluate(
|
||||
"() => Element.prototype.attachShadow.toString()"
|
||||
)
|
||||
flagged = await dep.page.evaluate("() => '_shadowRootPatched' in window")
|
||||
log(f" attachShadow native: {'[native code]' in native}")
|
||||
log(f" _shadowRootPatched flag on window: {flagged}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" tamper probe failed: {str(exc)[:80]}")
|
||||
|
||||
watcher = asyncio.create_task(watch(dep.page, BUDGET))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await read_item(LinkRequest(url=URL, max_timeout=BUDGET), dep)
|
||||
took = time.perf_counter() - started
|
||||
cookies = [c["name"] for c in response.solution.cookies]
|
||||
log(f"\nRESULT ok in {took:.0f}s")
|
||||
log(f" solution.status = {response.solution.status}")
|
||||
log(f" bytes = {len(response.solution.response)}")
|
||||
log(f" cf_clearance = {'cf_clearance' in cookies}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"\nRESULT failed in {time.perf_counter() - started:.0f}s: {exc!r}"[:250])
|
||||
watcher.cancel()
|
||||
try:
|
||||
await dep.page.screenshot(path=str(OUT / "probe-final.png"))
|
||||
log(f"final title = {await dep.page.title()!r}")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Two variables at once: hide the patch, and click where a human would.
|
||||
|
||||
Findings this is built on, both measured on a residential connection:
|
||||
|
||||
* On the challenge page the library's unlockShadowRoot.js has run --
|
||||
`_shadowRootPatched` is a global and attachShadow is visibly patched. Any
|
||||
anti-bot script can read that in one line.
|
||||
* Turnstile's <input type="checkbox"> is invisible. Playwright reports a
|
||||
successful click on it and `checked` never flips, because the real target
|
||||
is the overlay drawn on top.
|
||||
|
||||
So: MODE=stealth patches shadow roots without leaving a global or a
|
||||
non-native toString; MODE=library keeps the current behaviour. Either way the
|
||||
click is a real mouse press at the widget's visible position, not a synthetic
|
||||
click on a hidden input.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e MODE=stealth \\
|
||||
-e TRACE_URL=https://extratorrent.st/ -v "$PWD/out:/out" \\
|
||||
byparr-test uv run python debug/stealth_click.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright_captcha import ClickSolver, FrameworkType
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
MODE = os.environ.get("MODE", "stealth")
|
||||
WATCH = int(os.environ.get("WATCH", "60"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
PREFS = {
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
}
|
||||
|
||||
STEALTH_UNLOCK = """
|
||||
(() => {
|
||||
const nativeToString = Function.prototype.toString;
|
||||
const spoofed = new WeakMap();
|
||||
const asNative = (fake, real) => { spoofed.set(fake, real); return fake; };
|
||||
|
||||
Function.prototype.toString = asNative(function toString() {
|
||||
const real = spoofed.get(this);
|
||||
return nativeToString.call(real === undefined ? this : real);
|
||||
}, nativeToString);
|
||||
|
||||
const hidden = new WeakMap();
|
||||
const realAttach = Element.prototype.attachShadow;
|
||||
Element.prototype.attachShadow = asNative(function attachShadow(init) {
|
||||
const root = realAttach.call(this, Object.assign({}, init, {mode: 'open'}));
|
||||
hidden.set(this, root);
|
||||
return root;
|
||||
}, realAttach);
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'shadowRoot');
|
||||
if (desc && desc.get) {
|
||||
const realGet = desc.get;
|
||||
Object.defineProperty(Element.prototype, 'shadowRoot', {
|
||||
get: asNative(function shadowRoot() {
|
||||
return realGet.call(this) || hidden.get(this);
|
||||
}, realGet),
|
||||
configurable: desc.configurable,
|
||||
enumerable: desc.enumerable,
|
||||
});
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
PROBE = """
|
||||
() => ({
|
||||
flag: '_shadowRootPatched' in window,
|
||||
attachNative: /\\[native code\\]/.test(Element.prototype.attachShadow.toString()),
|
||||
})
|
||||
"""
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def widget_box(page):
|
||||
"""Where the widget is drawn, in main-page coordinates."""
|
||||
try:
|
||||
el = page.locator('iframe[src*="challenges.cloudflare.com"]').first
|
||||
if await el.count():
|
||||
return await el.bounding_box()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def checkbox_state(page) -> str:
|
||||
"""What the hidden input currently reports."""
|
||||
frame = cf_frame(page)
|
||||
if frame is None:
|
||||
return "no frame"
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
if not await box.count():
|
||||
return "no input"
|
||||
return f"checked={await box.first.is_checked()}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"unreadable ({str(exc)[:40]})"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Load the challenge, press the widget like a person, watch the verdict."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### mode={MODE} {URL}")
|
||||
|
||||
async with InvisiblePlaywright(
|
||||
headless=True, humanize=True, locale="auto", extra_prefs=PREFS
|
||||
) as browser:
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
solver = None
|
||||
if MODE == "stealth":
|
||||
await page.add_init_script(STEALTH_UNLOCK)
|
||||
else:
|
||||
solver = ClickSolver(
|
||||
framework=FrameworkType.PLAYWRIGHT, page=page, max_attempts=1
|
||||
)
|
||||
await solver.__aenter__()
|
||||
|
||||
goto_timeout = int(os.environ.get("GOTO_TIMEOUT", "120")) * 1000
|
||||
try:
|
||||
response = await page.goto(URL, timeout=goto_timeout)
|
||||
log(f" goto status: {response.status if response else None}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" goto FAILED: {type(exc).__name__}: {str(exc)[:150]}")
|
||||
return
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=60_000)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" domcontentloaded wait failed: {str(exc)[:100]}")
|
||||
log(f" on challenge page: {await page.evaluate(PROBE)}")
|
||||
log(f" title: {await page.title()!r}")
|
||||
|
||||
# Wait for the checkbox to actually be presented, not merely for the
|
||||
# iframe to exist. Cloudflare cycles: the widget frame appears first
|
||||
# while it is still "checking if you are human" with no input in it,
|
||||
# and only then renders the checkbox. Pressing during that first phase
|
||||
# is a click into nothing, which is what every earlier probe did.
|
||||
ready = False
|
||||
for i in range(90):
|
||||
frame = cf_frame(page)
|
||||
if frame is not None:
|
||||
try:
|
||||
count = await frame.locator('input[type="checkbox"]').count()
|
||||
except Exception: # noqa: BLE001
|
||||
count = 0
|
||||
if count:
|
||||
log(f" checkbox presented after {i}s")
|
||||
ready = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
if not ready:
|
||||
log(" checkbox never appeared")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-no-checkbox.png"))
|
||||
return
|
||||
|
||||
# Let it settle, then confirm it is still presented before pressing.
|
||||
await asyncio.sleep(2)
|
||||
frame = cf_frame(page)
|
||||
if frame is None or not await frame.locator('input[type="checkbox"]').count():
|
||||
log(" checkbox vanished while settling -- Cloudflare moved on")
|
||||
return
|
||||
|
||||
box = await widget_box(page)
|
||||
if not box:
|
||||
log(" widget has no bounding box")
|
||||
return
|
||||
log(f" widget box: {box}")
|
||||
log(f" before: {await checkbox_state(page)}")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-before-click.png"))
|
||||
|
||||
# The checkbox sits at the left of the widget, vertically centred.
|
||||
x = box["x"] + 30
|
||||
y = box["y"] + box["height"] / 2
|
||||
await page.mouse.move(x - 200, y - 130)
|
||||
await asyncio.sleep(0.4)
|
||||
await page.mouse.move(x - 50, y - 25, steps=22)
|
||||
await asyncio.sleep(0.25)
|
||||
await page.mouse.move(x, y, steps=12)
|
||||
await asyncio.sleep(0.4)
|
||||
await page.mouse.down()
|
||||
await asyncio.sleep(0.08)
|
||||
await page.mouse.up()
|
||||
log(f" pressed at ({x:.0f}, {y:.0f})")
|
||||
|
||||
for i in range(WATCH // 3):
|
||||
await asyncio.sleep(3)
|
||||
title = await page.title()
|
||||
log(f" +{(i + 1) * 3:3d}s {await checkbox_state(page)} title={title!r}")
|
||||
if "oment" not in title and "ierpliwo" not in title:
|
||||
log(f" >>> CLEARED by mode={MODE}")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-cleared.png"))
|
||||
log(f" cookies={[c['name'] for c in await context.cookies()]}")
|
||||
return
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-end.png"))
|
||||
log(f" mode={MODE}: still challenged")
|
||||
|
||||
if solver:
|
||||
await solver.__aexit__(None, None, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M243.9 511.9c-58.1-3.2-110.4-24-155-61.8-7-5.9-21.2-20.1-27.2-27.2-20-23.7-35.2-49.2-45.4-76.5-8.3-22-13-42.6-15.5-67.3-1.1-10.8-1.1-35.7 0-46.5 5.6-54.9 25.6-101.9 61-143.8 5.9-7 20.1-21.2 27.2-27.2 23.7-20 49.2-35.2 76.5-45.4 22-8.3 42.7-13 67.3-15.5 10.8-1.1 35.7-1.1 46.5 0 29.1 3 55.1 9.7 80 20.7 39.2 17.3 72.8 43.6 99.6 78 5.3 6.7 14.8 21 19.4 29.1 15.8 27.6 26.4 58.3 31.1 89.8 2 13.6 2.5 21.5 2.5 37.5s-.5 23.9-2.5 37.5c-7.1 47.8-27.1 91.6-59.2 129.5-5.9 7-20.1 21.2-27.2 27.2-16.4 13.8-34.2 25.8-51.8 34.6-28.8 14.5-59.1 23.1-91.7 26.3-7.4.8-28.9 1.3-35.6 1" style="fill:#fff"/><path d="M246.5 395.1c-1.7-.2-3.9-.3-5-.2-1 .1-3.5 0-5.5-.2-2-.3-6.1-.8-9.3-1.2s-6.7-.9-7.8-1.2c-1.2-.3-7.7-3.2-14.5-6.6s-14.3-7-16.8-8.1c-8.5-3.7-14.5-8.1-18.1-13.4-2.1-3.1-3.7-4.4-8.2-6.7-4-2-6-4.1-10.9-11-2-2.9-5-6.8-6.6-8.7-10.9-12.9-11.1-13.3-12.4-19.5-.5-2.5-1.6-5.4-2.9-7.8-4-7.4-5.4-10.8-6.4-14.9-.5-2.3-1.7-6.6-2.6-9.6-1.5-5.1-1.7-6.1-2.1-15.8-.4-9.4-.4-10.9.5-16.1.5-3.2 1.1-7.4 1.2-9.4.2-2 .6-5.4 1-7.6s.9-6.5 1.2-9.5c1-9.8 2.6-14.1 9.1-23.8 4.2-6.2 5.2-8.5 6-13.5.6-3.9 1.4-5.7 4.1-8.9 1.2-1.5 3.5-5.4 5.1-8.6 3.1-6.4 3.2-6.5 8.2-9.9 1.9-1.3 5.8-4 8.6-6s6.3-4.4 7.6-5.4c1.7-1.2 3.1-2.7 4.3-4.6 4.1-6.6 6.1-8 16.3-11.7 3.1-1.1 7.5-3.1 9.8-4.5 5.3-3.1 9-4.4 15-5 2.8-.3 6.8-1.2 9.8-2.1 4.5-1.4 5.6-1.6 10.4-1.6 5.7 0 6.4-.2 13.2-3.1 4.8-2.1 8.9-2.5 14.8-1.5 2.7.5 7.2 1.1 10.1 1.5 6.2.8 6.1.8 11.2 3.1 3.4 1.5 4.9 1.9 9.3 2.3 7 .7 7.9 1 16.1 5.1 3.9 2 9.5 4.5 12.4 5.7 9.8 3.9 14.3 7.2 22.1 15.7 3.3 3.6 8.6 9.2 11.9 12.5 14.5 14.5 14.9 15 16.5 19.4.8 2.2 2.6 6 3.9 8.3 5.2 9.2 12.2 23.8 13.4 28 .7 2.3 1.9 6.5 2.7 9.3 2.1 6.9 2.4 14.2 1 23.6-1.2 8-1.2 11.9-.3 18 .6 3.6.6 5.5.3 9.4-.7 6.7-1.6 10-4.1 14.1-1.3 2.2-2.4 4.7-2.9 6.6-1.8 7.8-5.6 16.3-10.7 24.3-1.4 2.2-3.4 5.5-4.3 7.4-2.2 4.3-4.2 6.6-8.2 9.6-3.7 2.7-14.5 14-17.7 18.4-2.2 3.1-7.1 7.4-10.6 9.4-1.1.6-4.3 1.8-7.2 2.6s-7.1 2.5-9.5 3.7c-3.6 1.8-5 3-8.6 6.6-2.3 2.4-4.9 4.7-5.7 5.1-1.6.8-5.5 1.5-10.7 2-1.7.2-4.6.8-6.4 1.5-7 2.6-7.3 2.6-16.1 2.7-6.9.1-9.3.3-12.3 1.2-4.9.9-8.8 1.2-12.7.6" style="fill:#cc7a3d"/><path d="M237.7 473.6c-42-3.8-79.9-18.3-113-43.2C56 378.7 24.1 291.7 43.1 207.6c6.6-29.2 19.8-57.8 37.7-81.9 47.7-64.1 126.8-97.1 205.8-85.9 36 5.1 70.5 19.3 99.7 41C455.7 132.5 488 220 468.9 304.4c-11.4 50.4-40.9 95.7-82.6 126.8-29.1 21.7-63.7 35.9-99.7 41-15.1 2.2-34.5 2.7-48.9 1.4m33.7-5.2c29-2.5 54.4-9.5 79-21.8 41.4-20.6 75.5-54.8 96.1-96.1 16.9-34 24.9-74.2 21.8-109.9-3.7-42.4-17.6-79.2-42.3-112-34.4-45.8-84.7-75.5-141.4-83.5-25.2-3.6-49.5-2.5-75.2 3.3-49.6 11.2-93.7 40-124.4 81.2-21.3 28.7-35 62-40.1 97.8-2.8 19.4-2.8 38 0 57.4 8.1 56.7 37.7 107 83.5 141.4 11.1 8.3 21 14.5 33 20.5 34.1 16.9 74.4 24.8 110 21.7" style="fill:#b1b1b1"/><path d="M253.9 367.3c-2.9-.7-4.3-1.4-6-2.9-2.9-2.6-3.9-4.9-3.9-8.9 0-3.9 1-6.3 3.9-8.9 6-5.4 15.8-3.1 19 4.3 1.1 2.6 1 7-.3 9.6-1.2 2.4-3.7 4.9-6 5.8-1.7.8-5.3 1.3-6.7 1m68.4-29.3c-9.2-3.4-11-15.4-3.1-21.1 8.1-5.9 20 1 18.8 11-.5 4-3 7.6-6.7 9.5-2.4 1.2-6.6 1.5-9 .6m-139.8-.2c-3.4-1.1-6.2-3.5-7.9-6.7-1.3-2.4-1.2-7.7.2-10.2 1.2-2.3 3.9-4.8 6.3-5.8 2.6-1.1 7-1 9.6.3 4.2 2.1 6.7 6 6.7 10.7 0 6.5-4.7 11.5-11 11.9-1.5.1-3.3 0-3.9-.2m43.2-22.8c-2.4-.8-4.1-1.9-5.8-3.8-5.4-6-3.1-15.8 4.3-19 2.6-1.1 7-1 9.6.3 2.5 1.2 4.9 3.7 5.9 6.2.9 2.1 1 6.3.1 8.8-.8 2.3-3.5 5.3-5.9 6.6-1.9 1-6.3 1.5-8.2.9m54.6 0c-2.4-.8-4.1-1.9-5.8-3.8-5.4-6-3.1-15.8 4.3-19 2.6-1.1 7-1 9.6.3 2.5 1.2 4.9 3.7 5.9 6.2.9 2.1 1 6.3.1 8.8-.8 2.3-3.5 5.3-5.9 6.6-1.9 1-6.3 1.5-8.2.9m-27.6-47c-2.2-.7-4-1.9-5.5-3.5-2.5-2.7-3.2-4.5-3.2-8.5 0-3.9.7-5.8 3.2-8.5s4.8-3.6 8.8-3.6 6.3.9 8.8 3.6 3.2 4.5 3.2 8.5c0 2.5-.3 4-.9 5.2-1.2 2.3-3.5 4.7-5.7 5.8-1.9 1.1-6.7 1.6-8.7 1m99.7 0c-2.4-.8-4.1-1.9-5.8-3.8-5.4-6-3.1-15.8 4.3-19 2.6-1.1 7-1 9.6.3 2.5 1.2 4.9 3.7 5.9 6.2.9 2.1 1 6.3.1 8.8-.8 2.3-3.5 5.3-5.9 6.6-1.9 1-6.3 1.5-8.2.9m-198.9-.7c-8.9-2.5-11.8-13.8-5.1-20.1 2.6-2.5 4.5-3.2 8.2-3.2 3.9 0 6.3 1 8.9 3.9 2.1 2.3 3 4.7 2.9 8.2 0 7.6-7.5 13.2-14.9 11.2m42.9-.4c-2.3-1.2-5.2-4.1-6.1-6.4-1-2.4-.9-7.1.3-9.5 1.1-2.3 4.1-5.2 6.4-6.2 2.4-1 7.1-.9 9.5.3 2.3 1.1 5.2 4.1 6.2 6.4 1 2.4.9 7.1-.3 9.5-1.1 2.3-4.1 5.2-6.4 6.2-2.5 1.1-7.2.9-9.6-.3m109-.3c-11.1-5.6-7-22.5 5.5-22.4 3.7 0 6.1 1.1 8.6 3.9 2.2 2.4 3.2 4.9 3.2 8s-1 5.6-3.2 8c-2.5 2.8-5 3.9-8.6 3.9-2.3-.1-3.5-.4-5.5-1.4m-79.7-46.3c-8.9-2.5-11.8-13.8-5.1-20.1 2.6-2.5 4.5-3.2 8.2-3.2 3.9 0 6.3 1 8.9 3.9 2.1 2.3 3 4.7 2.9 8.2-.1 7.6-7.6 13.3-14.9 11.2m54.5 0c-8-2.2-11.3-12-6.4-18.5 5.7-7.5 16.1-6.4 20.5 2 1.3 2.4 1.2 7.7-.2 10.2-2.6 5.1-8.6 7.8-13.9 6.3m43.2-22.8c-8-2.2-11.3-12-6.4-18.5 5.7-7.5 16.1-6.4 20.5 2 1.3 2.4 1.2 7.7-.2 10.2-2.6 5.2-8.6 7.8-13.9 6.3m-140.6-.5c-8.7-2.4-11.6-13-5.4-19.7 2.5-2.6 4.8-3.6 8.6-3.6 3.9 0 6.3 1.1 8.8 3.8 2.2 2.5 3 4.7 3 8 .1 8-7.3 13.6-15 11.5m70.3-28.9c-8-2.2-11.3-12-6.4-18.5 5.7-7.5 16.1-6.4 20.5 2 1.3 2.4 1.2 7.7-.2 10.2-2.6 5.1-8.5 7.8-13.9 6.3" style="fill:#884717"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,24 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from src.consts import LOG_LEVEL, VERSION
|
||||
from src.endpoints import router
|
||||
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 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))
|
||||
|
||||
app = FastAPI(debug=LOG_LEVEL == logging.DEBUG, log_level=LOG_LEVEL)
|
||||
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_browser():
|
||||
await health_check(browser)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8191, log_level=LOG_LEVEL) # noqa: S104
|
||||
# Check for --init flag to run the app in development mode
|
||||
if "--init" in sys.argv:
|
||||
logger.info("Running initialization script...")
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(init())
|
||||
logger.info("Initialization complete.")
|
||||
else:
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
|
||||
+22
-22
@@ -2,33 +2,34 @@
|
||||
[project]
|
||||
authors = [{ name = "ThePhaseless", email = "kukubaorch@gmail.com" }]
|
||||
license = { text = "LICENSE" }
|
||||
requires-python = "<4.0,>=3.12"
|
||||
requires-python = "==3.14.*"
|
||||
name = "Byparr"
|
||||
version = "0.1.0"
|
||||
description = "API for getting cookies for Cloudflare challenges"
|
||||
readme = "README.md"
|
||||
dependencies = [
|
||||
"fastapi[standard]==0.115.12",
|
||||
"pyautogui==0.9.54",
|
||||
"pydantic==2.11.2",
|
||||
"seleniumbase==4.37.1",
|
||||
"uvicorn==0.34.0",
|
||||
"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.1",
|
||||
"pytest-asyncio==0.26.0",
|
||||
"pytest-progress==1.3.0",
|
||||
"pytest-retry==1.7.0",
|
||||
"pytest-xdist==3.6.1",
|
||||
]
|
||||
dev = ["deptry==0.23.0", "ruff==0.11.4"]
|
||||
|
||||
test = [
|
||||
"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.25.*", "ruff==0.16.*"]
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["pyautogui"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = [
|
||||
"D203",
|
||||
@@ -53,13 +54,12 @@ ignore = [
|
||||
"G004",
|
||||
"ANN001",
|
||||
"ANN204",
|
||||
"ANN206",
|
||||
"CPY001",
|
||||
"BLE001",
|
||||
]
|
||||
select = ["ALL"]
|
||||
extend-safe-fixes = ["D415"]
|
||||
|
||||
[tool.pytest]
|
||||
log_cli = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
[tool.pyright]
|
||||
reportMissingTypeStubs = false
|
||||
typeCheckingMode = "strict"
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"$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,
|
||||
|
||||
+36
-29
@@ -1,43 +1,50 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def get_version_from_env():
|
||||
"""
|
||||
Retrieve the version from the environment variable 'VERSION'.
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
This function checks the 'VERSION' environment variable for a value
|
||||
that starts with 'v' and returns the version without the prefix.
|
||||
log_level: str = "INFO"
|
||||
version: str = "unknown"
|
||||
|
||||
Returns:
|
||||
str | None: The version string without the 'v' prefix, or None if
|
||||
the 'VERSION' environment variable is not set or does not start
|
||||
with 'v'.
|
||||
# The solver retries whenever it cannot reach the challenge widget, and
|
||||
# that failure is usually structural rather than transient -- an
|
||||
# unreachable widget stays unreachable. sys.maxsize meant a single request
|
||||
# burned its whole max_timeout on ~1300 identical failed attempts before
|
||||
# reporting a 408. Give it a handful of tries, then let the caller know.
|
||||
max_attempts: int = 5
|
||||
|
||||
"""
|
||||
version_env = os.getenv("VERSION")
|
||||
if not version_env:
|
||||
return None
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
|
||||
return version_env.removeprefix("v")
|
||||
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
|
||||
browser_locale: str | None = None
|
||||
|
||||
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL") or "INFO"
|
||||
LOG_LEVEL = logging.getLevelNamesMapping()[LOG_LEVEL.upper()]
|
||||
settings = Settings()
|
||||
|
||||
VERSION = get_version_from_env() or "unknown"
|
||||
LOG_LEVEL = logging.getLevelNamesMapping()[settings.log_level.upper()]
|
||||
VERSION = settings.version.removeprefix("v")
|
||||
|
||||
USE_XVFB = os.getenv("USE_XVFB") in ["true", "1"] if os.getenv("USE_XVFB") else None
|
||||
MAX_ATTEMPTS = settings.max_attempts
|
||||
|
||||
USE_HEADLESS = (
|
||||
os.getenv("USE_HEADLESS") in ["true", "1"] if os.getenv("USE_HEADLESS") else None
|
||||
)
|
||||
PROXY_SERVER = settings.proxy_server
|
||||
PROXY_USERNAME = settings.proxy_username
|
||||
PROXY_PASSWORD = settings.proxy_password
|
||||
|
||||
PROXY = os.getenv("PROXY")
|
||||
HOST = settings.host
|
||||
PORT = settings.port
|
||||
|
||||
CHALLENGE_TITLES = [
|
||||
# Cloudflare
|
||||
"Just a moment...",
|
||||
# DDoS-GUARD
|
||||
"DDoS-Guard",
|
||||
]
|
||||
BLOCK_MEDIA = settings.block_media
|
||||
RETURN_ONLY_COOKIES = settings.return_only_cookies
|
||||
|
||||
OWUI_API_KEY = settings.owui_api_key
|
||||
BROWSER_LOCALE = settings.browser_locale
|
||||
|
||||
Executable → Regular
+280
-32
@@ -1,23 +1,58 @@
|
||||
import base64
|
||||
import time
|
||||
import warnings
|
||||
from asyncio import sleep
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sbase import BaseCase
|
||||
from playwright.async_api import Page
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.consts import CHALLENGE_TITLES
|
||||
from src.models import (
|
||||
HealthcheckResponse,
|
||||
LinkRequest,
|
||||
LinkResponse,
|
||||
Solution,
|
||||
)
|
||||
from src.utils import BrowserDepClass, TimeoutTimer, get_browser, logger
|
||||
|
||||
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||
|
||||
from .utils import get_sb, logger, save_screenshot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SeleniumDep = Annotated[BaseCase, Depends(get_sb)]
|
||||
BrowserDep = Annotated[BrowserDepClass, Depends(get_browser)]
|
||||
|
||||
# Markup only an unsolved challenge has. Two near misses to avoid:
|
||||
#
|
||||
# script[src*="/cdn-cgi/challenge-platform/"] on its own also matches the jsd
|
||||
# bot-scoring beacon Cloudflare serves from that path on ordinary pages, so it
|
||||
# has to be narrowed to the challenge orchestrator (chl_page).
|
||||
#
|
||||
# iframe[src*="challenges.cloudflare.com"] looks like the widget but outlives
|
||||
# it: a cleared nowsecure.nl carries two of them with no challenge in sight.
|
||||
CHALLENGE_MARKERS = (
|
||||
'script[src*="/cdn-cgi/challenge-platform/"][src*="chl_page"], '
|
||||
"#challenge-error-text, #challenge-running, #challenge-stage"
|
||||
)
|
||||
|
||||
# The widget lives in an iframe served from here; the checkbox is an invisible
|
||||
# input inside it, so it is pressed by position rather than by locator. 30px in
|
||||
# from the widget's left edge is the middle of the box Cloudflare draws.
|
||||
CF_WIDGET_HOST = "challenges.cloudflare.com"
|
||||
CHECKBOX_SELECTOR = 'input[type="checkbox"]'
|
||||
CHECKBOX_OFFSET_X = 30
|
||||
|
||||
# How often to look, and how long to leave a press alone before trying again.
|
||||
# Verification takes 5-15s, and pressing over the top of it just restarts the
|
||||
# cycle.
|
||||
CHALLENGE_POLL_SECONDS = 1.0
|
||||
PRESS_INTERVAL_SECONDS = 12.0
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
@@ -28,9 +63,9 @@ def read_root():
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check(sb: SeleniumDep):
|
||||
async def health_check(sb: BrowserDep):
|
||||
"""Health check endpoint."""
|
||||
health_check_request = read_item(
|
||||
health_check_request = await read_item(
|
||||
LinkRequest.model_construct(url="https://google.com"),
|
||||
sb,
|
||||
)
|
||||
@@ -41,45 +76,258 @@ def health_check(sb: SeleniumDep):
|
||||
detail="Health check failed",
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
return HealthcheckResponse(user_agent=health_check_request.solution.user_agent)
|
||||
|
||||
|
||||
@router.post("/v1")
|
||||
def read_item(request: LinkRequest, sb: SeleniumDep) -> LinkResponse:
|
||||
async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
"""Handle POST requests."""
|
||||
start_time = int(time.time() * 1000)
|
||||
sb.uc_open_with_reconnect(request.url)
|
||||
logger.debug(f"Got webpage: {request.url}")
|
||||
source_bs = sb.get_beautiful_soup()
|
||||
title_tag = source_bs.title
|
||||
if title_tag and title_tag.string in CHALLENGE_TITLES:
|
||||
logger.debug("Challenge detected")
|
||||
sb.uc_gui_click_captcha()
|
||||
logger.info("Clicked captcha")
|
||||
timer = TimeoutTimer(duration=request.max_timeout)
|
||||
request.url = request.url.replace('"', "").strip()
|
||||
|
||||
if sb.get_title() in CHALLENGE_TITLES:
|
||||
save_screenshot(sb)
|
||||
raise HTTPException(status_code=500, detail="Could not bypass challenge")
|
||||
await setup_routes(request, dep)
|
||||
|
||||
cookies = sb.get_cookies()
|
||||
for cookie in cookies:
|
||||
name = cookie["name"]
|
||||
value = cookie["value"]
|
||||
cookie["size"] = len(f"{name}={value}".encode())
|
||||
try:
|
||||
challenge_detected, page_html, page_request, status = await _navigate_and_solve(
|
||||
dep, request, timer
|
||||
)
|
||||
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 loading the page or solving the challenge",
|
||||
) from e
|
||||
|
||||
cookie["session"] = False
|
||||
if "expiry" in cookie:
|
||||
cookie["expires"] = cookie["expiry"]
|
||||
cookies = await dep.context.cookies()
|
||||
content_type, response_content = await build_response_content(
|
||||
dep,
|
||||
request,
|
||||
page_request,
|
||||
challenge_detected=challenge_detected,
|
||||
page_html=page_html,
|
||||
)
|
||||
|
||||
user_agent = page_request.request.headers.get("user-agent") if page_request else ""
|
||||
|
||||
return LinkResponse(
|
||||
message="Success",
|
||||
solution=Solution(
|
||||
userAgent=sb.get_user_agent(),
|
||||
url=sb.get_current_url(),
|
||||
status=200,
|
||||
user_agent=user_agent,
|
||||
url=dep.page.url,
|
||||
status=status,
|
||||
cookies=cookies,
|
||||
headers={},
|
||||
response=str(sb.get_beautiful_soup()),
|
||||
headers=page_request.headers if page_request else {},
|
||||
response=response_content,
|
||||
content_type=content_type,
|
||||
),
|
||||
start_timestamp=start_time,
|
||||
)
|
||||
|
||||
|
||||
async def setup_routes(request: LinkRequest, dep: BrowserDep) -> None:
|
||||
"""Install request routes for media blocking."""
|
||||
if request.block_media:
|
||||
|
||||
async def block_media_route(route) -> None:
|
||||
if route.request.resource_type in ("image", "media", "font"):
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
|
||||
await dep.page.route("**/*", block_media_route)
|
||||
|
||||
|
||||
async def _navigate_and_solve(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
timer: TimeoutTimer,
|
||||
) -> tuple[bool, str | None, object, HTTPStatus]:
|
||||
"""Navigate to the URL, then solve a challenge or wait for network idle."""
|
||||
page_html: str | None = None
|
||||
page_request = await dep.page.goto(request.url, timeout=timer.remaining() * 1000)
|
||||
status = page_request.status if page_request else HTTPStatus.OK
|
||||
await dep.page.wait_for_load_state(
|
||||
state="domcontentloaded", timeout=timer.remaining() * 1000
|
||||
)
|
||||
|
||||
challenge_active = await detect_cloudflare_challenge(
|
||||
dep.page, "interstitial"
|
||||
) or await detect_cloudflare_challenge(dep.page, "turnstile")
|
||||
if not challenge_active:
|
||||
page_html = await dep.page.content()
|
||||
await _wait_for_networkidle(dep, timer)
|
||||
return False, page_html, page_request, status
|
||||
|
||||
await _solve_challenge(dep, timer)
|
||||
status = HTTPStatus.OK
|
||||
return True, page_html, page_request, status
|
||||
|
||||
|
||||
async def _solve_challenge(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""
|
||||
Attempt to solve a detected Cloudflare interstitial challenge.
|
||||
|
||||
Handles both shapes Cloudflare serves: the non-interactive challenge, which
|
||||
clears itself given a few seconds, and the interactive one, which needs the
|
||||
checkbox pressed. Both are covered by the same loop -- watch for the
|
||||
challenge markup to disappear, and press whenever a checkbox is on offer.
|
||||
|
||||
playwright-captcha's solver is deliberately not used here. It clicks the
|
||||
checkbox input directly, and that input is invisible, so the click reports
|
||||
success while `checked` never flips. It also judges the result by waiting
|
||||
for networkidle, which returned 9ms after the click while Cloudflare was
|
||||
still verifying, so it reported failure on challenges that were about to
|
||||
pass.
|
||||
"""
|
||||
logger.info("Challenge detected, attempting to solve...")
|
||||
last_press = -PRESS_INTERVAL_SECONDS
|
||||
while timer.remaining() > 0:
|
||||
if not await _challenge_visible(dep.page):
|
||||
logger.info("Challenge cleared")
|
||||
return
|
||||
|
||||
elapsed = timer.duration - timer.remaining()
|
||||
if elapsed - last_press >= PRESS_INTERVAL_SECONDS and await _press_checkbox(
|
||||
dep.page
|
||||
):
|
||||
last_press = elapsed
|
||||
|
||||
await sleep(CHALLENGE_POLL_SECONDS)
|
||||
|
||||
message = "Challenge still present when the request budget ran out"
|
||||
raise TimeoutError(message)
|
||||
|
||||
|
||||
def _cloudflare_frame(page: Page) -> object | None:
|
||||
"""Find the turnstile widget's frame; None while Cloudflare is between states."""
|
||||
for frame in page.frames:
|
||||
if CF_WIDGET_HOST in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def _press_checkbox(page: Page) -> bool:
|
||||
"""
|
||||
Press the checkbox, if one is currently on offer. True when a press happened.
|
||||
|
||||
Two things make this harder than locator.click():
|
||||
|
||||
Cloudflare cycles between "checking if you are human", where the widget
|
||||
frame holds no input at all, and the state where the checkbox is offered.
|
||||
Pressing during the first phase clicks nothing, so wait for the input to
|
||||
exist before reaching for the mouse.
|
||||
|
||||
And the input is invisible -- it sits under a styled overlay. Playwright
|
||||
reports a successful click on it and `checked` never flips, which is why
|
||||
playwright-captcha's own click has never solved one of these. Pressing the
|
||||
widget's visible pixels does work: measured against ext.to, this clears the
|
||||
challenge and returns a cf_clearance cookie.
|
||||
"""
|
||||
frame = _cloudflare_frame(page)
|
||||
if frame is None:
|
||||
return False
|
||||
try:
|
||||
checkbox = frame.locator(CHECKBOX_SELECTOR)
|
||||
if not await checkbox.count():
|
||||
return False
|
||||
if await checkbox.first.is_checked():
|
||||
# A press has already landed and Cloudflare is verifying it.
|
||||
# Pressing over the top restarts that verification, which is how
|
||||
# ext.to and speed.cd sat on "performing security verification" for
|
||||
# a full 300s budget while being pressed a dozen times.
|
||||
return False
|
||||
element = await frame.frame_element()
|
||||
box = await element.bounding_box()
|
||||
except Exception:
|
||||
# The widget is mid-swap; try again on the next poll.
|
||||
return False
|
||||
if not box:
|
||||
return False
|
||||
|
||||
x = box["x"] + CHECKBOX_OFFSET_X
|
||||
y = box["y"] + box["height"] / 2
|
||||
try:
|
||||
# Approach before pressing: a cursor that teleports onto the target is
|
||||
# itself a signal.
|
||||
await page.mouse.move(x - 180, y - 120)
|
||||
await sleep(0.3)
|
||||
await page.mouse.move(x - 45, y - 20, steps=18)
|
||||
await sleep(0.2)
|
||||
await page.mouse.move(x, y, steps=10)
|
||||
await sleep(0.35)
|
||||
await page.mouse.down()
|
||||
await sleep(0.08)
|
||||
await page.mouse.up()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Checkbox press failed: {exc}")
|
||||
return False
|
||||
logger.info(f"Pressed the Cloudflare checkbox at ({x:.0f}, {y:.0f}) in {box}")
|
||||
return True
|
||||
|
||||
|
||||
async def _challenge_visible(page: Page) -> bool:
|
||||
"""
|
||||
Report whether an unsolved challenge is still on the page.
|
||||
|
||||
Cloudflare serves two different scripts from /cdn-cgi/challenge-platform/:
|
||||
the challenge orchestrator on an interstitial, and the jsd bot-scoring
|
||||
beacon on ordinary pages once a visitor is cleared. detect_cloudflare_
|
||||
challenge() matches both, so on its own it never reports success. Match the
|
||||
orchestrator and the widget instead.
|
||||
"""
|
||||
try:
|
||||
return await page.locator(CHALLENGE_MARKERS).count() > 0
|
||||
except Exception:
|
||||
# A navigation tore down the execution context mid-check, which only
|
||||
# happens once Cloudflare has moved us on.
|
||||
logger.debug("Challenge lookup interrupted by a navigation")
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_for_networkidle(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""Wait for network idle, tolerating post-DOM-load stalls."""
|
||||
try:
|
||||
await dep.page.wait_for_load_state(
|
||||
"networkidle", timeout=timer.remaining() * 1000
|
||||
)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info(
|
||||
"networkidle timed out after domcontentloaded; continuing with loaded page"
|
||||
)
|
||||
|
||||
|
||||
async def build_response_content(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
page_request: object,
|
||||
*,
|
||||
challenge_detected: bool,
|
||||
page_html: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build (content_type, response_content) from the settled page."""
|
||||
if request.return_only_cookies:
|
||||
return "text/html", ""
|
||||
|
||||
if page_request and page_request.headers.get("content-type", "").startswith(
|
||||
"application/pdf"
|
||||
):
|
||||
return await _fetch_pdf_content(dep)
|
||||
|
||||
response_content = (
|
||||
page_html
|
||||
if page_html is not None and not challenge_detected
|
||||
else await dep.page.content()
|
||||
)
|
||||
return "text/html", response_content
|
||||
|
||||
|
||||
async def _fetch_pdf_content(dep: BrowserDep) -> tuple[str, str]:
|
||||
"""Fetch raw PDF bytes as base64, falling back to viewer HTML on failure."""
|
||||
try:
|
||||
fetch_response = await dep.page.request.fetch(dep.page.url)
|
||||
response_content = base64.b64encode(await fetch_response.body()).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch PDF bytes, falling back to viewer HTML")
|
||||
return "text/html", await dep.page.content()
|
||||
return "application/pdf", response_content
|
||||
|
||||
+5
-4
@@ -1,16 +1,17 @@
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
from src.models import LinkRequest
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class LogRequest(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
|
||||
"""Log requests."""
|
||||
if request.url.path != "/v1":
|
||||
if request.url.path != "/v1" or request.method != "POST":
|
||||
return await call_next(request)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
@@ -24,6 +25,6 @@ class LogRequest(BaseHTTPMiddleware):
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
logger.info(f"Done {request_body.url} in {process_time:.2f}s")
|
||||
else:
|
||||
logger.info(f"Failed {request_body.url} in {process_time:.2f}s")
|
||||
logger.warning(f"Failed {request_body.url} in {process_time:.2f}s")
|
||||
|
||||
return response
|
||||
|
||||
+49
-25
@@ -1,47 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from http.client import INTERNAL_SERVER_ERROR
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from playwright.sync_api import Cookie
|
||||
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.",
|
||||
)
|
||||
url: str = Field(pattern=r"^https?://", default="https://")
|
||||
max_timeout: int = Field(default=60)
|
||||
max_timeout: int = Field(
|
||||
default=60,
|
||||
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):
|
||||
model_config = {"alias_generator": to_camel, "populate_by_name": True}
|
||||
msg: str = "Byparr is working!"
|
||||
version: str = consts.VERSION
|
||||
user_agent: str
|
||||
|
||||
|
||||
class Solution(BaseModel):
|
||||
model_config = {"alias_generator": to_camel, "populate_by_name": True}
|
||||
url: str
|
||||
status: int
|
||||
cookies: list
|
||||
userAgent: str # noqa: N815 # Ignore to preserve compatibility
|
||||
headers: dict[str, Any]
|
||||
response: str
|
||||
|
||||
@classmethod
|
||||
def invalid(cls, url: str):
|
||||
"""
|
||||
Return an empty Solution with default values.
|
||||
|
||||
Useful for returning an error response.
|
||||
"""
|
||||
return cls(
|
||||
url=url,
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
cookies=[],
|
||||
userAgent="",
|
||||
headers={},
|
||||
response="",
|
||||
)
|
||||
cookies: list[Cookie] = []
|
||||
user_agent: str = ""
|
||||
headers: dict[str, Any] = {}
|
||||
response: str = ""
|
||||
content_type: str = Field(default="text/html", alias="contentType")
|
||||
|
||||
|
||||
class LinkResponse(BaseModel):
|
||||
@@ -54,7 +78,7 @@ class LinkResponse(BaseModel):
|
||||
version: str = consts.VERSION
|
||||
|
||||
@classmethod
|
||||
def invalid(cls, url: str):
|
||||
def invalid(cls, url: str) -> LinkResponse:
|
||||
"""
|
||||
Return an invalid LinkResponse with default error values.
|
||||
|
||||
@@ -63,6 +87,6 @@ class LinkResponse(BaseModel):
|
||||
return cls(
|
||||
status="error",
|
||||
message="Invalid request",
|
||||
solution=Solution.invalid(url),
|
||||
solution=Solution(url=url, status=INTERNAL_SERVER_ERROR),
|
||||
start_timestamp=int(time.time() * 1000),
|
||||
)
|
||||
|
||||
+76
@@ -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.locator("body").inner_text()
|
||||
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:
|
||||
logger.warning("Failed to load %s: %s", url, exc)
|
||||
content = ""
|
||||
results.append(LoadResult(page_content=content, metadata={"source": url}))
|
||||
return results
|
||||
+129
-22
@@ -1,10 +1,33 @@
|
||||
import logging
|
||||
from time import gmtime, strftime
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Annotated, NamedTuple, cast
|
||||
|
||||
from fastapi import Header
|
||||
from sbase import SB, BaseCase
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
from playwright_captcha import (
|
||||
ClickSolver,
|
||||
FrameworkType,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.consts import LOG_LEVEL, PROXY, USE_HEADLESS
|
||||
from src.consts import (
|
||||
BROWSER_LOCALE,
|
||||
LOG_LEVEL,
|
||||
MAX_ATTEMPTS,
|
||||
PROXY_PASSWORD,
|
||||
PROXY_SERVER,
|
||||
PROXY_USERNAME,
|
||||
)
|
||||
|
||||
solver_logger = logging.getLogger("playwright_captcha")
|
||||
solver_logger.handlers.clear()
|
||||
if LOG_LEVEL == logging.DEBUG:
|
||||
solver_logger.addHandler(logging.StreamHandler())
|
||||
solver_logger.setLevel(LOG_LEVEL)
|
||||
else:
|
||||
solver_logger.handlers.append(logging.NullHandler())
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
logger.setLevel(LOG_LEVEL)
|
||||
@@ -12,24 +35,108 @@ if len(logger.handlers) == 0:
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
def get_sb(
|
||||
proxy: str | None = Header(
|
||||
default=None,
|
||||
examples=["username:password@host:port"],
|
||||
description="Override default proxy from env",
|
||||
),
|
||||
):
|
||||
"""Get SeleniumBase instance."""
|
||||
with SB(
|
||||
uc=True,
|
||||
headless=USE_HEADLESS,
|
||||
locale_code="en",
|
||||
ad_block=True,
|
||||
proxy=proxy or PROXY,
|
||||
) as sb:
|
||||
yield sb
|
||||
# Cloudflare embeds its challenge widget in an iframe carrying
|
||||
# allow="cross-origin-isolated". Firefox honours that by moving the iframe into
|
||||
# a cross-origin-isolated content process, where Juggler sees a frame with no
|
||||
# docShell and no URL, so content_frame() raises "Permission denied to access
|
||||
# property docShell on cross-origin object" and the solver never reaches the
|
||||
# checkbox.
|
||||
#
|
||||
# Turning the two policies off (as upstream Playwright's Firefox does, and as
|
||||
# v2.1.0 did via camoufox's disable_coop=True) restores that access, and the
|
||||
# solver then clicks the checkbox successfully.
|
||||
#
|
||||
# It is not a demonstrated win: from a datacenter IP Cloudflare rejects the
|
||||
# click regardless -- measured across eight sites, nine clicks, and an
|
||||
# undetectable shadow-root patch -- so no outcome changed here. It is kept for
|
||||
# parity with v2, which users report worked on these sites, 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 CI does.
|
||||
BROWSER_PREFS = {
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
}
|
||||
|
||||
|
||||
def save_screenshot(sb: BaseCase):
|
||||
"""Save screenshot on HTTPException."""
|
||||
sb.save_screenshot(f"screenshots/{strftime('%Y-%m-%d %H:%M:%S', gmtime())}.png")
|
||||
class TimeoutTimer(BaseModel):
|
||||
duration: int # in seconds
|
||||
start_time: float = Field(default_factory=time.perf_counter)
|
||||
|
||||
def remaining(self) -> float:
|
||||
"""Get remaining time in seconds."""
|
||||
return max(0, self.duration - (time.perf_counter() - self.start_time))
|
||||
|
||||
|
||||
class BrowserDepClass(NamedTuple):
|
||||
page: Page
|
||||
solver: ClickSolver
|
||||
context: BrowserContext
|
||||
|
||||
|
||||
async def get_browser(
|
||||
x_proxy_server: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="X-Proxy-Server",
|
||||
description="Override proxy server for this request in protocol://host:port format.",
|
||||
),
|
||||
] = None,
|
||||
x_proxy_username: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="X-Proxy-Username",
|
||||
),
|
||||
] = None,
|
||||
x_proxy_password: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="X-Proxy-Password",
|
||||
),
|
||||
] = None,
|
||||
) -> AsyncGenerator[BrowserDepClass]:
|
||||
"""Get InvisiblePlaywright browser instance."""
|
||||
header_server = x_proxy_server
|
||||
header_username = x_proxy_username
|
||||
header_password = x_proxy_password
|
||||
|
||||
proxy_config = None
|
||||
|
||||
if header_server:
|
||||
proxy_config = {
|
||||
"server": header_server,
|
||||
"username": header_username,
|
||||
"password": header_password,
|
||||
}
|
||||
elif PROXY_SERVER:
|
||||
proxy_config = {
|
||||
"server": PROXY_SERVER,
|
||||
"username": PROXY_USERNAME,
|
||||
"password": PROXY_PASSWORD,
|
||||
}
|
||||
|
||||
async with InvisiblePlaywright(
|
||||
headless=True,
|
||||
proxy=proxy_config,
|
||||
humanize=True,
|
||||
locale=BROWSER_LOCALE or "auto",
|
||||
extra_prefs=BROWSER_PREFS,
|
||||
) as browser_raw:
|
||||
# InvisiblePlaywright yields a Browser instance
|
||||
browser = cast("Browser", browser_raw)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
async with ClickSolver(
|
||||
# Not PATCHRIGHT: that path skips the unlockShadowRoot init script
|
||||
# and injects it over CDP instead, which Firefox has no session for
|
||||
# ("CDP session is only available in Chromium"). Cloudflare builds
|
||||
# its widget inside a closed shadow root, so without that script
|
||||
# nothing -- not the solver, not page.locator -- can see the
|
||||
# challenge iframe, and every solve attempt fails outright.
|
||||
framework=FrameworkType.PLAYWRIGHT,
|
||||
page=page,
|
||||
max_attempts=MAX_ATTEMPTS,
|
||||
attempt_delay=1,
|
||||
) as solver:
|
||||
yield BrowserDepClass(page, solver, context)
|
||||
|
||||
+306
-23
@@ -1,50 +1,157 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
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 playwright_captcha.utils.exceptions import (
|
||||
CaptchaDetectionError,
|
||||
CaptchaSolvingError,
|
||||
)
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.endpoints import CHALLENGE_MARKERS, read_item
|
||||
from src.models import LinkRequest
|
||||
from src.utils import BrowserDepClass
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Real Firefox advertises 16 cipher suites; Playwright's HTTP client advertised
|
||||
# 52. A small margin absorbs Firefox version drift without letting 52 through.
|
||||
FIREFOX_CIPHER_SUITE_CEILING = 20
|
||||
|
||||
# Sites Byparr clears from any network, datacenter ranges included. These carry
|
||||
# the hard assertion: if the bypass breaks, one of these goes red.
|
||||
test_websites = [
|
||||
"https://ext.to/",
|
||||
"https://www.ygg.re/",
|
||||
"https://extratorrent.st/",
|
||||
"https://idope.se/",
|
||||
"https://speed.cd/login",
|
||||
# Purpose-built Cloudflare challenge target. Serves a real interstitial and
|
||||
# hands back a cf_clearance cookie once it is passed, so a pass here means
|
||||
# the challenge was solved rather than never presented.
|
||||
"https://nowsecure.nl/",
|
||||
'https://www.yggtorrent.top/engine/search?do=search&order=desc&sort=publish_date&name="UNESCAPED"+"DOUBLEQUOTES"&category=2145',
|
||||
]
|
||||
|
||||
# Cloudflare hands these its interactive checkbox challenge and then refuses the
|
||||
# click from datacenter ranges: the widget goes to "verifying you are human" and
|
||||
# comes back as a fresh unchecked box, indefinitely. Measured over four fresh
|
||||
# navigations and nine clicks, and reproduced from two unrelated hosting
|
||||
# providers on two architectures -- it is the visitor's IP being judged, not our
|
||||
# code. They still run rather than being skipped, so a real regression is
|
||||
# visible in the report and a pass is recorded as xpass, but the runner's luck
|
||||
# with Cloudflare cannot turn the build red.
|
||||
datacenter_hostile_websites = [
|
||||
"https://ext.to/",
|
||||
# "https://www.ygg.re/",
|
||||
"https://extratorrent.st/",
|
||||
"https://speed.cd/login",
|
||||
"https://1337x.to/home/",
|
||||
]
|
||||
|
||||
|
||||
def _bypass(website: str) -> None:
|
||||
"""Ask Byparr for the page and require a clean answer."""
|
||||
test_request = httpx2.get(
|
||||
website,
|
||||
)
|
||||
if (
|
||||
test_request.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
and "Just a moment..." not in test_request.text
|
||||
):
|
||||
try:
|
||||
error_details = test_request.json()
|
||||
except JSONDecodeError:
|
||||
error_details = test_request.text
|
||||
pytest.skip(
|
||||
f"Skipping {website} - ({test_request.status_code}) {error_details}"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=website, cmd="request.get").model_dump(),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.parametrize("website", test_websites)
|
||||
def test_bypass(website: str):
|
||||
"""
|
||||
Tests if the service can bypass cloudflare/DDOS-GUARD on given websites.
|
||||
"""Tests if the service can bypass cloudflare/DDOS-GUARD on given websites."""
|
||||
_bypass(website)
|
||||
|
||||
This test is skipped if the website is not reachable or does not have cloudflare/DDOS-GUARD.
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="Cloudflare refuses the checkbox click from datacenter IPs",
|
||||
strict=False,
|
||||
)
|
||||
@pytest.mark.parametrize("website", datacenter_hostile_websites)
|
||||
def test_bypass_datacenter_hostile(website: str):
|
||||
"""Same check against sites Cloudflare guards hardest, outcome permitting."""
|
||||
_bypass(website)
|
||||
|
||||
|
||||
def test_json_api():
|
||||
"""JSON APIs must return 200, not crash on the UA evaluate.
|
||||
|
||||
Firefox renders application/json in a built-in viewer whose CSP blocks
|
||||
Playwright's eval-based evaluate() (issue #394). The browser must be
|
||||
launched with the viewer disabled so /v1 works and returns the raw JSON.
|
||||
"""
|
||||
test_request = httpx.get(
|
||||
website,
|
||||
)
|
||||
if (
|
||||
test_request.status_code != HTTPStatus.OK
|
||||
and "Just a moment..." not in test_request.text
|
||||
):
|
||||
pytest.skip(f"Skipping {website} due to {test_request.status_code}")
|
||||
url = "https://api.ipify.org?format=json"
|
||||
test_request = httpx2.get(url)
|
||||
if test_request.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
pytest.skip(
|
||||
f"Skipping JSON API test - upstream error ({test_request.status_code})"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json={
|
||||
**LinkRequest.model_construct(
|
||||
url=website, max_timeout=30, cmd="request.get"
|
||||
).model_dump(),
|
||||
# "proxy": "203.174.15.83:8080",
|
||||
},
|
||||
json=LinkRequest.model_construct(url=url, cmd="request.get").model_dump(),
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.REQUEST_TIMEOUT:
|
||||
pytest.skip("Skipping JSON API test - timed out (upstream issue)")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
solution = response.json()["solution"]
|
||||
assert solution["userAgent"]
|
||||
assert '"ip"' in solution["response"]
|
||||
|
||||
|
||||
def test_tls_handshake_looks_like_firefox():
|
||||
"""
|
||||
The handshake must be Firefox's, not the HTTP client's (#398).
|
||||
|
||||
route.fetch() re-issued navigations through Playwright's own HTTP stack, so
|
||||
the ClientHello advertised 52 cipher suites where Firefox offers 16 -- a
|
||||
fingerprint no amount of header spoofing hides. Unlike a Cloudflare verdict
|
||||
this is deterministic, so it pins the regression that motivated this branch.
|
||||
"""
|
||||
url = "https://www.howsmyssl.com/a/check"
|
||||
if httpx2.get(url).status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
pytest.skip("Skipping TLS check - howsmyssl is down")
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=url, cmd="request.get").model_dump(),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
body = response.json()["solution"]["response"]
|
||||
report = json.loads(
|
||||
re.sub(r"<[^>]+>", "", re.search(r"\{.*\}", body, re.DOTALL).group(0))
|
||||
)
|
||||
suites = len(report["given_cipher_suites"])
|
||||
|
||||
# Firefox offers 16; Playwright's client offered 52. Anything in between
|
||||
# means the navigation is no longer going through the browser.
|
||||
assert suites <= FIREFOX_CIPHER_SUITE_CEILING, (
|
||||
f"{suites} cipher suites offered - the handshake is not Firefox's"
|
||||
)
|
||||
|
||||
|
||||
def test_health_check():
|
||||
@@ -56,3 +163,179 @@ 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
|
||||
|
||||
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,
|
||||
challenged: bool = False,
|
||||
marker_counts: list[int] | None = None,
|
||||
) -> BrowserDepClass:
|
||||
"""
|
||||
Build a browser dependency triple backed by mocks.
|
||||
|
||||
`challenged` makes the detector report a Cloudflare challenge.
|
||||
`marker_counts` drives the "is it still up?" check that runs after each
|
||||
solve attempt: one entry per look, the last one repeating forever.
|
||||
"""
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.test/login"
|
||||
page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.OK,
|
||||
headers={"content-type": "text/html"},
|
||||
request=MagicMock(headers={"user-agent": "UnitTestBrowser/1.0"}),
|
||||
)
|
||||
page.title.return_value = "Login"
|
||||
page.evaluate.return_value = "UnitTestBrowser/1.0"
|
||||
page.content.return_value = "<html><title>Login</title></html>"
|
||||
|
||||
remaining = list(marker_counts or [])
|
||||
|
||||
def count_for(selector: str) -> int:
|
||||
"""Answer the marker check from the script, everything else from `challenged`."""
|
||||
if selector != CHALLENGE_MARKERS or not remaining:
|
||||
return 1 if challenged else 0
|
||||
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
||||
|
||||
def locator(selector: str) -> MagicMock:
|
||||
handle = MagicMock()
|
||||
handle.count = AsyncMock(return_value=None)
|
||||
handle.count.side_effect = lambda: count_for(selector)
|
||||
return handle
|
||||
|
||||
page.locator = MagicMock(side_effect=locator)
|
||||
|
||||
def wait_for_load_state(state: str, **_kwargs: object) -> None:
|
||||
"""Fail the wait when asked for a configured state."""
|
||||
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."""
|
||||
dep = fake_dep(fail_states={"networkidle"})
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login"),
|
||||
dep,
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
assert response.solution.response == "<html><title>Login</title></html>"
|
||||
dep.solver.solve_captcha.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_clears_after_the_click_succeeds():
|
||||
"""
|
||||
The solver's own "challenge still present" verdict must not end the request.
|
||||
|
||||
It judges its click by waiting for networkidle, which returns as soon as the
|
||||
network happens to be quiet -- 9ms after the click, in practice -- while
|
||||
Cloudflare is still showing "verifying you are human". Byparr has to wait
|
||||
for the challenge markup itself to go away.
|
||||
"""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1, 0])
|
||||
dep.solver.solve_captcha.side_effect = CaptchaSolvingError(
|
||||
"challenge still present or expected content not detected"
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=5), dep
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_never_clears_returns_408():
|
||||
"""A challenge still up when the budget runs out is a timeout, not a 500."""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1])
|
||||
dep.solver.solve_captcha.side_effect = CaptchaDetectionError(
|
||||
"Cloudflare iframes not found"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=2), dep
|
||||
)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_agent_survives_csp_blocked_evaluate():
|
||||
"""UA comes from request headers when page CSP blocks evaluate (#394).
|
||||
|
||||
No CSP configuration (header, meta tag, or internal viewer document) may
|
||||
turn /v1 into a 500.
|
||||
"""
|
||||
dep = fake_dep()
|
||||
dep.page.evaluate.side_effect = Exception("call to eval() blocked by CSP")
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login"),
|
||||
dep,
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.user_agent == "UnitTestBrowser/1.0"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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.locator = MagicMock()
|
||||
page.locator.return_value.inner_text = AsyncMock(
|
||||
return_value="line one\n\nline two"
|
||||
)
|
||||
|
||||
def wait_for_load_state(state: str, **_kwargs: object) -> None:
|
||||
if state == "networkidle":
|
||||
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"
|
||||
Reference in New Issue
Block a user