fetta 0.1.2__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetta
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: Tool that fetches web pages for assistants
5
5
  Author: Sergei
6
6
  License-Expression: MIT
@@ -10,7 +10,7 @@ Classifier: Intended Audience :: Developers
10
10
  Classifier: Programming Language :: Python :: 3.12
11
11
  Requires-Dist: beautifulsoup4>=4.15.0,<5
12
12
  Requires-Dist: playwright==1.63.0
13
- Requires-Dist: requests>=2.34.2,<3
13
+ Requires-Dist: httpx>=0.28.1,<1
14
14
  Requires-Python: >=3.12
15
15
  Project-URL: Homepage, https://github.com/spapulin/fetta
16
16
  Project-URL: Repository, https://github.com/spapulin/fetta
@@ -41,7 +41,7 @@ from fetta import HttpFetcher
41
41
  async def fetch_content(url: str) -> str:
42
42
  async with HttpFetcher() as fetcher:
43
43
  page = await fetcher.fetch(url)
44
- return page.text
44
+ return page.content.html
45
45
 
46
46
  # Sync entry point is top of a script, not inside an async function
47
47
  text = asyncio.run(fetch_content("https://example.com"))
@@ -53,8 +53,6 @@ sync programs only.
53
53
 
54
54
  ## Install
55
55
 
56
- `fetta` is not on PyPI yet. Install directly from GitHub.
57
-
58
56
  ### As a library
59
57
 
60
58
  With uv (recommended):
@@ -78,7 +76,7 @@ On macOS, drop the flag.
78
76
 
79
77
  Skip this step entirely if you only use `HttpFetcher`.
80
78
 
81
- Development version
79
+ ### Development version
82
80
 
83
81
  The latest unreleased code from `main`. May contain bugs, may
84
82
  break without notice. Use only if you need a fix that hasn't
@@ -22,7 +22,7 @@ from fetta import HttpFetcher
22
22
  async def fetch_content(url: str) -> str:
23
23
  async with HttpFetcher() as fetcher:
24
24
  page = await fetcher.fetch(url)
25
- return page.text
25
+ return page.content.html
26
26
 
27
27
  # Sync entry point is top of a script, not inside an async function
28
28
  text = asyncio.run(fetch_content("https://example.com"))
@@ -34,8 +34,6 @@ sync programs only.
34
34
 
35
35
  ## Install
36
36
 
37
- `fetta` is not on PyPI yet. Install directly from GitHub.
38
-
39
37
  ### As a library
40
38
 
41
39
  With uv (recommended):
@@ -59,7 +57,7 @@ On macOS, drop the flag.
59
57
 
60
58
  Skip this step entirely if you only use `HttpFetcher`.
61
59
 
62
- Development version
60
+ ### Development version
63
61
 
64
62
  The latest unreleased code from `main`. May contain bugs, may
65
63
  break without notice. Use only if you need a fix that hasn't
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fetta"
3
- version = "0.1.2"
3
+ version = "0.3.0"
4
4
  description = "Tool that fetches web pages for assistants"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,7 +14,7 @@ requires-python = ">=3.12"
14
14
  dependencies = [
15
15
  "beautifulsoup4>=4.15.0,<5",
16
16
  "playwright==1.63.0",
17
- "requests>=2.34.2,<3",
17
+ "httpx>=0.28.1,<1",
18
18
  ]
19
19
 
20
20
  [[project.authors]]
@@ -39,7 +39,7 @@ dev = [
39
39
  "jupyterlab>=4.6.4",
40
40
  "pytest>=9.1.1",
41
41
  "pytest-asyncio>=1.4.0",
42
- "requests-mock>=1.12.1",
42
+ "respx>=0.23.1,<1",
43
43
  "ruff>=0.16.8",
44
44
  ]
45
45
 
@@ -50,6 +50,8 @@ module-root = "src"
50
50
  asyncio_mode = "auto"
51
51
  asyncio_default_fixture_loop_scope = "session"
52
52
  asyncio_default_test_loop_scope = "session"
53
+ markers = ["integration: tests that hit the real network"]
54
+ addopts = "-m 'not integration'"
53
55
  testpaths = ["tests"]
54
56
 
55
57
  [tool.ruff]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fetta"
3
- version = "0.1.2"
3
+ version = "0.3.0"
4
4
  description = "Tool that fetches web pages for assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Sergei" }]
