fetta 0.1.2__tar.gz → 0.2.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.2.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.2.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.2.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,18 @@
1
+ from .browser import BrowserManager
2
+ from .fetchers import BrowserFetcher, HttpFetcher
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
+ ]
@@ -0,0 +1,75 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from .models import FetchResult, PageContent
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
+ @abstractmethod
15
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
16
+ """Fetch a page and return its parsed content."""
17
+ pass
18
+
19
+ @abstractmethod
20
+ async def close(self) -> None:
21
+ """Release resources (HTTP connections, browser context, etc.)."""
22
+ pass
23
+
24
+ async def __aenter__(self) -> "BaseFetcher":
25
+ return self
26
+
27
+ async def __aexit__(self, exc_type, exc, tb) -> None:
28
+ await self.close()
29
+
30
+ def _parse(
31
+ self,
32
+ html: str,
33
+ url: str,
34
+ status: int,
35
+ title: str = "",
36
+ csr_data: list[dict] = None,
37
+ include: tuple[str, ...] = DEFAULT_SECTIONS
38
+ ) -> FetchResult:
39
+ """
40
+ Turn raw HTML into a PageContent.
41
+
42
+ Extracts the title (if not already provided), strips boilerplate
43
+ tags, and normalizes the remaining text.
44
+
45
+ Args:
46
+ html: Raw HTML as returned by the transport.
47
+ url: Original URL (stored on the result for traceability).
48
+ status: HTTP status code.
49
+ title: Pre-extracted title. If empty, the <title> tag is used.
50
+ """
51
+ parsed_content = parse_html(
52
+ html=html,
53
+ page_url=url,
54
+ include=include
55
+ )
56
+ page_metadata = parsed_content.get("metadata") or {}
57
+
58
+ return FetchResult(
59
+ url=url,
60
+ content=PageContent(
61
+ html=parsed_content.get("html") or "",
62
+ text=parsed_content.get("text"),
63
+ raw_html=parsed_content.get("raw_html"),
64
+ raw_text=parsed_content.get("raw_text"),
65
+ metadata=page_metadata,
66
+ links=tuple(parsed_content.get("links") or []),
67
+ images=parsed_content.get("images") or [],
68
+ csr_data=tuple(csr_data) if csr_data is not None else None
69
+ ),
70
+ title=page_metadata.get("title"),
71
+ icon=page_metadata.get("icon"),
72
+ image=page_metadata.get("image"),
73
+ description=page_metadata.get("description"),
74
+ status=status
75
+ )
@@ -1,11 +1,11 @@
1
1
  from typing import Any
2
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
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
8
+ from .playwright_fetcher import PlaywrightFetcher
9
9
 
10
10
 
11
11
  class HttpFetcher(BaseFetcher):
@@ -13,7 +13,7 @@ class HttpFetcher(BaseFetcher):
13
13
  Fetches pages over plain HTTP (no browser, no JavaScript).
14
14
 
15
15
  A facade over pluggable HTTP engines. By default, it uses
