fetta 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fetta-0.1.0/LICENSE +21 -0
- fetta-0.1.0/PKG-INFO +157 -0
- fetta-0.1.0/README.md +138 -0
- fetta-0.1.0/pyproject.toml +57 -0
- fetta-0.1.0/pyproject.toml.orig +55 -0
- fetta-0.1.0/src/fetta/__init__.py +8 -0
- fetta-0.1.0/src/fetta/base.py +61 -0
- fetta-0.1.0/src/fetta/browser.py +49 -0
- fetta-0.1.0/src/fetta/fetchers.py +53 -0
- fetta-0.1.0/src/fetta/json_fetcher.py +65 -0
- fetta-0.1.0/src/fetta/models.py +11 -0
- fetta-0.1.0/src/fetta/playwright_fetcher.py +56 -0
- fetta-0.1.0/src/fetta/protocol.py +8 -0
- fetta-0.1.0/src/fetta/requests_fetcher.py +53 -0
fetta-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergei Papulin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fetta-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fetta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tool that fetches web pages for assistants
|
|
5
|
+
Author: Sergei
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Dist: beautifulsoup4>=4.15.0,<5
|
|
12
|
+
Requires-Dist: playwright==1.63.0
|
|
13
|
+
Requires-Dist: requests>=2.34.2,<3
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Project-URL: Homepage, https://github.com/spapulin/fetta
|
|
16
|
+
Project-URL: Repository, https://github.com/spapulin/fetta
|
|
17
|
+
Project-URL: Issues, https://github.com/spapulin/fetta/issues
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# fetta
|
|
21
|
+
|
|
22
|
+
Tool that fetches web pages for assistants. Give it a URL, get back
|
|
23
|
+
clean text. Handles static pages and SPAs
|
|
24
|
+
|
|
25
|
+
## ⚡ Async-first
|
|
26
|
+
|
|
27
|
+
**fetta is async-only.** Every fetcher is an `async def`, every
|
|
28
|
+
method is awaited. There is no sync API.
|
|
29
|
+
|
|
30
|
+
This is deliberate. `fetta` is built for the runtimes where agents
|
|
31
|
+
actually run MCP servers, LangChain/LangGraph pipelines, FastAPI
|
|
32
|
+
backends. All of these are async. A sync fetcher would block the
|
|
33
|
+
event loop and stall every other request in the process.
|
|
34
|
+
|
|
35
|
+
If you're in a sync context, bridge it at your entry point:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from fetta import HttpFetcher
|
|
40
|
+
|
|
41
|
+
async def fetch_content(url: str) -> str:
|
|
42
|
+
async with HttpFetcher() as fetcher:
|
|
43
|
+
page = await fetcher.fetch(url)
|
|
44
|
+
return page.text
|
|
45
|
+
|
|
46
|
+
# Sync entry point is top of a script, not inside an async function
|
|
47
|
+
text = asyncio.run(fetch_content("https://example.com"))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Don't call `asyncio.run()` from inside an async function that
|
|
51
|
+
raises `RuntimeError`. The bridge is for the outermost layer of
|
|
52
|
+
sync programs only.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
`fetta` is not on PyPI yet. Install directly from GitHub.
|
|
57
|
+
|
|
58
|
+
### As a library
|
|
59
|
+
|
|
60
|
+
With uv (recommended):
|
|
61
|
+
|
|
62
|
+
uv add "fetta @ git+https://github.com/spapulin/fetta.git"
|
|
63
|
+
|
|
64
|
+
With pip:
|
|
65
|
+
|
|
66
|
+
pip install "fetta @ git+https://github.com/spapulin/fetta.git"
|
|
67
|
+
|
|
68
|
+
For browser-based fetching, also install Chromium:
|
|
69
|
+
|
|
70
|
+
# With uv
|
|
71
|
+
uv run playwright install chromium --with-deps
|
|
72
|
+
|
|
73
|
+
# With pip (venv activated)
|
|
74
|
+
python -m playwright install chromium --with-deps
|
|
75
|
+
|
|
76
|
+
`--with-deps` installs system libraries and requires `sudo` on Linux.
|
|
77
|
+
On macOS, drop the flag.
|
|
78
|
+
|
|
79
|
+
Skip this step entirely if you only use `HttpFetcher`.
|
|
80
|
+
|
|
81
|
+
### Set up for development
|
|
82
|
+
|
|
83
|
+
#### With Docker
|
|
84
|
+
|
|
85
|
+
git clone https://github.com/spapulin/fetta.git
|
|
86
|
+
cd fetta
|
|
87
|
+
|
|
88
|
+
make up # build image, start container
|
|
89
|
+
make sync # install dependencies in the container
|
|
90
|
+
make test # run tests in the container
|
|
91
|
+
|
|
92
|
+
Playwright browsers and system libraries are baked into the image.
|
|
93
|
+
|
|
94
|
+
**Optional**: IDE autocompletion
|
|
95
|
+
|
|
96
|
+
PyCharm and other IDEs need a local Python interpreter for
|
|
97
|
+
autocompletion and "go to definition". Create a lightweight
|
|
98
|
+
host venv - runtime packages only, no dev tools, no browsers:
|
|
99
|
+
|
|
100
|
+
# With uv
|
|
101
|
+
uv venv
|
|
102
|
+
uv pip install -e .
|
|
103
|
+
|
|
104
|
+
# With pip
|
|
105
|
+
python -m venv .venv
|
|
106
|
+
source .venv/bin/activate
|
|
107
|
+
pip install -e .
|
|
108
|
+
|
|
109
|
+
Point your IDE at `.venv/bin/python`.
|
|
110
|
+
|
|
111
|
+
Code still runs inside Docker (`make test`). The host venv is
|
|
112
|
+
only for the editor - it has no Playwright browsers and cannot
|
|
113
|
+
execute `BrowserFetcher`.
|
|
114
|
+
|
|
115
|
+
#### Without Docker
|
|
116
|
+
|
|
117
|
+
git clone https://github.com/spapulin/fetta.git
|
|
118
|
+
cd fetta
|
|
119
|
+
|
|
120
|
+
uv sync --dev
|
|
121
|
+
uv run playwright install chromium --with-deps
|
|
122
|
+
uv run pytest
|
|
123
|
+
|
|
124
|
+
## Quickstart
|
|
125
|
+
|
|
126
|
+
The fastest way to try `fetta` is the Jupyter notebook:
|
|
127
|
+
|
|
128
|
+
notebooks/quickstart.ipynb
|
|
129
|
+
|
|
130
|
+
It walks through:
|
|
131
|
+
|
|
132
|
+
- Fetching a static page with `HttpFetcher`
|
|
133
|
+
- Rendering a JavaScript-heavy page with `BrowserFetcher`
|
|
134
|
+
- Using `SmartFetcher` to pick the right engine automatically
|
|
135
|
+
- Extracting text and inspecting the result
|
|
136
|
+
|
|
137
|
+
### Running the notebook
|
|
138
|
+
|
|
139
|
+
With Docker (recommended):
|
|
140
|
+
|
|
141
|
+
make jupyter
|
|
142
|
+
|
|
143
|
+
Then open http://localhost:8888 and navigate to `notebooks/quickstart.ipynb`.
|
|
144
|
+
|
|
145
|
+
Without Docker:
|
|
146
|
+
|
|
147
|
+
uv run jupyter lab
|
|
148
|
+
|
|
149
|
+
## Requirements
|
|
150
|
+
|
|
151
|
+
- Python 3.12+
|
|
152
|
+
- Playwright browsers (Chromium) - only for `BrowserFetcher` and `SmartFetcher`
|
|
153
|
+
- Docker - only for development
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT — see [LICENSE](LICENSE)
|
fetta-0.1.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# fetta
|
|
2
|
+
|
|
3
|
+
Tool that fetches web pages for assistants. Give it a URL, get back
|
|
4
|
+
clean text. Handles static pages and SPAs
|
|
5
|
+
|
|
6
|
+
## ⚡ Async-first
|
|
7
|
+
|
|
8
|
+
**fetta is async-only.** Every fetcher is an `async def`, every
|
|
9
|
+
method is awaited. There is no sync API.
|
|
10
|
+
|
|
11
|
+
This is deliberate. `fetta` is built for the runtimes where agents
|
|
12
|
+
actually run MCP servers, LangChain/LangGraph pipelines, FastAPI
|
|
13
|
+
backends. All of these are async. A sync fetcher would block the
|
|
14
|
+
event loop and stall every other request in the process.
|
|
15
|
+
|
|
16
|
+
If you're in a sync context, bridge it at your entry point:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import asyncio
|
|
20
|
+
from fetta import HttpFetcher
|
|
21
|
+
|
|
22
|
+
async def fetch_content(url: str) -> str:
|
|
23
|
+
async with HttpFetcher() as fetcher:
|
|
24
|
+
page = await fetcher.fetch(url)
|
|
25
|
+
return page.text
|
|
26
|
+
|
|
27
|
+
# Sync entry point is top of a script, not inside an async function
|
|
28
|
+
text = asyncio.run(fetch_content("https://example.com"))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Don't call `asyncio.run()` from inside an async function that
|
|
32
|
+
raises `RuntimeError`. The bridge is for the outermost layer of
|
|
33
|
+
sync programs only.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
`fetta` is not on PyPI yet. Install directly from GitHub.
|
|
38
|
+
|
|
39
|
+
### As a library
|
|
40
|
+
|
|
41
|
+
With uv (recommended):
|
|
42
|
+
|
|
43
|
+
uv add "fetta @ git+https://github.com/spapulin/fetta.git"
|
|
44
|
+
|
|
45
|
+
With pip:
|
|
46
|
+
|
|
47
|
+
pip install "fetta @ git+https://github.com/spapulin/fetta.git"
|
|
48
|
+
|
|
49
|
+
For browser-based fetching, also install Chromium:
|
|
50
|
+
|
|
51
|
+
# With uv
|
|
52
|
+
uv run playwright install chromium --with-deps
|
|
53
|
+
|
|
54
|
+
# With pip (venv activated)
|
|
55
|
+
python -m playwright install chromium --with-deps
|
|
56
|
+
|
|
57
|
+
`--with-deps` installs system libraries and requires `sudo` on Linux.
|
|
58
|
+
On macOS, drop the flag.
|
|
59
|
+
|
|
60
|
+
Skip this step entirely if you only use `HttpFetcher`.
|
|
61
|
+
|
|
62
|
+
### Set up for development
|
|
63
|
+
|
|
64
|
+
#### With Docker
|
|
65
|
+
|
|
66
|
+
git clone https://github.com/spapulin/fetta.git
|
|
67
|
+
cd fetta
|
|
68
|
+
|
|
69
|
+
make up # build image, start container
|
|
70
|
+
make sync # install dependencies in the container
|
|
71
|
+
make test # run tests in the container
|
|
72
|
+
|
|
73
|
+
Playwright browsers and system libraries are baked into the image.
|
|
74
|
+
|
|
75
|
+
**Optional**: IDE autocompletion
|
|
76
|
+
|
|
77
|
+
PyCharm and other IDEs need a local Python interpreter for
|
|
78
|
+
autocompletion and "go to definition". Create a lightweight
|
|
79
|
+
host venv - runtime packages only, no dev tools, no browsers:
|
|
80
|
+
|
|
81
|
+
# With uv
|
|
82
|
+
uv venv
|
|
83
|
+
uv pip install -e .
|
|
84
|
+
|
|
85
|
+
# With pip
|
|
86
|
+
python -m venv .venv
|
|
87
|
+
source .venv/bin/activate
|
|
88
|
+
pip install -e .
|
|
89
|
+
|
|
90
|
+
Point your IDE at `.venv/bin/python`.
|
|
91
|
+
|
|
92
|
+
Code still runs inside Docker (`make test`). The host venv is
|
|
93
|
+
only for the editor - it has no Playwright browsers and cannot
|
|
94
|
+
execute `BrowserFetcher`.
|
|
95
|
+
|
|
96
|
+
#### Without Docker
|
|
97
|
+
|
|
98
|
+
git clone https://github.com/spapulin/fetta.git
|
|
99
|
+
cd fetta
|
|
100
|
+
|
|
101
|
+
uv sync --dev
|
|
102
|
+
uv run playwright install chromium --with-deps
|
|
103
|
+
uv run pytest
|
|
104
|
+
|
|
105
|
+
## Quickstart
|
|
106
|
+
|
|
107
|
+
The fastest way to try `fetta` is the Jupyter notebook:
|
|
108
|
+
|
|
109
|
+
notebooks/quickstart.ipynb
|
|
110
|
+
|
|
111
|
+
It walks through:
|
|
112
|
+
|
|
113
|
+
- Fetching a static page with `HttpFetcher`
|
|
114
|
+
- Rendering a JavaScript-heavy page with `BrowserFetcher`
|
|
115
|
+
- Using `SmartFetcher` to pick the right engine automatically
|
|
116
|
+
- Extracting text and inspecting the result
|
|
117
|
+
|
|
118
|
+
### Running the notebook
|
|
119
|
+
|
|
120
|
+
With Docker (recommended):
|
|
121
|
+
|
|
122
|
+
make jupyter
|
|
123
|
+
|
|
124
|
+
Then open http://localhost:8888 and navigate to `notebooks/quickstart.ipynb`.
|
|
125
|
+
|
|
126
|
+
Without Docker:
|
|
127
|
+
|
|
128
|
+
uv run jupyter lab
|
|
129
|
+
|
|
130
|
+
## Requirements
|
|
131
|
+
|
|
132
|
+
- Python 3.12+
|
|
133
|
+
- Playwright browsers (Chromium) - only for `BrowserFetcher` and `SmartFetcher`
|
|
134
|
+
- Docker - only for development
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fetta"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tool that fetches web pages for assistants"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"beautifulsoup4>=4.15.0,<5",
|
|
16
|
+
"playwright==1.63.0",
|
|
17
|
+
"requests>=2.34.2,<3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[[project.authors]]
|
|
21
|
+
name = "Sergei"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
fetta = "fetta:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/spapulin/fetta"
|
|
28
|
+
Repository = "https://github.com/spapulin/fetta"
|
|
29
|
+
Issues = "https://github.com/spapulin/fetta/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"debugpy>=1.8.22",
|
|
38
|
+
"ipykernel>=7.3.0",
|
|
39
|
+
"jupyterlab>=4.6.4",
|
|
40
|
+
"pytest>=9.1.1",
|
|
41
|
+
"pytest-asyncio>=1.4.0",
|
|
42
|
+
"requests-mock>=1.12.1",
|
|
43
|
+
"ruff>=0.16.8",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-root = "src"
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
52
|
+
asyncio_default_test_loop_scope = "session"
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py312"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fetta"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tool that fetches web pages for assistants"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Sergei" }]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"beautifulsoup4>=4.15.0,<5",
|
|
17
|
+
"playwright==1.63.0",
|
|
18
|
+
"requests>=2.34.2,<3"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
fetta = "fetta:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/spapulin/fetta"
|
|
26
|
+
Repository = "https://github.com/spapulin/fetta"
|
|
27
|
+
Issues = "https://github.com/spapulin/fetta/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"debugpy>=1.8.22",
|
|
36
|
+
"ipykernel>=7.3.0",
|
|
37
|
+
"jupyterlab>=4.6.4",
|
|
38
|
+
"pytest>=9.1.1",
|
|
39
|
+
"pytest-asyncio>=1.4.0",
|
|
40
|
+
"requests-mock>=1.12.1",
|
|
41
|
+
"ruff>=0.16.8",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.uv.build-backend]
|
|
45
|
+
module-root = "src"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
50
|
+
asyncio_default_test_loop_scope = "session"
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py312"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from fetta.browser import BrowserManager
|
|
2
|
+
from fetta.fetchers import BrowserFetcher, HttpFetcher
|
|
3
|
+
from fetta.json_fetcher import JsonFetcher
|
|
4
|
+
from fetta.models import PageContent
|
|
5
|
+
from fetta.protocol import FetcherProtocol
|
|
6
|
+
from fetta.requests_fetcher import RequestsFetcher
|
|
7
|
+
|
|
8
|
+
__all__ = ["BrowserManager", "PageContent", "BrowserFetcher", "HttpFetcher", "FetcherProtocol"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from bs4 import BeautifulSoup
|
|
5
|
+
|
|
6
|
+
from fetta.models import PageContent
|
|
7
|
+
|
|
8
|
+
# Tags that never contain useful article text.
|
|
9
|
+
# Kept module-level so subclasses can reference the same tuple.
|
|
10
|
+
_STRIP_TAGS = ("script", "style", "nav", "footer", "header", "noscript")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseFetcher(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Abstract base class for fetchers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def fetch(self, url: str, **kwargs: Any) -> PageContent:
|
|
20
|
+
"""Fetch a page and return its parsed content."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def close(self) -> None:
|
|
25
|
+
"""Release resources (HTTP connections, browser context, etc.)."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
async def __aenter__(self) -> "BaseFetcher":
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
32
|
+
await self.close()
|
|
33
|
+
|
|
34
|
+
def _parse(
|
|
35
|
+
self, html: str, url: str, status: int, title: str = "", data: list[dict] = None
|
|
36
|
+
) -> PageContent:
|
|
37
|
+
"""
|
|
38
|
+
Turn raw HTML into a PageContent.
|
|
39
|
+
|
|
40
|
+
Extracts the title (if not already provided), strips boilerplate
|
|
41
|
+
tags, and normalizes the remaining text.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
html: Raw HTML as returned by the transport.
|
|
45
|
+
url: Original URL (stored on the result for traceability).
|
|
46
|
+
status: HTTP status code.
|
|
47
|
+
title: Pre-extracted title. If empty, the <title> tag is used.
|
|
48
|
+
"""
|
|
49
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
50
|
+
|
|
51
|
+
# Extract title before stripping tags as some sites put <style>
|
|
52
|
+
# inside <head>, and decomposing early keeps the tree clean.
|
|
53
|
+
if not title and soup.title is not None:
|
|
54
|
+
title = soup.title.get_text(strip=True)
|
|
55
|
+
|
|
56
|
+
for tag in soup(_STRIP_TAGS):
|
|
57
|
+
tag.decompose()
|
|
58
|
+
|
|
59
|
+
text = soup.get_text(separator="\n", strip=True)
|
|
60
|
+
|
|
61
|
+
return PageContent(url=url, title=title, text=text, html=html, data=data, status=status)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
3
|
+
from playwright.async_api import (
|
|
4
|
+
Browser,
|
|
5
|
+
Playwright,
|
|
6
|
+
async_playwright,
|
|
7
|
+
ViewportSize,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrowserManager:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._playwright: Playwright | None = None
|
|
14
|
+
self._browser: Browser | None = None
|
|
15
|
+
|
|
16
|
+
async def start(self) -> None:
|
|
17
|
+
self._playwright = await async_playwright().start()
|
|
18
|
+
self._browser = await self._playwright.chromium.launch(headless=True)
|
|
19
|
+
|
|
20
|
+
async def stop(self) -> None:
|
|
21
|
+
if self._browser is not None:
|
|
22
|
+
await self._browser.close()
|
|
23
|
+
self._browser = None
|
|
24
|
+
if self._playwright is not None:
|
|
25
|
+
await self._playwright.stop()
|
|
26
|
+
self._playwright = None
|
|
27
|
+
|
|
28
|
+
@asynccontextmanager
|
|
29
|
+
async def page(self):
|
|
30
|
+
if self._browser is None:
|
|
31
|
+
raise RuntimeError("BrowserManager is not started")
|
|
32
|
+
|
|
33
|
+
context = await self._browser.new_context(
|
|
34
|
+
user_agent=(
|
|
35
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
36
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
37
|
+
"Chrome/153.0.0.0 Safari/537.36"
|
|
38
|
+
),
|
|
39
|
+
viewport=ViewportSize(**{"width": 1920, "height": 1080}),
|
|
40
|
+
locale="en-GB",
|
|
41
|
+
extra_http_headers={
|
|
42
|
+
"Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
page = await context.new_page()
|
|
46
|
+
try:
|
|
47
|
+
yield page
|
|
48
|
+
finally:
|
|
49
|
+
await context.close()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fetta.protocol import FetcherProtocol
|
|
4
|
+
from fetta.base import BaseFetcher
|
|
5
|
+
from fetta.browser import BrowserManager
|
|
6
|
+
from fetta.models import PageContent
|
|
7
|
+
from fetta.playwright_fetcher import PlaywrightFetcher
|
|
8
|
+
from fetta.requests_fetcher import RequestsFetcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HttpFetcher(BaseFetcher):
|
|
12
|
+
"""
|
|
13
|
+
Fetches pages over plain HTTP (no browser, no JavaScript).
|
|
14
|
+
|
|
15
|
+
A facade over pluggable HTTP engines. By default, it uses
|
|
16
|
+
RequestsFetcher (sync `requests` offloaded to a worker thread).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
engine: FetcherProtocol | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._engine: FetcherProtocol = engine or RequestsFetcher()
|
|
25
|
+
|
|
26
|
+
async def fetch(self, url: str, **kwargs: Any) -> PageContent:
|
|
27
|
+
return await self._engine.fetch(url, **kwargs)
|
|
28
|
+
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
await self._engine.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BrowserFetcher(BaseFetcher):
|
|
34
|
+
"""
|
|
35
|
+
Fetches pages through a real browser.
|
|
36
|
+
|
|
37
|
+
The engine is an implementation detail. Currently, Playwright is
|
|
38
|
+
used; alternative engines can be injected via the `engine` argument.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
manager: BrowserManager,
|
|
44
|
+
*,
|
|
45
|
+
engine: FetcherProtocol | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._engine: FetcherProtocol = engine or PlaywrightFetcher(manager)
|
|
48
|
+
|
|
49
|
+
async def fetch(self, url: str, **kwargs: Any) -> PageContent:
|
|
50
|
+
return await self._engine.fetch(url, **kwargs)
|
|
51
|
+
|
|
52
|
+
async def close(self) -> None:
|
|
53
|
+
await self._engine.close()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from fetta.base import BaseFetcher
|
|
2
|
+
from fetta.browser import BrowserManager
|
|
3
|
+
from fetta.models import PageContent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_BLOCK = {"image", "media", "font"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonFetcher(BaseFetcher):
|
|
10
|
+
def __init__(self, browser: BrowserManager) -> None:
|
|
11
|
+
self._browser = browser
|
|
12
|
+
|
|
13
|
+
async def fetch(
|
|
14
|
+
self,
|
|
15
|
+
url: str,
|
|
16
|
+
*,
|
|
17
|
+
wait_until: str = "load",
|
|
18
|
+
timeout_ms: int = 30_000,
|
|
19
|
+
**kwargs,
|
|
20
|
+
) -> PageContent:
|
|
21
|
+
|
|
22
|
+
json_responses: list[dict] = []
|
|
23
|
+
|
|
24
|
+
async def on_response(response):
|
|
25
|
+
content_type = response.headers.get("content-type", "")
|
|
26
|
+
if "application/json" in content_type:
|
|
27
|
+
try:
|
|
28
|
+
data = await response.json()
|
|
29
|
+
json_responses.append(
|
|
30
|
+
{
|
|
31
|
+
"url": response.url,
|
|
32
|
+
"status": response.status,
|
|
33
|
+
"method": response.request.method,
|
|
34
|
+
"data": data,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass # not valid JSON, skip
|
|
39
|
+
|
|
40
|
+
async with self._browser.page() as page:
|
|
41
|
+
page.on("response", on_response)
|
|
42
|
+
|
|
43
|
+
await page.route("**/*", self._block_resources)
|
|
44
|
+
|
|
45
|
+
response = await page.goto(url, wait_until=wait_until, timeout=timeout_ms)
|
|
46
|
+
|
|
47
|
+
# Give XHR calls that start after load time to complete
|
|
48
|
+
await page.wait_for_timeout(500)
|
|
49
|
+
|
|
50
|
+
html = await page.content()
|
|
51
|
+
title = await page.title()
|
|
52
|
+
|
|
53
|
+
return self._parse(html, url, response.status if response else 0, title, json_responses)
|
|
54
|
+
|
|
55
|
+
async def close(self) -> None:
|
|
56
|
+
# Browser lifecycle is owned by BrowserManager, not by the fetcher.
|
|
57
|
+
# Nothing to release here, but the method exists to satisfy the contract.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def _block_resources(self, route):
|
|
61
|
+
"""Abort requests for heavy resources that don't affect text content."""
|
|
62
|
+
if route.request.resource_type in _BLOCK:
|
|
63
|
+
await route.abort()
|
|
64
|
+
else:
|
|
65
|
+
await route.continue_()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from fetta.base import BaseFetcher
|
|
2
|
+
from fetta.browser import BrowserManager
|
|
3
|
+
from fetta.models import PageContent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_BLOCK = {"image", "media", "font"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PlaywrightFetcher(BaseFetcher):
|
|
10
|
+
def __init__(self, browser: BrowserManager) -> None:
|
|
11
|
+
self._browser = browser
|
|
12
|
+
|
|
13
|
+
async def fetch(
|
|
14
|
+
self,
|
|
15
|
+
url: str,
|
|
16
|
+
*,
|
|
17
|
+
wait_for: str | None = None,
|
|
18
|
+
wait_until: str = "load",
|
|
19
|
+
timeout_ms: int = 30_000,
|
|
20
|
+
**kwargs,
|
|
21
|
+
) -> PageContent:
|
|
22
|
+
async with self._browser.page() as page:
|
|
23
|
+
await page.route("**/*", self._block_resources)
|
|
24
|
+
|
|
25
|
+
response = await page.goto(url=url, wait_until=wait_until, timeout=timeout_ms)
|
|
26
|
+
if wait_for:
|
|
27
|
+
await page.wait_for_selector(wait_for, timeout=timeout_ms)
|
|
28
|
+
else:
|
|
29
|
+
# If unknown page, wait for body to fill in, cap at 5s
|
|
30
|
+
# try:
|
|
31
|
+
# await page.wait_for_function(
|
|
32
|
+
# "() => document.body && document.body.innerText.trim().length > 200",
|
|
33
|
+
# timeout=5_000,
|
|
34
|
+
# )
|
|
35
|
+
# except Exception:
|
|
36
|
+
# pass
|
|
37
|
+
|
|
38
|
+
# Small extra settle for late JS
|
|
39
|
+
await page.wait_for_timeout(500)
|
|
40
|
+
|
|
41
|
+
html = await page.content()
|
|
42
|
+
title = await page.title()
|
|
43
|
+
|
|
44
|
+
return self._parse(html, url, response.status if response else 0, title)
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
# Browser lifecycle is owned by BrowserManager, not by the fetcher.
|
|
48
|
+
# Nothing to release here, but the method exists to satisfy the contract.
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
async def _block_resources(self, route):
|
|
52
|
+
"""Abort requests for heavy resources that don't affect text content."""
|
|
53
|
+
if route.request.resource_type in _BLOCK:
|
|
54
|
+
await route.abort()
|
|
55
|
+
else:
|
|
56
|
+
await route.continue_()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from fetta.base import BaseFetcher
|
|
7
|
+
from fetta.models import PageContent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestsFetcher(BaseFetcher):
|
|
11
|
+
"""
|
|
12
|
+
Synchronous requests, offloaded to worker threads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *, timeout: float = 10.0) -> None:
|
|
16
|
+
self._session = requests.Session()
|
|
17
|
+
self._session.headers.update(
|
|
18
|
+
{
|
|
19
|
+
"User-Agent": (
|
|
20
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
21
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
22
|
+
"Chrome/149.0.0.0 Safari/537.36"
|
|
23
|
+
),
|
|
24
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
25
|
+
"Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
26
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
27
|
+
"Connection": "keep-alive",
|
|
28
|
+
"Upgrade-Insecure-Requests": "1",
|
|
29
|
+
"Sec-Fetch-Dest": "document",
|
|
30
|
+
"Sec-Fetch-Mode": "navigate",
|
|
31
|
+
"Sec-Fetch-Site": "none",
|
|
32
|
+
"Sec-Fetch-User": "?1",
|
|
33
|
+
"Cache-Control": "max-age=0",
|
|
34
|
+
"sec-ch-ua": '"Chromium";v="149", "Not)A;Brand";v="24"',
|
|
35
|
+
"sec-ch-ua-mobile": "?0",
|
|
36
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
self._timeout = timeout
|
|
40
|
+
|
|
41
|
+
def _get_sync(self, url: str) -> tuple[str, int]:
|
|
42
|
+
response = self._session.get(url, timeout=self._timeout)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
return response.text, response.status_code
|
|
45
|
+
|
|
46
|
+
async def fetch(self, url: str, **kwargs: Any) -> PageContent:
|
|
47
|
+
# Hand the blocking call to a thread. The event loop is free
|
|
48
|
+
# to handle other requests while this thread waits on the socket.
|
|
49
|
+
html, status = await asyncio.to_thread(self._get_sync, url)
|
|
50
|
+
return self._parse(html, url, status)
|
|
51
|
+
|
|
52
|
+
async def close(self) -> None:
|
|
53
|
+
self._session.close()
|