fetta 0.2.0__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.
Files changed (26) hide show
  1. {fetta-0.2.0 → fetta-0.3.0}/PKG-INFO +1 -1
  2. {fetta-0.2.0 → fetta-0.3.0}/pyproject.toml +1 -1
  3. {fetta-0.2.0 → fetta-0.3.0}/pyproject.toml.orig +1 -1
  4. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/__init__.py +2 -1
  5. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/base.py +6 -3
  6. fetta-0.3.0/src/fetta/browser.py +54 -0
  7. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/fetchers.py +39 -1
  8. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/httpx_fetcher.py +3 -1
  9. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/json_fetcher.py +6 -4
  10. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/models.py +5 -2
  11. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/playwright_fetcher.py +5 -2
  12. fetta-0.3.0/src/fetta/protocol.py +11 -0
  13. fetta-0.3.0/src/fetta/smart_fetcher.py +137 -0
  14. fetta-0.2.0/src/fetta/browser.py +0 -49
  15. fetta-0.2.0/src/fetta/protocol.py +0 -8
  16. {fetta-0.2.0 → fetta-0.3.0}/LICENSE +0 -0
  17. {fetta-0.2.0 → fetta-0.3.0}/README.md +0 -0
  18. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/__init__.py +0 -0
  19. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/cleaning.py +0 -0
  20. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/constants.py +0 -0
  21. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/content.py +0 -0
  22. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/helpers.py +0 -0
  23. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/images.py +0 -0
  24. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/metadata.py +0 -0
  25. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/navigation.py +0 -0
  26. {fetta-0.2.0 → fetta-0.3.0}/src/fetta/page_parser/parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetta
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Tool that fetches web pages for assistants
5
5
  Author: Sergei
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fetta"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Tool that fetches web pages for assistants"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fetta"
3
- version = "0.2.0"
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" }]
@@ -1,5 +1,5 @@
1
1
  from .browser import BrowserManager
2
- from .fetchers import BrowserFetcher, HttpFetcher
2
+ from .fetchers import BrowserFetcher, HttpFetcher, SmartFetcher
3
3
  from .json_fetcher import JsonFetcher
4
4
  from .models import Link, Image, PageContent, PageMetadata, FetchResult
5
5
  from .protocol import FetcherProtocol
@@ -15,4 +15,5 @@ __all__ = [
15
15
  "HttpFetcher",
16
16
  "FetcherProtocol",
17
17
  "JsonFetcher",
18
+ "SmartFetcher"
18
19
  ]
@@ -1,7 +1,7 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from typing import Any
3
3
 
4
- from .models import FetchResult, PageContent
4
+ from .models import FetchResult, PageContent, PageMetadata, FetcherKind
5
5
  from .page_parser.constants import DEFAULT_SECTIONS
6
6
  from .page_parser.parser import parse_html
7
7
 
@@ -11,6 +11,8 @@ class BaseFetcher(ABC):
11
11
  Abstract base class for fetchers.
12
12
  """
13
13
 
14
+ fetcher_kind: FetcherKind
15
+
14
16
  @abstractmethod
15
17
  async def fetch(self, url: str, **kwargs: Any) -> FetchResult:
16
18
  """Fetch a page and return its parsed content."""
@@ -62,7 +64,7 @@ class BaseFetcher(ABC):
62
64
  text=parsed_content.get("text"),
63
65
  raw_html=parsed_content.get("raw_html"),
64
66
  raw_text=parsed_content.get("raw_text"),
65
- metadata=page_metadata,
67
+ metadata=parsed_content.get("metadata"),
66
68
  links=tuple(parsed_content.get("links") or []),
67
69
  images=parsed_content.get("images") or [],
68
70
  csr_data=tuple(csr_data) if csr_data is not None else None
@@ -71,5 +73,6 @@ class BaseFetcher(ABC):
71
73
  icon=page_metadata.get("icon"),
72
74
  image=page_metadata.get("image"),
73
75
  description=page_metadata.get("description"),
74
- status=status
76
+ status=status,
77
+ fetcher_kind=self.fetcher_kind
75
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
+
@@ -4,8 +4,9 @@ from .httpx_fetcher import HttpxFetcher
4
4
  from .protocol import FetcherProtocol
5
5
  from .base import BaseFetcher
6
6
  from .browser import BrowserManager
7
- from .models import FetchResult
7
+ from .models import FetchResult, FetcherKind
8
8
  from .playwright_fetcher import PlaywrightFetcher
9
+ from .smart_fetcher import VanillaSmartFetcher
9
10
 
10
11
 
11
12
  class HttpFetcher(BaseFetcher):
@@ -16,6 +17,8 @@ class HttpFetcher(BaseFetcher):
16
17
  HttpxFetcher (sync `requests` offloaded to a worker thread).
17
18
  """
18
19
 