16
- RequestsFetcher (sync `requests` offloaded to a worker thread).
16
+ HttpxFetcher (sync `requests` offloaded to a worker thread).
17
17
  """
18
18
 
19
19
  def __init__(
@@ -21,9 +21,9 @@ class HttpFetcher(BaseFetcher):
21
21
  *,
22
22
  engine: FetcherProtocol | None = None,
23
23
  ) -> None:
24
- self._engine: FetcherProtocol = engine or RequestsFetcher()
24
+ self._engine: FetcherProtocol = engine or HttpxFetcher()
25
25
 
26
- async def fetch(self, url: str, **kwargs: Any) -> PageContent:
26
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
27
27
  return await self._engine.fetch(url, **kwargs)
28
28
 
29
29
  async def close(self) -> None:
@@ -42,11 +42,11 @@ class BrowserFetcher(BaseFetcher):
42
42
  self,
43
43
  manager: BrowserManager,
44
44
  *,
45
- engine: FetcherProtocol | None = None,
45
+ engine: FetcherProtocol | None = None
46
46
  ) -> None:
47
47
  self._engine: FetcherProtocol = engine or PlaywrightFetcher(manager)
48
48
 
49
- async def fetch(self, url: str, **kwargs: Any) -> PageContent:
49
+ async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
50
50
  return await self._engine.fetch(url, **kwargs)
51
51
 
52
52
  async def close(self) -> None:
@@ -0,0 +1,67 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from .models import FetchResult
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
+ def __init__(self, *, timeout: float = 10.0, http2: bool = False) -> None:
20
+ self._client = httpx.AsyncClient(
21
+ timeout=timeout,
22
+ follow_redirects=True,
23
+ http2=http2,
24
+ headers={
25
+ "User-Agent": (
26
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
27
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
28
+ "Chrome/149.0.0.0 Safari/537.36"
29
+ ),
30
+ "Accept": (
31
+ "text/html,application/xhtml+xml,application/xml;q=0.9,"
32
+ "image/avif,image/webp,*/*;q=0.8"
33
+ ),
34
+ "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,en;q=0.7",
35
+ # Let httpx negotiate Accept-Encoding automatically
36
+ "Connection": "keep-alive",
37
+ "Upgrade-Insecure-Requests": "1",
38
+ "Sec-Fetch-Dest": "document",
39
+ "Sec-Fetch-Mode": "navigate",
40
+ "Sec-Fetch-Site": "none",
41
+ "Sec-Fetch-User": "?1",
42
+ "Cache-Control": "max-age=0",
43
+ "sec-ch-ua": '"Chromium";v="149", "Not)A;Brand";v="24"',
44
+ "sec-ch-ua-mobile": "?0",
45
+ "sec-ch-ua-platform": '"Windows"',
46
+ },
47
+ )
48
+
49
+ async def fetch(
50
+ self,
51
+ url: str,
52
+ *,
53
+ include: tuple[str, ...] = DEFAULT_SECTIONS,
54
+ **kwargs: Any
55
+ ) -> FetchResult:
56
+ response = await self._client.get(url)
57
+ response.raise_for_status()
58
+
59
+ return self._parse(
60
+ html=response.text,
61
+ url=url,
62
+ status=response.status_code,
63
+ include=include,
64
+ )
65
+
66
+ async def close(self) -> None:
67
+ await self._client.aclose()
@@ -1,6 +1,6 @@
1
1
  from fetta.base import BaseFetcher
2
2
  from fetta.browser import BrowserManager
3
- from fetta.models import PageContent
3
+ from fetta.models import FetchResult
4
4
 
5
5
 
6
6
  _BLOCK = {"image", "media", "font"}
@@ -16,8 +16,9 @@ class JsonFetcher(BaseFetcher):
16
16
  *,
17
17
  wait_until: str = "load",
18
18
  timeout_ms: int = 30_000,
19
+ include: tuple[str, ...] = ("metadata",),
19
20
  **kwargs,
20
- ) -> PageContent:
21
+ ) -> FetchResult:
21
22
 
22
23
  json_responses: list[dict] = []
23
24
 
@@ -50,7 +51,14 @@ class JsonFetcher(BaseFetcher):
50
51
  html = await page.content()
51
52
  title = await page.title()
52
53
 
53
- return self._parse(html, url, response.status if response else 0, title, json_responses)
54
+ return self._parse(
55
+ html=html,
56
+ url=url,
57
+ status=response.status if response else 0,
58
+ title=title,
59
+ csr_data=json_responses,
60
+ include=include
61
+ )
54
62
 
55
63
  async def close(self) -> None:
56
64
  # Browser lifecycle is owned by BrowserManager, not by the fetcher.
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass
2
+ from typing import TypedDict
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
39
+ links: tuple[Link, ...]
40
+ images: tuple[Image, ...]
41
+ csr_data: tuple[dict, ...] | None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class FetchResult:
46
+ url: str
47
+ status: int
48
+ # Meta
49
+ title: str | None
50
+ icon: str | None
51
+ description: str | None
52
+ image: str | None
53
+ # Content
54
+ 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]
@@ -0,0 +1,61 @@
1
+ import re
2
+
3
+ DROP_TAGS = frozenset({
4
+ "script", "style", "noscript", "template",
5
+ "iframe", "canvas", "svg", "object", "embed",
6
+ "form", "input", "select", "textarea", "button", "label",
7
+ "video", "audio", "source", "track",
8
+ "dialog", "menu"
9
+ })
10
+
11
+ NOISE_RE = re.compile(
12
+ r"(cookie|consent|gdpr|"
13
+ r"banner|advert|ads?[-_]|adslot|promo|sponsor|"
14
+ r"popup|modal|overlay|lightbox|tooltip|"
15
+ r"social|share|sharing|"
16
+ r"newsletter|subscribe|subscription|"
17
+ r"comment|disqus|"
18
+ r"sidebar|aside|widget|"
19
+ r"breadcrumb|pagination|pager|"
20
+ r"^nav$|navigation|menu|toolbar|"
21
+ r"^footer$|^header$|"
22
+ r"related|recommend|similar|"
23
+ r"tag-?list|tags-?list|"
24
+ r"author-?box|author-?card)",
25
+ re.IGNORECASE
26
+ )
27
+
28
+ KEEP_ATTRS = frozenset({"href", "src", "alt", "title", "datetime"})
29
+
30
+ KEEP_TAGS = frozenset({
31
+ "h1","h2","h3","h4","h5","h6",
32
+ "p","br","hr","ul","ol","li",
33
+ "a","img","figure","figcaption",
34
+ "blockquote","strong","em","b","i",
35
+ "table","thead","tbody","tr","th","td",
36
+ "time","span","div"
37
+ })
38
+
39
+ NAV_CONTAINERS = (
40
+ "nav",
41
+ "header",
42
+ "footer",
43
+ '[role="navigation"]',
44
+ '[class*="nav" i]',
45
+ '[class*="menu" i]',
46
+ '[class*="breadcrumb" i]',
47
+ '[class*="footer" i]',
48
+ '[class*="header" i]'
49
+ )
50
+ NAV_HEADER_SELECTOR = ("header nav", "header", '[class*="header" i] nav', '[class*="header" i]')
51
+ NAV_FOOTER_SELECTOR = ("footer nav", "footer", '[class*="footer" i]')
52
+
53
+ SKIP_SCHEMES = ("javascript:", "mailto:", "tel:", "data:", "#")
54
+
55
+ SKIP_PATH_PARTS = (
56
+ "/login", "/logout", "/signup", "/register", "/cart",
57
+ "/account", "/privacy", "/terms", "/cookie"
58
+ )
59
+
60
+ DEFAULT_SECTIONS = ("metadata", "links", "images", "html", "text")
61
+ VALID_SECTIONS = frozenset(DEFAULT_SECTIONS + ("raw_html", "raw_text"))
@@ -0,0 +1,37 @@
1
+ from bs4 import BeautifulSoup
2
+ from bs4.element import Tag
3
+
4
+
5
+ def _text_len(node: Tag) -> int:
6
+ return len(node.get_text(" ", strip=True))
7
+
8
+
9
+ def _link_density(node: Tag) -> float:
10
+ total = _text_len(node)
11
+ if total == 0:
12
+ return 1.0
13
+ link_text = sum(_text_len(a) for a in node.find_all("a"))
14
+ return link_text / total
15
+
16
+
17
+ def _score(node: Tag) -> float:
18
+ length = _text_len(node)
19
+ if length < 100:
20
+ return 0.0
21
+ return length * (1.0 - _link_density(node))
22
+
23
+
24
+ def extract_main_content(soup: BeautifulSoup) -> Tag:
25
+ """Return the main content node of the page. Does not mutate soup."""
26
+ for selector in ("article", "main", "[role=main]"):
27
+ node = soup.select_one(selector)
28
+ if node and _text_len(node) >= 200:
29
+ return node
30
+
31
+ best, best_score = None, 0.0
32
+ for node in soup.find_all(["div", "section", "td"]):
33
+ s = _score(node)
34
+ if s > best_score:
35
+ best, best_score = node, s
36
+
37
+ return best if best is not None else (soup.body or soup)
@@ -0,0 +1,24 @@
1
+ from urllib.parse import urljoin, urlparse
2
+ from bs4 import BeautifulSoup
3
+
4
+
5
+ def canonical_url(soup: BeautifulSoup, page_url: str | None = None) -> str | None:
6
+ tag = soup.find("link", rel="canonical")
7
+ href = tag.get("href") if tag else None
8
+ if href and page_url:
9
+ return urljoin(page_url, href)
10
+ return href or None
11
+
12
+
13
+ def base_url(soup: BeautifulSoup, page_url: str | None = None) -> str:
14
+ return page_url or canonical_url(soup, page_url) or ""
15
+
16
+
17
+ def resolve(href: str, base: str) -> tuple[str, str]:
18
+ """Return (absolute_url, netloc)."""
19
+ abs_url = urljoin(base, href)
20
+ return abs_url, urlparse(abs_url).netloc
21
+
22
+
23
+ def is_http(url: str) -> bool:
24
+ return urlparse(url).scheme in ("http", "https")
@@ -0,0 +1,20 @@
1
+ from bs4 import BeautifulSoup
2
+ from .helpers import base_url, resolve
3
+ from ..models import Image
4
+
5
+
6
+ def extract_images(soup: BeautifulSoup, page_url: str | None = None) -> list[dict]:
7
+ base = base_url(soup, page_url)
8
+ images = []
9
+ for img in soup.find_all("img"):
10
+ src = img.get("src") or img.get("data-src") or img.get("data-lazy-src")
11
+ if not src:
12
+ continue
13
+ images.append(
14
+ Image(
15
+ src=resolve(src, base)[0],
16
+ alt=img.get("alt") or None,
17
+ title=img.get("title") or None
18
+ )
19
+ )
20
+ return images
@@ -0,0 +1,48 @@
1
+ import json
2
+ from urllib.parse import urlparse, urljoin
3
+ from bs4 import BeautifulSoup
4
+ from .helpers import base_url, canonical_url
5
+ from ..models import PageMetadata
6
+
7
+
8
+ def extract_favicon(soup: BeautifulSoup, page_url: str | None = None) -> str | None:
9
+ """Return absolute URL of the page's favicon, or None."""
10
+ for link in soup.find_all("link", href=True):
11
+ rels = link.get("rel") or []
12
+ rels = [r.lower() for r in rels]
13
+ if "icon" in rels:
14
+ return urljoin(page_url or "", link["href"].strip())
15
+
16
+ # Fallback: the browser default location
17
+ return urljoin(page_url, "/favicon.ico") if page_url else None
18
+
19
+
20
+ def extract_metadata(soup: BeautifulSoup, page_url: str | None = None) -> PageMetadata:
21
+
22
+ def meta(*, prop: str | None = None, name: str | None = None) -> str | None:
23
+ attrs = {k: v for k, v in (("property", prop), ("name", name)) if v}
24
+ tag = soup.find("meta", attrs=attrs)
25
+ return tag["content"] if tag and tag.get("content") else None
26
+
27
+ canonical = canonical_url(soup, page_url)
28
+ base = base_url(soup, page_url)
29
+
30
+ ld_objects = []
31
+ for script in soup.find_all("script", type="application/ld+json"):
32
+ try:
33
+ data = json.loads(script.string or "{}")
34
+ except json.JSONDecodeError:
35
+ continue
36
+ ld_objects.extend(data if isinstance(data, list) else [data])
37
+
38
+ return PageMetadata(
39
+ page_url=base,
40
+ canonical_url=canonical,
41
+ icon=extract_favicon(soup, page_url),
42
+ title=meta(prop="og:title") or (soup.title.string if soup.title else None),
43
+ description=meta(prop="og:description") or meta(name="description"),
44
+ site_name=meta(prop="og:site_name") or urlparse(base).netloc or None,
45
+ og_image=meta(prop="og:image"),
46
+ og_type=meta(prop="og:type"),
47
+ ld_json=tuple(ld_objects)
48
+ )
@@ -0,0 +1,54 @@
1
+ from bs4 import BeautifulSoup
2
+ from .constants import (
3
+ NAV_CONTAINERS,
4
+ SKIP_SCHEMES,
5
+ SKIP_PATH_PARTS,
6
+ NAV_HEADER_SELECTOR,
7
+ NAV_FOOTER_SELECTOR,
8
+ )
9
+ from .helpers import base_url, is_http, resolve
10
+ from ..models import Link
11
+
12
+
13
+ def extract_navigation(soup: BeautifulSoup, page_url: str | None = None) -> list:
14
+ base = base_url(soup, page_url)
15
+ base_host = resolve(base, base)[1]
16
+ links, seen = [], set()
17
+
18
+ def collect(container, section):
19
+ for a in container.find_all("a", href=True):
20
+ href = a["href"].strip()
21
+ if not href or href.startswith(SKIP_SCHEMES):
22
+ continue
23
+ abs_url, host = resolve(href, base)
24
+ if not is_http(abs_url):
25
+ continue
26
+ if any(p in abs_url for p in SKIP_PATH_PARTS):
27
+ continue
28
+ if abs_url in seen:
29
+ continue
30
+ text = a.get_text(" ", strip=True)
31
+ if not text:
32
+ continue
33
+ seen.add(abs_url)
34
+ links.append(Link(text=text, url=abs_url, external=host != base_host, section=section))
35
+
36
+ def pick(selectors, section):
37
+ for sel in selectors:
38
+ node = soup.select_one(sel)
39
+ if node:
40
+ collect(node, section)
41
+ break
42
+
43
+ breadcrumb_node = (
44
+ soup.select_one('[class*="breadcrumb" i]')
45
+ or soup.select_one('nav[aria-label*="breadcrumb" i]')
46
+ )
47
+ if breadcrumb_node:
48
+ collect(breadcrumb_node, "breadcrumbs")
49
+
50
+ pick(NAV_HEADER_SELECTOR, "header")
51
+ pick(NAV_FOOTER_SELECTOR, "footer")
52
+ pick(NAV_CONTAINERS, "nav")
53
+
54
+ return links
@@ -0,0 +1,53 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ from .cleaning import clean_html, minify_html
4
+ from .constants import DEFAULT_SECTIONS, VALID_SECTIONS
5
+ from .content import extract_main_content
6
+ from .images import extract_images
7
+ from .metadata import extract_metadata
8
+ from .navigation import extract_navigation
9
+
10
+
11
+ def parse_html(
12
+ html: str,
13
+ page_url: str | None = None,
14
+ *,
15
+ include: tuple[str, ...] = DEFAULT_SECTIONS
16
+ ) -> dict:
17
+ """Parse an HTML page into structured sections.
18
+
19
+ include may contain any of: metadata, links, images, html, text,
20
+ raw_html, raw_text. Unknown values raise ValueError.
21
+ """
22
+ unknown = set(include) - VALID_SECTIONS
23
+ if unknown:
24
+ raise ValueError(f"Unknown sections: {sorted(unknown)}")
25
+
26
+ raw_soup = BeautifulSoup(html, "html.parser")
27
+ output: dict = {}
28
+
29
+ # Sections that must run on the unmodified DOM
30
+ if "metadata" in include:
31
+ output["metadata"] = extract_metadata(raw_soup, page_url)
32
+ if "links" in include:
33
+ output["links"] = extract_navigation(raw_soup, page_url)
34
+ if "images" in include:
35
+ output["images"] = extract_images(raw_soup, page_url)
36
+ if "raw_html" in include:
37
+ output["raw_html"] = html
38
+ if "raw_text" in include:
39
+ output["raw_text"] = raw_soup.get_text("\n", strip=True)
40
+
41
+ # Content runs on a cleaned copy. clean_html copies by default, so
42
+ # node stays intact for any later stage
43
+ output["html"] = ""
44
+ if "html" in include or "text" in include:
45
+ node = extract_main_content(raw_soup)
46
+ cleaned_node = clean_html(node)
47
+ minified_html, text = minify_html(cleaned_node, inplace=True)
48
+ if "html" in include:
49
+ output["html"] = minified_html
50
+ if "text" in include:
51
+ output["text"] = text
52
+
53
+ return output
@@ -1,7 +1,7 @@
1
1
  from fetta.base import BaseFetcher
