fetta 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
fetta/base.py ADDED
@@ -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)
fetta/browser.py ADDED
@@ -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()
fetta/fetchers.py ADDED
@@ -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()
fetta/json_fetcher.py ADDED
@@ -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_()
fetta/models.py ADDED
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class PageContent:
6
+ url: str
7
+ title: str
8
+ text: str
9
+ html: str
10
+ data: list[dict] | None
11
+ status: int
@@ -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_()
fetta/protocol.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import Protocol
2
+
3
+ from fetta.models import PageContent
4
+
5
+
6
+ class FetcherProtocol(Protocol):
7
+ async def fetch(self, url: str, **kwargs) -> PageContent: ...
8
+ async def close(self) -> None: ...
@@ -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()
@@ -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)
@@ -0,0 +1,14 @@
1
+ fetta/__init__.py,sha256=gtV61mrOZKkoBckhZ9Fi3oV9kEHsxmUAUxJX4BCALx8,367
2
+ fetta/base.py,sha256=n5C_lqiLq6aSu92Z3_yciP_4gpGibCPY8T8wnMs2v3Q,1941
3
+ fetta/browser.py,sha256=jk2eF0HyZG5L2pd6_1-fCdIgPHUgP4d31dOhEbSnp1o,1508
4
+ fetta/fetchers.py,sha256=NIZDLqmiFULPnmJzhIYp13u6dWitjHMU34TBmTJlsQc,1546
5
+ fetta/json_fetcher.py,sha256=XMxDEg3k_GP44ytOd0wRd2SL3EMPY4uOKbxGWJqHvmI,2130
6
+ fetta/models.py,sha256=sy5psqcqQLB4eqqLbWf46rzgPcpFlBYKG3YwTligu2w,191
7
+ fetta/playwright_fetcher.py,sha256=uuNzSt-Sn4iUfp2f9VXTh7LK-XcFlX6DdDxHjqHpkZg,1906
8
+ fetta/protocol.py,sha256=-CdOI6-3PlFssIdfeZ0olC7pNBKV0ilSnvZZKlo4i5M,206
9
+ fetta/requests_fetcher.py,sha256=3gXSLDGkQi0kBkVpPshTIeAqdjANKqnFEz0lsB9kz94,2001
10
+ fetta-0.1.0.dist-info/licenses/LICENSE,sha256=aSpP3-ohcs8WhhfueYi73lOO9DzO6QzUylOULxmei30,1070
11
+ fetta-0.1.0.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
12
+ fetta-0.1.0.dist-info/entry_points.txt,sha256=ZF7bQQOhcxB_L1EXj7ra5mTQoZPvRNvPwIGlm9HeQZw,38
13
+ fetta-0.1.0.dist-info/METADATA,sha256=scLqTxoTyEyOeBDFqMiNB--4rCyd3WK2pIMNoIyv84w,4083
14
+ fetta-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fetta = fetta:main
3
+
@@ -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.