@@ -15,7 +15,7 @@ requires-python = ">=3.12"
15
15
  dependencies = [
16
16
  "beautifulsoup4>=4.15.0,<5",
17
17
  "playwright==1.63.0",
18
- "requests>=2.34.2,<3"
18
+ "httpx>=0.28.1,<1"
19
19
  ]
20
20
 
21
21
  [project.scripts]
@@ -37,7 +37,7 @@ dev = [
37
37
  "jupyterlab>=4.6.4",
38
38
  "pytest>=9.1.1",
39
39
  "pytest-asyncio>=1.4.0",
40
- "requests-mock>=1.12.1",
40
+ "respx>=0.23.1,<1",
41
41
  "ruff>=0.16.8",
42
42
  ]
43
43
 
@@ -48,6 +48,8 @@ module-root = "src"
48
48
  asyncio_mode = "auto"
49
49
  asyncio_default_fixture_loop_scope = "session"
50
50
  asyncio_default_test_loop_scope = "session"
51
+ markers = ["integration: tests that hit the real network"]
52
+ addopts = "-m 'not integration'"
51
53
  testpaths = ["tests"]
52
54
 
53
55
  [tool.ruff]
@@ -0,0 +1,19 @@
1
+ from .browser import BrowserManager
2
+ from .fetchers import BrowserFetcher, HttpFetcher, SmartFetcher
3
+ from .json_fetcher import JsonFetcher
4
+ from .models import Link, Image, PageContent, PageMetadata, FetchResult
5
+ from .protocol import FetcherProtocol
6
+
7
+ __all__ = [
8
+ "BrowserManager",
9
+ "PageContent",
10
+ "Link",
11
+ "Image",
12
+ "PageMetadata",
13
+ "FetchResult",
14
+ "BrowserFetcher",
15
+ "HttpFetcher",
16
+ "FetcherProtocol",
17
+ "JsonFetcher",
18
+ "SmartFetcher"
19
+ ]
@@ -0,0 +1,78 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from .models import FetchResult, PageContent, PageMetadata, FetcherKind
5
+ from .page_parser.constants import DEFAULT_SECTIONS
6
+ from .page_parser.parser import parse_html
7
+
8
+
9
+ class BaseFetcher(ABC):
10
+ """
11
+ Abstract base class for fetchers.
12
+ """
13
+
14
+ fetcher_kind: FetcherKind
15
+
16
+ @abstractmethod
17
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
18
+ """Fetch a page and return its parsed content."""
19
+ pass
20
+
21
+ @abstractmethod
22
+ async def close(self) -> None:
23
+ """Release resources (HTTP connections, browser context, etc.)."""
24
+ pass
25
+
26
+ async def __aenter__(self) -> "BaseFetcher":
27
+ return self
28
+
29
+ async def __aexit__(self, exc_type, exc, tb) -> None:
30
+ await self.close()
31
+
32
+ def _parse(
33
+ self,
34
+ html: str,
35
+ url: str,
36
+ status: int,
37
+ title: str = "",
38
+ csr_data: list[dict] = None,
39
+ include: tuple[str, ...] = DEFAULT_SECTIONS
40
+ ) -> FetchResult:
41
+ """
42
+ Turn raw HTML into a PageContent.
43
+
44
+ Extracts the title (if not already provided), strips boilerplate
45
+ tags, and normalizes the remaining text.
46
+
47
+ Args:
48
+ html: Raw HTML as returned by the transport.
49
+ url: Original URL (stored on the result for traceability).
50
+ status: HTTP status code.
51
+ title: Pre-extracted title. If empty, the <title> tag is used.
52
+ """
53
+ parsed_content = parse_html(
54
+ html=html,
55
+ page_url=url,
56
+ include=include
57
+ )
58
+ page_metadata = parsed_content.get("metadata") or {}
59
+
60
+ return FetchResult(
61
+ url=url,
62
+ content=PageContent(
63
+ html=parsed_content.get("html") or "",
64
+ text=parsed_content.get("text"),
65
+ raw_html=parsed_content.get("raw_html"),
66
+ raw_text=parsed_content.get("raw_text"),
67
+ metadata=parsed_content.get("metadata"),
68
+ links=tuple(parsed_content.get("links") or []),
69
+ images=parsed_content.get("images") or [],
70
+ csr_data=tuple(csr_data) if csr_data is not None else None
71
+ ),
72
+ title=page_metadata.get("title"),
73
+ icon=page_metadata.get("icon"),
74
+ image=page_metadata.get("image"),
75
+ description=page_metadata.get("description"),
76
+ status=status,
77
+ fetcher_kind=self.fetcher_kind
78
+ )
@@ -0,0 +1,54 @@
1
+ from asyncio import Semaphore
2
+ from contextlib import asynccontextmanager
3
+ from typing import AsyncIterator
4
+
5
+ from playwright.async_api import (
6
+ Browser,
7
+ Playwright,
8
+ async_playwright,
9
+ ViewportSize,
10
+ Page
11
+ )
12
+
13
+
14
+ class BrowserManager:
15
+ def __init__(self, max_contexts: int = 10) -> None:
16
+ self._semaphore = Semaphore(max_contexts)
17
+ self._playwright: Playwright | None = None
18
+ self._browser: Browser | None = None
19
+
20
+ async def start(self) -> None:
21
+ self._playwright = await async_playwright().start()
22
+ self._browser = await self._playwright.chromium.launch(headless=True)
23
+
24
+ async def stop(self) -> None:
25
+ if self._browser is not None:
26
+ await self._browser.close()
27
+ self._browser = None
28
+ if self._playwright is not None:
29
+ await self._playwright.stop()
30
+ self._playwright = None
31
+
32
+ @asynccontextmanager
33
+ async def page(self) -> AsyncIterator[Page]:
34
+ if self._browser is None:
35
+ raise RuntimeError("BrowserManager is not started")
36
+ async with self._semaphore:
37
+ context = await self._browser.new_context(
38
+ user_agent=(
39
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
40
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
41
+ "Chrome/153.0.0.0 Safari/537.36"
42
+ ),
43
+ viewport=ViewportSize(**{"width": 1920, "height": 1080}),
44
+ locale="en-GB",
45
+ extra_http_headers={
46
+ "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,en;q=0.7",
47
+ },
48
+ )
49
+ page = await context.new_page()
50
+ try:
51
+ yield page
52
+ finally:
53
+ await context.close()
54
+
@@ -0,0 +1,91 @@
1
+ from typing import Any
2
+
3
+ from .httpx_fetcher import HttpxFetcher
4
+ from .protocol import FetcherProtocol
5
+ from .base import BaseFetcher
6
+ from .browser import BrowserManager
7
+ from .models import FetchResult, FetcherKind
8
+ from .playwright_fetcher import PlaywrightFetcher
9
+ from .smart_fetcher import VanillaSmartFetcher
10
+
11
+
12
+ class HttpFetcher(BaseFetcher):
13
+ """
14
+ Fetches pages over plain HTTP (no browser, no JavaScript).
15
+
16
+ A facade over pluggable HTTP engines. By default, it uses
17
+ HttpxFetcher (sync `requests` offloaded to a worker thread).
18
+ """
19
+
20
+ fetcher_kind: FetcherKind = "http"
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ engine: FetcherProtocol | None = None,
26
+ ) -> None:
27
+ self._engine: FetcherProtocol = engine or HttpxFetcher()
28
+
29
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
30
+ return await self._engine.fetch(url, **kwargs)
31
+
32
+ async def close(self) -> None:
33
+ await self._engine.close()
34
+
35
+
36
+ class BrowserFetcher(BaseFetcher):
37
+ """
38
+ Fetches pages through a real browser.
39
+
40
+ The engine is an implementation detail. Currently, Playwright is
41
+ used; alternative engines can be injected via the `engine` argument.
42
+ """
43
+
44
+ fetcher_kind: FetcherKind = "browser"
45
+
46
+ def __init__(
47
+ self,
48
+ manager: BrowserManager,
49
+ *,
50
+ engine: FetcherProtocol | None = None
51
+ ) -> None:
52
+ self._engine: FetcherProtocol = engine or PlaywrightFetcher(manager)
53
+
54
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
55
+ return await self._engine.fetch(url, **kwargs)
56
+
57
+ async def close(self) -> None:
58
+ await self._engine.close()
59
+
60
+
61
+
62
+ class SmartFetcher(BaseFetcher):
63
+ """
64
+ Fetches pages with the cheapest fetcher that can render them.
65
+
66
+ The page is first fetched via the HTML fetcher. If the response
67
+ looks client-side rendered, the browser fetcher is used instead.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ manager: BrowserManager | None = None,
73
+ *,
74
+ html_fetcher: FetcherProtocol | None = None,
75
+ browser_fetcher: FetcherProtocol | None = None,
76
+ ) -> None:
77
+ html_engine: FetcherProtocol = html_fetcher or HttpxFetcher()
78
+ if not browser_fetcher and not manager:
79
+ browser_engine = None
80
+ else:
81
+ browser_engine = browser_fetcher or PlaywrightFetcher(manager)
82
+ self._engine = VanillaSmartFetcher(
83
+ html_fetcher=html_engine,
84
+ browser_fetcher=browser_engine
85
+ )
86
+
87
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
88
+ return await self._engine.fetch(url, **kwargs)
89
+
90
+ async def close(self) -> None:
91
+ await self._engine.close()
@@ -0,0 +1,69 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from .models import FetchResult, FetcherKind
6
+ from .base import BaseFetcher
7
+ from .page_parser.constants import DEFAULT_SECTIONS
8
+
9
+
10
+ class HttpxFetcher(BaseFetcher):
11
+ """
12
+ Native async HTTP fetcher using httpx.AsyncClient.
13
+
14
+ Connection pooling is handled by the single client instance; do not
15
+ create a new client per request (that defeats pooling and adds
16
+ TLS-handshake overhead).
17
+ """
18
+
19
+ fetcher_kind: FetcherKind = "http"
20
+
21
+ def __init__(self, *, timeout: float = 10.0, http2: bool = False) -> None:
22
+ self._client = httpx.AsyncClient(
23
+ timeout=timeout,
24
+ follow_redirects=True,
25
+ http2=http2,
26
+ headers={
27
+ "User-Agent": (
28
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
29
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
30
+ "Chrome/149.0.0.0 Safari/537.36"
31
+ ),
32
+ "Accept": (
33
+ "text/html,application/xhtml+xml,application/xml;q=0.9,"
34
+ "image/avif,image/webp,*/*;q=0.8"
35
+ ),
36
+ "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,en;q=0.7",
37
+ # Let httpx negotiate Accept-Encoding automatically
38
+ "Connection": "keep-alive",
39
+ "Upgrade-Insecure-Requests": "1",
40
+ "Sec-Fetch-Dest": "document",
41
+ "Sec-Fetch-Mode": "navigate",
42
+ "Sec-Fetch-Site": "none",
43
+ "Sec-Fetch-User": "?1",
44
+ "Cache-Control": "max-age=0",
45
+ "sec-ch-ua": '"Chromium";v="149", "Not)A;Brand";v="24"',
46
+ "sec-ch-ua-mobile": "?0",
47
+ "sec-ch-ua-platform": '"Windows"',
48
+ },
49
+ )
50
+
51
+ async def fetch(
52
+ self,
53
+ url: str,
54
+ *,
55
+ include: tuple[str, ...] = DEFAULT_SECTIONS,
56
+ **kwargs: Any
57
+ ) -> FetchResult:
58
+ response = await self._client.get(url)
59
+ response.raise_for_status()
60
+
61
+ return self._parse(
62
+ html=response.text,
63
+ url=url,
64
+ status=response.status_code,
65
+ include=include,
66
+ )
67
+
68
+ async def close(self) -> None:
69
+ await self._client.aclose()
@@ -1,12 +1,14 @@
1
- from fetta.base import BaseFetcher
2
- from fetta.browser import BrowserManager
3
- from fetta.models import PageContent
4
-
1
+ from .base import BaseFetcher
2
+ from .browser import BrowserManager
3
+ from .models import FetchResult, FetcherKind
5
4
 
6
5
  _BLOCK = {"image", "media", "font"}
7
6
 
8
7
 
9
8
  class JsonFetcher(BaseFetcher):
9
+
10
+ fetcher_kind: FetcherKind = "browser"
11
+
10
12
  def __init__(self, browser: BrowserManager) -> None:
11
13
  self._browser = browser
12
14
 
@@ -16,8 +18,9 @@ class JsonFetcher(BaseFetcher):
16
18
  *,
17
19
  wait_until: str = "load",
18
20
  timeout_ms: int = 30_000,
21
+ include: tuple[str, ...] = ("metadata",),
19
22
  **kwargs,
20
- ) -> PageContent:
23
+ ) -> FetchResult:
21
24
 
22
25
  json_responses: list[dict] = []
23
26
 
@@ -50,7 +53,14 @@ class JsonFetcher(BaseFetcher):
50
53
  html = await page.content()
51
54
  title = await page.title()
52
55
 
53
- return self._parse(html, url, response.status if response else 0, title, json_responses)
56
+ return self._parse(
57
+ html=html,
58
+ url=url,
59
+ status=response.status if response else 0,
60
+ title=title,
61
+ csr_data=json_responses,
62
+ include=include
63
+ )
54
64
 
55
65
  async def close(self) -> None:
56
66
  # Browser lifecycle is owned by BrowserManager, not by the fetcher.
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+ from typing import TypedDict, Literal
3
+
4
+
5
+ class PageMetadata(TypedDict):
6
+ page_url: str
7
+ canonical_url: str | None
8
+ icon: str | None
9
+ title: str | None
10
+ description: str | None
11
+ site_name: str | None
12
+ og_image: str | None
13
+ og_type: str | None
14
+ ld_json: tuple | None
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Link:
19
+ text: str
20
+ url: str
21
+ external: bool
22
+ section: str | None = None
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Image:
27
+ src: str
28
+ alt: str | None = None
29
+ title: str | None = None
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class PageContent:
34
+ html: str
35
+ text: str | None
36
+ raw_html: str | None
37
+ raw_text: str | None
38
+ metadata: PageMetadata | None
39
+ links: tuple[Link, ...]
40
+ images: tuple[Image, ...]
41
+ csr_data: tuple[dict, ...] | None
42
+
43
+
44
+ FetcherKind = Literal["http", "browser"]
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class FetchResult:
48
+ url: str
49
+ status: int
50
+ fetcher_kind: FetcherKind
51
+ # Meta
52
+ title: str | None
53
+ icon: str | None
54
+ description: str | None
55
+ image: str | None
56
+ # Content
57
+ content: PageContent
File without changes
@@ -0,0 +1,76 @@
1
+ import copy
2
+ import re
3
+
4
+ from bs4 import Comment
5
+ from bs4.element import Tag
6
+
7
+ from .constants import DROP_TAGS, NOISE_RE, KEEP_TAGS, KEEP_ATTRS
8
+
9
+
10
+ STRUCTURAL = frozenset({"html", "head", "body", "main", "article"})
11
+
12
+
13
+ def clean_html(node: Tag, *, inplace: bool = False) -> Tag:
14
+ """Drop scripts, styles, comments, and noise blocks.
15
+
16
+ Returns a new soup unless inplace=True
17
+ """
18
+
19
+ if not inplace:
20
+ node = copy.deepcopy(node)
21
+
22
+ for tag in node(DROP_TAGS):
23
+ if not tag.decomposed:
24
+ tag.decompose()
25
+
26
+ for comment in node.find_all(string=lambda s: isinstance(s, Comment)):
27
+ comment.extract()
28
+
29
+ for tag in node.find_all(True):
30
+ if tag.name in STRUCTURAL or tag.decomposed or not tag.attrs:
31
+ continue
32
+
33
+ attrs = " ".join(
34
+ filter(
35
+ None,
36
+ (
37
+ " ".join(tag.get("class", [])),
38
+ tag.get("id", ""),
39
+ tag.get("role", ""),
40
+ ),
41
+ )
42
+ )
43
+ if attrs and NOISE_RE.search(attrs):
44
+ tag.decompose()
45
+
46
+ return node
47
+
48
+
49
+ def minify_html(
50
+ node: Tag, *, max_chars: int = 25_000, inplace: bool = False
51
+ ) -> tuple[str, str | None]:
52
+ """Strip unwanted tags/attrs and collapse whitespace.
53
+
54
+ Returns a string. Input tree is copied unless inplace=True
55
+ """
56
+ if not inplace:
57
+ node = copy.deepcopy(node)
58
+
59
+ for tag in list(node.find_all(True)):
60
+ if tag.name not in KEEP_TAGS:
61
+ tag.unwrap()
62
+ continue
63
+
64
+ for attr in list(tag.attrs):
65
+ if attr not in KEEP_ATTRS or tag.attrs[attr] in ("", None):
66
+ del tag[attr]
67
+
68
+ # Flatten bare wrappers
69
+ for tag in node.find_all(["div", "span"]):
70
+ if not tag.attrs:
71
+ tag.unwrap()
72
+
73
+ text = str(node)
74
+ text = re.sub(r"\s+", " ", text)
75
+ text = re.sub(r">\s+<", "><", text)
76
+ return text.strip()[:max_chars], node.get_text(" ", strip=True)[:max_chars]