mirror of
https://github.com/ThePhaseless/Byparr.git
synced 2026-09-24 22:03:30 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,24 +121,24 @@ 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 }}
|
||||
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 }}
|
||||
@@ -115,7 +146,6 @@ jobs:
|
||||
merge-and-push:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -123,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 }}
|
||||
@@ -144,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
|
||||
@@ -156,6 +186,7 @@ jobs:
|
||||
|
||||
# Create manifest lists and push
|
||||
- name: Create and push manifest lists
|
||||
id: manifests
|
||||
run: |
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
args=""
|
||||
@@ -174,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 --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]
|
||||
> 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
|
||||
|
||||
| Environment Variable | 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 in format: `protocol://username:password@host:port`. [SOCKS5 with authentication is not supported by Chrome](https://stackoverflow.com/questions/75602916/connection-to-private-proxy-socks5-with-chrome-webrequest-onauthrequired-and), see `compose.yaml` file for a workaround |
|
||||
|
||||
## 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
|
||||
|
||||
+4
-17
@@ -1,23 +1,10 @@
|
||||
services:
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# environment: # Uncomment if using pproxy
|
||||
# - proxy=http://pproxy:8080
|
||||
volumes:
|
||||
- ./screenshots:/app/screenshots # For screenshots when exception occurs
|
||||
ports:
|
||||
- "8191:8191"
|
||||
# # Uncomment the following lines to enable pproxy
|
||||
# pproxy:
|
||||
# tty: true # Required for pproxy to work
|
||||
# container_name: pproxy
|
||||
# restart: unless-stopped
|
||||
# image: mosajjal/pproxy:latest
|
||||
# command:
|
||||
# - -vv
|
||||
# - -l http://:8080
|
||||
# - -r socks5://host:port#username:password
|
||||
# Uncomment below to use byparr outside of internal network
|
||||
# ports:
|
||||
# - "8191:8191"
|
||||
|
||||
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.3",
|
||||
"seleniumbase==4.37.2",
|
||||
"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.5"]
|
||||
|
||||
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,
|
||||
|
||||
+32
-29
@@ -1,43 +1,46 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
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'.
|
||||
max_attempts: int = sys.maxsize
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
+195
-32
@@ -1,23 +1,44 @@
|
||||
import base64
|
||||
import time
|
||||
import warnings
|
||||
from asyncio import wait_for
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sbase import BaseCase
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha import CaptchaType
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.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)]
|
||||
|
||||
# Headers to strip from the fulfilled response: CSP is removed for navigation
|
||||
# freedom, and content-encoding/content-length are stale once we request an
|
||||
# uncompressed body via accept-encoding: identity below.
|
||||
DROP_HEADERS = frozenset(
|
||||
{
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
@@ -28,9 +49,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 +62,187 @@ 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")
|
||||
final_url = 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,
|
||||
)
|
||||
|
||||
return LinkResponse(
|
||||
message="Success",
|
||||
solution=Solution(
|
||||
userAgent=sb.get_user_agent(),
|
||||
url=sb.get_current_url(),
|
||||
status=200,
|
||||
user_agent=await dep.page.evaluate("navigator.userAgent"),
|
||||
url=final_url if final_url is not None else 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) -> str | None:
|
||||
"""
|
||||
Install request routes for media blocking and CSP stripping.
|
||||
|
||||
Returns the final URL captured during navigation; callers read it after
|
||||
the page settles.
|
||||
"""
|
||||
if request.block_media:
|
||||
|
||||
async def block_media_route(route) -> None:
|
||||
if route.request.resource_type in ("image", "media", "font"):
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
|
||||
await dep.page.route("**/*", block_media_route)
|
||||
|
||||
final_url: str | None = None
|
||||
|
||||
async def strip_csp_route(route) -> None:
|
||||
nonlocal final_url
|
||||
if route.request.resource_type != "document":
|
||||
await route.continue_()
|
||||
return
|
||||
# Request an uncompressed body via accept-encoding: identity. When
|
||||
# route.fulfill re-serves the fetched response (by uid), it forwards
|
||||
# the original compressed bytes; stripping content-encoding below
|
||||
# would leave the browser reading compressed bytes as plain text.
|
||||
response = await route.fetch(
|
||||
headers={**route.request.headers, "accept-encoding": "identity"}
|
||||
)
|
||||
if route.request.frame == dep.page.main_frame:
|
||||
final_url = response.url
|
||||
await route.fulfill(
|
||||
response=response,
|
||||
headers={
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() not in DROP_HEADERS
|
||||
},
|
||||
)
|
||||
|
||||
await dep.page.route("**/*", strip_csp_route)
|
||||
return final_url
|
||||
|
||||
|
||||
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."""
|
||||
logger.info("Challenge detected, attempting to solve...")
|
||||
await wait_for(
|
||||
dep.solver.solve_captcha( # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
captcha_container=dep.page,
|
||||
captcha_type=CaptchaType.CLOUDFLARE_INTERSTITIAL,
|
||||
wait_checkbox_attempts=1,
|
||||
wait_checkbox_delay=0.5,
|
||||
),
|
||||
timeout=timer.remaining(),
|
||||
)
|
||||
logger.debug("Challenge solved successfully.")
|
||||
|
||||
|
||||
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.evaluate("() => document.body ? document.body.innerText : ''")
|
||||
return "\n".join(line.strip() for line in result.splitlines() if line.strip())
|
||||
|
||||
|
||||
@router.post("/load", response_model=list[LoadResult])
|
||||
async def load_urls(
|
||||
request: LoadRequest,
|
||||
_auth: Annotated[None, Depends(require_auth)],
|
||||
dep: BrowserDep,
|
||||
) -> list[LoadResult]:
|
||||
"""
|
||||
Fetch URLs through the anti-bot browser and return their text content.
|
||||
|
||||
Each URL is fetched sequentially; a failing URL yields empty
|
||||
page_content so Open WebUI's RAG pipeline degrades gracefully.
|
||||
"""
|
||||
results: list[LoadResult] = []
|
||||
for url in request.urls:
|
||||
try:
|
||||
await dep.page.goto(url, timeout=60_000)
|
||||
await dep.page.wait_for_load_state("domcontentloaded", timeout=30_000)
|
||||
try:
|
||||
await dep.page.wait_for_load_state("networkidle", timeout=15_000)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.debug("networkidle timed out for %s; extracting anyway", url)
|
||||
content = await _extract_content(dep.page)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load %s: %s", url, exc)
|
||||
content = ""
|
||||
results.append(LoadResult(page_content=content, metadata={"source": url}))
|
||||
return results
|
||||
+97
-29
@@ -1,11 +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, HTTPException
|
||||
from httpx import codes
|
||||
from sbase import SB, BaseCase
|
||||
from fastapi import Header
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
from playwright_captcha import (
|
||||
ClickSolver,
|
||||
FrameworkType,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.consts import 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)
|
||||
@@ -13,30 +35,76 @@ if len(logger.handlers) == 0:
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
def get_sb(
|
||||
proxy: str | None = Header(
|
||||
default=PROXY,
|
||||
examples=["protocol://username:password@host:port"],
|
||||
description="Override default proxy address",
|
||||
),
|
||||
):
|
||||
"""Get SeleniumBase instance."""
|
||||
if proxy and proxy.startswith("socks5://") and "@" in proxy:
|
||||
raise HTTPException(
|
||||
status_code=codes.BAD_REQUEST,
|
||||
detail="SOCKS5 proxy with authentication is not supported. Check README for more info.",
|
||||
)
|
||||
class TimeoutTimer(BaseModel):
|
||||
duration: int # in seconds
|
||||
start_time: float = Field(default_factory=time.perf_counter)
|
||||
|
||||
with SB(
|
||||
uc=True,
|
||||
headless=USE_HEADLESS,
|
||||
locale_code="en",
|
||||
ad_block=True,
|
||||
proxy=proxy,
|
||||
) as sb:
|
||||
yield sb
|
||||
def remaining(self) -> float:
|
||||
"""Get remaining time in seconds."""
|
||||
return max(0, self.duration - (time.perf_counter() - self.start_time))
|
||||
|
||||
|
||||
def save_screenshot(sb: BaseCase):
|
||||
"""Save screenshot on HTTPException."""
|
||||
sb.save_screenshot(f"screenshots/{strftime('%Y-%m-%d %H:%M:%S', gmtime())}.png")
|
||||
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",
|
||||
) as browser_raw:
|
||||
# InvisiblePlaywright yields a Browser instance
|
||||
browser = cast("Browser", browser_raw)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
async with ClickSolver(
|
||||
framework=FrameworkType.PLAYWRIGHT,
|
||||
page=page,
|
||||
max_attempts=MAX_ATTEMPTS,
|
||||
attempt_delay=1,
|
||||
) as solver:
|
||||
yield BrowserDepClass(page, solver, context)
|
||||
|
||||
+114
-12
@@ -1,20 +1,28 @@
|
||||
import base64
|
||||
from http import HTTPStatus
|
||||
from json import JSONDecodeError
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.endpoints import read_item
|
||||
from src.models import LinkRequest
|
||||
from src.utils import BrowserDepClass
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
test_websites = [
|
||||
"https://ext.to/",
|
||||
"https://www.ygg.re/",
|
||||
# "https://www.ygg.re/",
|
||||
"https://extratorrent.st/",
|
||||
"https://idope.se/",
|
||||
"https://speed.cd/login",
|
||||
'https://www.yggtorrent.top/engine/search?do=search&order=desc&sort=publish_date&name="UNESCAPED"+"DOUBLEQUOTES"&category=2145',
|
||||
"https://1337x.to/home/",
|
||||
]
|
||||
|
||||
|
||||
@@ -25,25 +33,29 @@ def test_bypass(website: str):
|
||||
|
||||
This test is skipped if the website is not reachable or does not have cloudflare/DDOS-GUARD.
|
||||
"""
|
||||
test_request = httpx.get(
|
||||
test_request = httpx2.get(
|
||||
website,
|
||||
)
|
||||
if (
|
||||
test_request.status_code != HTTPStatus.OK
|
||||
test_request.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
and "Just a moment..." not in test_request.text
|
||||
):
|
||||
pytest.skip(f"Skipping {website} due to {test_request.status_code}")
|
||||
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, max_timeout=30, cmd="request.get"
|
||||
).model_dump(),
|
||||
# "proxy": "203.174.15.83:8080",
|
||||
},
|
||||
json=LinkRequest.model_construct(url=website, cmd="request.get").model_dump(),
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.REQUEST_TIMEOUT:
|
||||
pytest.skip(f"Skipping {website} - timed out (upstream issue)")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -56,3 +68,93 @@ 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) -> BrowserDepClass:
|
||||
"""Build a browser dependency triple backed by mocks."""
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.test/login"
|
||||
page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.OK, headers={"content-type": "text/html"}
|
||||
)
|
||||
page.title.return_value = "Login"
|
||||
page.evaluate.return_value = "UnitTestBrowser/1.0"
|
||||
page.content.return_value = "<html><title>Login</title></html>"
|
||||
locator = MagicMock()
|
||||
locator.count = AsyncMock(return_value=0)
|
||||
page.locator = MagicMock(return_value=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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.owui import LoadRequest, load_urls
|
||||
from src.utils import BrowserDepClass
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_owui_load_basic():
|
||||
"""/load returns one result per URL with the expected shape."""
|
||||
response = client.post("/load", json={"urls": ["https://example.com"]})
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
results = response.json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["page_content"]
|
||||
assert results[0]["metadata"] == {"source": "https://example.com"}
|
||||
|
||||
|
||||
def test_owui_load_multiple_urls():
|
||||
"""/load returns one result per URL, in order."""
|
||||
urls = ["https://example.com", "https://example.org"]
|
||||
response = client.post("/load", json={"urls": urls})
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
results = response.json()
|
||||
assert [r["metadata"]["source"] for r in results] == urls
|
||||
|
||||
|
||||
def test_owui_load_invalid_url_graceful():
|
||||
"""Unreachable URLs yield empty page_content instead of an error."""
|
||||
response = client.post(
|
||||
"/load", json={"urls": ["https://this-domain-does-not-exist-12345.invalid"]}
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
results = response.json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["page_content"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[None, {"Authorization": "Bearer wrong-key"}],
|
||||
)
|
||||
def test_owui_load_rejects_missing_or_wrong_key(headers):
|
||||
"""/load returns 401 without a valid bearer token when a key is set."""
|
||||
with patch("src.owui.OWUI_API_KEY", "test-secret-key"):
|
||||
response = client.post(
|
||||
"/load", json={"urls": ["https://example.com"]}, headers=headers
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
def test_owui_load_accepts_valid_key():
|
||||
"""/load succeeds with the configured bearer token."""
|
||||
with patch("src.owui.OWUI_API_KEY", "test-secret-key"):
|
||||
response = client.post(
|
||||
"/load",
|
||||
json={"urls": ["https://example.com"]},
|
||||
headers={"Authorization": "Bearer test-secret-key"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
ARTICLE_HTML = """<html><head><title>Test</title></head><body>
|
||||
<article><h1>Example Title</h1><p>This is the main article body with enough words for
|
||||
trafilatura to consider it real content rather than boilerplate.</p></article>
|
||||
<nav><a href="/x">nav link</a></nav>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def fake_dep(*, html: str = ARTICLE_HTML) -> BrowserDepClass:
|
||||
"""Browser dependency whose page loads HTML but never reaches networkidle."""
|
||||
page = AsyncMock()
|
||||
page.goto.return_value = MagicMock()
|
||||
page.content.return_value = html
|
||||
page.evaluate.return_value = "line one\n\nline two"
|
||||
|
||||
def wait_for_load_state(state: str, **_kwargs: object) -> None:
|
||||
if state == "networkidle":
|
||||
message = "load state wait timed out"
|
||||
raise PlaywrightTimeoutError(message)
|
||||
|
||||
page.wait_for_load_state.side_effect = wait_for_load_state
|
||||
return BrowserDepClass(page=page, solver=AsyncMock(), context=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_networkidle_timeout_still_extracts_content():
|
||||
"""A page that never reaches networkidle still yields its article text."""
|
||||
results = await load_urls(
|
||||
LoadRequest(urls=["https://example.test"]), None, fake_dep()
|
||||
)
|
||||
assert results[0].page_content == (
|
||||
"Example TitleThis is the main article body with enough words for "
|
||||
"trafilatura to consider it real content rather than boilerplate."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_falls_back_to_innertext():
|
||||
"""Pages trafilatura cannot score fall back to the rendered innerText."""
|
||||
results = await load_urls(
|
||||
LoadRequest(urls=["https://example.test"]),
|
||||
None,
|
||||
fake_dep(html="<html><body></body></html>"),
|
||||
)
|
||||
assert results[0].page_content == "line one\nline two"
|
||||
Reference in New Issue
Block a user