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.
- {fetta-0.1.2 → fetta-0.2.0}/PKG-INFO +4 -6
- {fetta-0.1.2 → fetta-0.2.0}/README.md +2 -4
- {fetta-0.1.2 → fetta-0.2.0}/pyproject.toml +5 -3
- {fetta-0.1.2 → fetta-0.2.0}/pyproject.toml.orig +5 -3
- fetta-0.2.0/src/fetta/__init__.py +18 -0
- fetta-0.2.0/src/fetta/base.py +75 -0
- {fetta-0.1.2 → fetta-0.2.0}/src/fetta/fetchers.py +11 -11
- fetta-0.2.0/src/fetta/httpx_fetcher.py +67 -0
- {fetta-0.1.2 → fetta-0.2.0}/src/fetta/json_fetcher.py +11 -3
- fetta-0.2.0/src/fetta/models.py +54 -0
- fetta-0.2.0/src/fetta/page_parser/__init__.py +0 -0
- fetta-0.2.0/src/fetta/page_parser/cleaning.py +76 -0
- fetta-0.2.0/src/fetta/page_parser/constants.py +61 -0
- fetta-0.2.0/src/fetta/page_parser/content.py +37 -0
- fetta-0.2.0/src/fetta/page_parser/helpers.py +24 -0
- fetta-0.2.0/src/fetta/page_parser/images.py +20 -0
- fetta-0.2.0/src/fetta/page_parser/metadata.py +48 -0
- fetta-0.2.0/src/fetta/page_parser/navigation.py +54 -0
- fetta-0.2.0/src/fetta/page_parser/parser.py +53 -0
- {fetta-0.1.2 → fetta-0.2.0}/src/fetta/playwright_fetcher.py +11 -4
- {fetta-0.1.2 → fetta-0.2.0}/src/fetta/protocol.py +2 -2
- fetta-0.1.2/src/fetta/__init__.py +0 -8
- fetta-0.1.2/src/fetta/base.py +0 -61
- fetta-0.1.2/src/fetta/models.py +0 -11
- fetta-0.1.2/src/fetta/requests_fetcher.py +0 -53
- {fetta-0.1.2 → fetta-0.2.0}/LICENSE +0 -0
- {fetta-0.1.2 → fetta-0.2.0}/src/fetta/browser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fetta
|
|
3
|
-
Version: 0.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from
|
|
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
|
-
|
|
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
|
|
24
|
+
self._engine: FetcherProtocol = engine or HttpxFetcher()
|
|
25
25
|
|
|
26
|
-
async def fetch(self, url: str, **kwargs: Any) ->
|
|
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) ->
|
|
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
|
|
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
|
-
) ->
|
|
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(
|
|
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
|
|
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
|
-
) ->
|
|
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(
|
|
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
|
|
3
|
+
from fetta.models import FetchResult
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class FetcherProtocol(Protocol):
|
|
7
|
-
async def fetch(self, url: str, **kwargs) ->
|
|
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"]
|
fetta-0.1.2/src/fetta/base.py
DELETED
|
@@ -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)
|
fetta-0.1.2/src/fetta/models.py
DELETED
|
@@ -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
|