20
+ fetcher_kind: FetcherKind = "http"
21
+
19
22
  def __init__(
20
23
  self,
21
24
  *,
@@ -38,6 +41,8 @@ class BrowserFetcher(BaseFetcher):
38
41
  used; alternative engines can be injected via the `engine` argument.
39
42
  """
40
43
 
44
+ fetcher_kind: FetcherKind = "browser"
45
+
41
46
  def __init__(
42
47
  self,
43
48
  manager: BrowserManager,
@@ -51,3 +56,36 @@ class BrowserFetcher(BaseFetcher):
51
56
 
52
57
  async def close(self) -> None:
53
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()
@@ -2,7 +2,7 @@ from typing import Any
2
2
 
3
3
  import httpx
4
4
 
5
- from .models import FetchResult
5
+ from .models import FetchResult, FetcherKind
6
6
  from .base import BaseFetcher
7
7
  from .page_parser.constants import DEFAULT_SECTIONS
8
8
 
@@ -16,6 +16,8 @@ class HttpxFetcher(BaseFetcher):
16
16
  TLS-handshake overhead).
17
17
  """
18
18
 
19
+ fetcher_kind: FetcherKind = "http"
20
+
19
21
  def __init__(self, *, timeout: float = 10.0, http2: bool = False) -> None:
20
22
  self._client = httpx.AsyncClient(
21
23
  timeout=timeout,
@@ -1,12 +1,14 @@
1
- from fetta.base import BaseFetcher
2
- from fetta.browser import BrowserManager
3
- from fetta.models import FetchResult
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
 
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass
2
- from typing import TypedDict
2
+ from typing import TypedDict, Literal
3
3
 
4
4
 
5
5
  class PageMetadata(TypedDict):
@@ -35,16 +35,19 @@ class PageContent:
35
35
  text: str | None
36
36
  raw_html: str | None
37
37
  raw_text: str | None
38
- metadata: PageMetadata
38
+ metadata: PageMetadata | None
39
39
  links: tuple[Link, ...]
40
40
  images: tuple[Image, ...]
41
41
  csr_data: tuple[dict, ...] | None
42
42
 
43
43
 
44
+ FetcherKind = Literal["http", "browser"]
45
+
44
46
  @dataclass(frozen=True, slots=True)
45
47
  class FetchResult:
46
48
  url: str
47
49
  status: int
50
+ fetcher_kind: FetcherKind
48
51
  # Meta
49
52
  title: str | None
50
53
  icon: str | None
@@ -1,12 +1,15 @@
1
1
  from fetta.base import BaseFetcher
2
2
  from fetta.browser import BrowserManager
3
- from fetta.models import FetchResult
3
+ from fetta.models import FetchResult, FetcherKind
4
4
  from fetta.page_parser.constants import DEFAULT_SECTIONS
5
5
 
6
6
  _BLOCK = {"image", "media", "font"}
7
7
 
8
8
 
9
9
  class PlaywrightFetcher(BaseFetcher):
10
+
11
+ fetcher_kind: FetcherKind = "browser"
12
+
10
13
  def __init__(self, browser: BrowserManager) -> None:
11
14
  self._browser = browser
12
15
 
@@ -45,7 +48,7 @@ class PlaywrightFetcher(BaseFetcher):
45
48
  return self._parse(
46
49
  html=html,
47
50
  url=url,
48
- status=response.status if response else 0,
51
+ status=response.status if response else 500,
49
52
  title=title,
50
53
  include=include
51
54
  )
@@ -0,0 +1,11 @@
1
+ from typing import Protocol, Any
2
+
3
+ from fetta.models import FetchResult
4
+
5
+
6
+ class FetcherProtocol(Protocol):
7
+ async def fetch(
8
+ self,
9
+ url: str,
10
+ **kwargs: Any) -> FetchResult: ...
11
+ async def close(self) -> None: ...
@@ -0,0 +1,137 @@
1
+ import logging
2
+ from dataclasses import replace
3
+ from typing import Any
4
+
5
+ from bs4 import BeautifulSoup
6
+
7
+ from .protocol import FetcherProtocol
8
+ from .models import FetchResult
9
+ from .base import BaseFetcher
10
+ from .page_parser.constants import DEFAULT_SECTIONS
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ EMPTY_ROOT_IDS = frozenset({
16
+ "root", "app", "__next", "__nuxt", "___gatsby",
17
+ "react-root", "svelte", "main-app", "ember-app"
18
+ })
19
+
20
+ FRAMEWORK_MARKERS = (
21
+ "data-reactroot", "_reactRootContainer",
22
+ "__NEXT_DATA__", "__NUXT__", "__SVELTE__",
23
+ "___gatsby", "ng-version=", "ng-app=",
24
+ "window.__INITIAL_STATE__", "window.__APOLLO_STATE__",
25
+ )
26
+
27
+ SSR_MARKERS = (
28
+ 'data-server-rendered="true"', # Vue SSR
29
+ "data-reactroot", # React SSR (hydrated)
30
+ )
31
+
32
+ NON_CONTAINER_TAGS = frozenset({"script", "style", "link", "meta"})
33
+
34
+
35
+ class VanillaSmartFetcher(BaseFetcher):
36
+ """
37
+ Wraps an HTML fetcher and a browser fetcher.
38
+
39
+ Contract: the underlying HTML fetcher must be able to return
40
+ `raw_html`, otherwise `_is_client_side_rendering` cannot detect
41
+ client-side-rendered pages reliably. VanillaSmartFetcher adds
42
+ `raw_html` to `include` whenever the caller supplies one.
43
+
44
+ `fetcher_kind` on the result reflects the fetcher that actually
45
+ produced the content ("http" or "browser"), not "composite".
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ html_fetcher: FetcherProtocol,
51
+ browser_fetcher: FetcherProtocol | None
52
+ ):
53
+ self._html_fetcher = html_fetcher
54
+ self._browser_fetcher = browser_fetcher
55
+
56
+ async def fetch(
57
+ self,
58
+ url: str,
59
+ *,
60
+ include: tuple[str, ...] = DEFAULT_SECTIONS,
61
+ **kwargs: Any
62
+ ) -> FetchResult:
63
+ # Note: We must return raw_html to check whether content is CSR
64
+ html_include = tuple(set(include + ("raw_html", "raw_text")))
65
+ response = await self._html_fetcher.fetch(url=url, include=html_include, **kwargs)
66
+
67
+ if response.status >= 400:
68
+ return self._build_response(response, include)
69
+
70
+ if self._browser_fetcher and self._is_client_side_rendering(response):
71
+ return await self._browser_fetcher.fetch(url=url, include=include, **kwargs)
72
+
73
+ return self._build_response(response, include)
74
+
75
+ async def close(self) -> None:
76
+ await self._html_fetcher.close()
77
+ if self._browser_fetcher:
78
+ await self._browser_fetcher.close()
79
+
80
+ def _build_response(
81
+ self,
82
+ response: FetchResult,
83
+ include: tuple[str, ...]
84
+ ) -> FetchResult:
85
+ new_content = replace(
86
+ response.content,
87
+ raw_html=response.content.raw_html if "raw_html" in include else None,
88
+ raw_text=response.content.raw_text if "raw_text" in include else None
89
+ )
90
+ return replace(response, content=new_content)
91
+
92
+ def _is_client_side_rendering(self, response: FetchResult) -> bool:
93
+
94
+ html = response.content.raw_html
95
+ html_len = len(html)
96
+ if not html or html_len < 300:
97
+ logger.debug("CSR check: skip, html_len=%d < 300", html_len)
98
+ return False
99
+
100
+ raw_text = response.content.raw_text
101
+ text_len = len(raw_text) if raw_text else 0
102
+
103
+ has_framework = any(m in html for m in FRAMEWORK_MARKERS)
104
+ has_ssr = any(m in html for m in SSR_MARKERS)
105
+
106
+ logger.debug(
107
+ "CSR check: html_len=%d text_len=%d framework=%s ssr=%s",
108
+ html_len, text_len, has_framework, has_ssr,
109
+ )
110
+
111
+ if has_framework and has_ssr:
112
+ logger.debug("CSR check: NOT CSR (framework + SSR marker)")
113
+ return False
114
+
115
+ # No framework marker. Extremely thin text on a big page
116
+ if text_len < 2000 and len(html) > 10000:
117
+ logger.debug("CSR check: CSR (thin text on large html)")
118
+ return True
119
+
120
+ # Last resort: explicit empty root
121
+ if text_len < 2000:
122
+ soup = BeautifulSoup(html, "html.parser")
123
+ empty_root = self._has_empty_spa_root(soup)
124
+ logger.debug("CSR check: empty_root=%s", empty_root)
125
+ return empty_root
126
+
127
+ logger.debug("CSR check: NOT CSR (default)")
128
+ return False
129
+
130
+ def _has_empty_spa_root(self, soup: BeautifulSoup) -> bool:
131
+ for elem in soup.find_all(id=True):
132
+ if elem.name in NON_CONTAINER_TAGS:
133
+ continue
134
+ if elem["id"].lower() in EMPTY_ROOT_IDS and not elem.get_text(strip=True):
135
+ return True
136
+ return False
137
+
@@ -1,49 +0,0 @@
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()
@@ -1,8 +0,0 @@
1
- from typing import Protocol
2
-
3
- from fetta.models import FetchResult
4
-
5
-
6
- class FetcherProtocol(Protocol):
7
- async def fetch(self, url: str, **kwargs) -> FetchResult: ...
8
- async def close(self) -> None: ...
File without changes
File without changes