2
2
  from fetta.browser import BrowserManager
3
- from fetta.models import PageContent
4
-
3
+ from fetta.models import FetchResult
4
+ from fetta.page_parser.constants import DEFAULT_SECTIONS
5
5
 
6
6
  _BLOCK = {"image", "media", "font"}
7
7
 
@@ -17,8 +17,9 @@ class PlaywrightFetcher(BaseFetcher):
17
17
  wait_for: str | None = None,
18
18
  wait_until: str = "load",
19
19
  timeout_ms: int = 30_000,
20
+ include: tuple[str, ...] = DEFAULT_SECTIONS,
20
21
  **kwargs,
21
- ) -> PageContent:
22
+ ) -> FetchResult:
22
23
  async with self._browser.page() as page:
23
24
  await page.route("**/*", self._block_resources)
24
25
 
@@ -41,7 +42,13 @@ class PlaywrightFetcher(BaseFetcher):
41
42
  html = await page.content()
42
43
  title = await page.title()
43
44
 
44
- return self._parse(html, url, response.status if response else 0, title)
45
+ return self._parse(
46
+ html=html,
47
+ url=url,
48
+ status=response.status if response else 0,
49
+ title=title,
50
+ include=include
51
+ )
45
52
 
46
53
  async def close(self) -> None:
47
54
  # Browser lifecycle is owned by BrowserManager, not by the fetcher.
@@ -1,8 +1,8 @@
1
1
  from typing import Protocol
2
2
 
3
- from fetta.models import PageContent
3
+ from fetta.models import FetchResult
4
4
 
5
5
 
6
6
  class FetcherProtocol(Protocol):
7
- async def fetch(self, url: str, **kwargs) -> PageContent: ...
7
+ async def fetch(self, url: str, **kwargs) -> FetchResult: ...
8
8
  async def close(self) -> None: ...
@@ -1,8 +0,0 @@
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"]
@@ -1,61 +0,0 @@
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)
@@ -1,11 +0,0 @@
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
@@ -1,53 +0,0 @@
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()
File without changes
File without changes