webarchiver 1.0.0__py3-none-any.whl
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.
- webarchiver/__init__.py +54 -0
- webarchiver/__main__.py +4 -0
- webarchiver/_version.py +3 -0
- webarchiver/api/__init__.py +10 -0
- webarchiver/api/api_client_system.py +166 -0
- webarchiver/api/browser.py +247 -0
- webarchiver/api/extraction.py +130 -0
- webarchiver/api/fetcher.py +146 -0
- webarchiver/api/policy.py +170 -0
- webarchiver/auth.py +47 -0
- webarchiver/connector_manifest.yml +47 -0
- webarchiver/connectors/__init__.py +0 -0
- webarchiver/connectors/mcp_source_presets.json +1 -0
- webarchiver/connectors/tool_schema_fingerprints.json +1 -0
- webarchiver/error_authority.py +248 -0
- webarchiver/error_rendering.py +105 -0
- webarchiver/kg_ingest.py +93 -0
- webarchiver/mcp/__init__.py +3 -0
- webarchiver/mcp/mcp_system.py +11 -0
- webarchiver/mcp_config.json +3 -0
- webarchiver/mcp_server.py +59 -0
- webarchiver/ontology/__init__.py +6 -0
- webarchiver/ontology/pack.ttl +48 -0
- webarchiver/ontology/webarchiver.ttl +29 -0
- webarchiver/prompts/__init__.py +0 -0
- webarchiver/prompts/webarchiver_specialist.json +30 -0
- webarchiver/skills/__init__.py +0 -0
- webarchiver/skills/webarchiver-starter/SKILL.md +25 -0
- webarchiver/webarchiver_input_models.py +12 -0
- webarchiver/webarchiver_response_models.py +15 -0
- webarchiver-1.0.0.dist-info/METADATA +201 -0
- webarchiver-1.0.0.dist-info/RECORD +36 -0
- webarchiver-1.0.0.dist-info/WHEEL +5 -0
- webarchiver-1.0.0.dist-info/entry_points.txt +2 -0
- webarchiver-1.0.0.dist-info/licenses/LICENSE +21 -0
- webarchiver-1.0.0.dist-info/top_level.txt +1 -0
webarchiver/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
10
|
+
|
|
11
|
+
CORE_MODULES = [
|
|
12
|
+
"webarchiver.api",
|
|
13
|
+
"webarchiver.error_authority",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
OPTIONAL_MODULES = {
|
|
17
|
+
"webarchiver.mcp_server": "mcp",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _import_module_safely(module_name: str):
|
|
22
|
+
"""Try to import a module and return it, or None if not available."""
|
|
23
|
+
try:
|
|
24
|
+
return importlib.import_module(module_name)
|
|
25
|
+
except ImportError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _expose_members(module):
|
|
30
|
+
"""Expose public classes and functions from a module into globals and __all__."""
|
|
31
|
+
for name, obj in inspect.getmembers(module):
|
|
32
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
33
|
+
"_"
|
|
34
|
+
):
|
|
35
|
+
globals()[name] = obj
|
|
36
|
+
__all__.append(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
for module_name in CORE_MODULES:
|
|
40
|
+
try:
|
|
41
|
+
module = importlib.import_module(module_name)
|
|
42
|
+
_expose_members(module)
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
for module_name, extra_name in OPTIONAL_MODULES.items():
|
|
47
|
+
module = _import_module_safely(module_name)
|
|
48
|
+
if module is not None:
|
|
49
|
+
_expose_members(module)
|
|
50
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = True
|
|
51
|
+
else:
|
|
52
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = False
|
|
53
|
+
|
|
54
|
+
__all__.extend(["_MCP_AVAILABLE"])
|
webarchiver/__main__.py
ADDED
webarchiver/_version.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The webarchiver operations: one public method per MCP tool.
|
|
2
|
+
|
|
3
|
+
CONN-WEBARCHIVER-WEB-SOURCE-R001. ``register_tool_surface`` turns each public
|
|
4
|
+
method of :class:`ApiClientSystem` into one typed tool named
|
|
5
|
+
``webarchiver_<method>``: ``fetch``, ``extract``, ``render``, ``screenshot``
|
|
6
|
+
and ``links``. All five only read the web, so each is declared ``GET`` and
|
|
7
|
+
served read-only. Every operation honours robots.txt and the per-domain rate
|
|
8
|
+
limit; a refusal is returned as a typed :class:`PolicyRefused`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
|
|
18
|
+
from .browser import Browser, RenderedPage, Screenshot
|
|
19
|
+
from .extraction import ExtractedPage, PageLink, extract_links, extract_page
|
|
20
|
+
from .fetcher import FetchedResponse, Fetcher
|
|
21
|
+
from .policy import FetchPolicy, PolicyRefused, origin_of
|
|
22
|
+
|
|
23
|
+
__all__ = ["ApiClientSystem", "ExtractResult", "LinksResult"]
|
|
24
|
+
|
|
25
|
+
#: Rendered HTML returned by ``render`` is capped to keep tool results bounded.
|
|
26
|
+
_MAX_RENDERED_HTML = 2 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_only(method: Any) -> Any:
|
|
30
|
+
method.__http_method__ = "GET"
|
|
31
|
+
return method
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExtractResult(BaseModel):
|
|
35
|
+
"""Extracted main content plus the provenance of how it was fetched."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
38
|
+
|
|
39
|
+
backend: Literal["webarchiver"] = "webarchiver"
|
|
40
|
+
fetch_mode: Literal["http", "render"]
|
|
41
|
+
url: str
|
|
42
|
+
final_url: str
|
|
43
|
+
status: int
|
|
44
|
+
content_type: str
|
|
45
|
+
byte_count: int
|
|
46
|
+
content_digest: str
|
|
47
|
+
retrieved_at: str
|
|
48
|
+
robots: Literal["allowed", "not_checked"]
|
|
49
|
+
page: ExtractedPage
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LinksResult(BaseModel):
|
|
53
|
+
"""The outbound links of one page."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
url: str
|
|
58
|
+
final_url: str
|
|
59
|
+
links: tuple[PageLink, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ApiClientSystem:
|
|
63
|
+
"""Policy-governed web fetch, extraction, rendering and screenshots."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
policy: FetchPolicy | None = None,
|
|
68
|
+
*,
|
|
69
|
+
allow_loopback: bool = False,
|
|
70
|
+
allowed_private_hosts: tuple[str, ...] = (),
|
|
71
|
+
) -> None:
|
|
72
|
+
self.policy = policy or FetchPolicy()
|
|
73
|
+
self.fetcher = Fetcher(
|
|
74
|
+
self.policy,
|
|
75
|
+
allow_loopback=allow_loopback,
|
|
76
|
+
allowed_private_hosts=allowed_private_hosts,
|
|
77
|
+
)
|
|
78
|
+
self.browser = Browser(allow_loopback=allow_loopback)
|
|
79
|
+
|
|
80
|
+
async def _admit(self, url: str) -> None:
|
|
81
|
+
"""Robots and rate-limit admission for browser-driven operations."""
|
|
82
|
+
if self.policy.obey_robots and not await self.fetcher.robots.allowed(
|
|
83
|
+
url, self.policy.user_agent
|
|
84
|
+
):
|
|
85
|
+
raise PolicyRefused("robots_disallowed", url)
|
|
86
|
+
await self.fetcher.limiter.acquire(origin_of(url))
|
|
87
|
+
|
|
88
|
+
@_read_only
|
|
89
|
+
async def fetch(self, url: str) -> FetchedResponse:
|
|
90
|
+
"""GET one URL under robots.txt, rate-limit, size and content-type policy."""
|
|
91
|
+
return await self.fetcher.fetch(url)
|
|
92
|
+
|
|
93
|
+
@_read_only
|
|
94
|
+
async def extract(self, url: str, render: bool = False) -> ExtractResult:
|
|
95
|
+
"""Fetch one URL and extract its main content as text and markdown.
|
|
96
|
+
|
|
97
|
+
Returns title, author, published date, canonical URL and outbound
|
|
98
|
+
links. ``render=True`` runs the page's scripts in headless Chromium
|
|
99
|
+
first, for pages that build their content client-side.
|
|
100
|
+
"""
|
|
101
|
+
if render:
|
|
102
|
+
rendered = await self.render(url)
|
|
103
|
+
page = extract_page(rendered.html, rendered.final_url)
|
|
104
|
+
return ExtractResult(
|
|
105
|
+
fetch_mode="render",
|
|
106
|
+
url=url,
|
|
107
|
+
final_url=rendered.final_url,
|
|
108
|
+
status=rendered.status,
|
|
109
|
+
content_type="text/html",
|
|
110
|
+
byte_count=len(rendered.html.encode("utf-8")),
|
|
111
|
+
content_digest=page.content_sha256,
|
|
112
|
+
retrieved_at=_now(),
|
|
113
|
+
robots=self._robots_decision(),
|
|
114
|
+
page=page.model_copy(update={"url": url}),
|
|
115
|
+
)
|
|
116
|
+
fetched = await self.fetcher.fetch(url)
|
|
117
|
+
page = extract_page(fetched.text, fetched.final_url)
|
|
118
|
+
return ExtractResult(
|
|
119
|
+
fetch_mode="http",
|
|
120
|
+
url=url,
|
|
121
|
+
final_url=fetched.final_url,
|
|
122
|
+
status=fetched.status,
|
|
123
|
+
content_type=fetched.content_type,
|
|
124
|
+
byte_count=fetched.bytes,
|
|
125
|
+
content_digest=page.content_sha256,
|
|
126
|
+
retrieved_at=fetched.fetched_at,
|
|
127
|
+
robots=self._robots_decision(),
|
|
128
|
+
page=page.model_copy(update={"url": url}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _robots_decision(self) -> Literal["allowed", "not_checked"]:
|
|
132
|
+
return "allowed" if self.policy.obey_robots else "not_checked"
|
|
133
|
+
|
|
134
|
+
@_read_only
|
|
135
|
+
async def render(self, url: str) -> RenderedPage:
|
|
136
|
+
"""Render one URL in ad-blocked headless Chromium and return its DOM."""
|
|
137
|
+
await self._admit(url)
|
|
138
|
+
rendered = await self.browser.render(url, user_agent=self.policy.user_agent)
|
|
139
|
+
if len(rendered.html) > _MAX_RENDERED_HTML:
|
|
140
|
+
raise PolicyRefused("size_exceeded", url, "rendered DOM")
|
|
141
|
+
return rendered
|
|
142
|
+
|
|
143
|
+
@_read_only
|
|
144
|
+
async def screenshot(self, url: str, full_page: bool = True) -> Screenshot:
|
|
145
|
+
"""Take a PNG screenshot of one URL, the full page by default."""
|
|
146
|
+
await self._admit(url)
|
|
147
|
+
return await self.browser.screenshot(
|
|
148
|
+
url, user_agent=self.policy.user_agent, full_page=full_page
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@_read_only
|
|
152
|
+
async def links(self, url: str, same_site_only: bool = False) -> LinksResult:
|
|
153
|
+
"""List the outbound http(s) links of one URL."""
|
|
154
|
+
fetched = await self.fetcher.fetch(url)
|
|
155
|
+
found = extract_links(fetched.text, fetched.final_url)
|
|
156
|
+
if same_site_only:
|
|
157
|
+
found = tuple(link for link in found if link.same_site)
|
|
158
|
+
return LinksResult(url=url, final_url=fetched.final_url, links=found)
|
|
159
|
+
|
|
160
|
+
async def _close(self) -> None:
|
|
161
|
+
"""Release the shared browser."""
|
|
162
|
+
await self.browser.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _now() -> str:
|
|
166
|
+
return _dt.datetime.now(_dt.UTC).isoformat()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Headless, ad-blocked page rendering and full-page screenshots.
|
|
2
|
+
|
|
3
|
+
CONN-WEBARCHIVER-WEB-SOURCE-R001. Chromium runs through Playwright. Every
|
|
4
|
+
request the page makes, sub-resources included, is routed through
|
|
5
|
+
:meth:`Browser._route`: requests to a known ad or tracker host, or to an
|
|
6
|
+
address outside the public egress boundary, are aborted. Before a page loads,
|
|
7
|
+
fixed and sticky overlays are removed and scrolling is re-enabled, so a full
|
|
8
|
+
page screenshot shows the content rather than a cookie banner (the behaviour
|
|
9
|
+
the original Selenium archiver had).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import socket
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from agent_connector_sdk.http.egress import egress_ip_is_blocked
|
|
23
|
+
from pydantic import BaseModel, ConfigDict
|
|
24
|
+
|
|
25
|
+
from .policy import PolicyRefused
|
|
26
|
+
|
|
27
|
+
__all__ = ["AD_HOST_SUFFIXES", "Browser", "RenderedPage", "Screenshot"]
|
|
28
|
+
|
|
29
|
+
#: Ad and tracker hosts aborted at the network layer (suffix match).
|
|
30
|
+
AD_HOST_SUFFIXES = (
|
|
31
|
+
"doubleclick.net",
|
|
32
|
+
"googlesyndication.com",
|
|
33
|
+
"googleadservices.com",
|
|
34
|
+
"google-analytics.com",
|
|
35
|
+
"googletagmanager.com",
|
|
36
|
+
"googletagservices.com",
|
|
37
|
+
"adservice.google.com",
|
|
38
|
+
"amazon-adsystem.com",
|
|
39
|
+
"adnxs.com",
|
|
40
|
+
"adsrvr.org",
|
|
41
|
+
"criteo.com",
|
|
42
|
+
"criteo.net",
|
|
43
|
+
"taboola.com",
|
|
44
|
+
"outbrain.com",
|
|
45
|
+
"scorecardresearch.com",
|
|
46
|
+
"quantserve.com",
|
|
47
|
+
"hotjar.com",
|
|
48
|
+
"facebook.net",
|
|
49
|
+
"connect.facebook.net",
|
|
50
|
+
"ads-twitter.com",
|
|
51
|
+
"rubiconproject.com",
|
|
52
|
+
"pubmatic.com",
|
|
53
|
+
"openx.net",
|
|
54
|
+
"moatads.com",
|
|
55
|
+
"chartbeat.com",
|
|
56
|
+
"segment.io",
|
|
57
|
+
"mixpanel.com",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
#: Removes fixed/sticky overlays and re-enables scrolling.
|
|
61
|
+
_DECLUTTER_JS = """
|
|
62
|
+
() => {
|
|
63
|
+
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
|
64
|
+
const pos = getComputedStyle(el).position;
|
|
65
|
+
if (pos === 'fixed' || pos === 'sticky' || pos === '-webkit-sticky') {
|
|
66
|
+
el.remove();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const el of [document.documentElement, document.body]) {
|
|
70
|
+
if (el) { el.style.overflow = 'visible'; el.style.position = 'relative'; }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
#: Scrolls through the page so lazy content loads, then back to the top.
|
|
76
|
+
_SCROLL_JS = """
|
|
77
|
+
async (maxHeight) => {
|
|
78
|
+
const step = Math.max(window.innerHeight, 200);
|
|
79
|
+
const height = Math.min(document.body.scrollHeight, maxHeight);
|
|
80
|
+
for (let y = 0; y < height; y += step) {
|
|
81
|
+
window.scrollTo(0, y);
|
|
82
|
+
await new Promise(r => setTimeout(r, 50));
|
|
83
|
+
}
|
|
84
|
+
window.scrollTo(0, 0);
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RenderedPage(BaseModel):
|
|
90
|
+
"""The DOM of a page after scripts ran."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
93
|
+
|
|
94
|
+
url: str
|
|
95
|
+
final_url: str
|
|
96
|
+
status: int
|
|
97
|
+
title: str
|
|
98
|
+
html: str
|
|
99
|
+
blocked_requests: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Screenshot(BaseModel):
|
|
103
|
+
"""A PNG screenshot, base64-encoded."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
106
|
+
|
|
107
|
+
url: str
|
|
108
|
+
final_url: str
|
|
109
|
+
full_page: bool
|
|
110
|
+
width: int
|
|
111
|
+
png_base64: str
|
|
112
|
+
bytes: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_ad_host(host: str) -> bool:
|
|
116
|
+
host = host.lower().rstrip(".")
|
|
117
|
+
return any(host == s or host.endswith(f".{s}") for s in AD_HOST_SUFFIXES)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@lru_cache(maxsize=1024)
|
|
121
|
+
def _resolve(host: str) -> tuple[str, ...]:
|
|
122
|
+
try:
|
|
123
|
+
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
124
|
+
except OSError:
|
|
125
|
+
return ()
|
|
126
|
+
return tuple(sorted({str(info[4][0]) for info in infos}))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Browser:
|
|
131
|
+
"""One lazily launched Chromium shared by render and screenshot."""
|
|
132
|
+
|
|
133
|
+
allow_loopback: bool = False
|
|
134
|
+
navigation_timeout_ms: int = 30_000
|
|
135
|
+
max_scroll_height: int = 20_000
|
|
136
|
+
viewport_width: int = 1366
|
|
137
|
+
viewport_height: int = 900
|
|
138
|
+
_playwright: Any = field(default=None, init=False)
|
|
139
|
+
_browser: Any = field(default=None, init=False)
|
|
140
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
|
|
141
|
+
|
|
142
|
+
def _egress_blocked(self, host: str) -> bool:
|
|
143
|
+
addresses = _resolve(host)
|
|
144
|
+
if not addresses:
|
|
145
|
+
return True
|
|
146
|
+
return any(
|
|
147
|
+
egress_ip_is_blocked(ip, allow_loopback=self.allow_loopback)
|
|
148
|
+
for ip in addresses
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _ensure(self) -> Any:
|
|
152
|
+
async with self._lock:
|
|
153
|
+
if self._browser is None:
|
|
154
|
+
from playwright.async_api import async_playwright
|
|
155
|
+
|
|
156
|
+
self._playwright = await async_playwright().start()
|
|
157
|
+
try:
|
|
158
|
+
self._browser = await asyncio.wait_for(
|
|
159
|
+
self._playwright.chromium.launch(
|
|
160
|
+
headless=True, args=["--disable-dev-shm-usage"]
|
|
161
|
+
),
|
|
162
|
+
self.navigation_timeout_ms / 1000,
|
|
163
|
+
)
|
|
164
|
+
except Exception as exc: # noqa: BLE001 - launch failure is typed
|
|
165
|
+
await self._playwright.stop()
|
|
166
|
+
self._playwright = None
|
|
167
|
+
raise PolicyRefused(
|
|
168
|
+
"render_failed", "chromium", type(exc).__name__
|
|
169
|
+
) from exc
|
|
170
|
+
return self._browser
|
|
171
|
+
|
|
172
|
+
async def close(self) -> None:
|
|
173
|
+
if self._browser is not None:
|
|
174
|
+
await self._browser.close()
|
|
175
|
+
self._browser = None
|
|
176
|
+
if self._playwright is not None:
|
|
177
|
+
await self._playwright.stop()
|
|
178
|
+
self._playwright = None
|
|
179
|
+
|
|
180
|
+
async def _open(self, url: str, user_agent: str) -> tuple[Any, Any, int, list[int]]:
|
|
181
|
+
host = urlsplit(url).hostname or ""
|
|
182
|
+
if self._egress_blocked(host):
|
|
183
|
+
raise PolicyRefused("egress_denied", url)
|
|
184
|
+
browser = await self._ensure()
|
|
185
|
+
context = await browser.new_context(
|
|
186
|
+
user_agent=user_agent,
|
|
187
|
+
viewport={"width": self.viewport_width, "height": self.viewport_height},
|
|
188
|
+
java_script_enabled=True,
|
|
189
|
+
accept_downloads=False,
|
|
190
|
+
)
|
|
191
|
+
blocked = [0]
|
|
192
|
+
|
|
193
|
+
async def _route(route: Any) -> None:
|
|
194
|
+
req_host = urlsplit(route.request.url).hostname or ""
|
|
195
|
+
if req_host and (is_ad_host(req_host) or self._egress_blocked(req_host)):
|
|
196
|
+
blocked[0] += 1
|
|
197
|
+
await route.abort()
|
|
198
|
+
else:
|
|
199
|
+
await route.continue_()
|
|
200
|
+
|
|
201
|
+
await context.route("**/*", _route)
|
|
202
|
+
page = await context.new_page()
|
|
203
|
+
try:
|
|
204
|
+
response = await page.goto(
|
|
205
|
+
url, wait_until="networkidle", timeout=self.navigation_timeout_ms
|
|
206
|
+
)
|
|
207
|
+
except Exception as exc: # noqa: BLE001 - Playwright raises its own types
|
|
208
|
+
await context.close()
|
|
209
|
+
raise PolicyRefused("upstream_failed", url, type(exc).__name__) from exc
|
|
210
|
+
status = response.status if response is not None else 0
|
|
211
|
+
if status >= 400:
|
|
212
|
+
await context.close()
|
|
213
|
+
raise PolicyRefused("upstream_failed", url, str(status))
|
|
214
|
+
await page.evaluate(_SCROLL_JS, self.max_scroll_height)
|
|
215
|
+
await page.evaluate(_DECLUTTER_JS)
|
|
216
|
+
return context, page, status, blocked
|
|
217
|
+
|
|
218
|
+
async def render(self, url: str, *, user_agent: str) -> RenderedPage:
|
|
219
|
+
context, page, status, blocked = await self._open(url, user_agent)
|
|
220
|
+
try:
|
|
221
|
+
return RenderedPage(
|
|
222
|
+
url=url,
|
|
223
|
+
final_url=page.url,
|
|
224
|
+
status=status,
|
|
225
|
+
title=await page.title(),
|
|
226
|
+
html=await page.content(),
|
|
227
|
+
blocked_requests=blocked[0],
|
|
228
|
+
)
|
|
229
|
+
finally:
|
|
230
|
+
await context.close()
|
|
231
|
+
|
|
232
|
+
async def screenshot(
|
|
233
|
+
self, url: str, *, user_agent: str, full_page: bool = True
|
|
234
|
+
) -> Screenshot:
|
|
235
|
+
context, page, _, _ = await self._open(url, user_agent)
|
|
236
|
+
try:
|
|
237
|
+
png = await page.screenshot(full_page=full_page, type="png")
|
|
238
|
+
return Screenshot(
|
|
239
|
+
url=url,
|
|
240
|
+
final_url=page.url,
|
|
241
|
+
full_page=full_page,
|
|
242
|
+
width=self.viewport_width,
|
|
243
|
+
png_base64=base64.b64encode(png).decode("ascii"),
|
|
244
|
+
bytes=len(png),
|
|
245
|
+
)
|
|
246
|
+
finally:
|
|
247
|
+
await context.close()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Main-content extraction from fetched HTML (CONN-WEBARCHIVER-WEB-SOURCE-R001).
|
|
2
|
+
|
|
3
|
+
Readability-style boilerplate removal is trafilatura's; this module only maps
|
|
4
|
+
its output onto one typed :class:`ExtractedPage` and collects outbound links
|
|
5
|
+
with lxml. A page with no extractable main content is refused rather than
|
|
6
|
+
returned empty, so a caller never ingests a fabricated blank document.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from urllib.parse import urldefrag, urljoin, urlsplit
|
|
14
|
+
|
|
15
|
+
import lxml.html
|
|
16
|
+
import trafilatura
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
|
|
19
|
+
from .policy import PolicyRefused
|
|
20
|
+
|
|
21
|
+
__all__ = ["ExtractedPage", "PageLink", "extract_links", "extract_page"]
|
|
22
|
+
|
|
23
|
+
_MAX_LINKS = 2000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PageLink(BaseModel):
|
|
27
|
+
"""One outbound hyperlink, resolved to an absolute URL."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
30
|
+
|
|
31
|
+
url: str
|
|
32
|
+
text: str = ""
|
|
33
|
+
same_site: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExtractedPage(BaseModel):
|
|
37
|
+
"""The main content of one page plus its document metadata."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
url: str
|
|
42
|
+
canonical_url: str
|
|
43
|
+
title: str = ""
|
|
44
|
+
author: str = ""
|
|
45
|
+
published: str = ""
|
|
46
|
+
site_name: str = ""
|
|
47
|
+
language: str = ""
|
|
48
|
+
description: str = ""
|
|
49
|
+
text: str
|
|
50
|
+
markdown: str
|
|
51
|
+
content_sha256: str
|
|
52
|
+
links: tuple[PageLink, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _canonical(html_doc: lxml.html.HtmlElement, base: str) -> str:
|
|
56
|
+
for href in html_doc.xpath('//link[@rel="canonical"]/@href'):
|
|
57
|
+
if str(href).strip():
|
|
58
|
+
return urldefrag(urljoin(base, str(href).strip()))[0]
|
|
59
|
+
return urldefrag(base)[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_links(html: str, base_url: str) -> tuple[PageLink, ...]:
|
|
63
|
+
"""Every distinct http(s) ``<a href>`` in ``html``, absolute and de-fragmented."""
|
|
64
|
+
if not html.strip():
|
|
65
|
+
return ()
|
|
66
|
+
doc = lxml.html.fromstring(html)
|
|
67
|
+
site = (urlsplit(base_url).hostname or "").lower()
|
|
68
|
+
seen: dict[str, PageLink] = {}
|
|
69
|
+
for anchor in doc.iter("a"):
|
|
70
|
+
href = (anchor.get("href") or "").strip()
|
|
71
|
+
if not href:
|
|
72
|
+
continue
|
|
73
|
+
url = urldefrag(urljoin(base_url, href))[0]
|
|
74
|
+
parts = urlsplit(url)
|
|
75
|
+
if parts.scheme not in {"http", "https"} or url in seen:
|
|
76
|
+
continue
|
|
77
|
+
seen[url] = PageLink(
|
|
78
|
+
url=url,
|
|
79
|
+
text=" ".join(anchor.text_content().split())[:300],
|
|
80
|
+
same_site=(parts.hostname or "").lower() == site,
|
|
81
|
+
)
|
|
82
|
+
if len(seen) >= _MAX_LINKS:
|
|
83
|
+
break
|
|
84
|
+
return tuple(seen.values())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_page(html: str, url: str) -> ExtractedPage:
|
|
88
|
+
"""Extract ``html``'s main content as text and markdown with metadata."""
|
|
89
|
+
if not html.strip():
|
|
90
|
+
raise PolicyRefused("extract_empty", url, "empty body")
|
|
91
|
+
doc = lxml.html.fromstring(html)
|
|
92
|
+
canonical = _canonical(doc, url)
|
|
93
|
+
raw = trafilatura.extract(
|
|
94
|
+
html,
|
|
95
|
+
url=url,
|
|
96
|
+
output_format="json",
|
|
97
|
+
with_metadata=True,
|
|
98
|
+
include_comments=False,
|
|
99
|
+
include_tables=True,
|
|
100
|
+
favor_precision=True,
|
|
101
|
+
)
|
|
102
|
+
meta = json.loads(raw) if raw else {}
|
|
103
|
+
text = str(meta.get("text") or "").strip()
|
|
104
|
+
if not text:
|
|
105
|
+
raise PolicyRefused("extract_empty", url, "no main content")
|
|
106
|
+
markdown = (
|
|
107
|
+
trafilatura.extract(
|
|
108
|
+
html,
|
|
109
|
+
url=url,
|
|
110
|
+
output_format="markdown",
|
|
111
|
+
include_comments=False,
|
|
112
|
+
include_tables=True,
|
|
113
|
+
favor_precision=True,
|
|
114
|
+
)
|
|
115
|
+
or text
|
|
116
|
+
)
|
|
117
|
+
return ExtractedPage(
|
|
118
|
+
url=url,
|
|
119
|
+
canonical_url=canonical,
|
|
120
|
+
title=str(meta.get("title") or ""),
|
|
121
|
+
author=str(meta.get("author") or ""),
|
|
122
|
+
published=str(meta.get("date") or ""),
|
|
123
|
+
site_name=str(meta.get("sitename") or ""),
|
|
124
|
+
language=str(meta.get("language") or ""),
|
|
125
|
+
description=str(meta.get("description") or ""),
|
|
126
|
+
text=text,
|
|
127
|
+
markdown=markdown.strip(),
|
|
128
|
+
content_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
129
|
+
links=extract_links(html, url),
|
|
130
|
+
)
|