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.
- {fetta-0.1.2 → fetta-0.3.0}/PKG-INFO +4 -6
- {fetta-0.1.2 → fetta-0.3.0}/README.md +2 -4
- {fetta-0.1.2 → fetta-0.3.0}/pyproject.toml +5 -3
- {fetta-0.1.2 → fetta-0.3.0}/pyproject.toml.orig +5 -3
- fetta-0.3.0/src/fetta/__init__.py +19 -0
- fetta-0.3.0/src/fetta/base.py +78 -0
- fetta-0.3.0/src/fetta/browser.py +54 -0
- fetta-0.3.0/src/fetta/fetchers.py +91 -0
- fetta-0.3.0/src/fetta/httpx_fetcher.py +69 -0
- {fetta-0.1.2 → fetta-0.3.0}/src/fetta/json_fetcher.py +16 -6
- fetta-0.3.0/src/fetta/models.py +57 -0
- fetta-0.3.0/src/fetta/page_parser/__init__.py +0 -0
- fetta-0.3.0/src/fetta/page_parser/cleaning.py +76 -0
- fetta-0.3.0/src/fetta/page_parser/constants.py +61 -0
- fetta-0.3.0/src/fetta/page_parser/content.py +37 -0
- fetta-0.3.0/src/fetta/page_parser/helpers.py +24 -0
- fetta-0.3.0/src/fetta/page_parser/images.py +20 -0
- fetta-0.3.0/src/fetta/page_parser/metadata.py +48 -0
- fetta-0.3.0/src/fetta/page_parser/navigation.py +54 -0
- fetta-0.3.0/src/fetta/page_parser/parser.py +53 -0
- {fetta-0.1.2 → fetta-0.3.0}/src/fetta/playwright_fetcher.py +14 -4
- fetta-0.3.0/src/fetta/protocol.py +11 -0
- fetta-0.3.0/src/fetta/smart_fetcher.py +137 -0
- 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/browser.py +0 -49
- fetta-0.1.2/src/fetta/fetchers.py +0 -53
- fetta-0.1.2/src/fetta/models.py +0 -11
- fetta-0.1.2/src/fetta/protocol.py +0 -8
- fetta-0.1.2/src/fetta/requests_fetcher.py +0 -53
- {fetta-0.1.2 → fetta-0.3.0}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fetta
|
|
3
|
-
Version: 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
|
|
@@ -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.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
|
-
"
|
|
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.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
|
-
"
|
|
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,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
|
|
2
|
-
from
|
|
3
|
-
from
|
|
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
|
-
) ->
|
|
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(
|
|
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]
|