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.
@@ -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"])
@@ -0,0 +1,4 @@
1
+ from .mcp_server import mcp_server
2
+
3
+ if __name__ == "__main__":
4
+ mcp_server()
@@ -0,0 +1,3 @@
1
+ """Single source of the package version (read by bump2version)."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,10 @@
1
+ from .api_client_system import ApiClientSystem, ExtractResult, LinksResult
2
+ from .policy import FetchPolicy, PolicyRefused
3
+
4
+ __all__ = [
5
+ "ApiClientSystem",
6
+ "ExtractResult",
7
+ "FetchPolicy",
8
+ "LinksResult",
9
+ "PolicyRefused",
10
+ ]
@@ -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
+ )