agentgraph-connector-web 0.5.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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentgraph-connector-web
3
+ Version: 0.5.0
4
+ Summary: Generic web page connector for AgentGraph
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: agentgraph-server<0.6,>=0.5.0
7
+ Requires-Dist: httpx>=0.28.1
@@ -0,0 +1,381 @@
1
+ """Generic web connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import html
7
+ import json
8
+ import re
9
+ from datetime import UTC, datetime
10
+ from html.parser import HTMLParser
11
+ from typing import ClassVar, cast
12
+ from urllib.parse import urldefrag, urlparse
13
+ from xml.etree import ElementTree
14
+
15
+ import httpx
16
+
17
+ from agentgraph.connectors.base import (
18
+ BaseConnector,
19
+ EntityBatch,
20
+ EntityRecord,
21
+ FetchPolicy,
22
+ ResourceType,
23
+ SourceReference,
24
+ )
25
+ from agentgraph.core.context import get_backend
26
+ from agentgraph.graph.upsert import upsert_batch
27
+
28
+ _STALE_AFTER = 24 * 60 * 60
29
+ _MAX_BYTES = 2_000_000
30
+ _MAX_REDIRECTS = 5
31
+ _ACCEPT = (
32
+ "text/markdown, text/html, application/json, text/plain, "
33
+ "application/atom+xml, application/rss+xml, application/xml, text/xml, "
34
+ "application/pdf;q=0.7, */*;q=0.1"
35
+ )
36
+
37
+
38
+ class UnsupportedFormatError(ValueError):
39
+ """Raised when a URL returns content AgentGraph cannot store for LLM use."""
40
+
41
+
42
+ class WebConnector(BaseConnector):
43
+ source = "web"
44
+ fetch_policy = FetchPolicy(stale_after_seconds=_STALE_AFTER)
45
+ is_generic_url_fallback = True
46
+ url_patterns: ClassVar[list[str]] = []
47
+ auth_description = "Generic web pages: HTML, Markdown, JSON, plain text, and XML/RSS/Atom documents fetched directly over HTTP."
48
+ appears_in_auth_status = False
49
+
50
+ def can_handle(self, url: str) -> bool:
51
+ return self.resolve_url(url) is not None
52
+
53
+ def resolve_url(self, url: str) -> SourceReference | None:
54
+ parsed = urlparse(url)
55
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
56
+ return None
57
+ return SourceReference(
58
+ source=self.source,
59
+ resource_type="document",
60
+ resource_id=_canonical_url(url),
61
+ )
62
+
63
+ async def fetch(
64
+ self,
65
+ resource_type: ResourceType,
66
+ resource_id: str,
67
+ meta: dict[str, str] | None = None,
68
+ account_id: str | None = None,
69
+ ) -> EntityBatch:
70
+ _ = (resource_type, meta, account_id)
71
+ ref = self.resolve_url(resource_id)
72
+ if ref is None:
73
+ raise ValueError("Web connector only supports http:// and https:// URLs")
74
+
75
+ existing = await get_backend().get_entity_by_platform(self.source, ref.resource_id)
76
+ entity = await _fetch_web_entity(ref.resource_id, existing_entity=existing)
77
+ batch = EntityBatch(entities=[entity])
78
+ await upsert_batch(batch)
79
+ return batch
80
+
81
+ def entity_url(self, platform_entity_id: str) -> str | None:
82
+ return platform_entity_id
83
+
84
+
85
+ async def _fetch_web_entity(
86
+ url: str,
87
+ *,
88
+ client: httpx.AsyncClient | None = None,
89
+ existing_entity: dict[str, object] | None = None,
90
+ ) -> EntityRecord:
91
+ if client is None:
92
+ async with httpx.AsyncClient(
93
+ follow_redirects=True,
94
+ max_redirects=_MAX_REDIRECTS,
95
+ timeout=httpx.Timeout(10.0, connect=5.0),
96
+ ) as owned_client:
97
+ return await _fetch_web_entity(
98
+ url,
99
+ client=owned_client,
100
+ existing_entity=existing_entity,
101
+ )
102
+
103
+ existing_metadata = _entity_metadata(existing_entity)
104
+ headers = {
105
+ "Accept": _ACCEPT,
106
+ "User-Agent": "AgentGraph/0.1",
107
+ **_conditional_request_headers(existing_metadata),
108
+ }
109
+ async with client.stream("GET", url, headers=headers) as response:
110
+ if response.status_code == 304:
111
+ if existing_entity is None:
112
+ raise ValueError(f"Received 304 for {url} without an existing Document")
113
+ return _not_modified_entity(url, response, existing_entity)
114
+
115
+ response.raise_for_status()
116
+ body = bytearray()
117
+ async for chunk in response.aiter_bytes():
118
+ body.extend(chunk)
119
+ if len(body) > _MAX_BYTES:
120
+ raise ValueError(f"Response too large for web bookmark: limit is {_MAX_BYTES} bytes")
121
+
122
+ content_type = _normalise_content_type(response.headers.get("content-type", ""))
123
+ final_url = _canonical_url(str(response.url))
124
+ parsed = _parse_content(bytes(body), content_type, final_url)
125
+ return EntityRecord(
126
+ entity_type="Document",
127
+ platform="web",
128
+ platform_entity_id=final_url,
129
+ title=parsed.title,
130
+ content=parsed.content,
131
+ updated_at=datetime.now(UTC),
132
+ metadata={
133
+ "url": url,
134
+ "final_url": final_url,
135
+ "web_url": final_url,
136
+ "content_type": content_type,
137
+ "status_code": response.status_code,
138
+ "fetched_at": datetime.now(UTC).isoformat(),
139
+ "content_sha256": hashlib.sha256(body).hexdigest(),
140
+ **_response_cache_metadata(response.headers),
141
+ },
142
+ )
143
+
144
+
145
+ async def fetch_http_document(
146
+ url: str,
147
+ *,
148
+ existing_entity: dict[str, object] | None = None,
149
+ ) -> EntityRecord:
150
+ """Fetch an HTTP-backed Document, using validators from existing metadata when present."""
151
+ return await _fetch_web_entity(url, existing_entity=existing_entity)
152
+
153
+
154
+ def _conditional_request_headers(metadata: dict[str, object]) -> dict[str, str]:
155
+ headers: dict[str, str] = {}
156
+ etag = metadata.get("http_etag")
157
+ if isinstance(etag, str) and etag:
158
+ headers["If-None-Match"] = etag
159
+ last_modified = metadata.get("http_last_modified")
160
+ if isinstance(last_modified, str) and last_modified:
161
+ headers["If-Modified-Since"] = last_modified
162
+ return headers
163
+
164
+
165
+ def _response_cache_metadata(headers: httpx.Headers) -> dict[str, str]:
166
+ metadata: dict[str, str] = {}
167
+ etag = headers.get("etag")
168
+ if etag:
169
+ metadata["http_etag"] = etag
170
+ last_modified = headers.get("last-modified")
171
+ if last_modified:
172
+ metadata["http_last_modified"] = last_modified
173
+ return metadata
174
+
175
+
176
+ def _entity_metadata(entity: dict[str, object] | None) -> dict[str, object]:
177
+ metadata = entity.get("metadata") if entity is not None else None
178
+ return cast(dict[str, object], metadata) if isinstance(metadata, dict) else {}
179
+
180
+
181
+ def _not_modified_entity(
182
+ url: str,
183
+ response: httpx.Response,
184
+ existing_entity: dict[str, object],
185
+ ) -> EntityRecord:
186
+ metadata: dict[str, str | int | float | bool | None] = {
187
+ **_entity_record_metadata(existing_entity),
188
+ "url": url,
189
+ "final_url": str(existing_entity.get("platform_entity_id") or url),
190
+ "web_url": str(existing_entity.get("platform_entity_id") or url),
191
+ "status_code": response.status_code,
192
+ "fetched_at": datetime.now(UTC).isoformat(),
193
+ **_response_cache_metadata(response.headers),
194
+ }
195
+ return EntityRecord(
196
+ entity_type=str(existing_entity.get("entity_type") or "Document"),
197
+ platform="web",
198
+ platform_entity_id=str(existing_entity.get("platform_entity_id") or url),
199
+ title=_optional_str(existing_entity.get("title")),
200
+ content=_optional_str(existing_entity.get("content")),
201
+ metadata=metadata,
202
+ )
203
+
204
+
205
+ def _optional_str(value: object) -> str | None:
206
+ return value if isinstance(value, str) else None
207
+
208
+
209
+ def _entity_record_metadata(
210
+ entity: dict[str, object],
211
+ ) -> dict[str, str | int | float | bool | None]:
212
+ metadata: dict[str, str | int | float | bool | None] = {}
213
+ for key, value in _entity_metadata(entity).items():
214
+ if isinstance(value, (str, int, float, bool)) or value is None:
215
+ metadata[key] = value
216
+ return metadata
217
+
218
+
219
+ class _ParsedContent:
220
+ def __init__(self, *, title: str, content: str) -> None:
221
+ self.title = title
222
+ self.content = content
223
+
224
+
225
+ def _parse_content(body: bytes, content_type: str, url: str) -> _ParsedContent:
226
+ text = _decode_text(body)
227
+ if _is_html(content_type):
228
+ return _parse_html(text, url)
229
+ if _is_markdown(content_type, url):
230
+ return _parse_markdown(text, url)
231
+ if _is_json(content_type):
232
+ return _parse_json(text, url)
233
+ if _is_plain_text(content_type):
234
+ return _ParsedContent(title=_title_from_url(url), content=text.strip())
235
+ if _is_xml(content_type):
236
+ return _parse_xml(text, url)
237
+ raise UnsupportedFormatError(
238
+ f"Unsupported content type for web bookmark: {content_type or 'unknown'}"
239
+ )
240
+
241
+
242
+ def _decode_text(body: bytes) -> str:
243
+ return body.decode("utf-8", errors="replace")
244
+
245
+
246
+ def _normalise_content_type(content_type: str) -> str:
247
+ return content_type.split(";", 1)[0].strip().lower()
248
+
249
+
250
+ def _is_html(content_type: str) -> bool:
251
+ return content_type in {"text/html", "application/xhtml+xml"}
252
+
253
+
254
+ def _is_markdown(content_type: str, url: str) -> bool:
255
+ return content_type in {"text/markdown", "text/x-markdown"} or urlparse(url).path.endswith(
256
+ (".md", ".markdown")
257
+ )
258
+
259
+
260
+ def _is_json(content_type: str) -> bool:
261
+ return content_type == "application/json" or content_type.endswith("+json")
262
+
263
+
264
+ def _is_plain_text(content_type: str) -> bool:
265
+ return content_type == "text/plain"
266
+
267
+
268
+ def _is_xml(content_type: str) -> bool:
269
+ return content_type in {
270
+ "application/atom+xml",
271
+ "application/rss+xml",
272
+ "application/xml",
273
+ "text/xml",
274
+ } or content_type.endswith("+xml")
275
+
276
+
277
+ class _TextHTMLParser(HTMLParser):
278
+ def __init__(self) -> None:
279
+ super().__init__(convert_charrefs=True)
280
+ self.title_parts: list[str] = []
281
+ self.text_parts: list[str] = []
282
+ self._tag_stack: list[str] = []
283
+
284
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
285
+ _ = attrs
286
+ self._tag_stack.append(tag)
287
+ if tag in {"p", "br", "div", "section", "article", "li", "h1", "h2", "h3"}:
288
+ self.text_parts.append("\n")
289
+
290
+ def handle_endtag(self, tag: str) -> None:
291
+ if self._tag_stack and self._tag_stack[-1] == tag:
292
+ self._tag_stack.pop()
293
+ elif tag in self._tag_stack:
294
+ self._tag_stack.remove(tag)
295
+ if tag in {"p", "div", "section", "article", "li", "h1", "h2", "h3"}:
296
+ self.text_parts.append("\n")
297
+
298
+ def handle_data(self, data: str) -> None:
299
+ if any(tag in {"script", "style", "noscript"} for tag in self._tag_stack):
300
+ return
301
+ if self._tag_stack and self._tag_stack[-1] == "title":
302
+ self.title_parts.append(data)
303
+ return
304
+ self.text_parts.append(data)
305
+
306
+
307
+ def _parse_html(text: str, url: str) -> _ParsedContent:
308
+ parser = _TextHTMLParser()
309
+ parser.feed(text)
310
+ title = _clean_text(" ".join(parser.title_parts)) or _title_from_url(url)
311
+ return _ParsedContent(title=title, content=text.strip())
312
+
313
+
314
+ def _parse_markdown(text: str, url: str) -> _ParsedContent:
315
+ stripped = text.strip()
316
+ title = _title_from_url(url)
317
+ for line in stripped.splitlines():
318
+ match = re.match(r"^#\s+(.+)$", line.strip())
319
+ if match:
320
+ title = match.group(1).strip()
321
+ break
322
+ return _ParsedContent(title=title, content=stripped)
323
+
324
+
325
+ def _parse_json(text: str, url: str) -> _ParsedContent:
326
+ try:
327
+ value = json.loads(text)
328
+ except json.JSONDecodeError as exc:
329
+ raise ValueError(f"Invalid JSON response for web bookmark: {exc.msg}") from exc
330
+ title = _json_title(value) or _title_from_url(url)
331
+ return _ParsedContent(title=title, content=text.strip())
332
+
333
+
334
+ def _json_title(value: object) -> str | None:
335
+ if not isinstance(value, dict):
336
+ return None
337
+ items = cast(dict[str, object], value)
338
+ for key in ("title", "name", "headline"):
339
+ item = items.get(key)
340
+ if isinstance(item, str) and item.strip():
341
+ return item.strip()
342
+ return None
343
+
344
+
345
+ def _parse_xml(text: str, url: str) -> _ParsedContent:
346
+ try:
347
+ root = ElementTree.fromstring(text)
348
+ except ElementTree.ParseError as exc:
349
+ raise ValueError(f"Invalid XML response for web bookmark: {exc}") from exc
350
+ title = _xml_title(root) or _title_from_url(url)
351
+ return _ParsedContent(title=title, content=text.strip())
352
+
353
+
354
+ def _xml_title(root: ElementTree.Element) -> str | None:
355
+ for element in root.iter():
356
+ if _strip_namespace(element.tag).lower() == "title":
357
+ title = _clean_text(element.text or "")
358
+ if title:
359
+ return title
360
+ return None
361
+
362
+
363
+ def _strip_namespace(tag: str) -> str:
364
+ return tag.rsplit("}", 1)[-1]
365
+
366
+
367
+ def _title_from_url(url: str) -> str:
368
+ parsed = urlparse(url)
369
+ path = parsed.path.rstrip("/")
370
+ if not path:
371
+ return parsed.netloc
372
+ return html.unescape(path.rsplit("/", 1)[-1]) or parsed.netloc
373
+
374
+
375
+ def _canonical_url(url: str) -> str:
376
+ return urldefrag(url)[0]
377
+
378
+
379
+ def _clean_text(text: str) -> str:
380
+ lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
381
+ return "\n".join(line for line in lines if line)
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentgraph-connector-web
3
+ Version: 0.5.0
4
+ Summary: Generic web page connector for AgentGraph
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: agentgraph-server<0.6,>=0.5.0
7
+ Requires-Dist: httpx>=0.28.1
@@ -0,0 +1,8 @@
1
+ pyproject.toml
2
+ agentgraph_connector_web/__init__.py
3
+ agentgraph_connector_web.egg-info/PKG-INFO
4
+ agentgraph_connector_web.egg-info/SOURCES.txt
5
+ agentgraph_connector_web.egg-info/dependency_links.txt
6
+ agentgraph_connector_web.egg-info/entry_points.txt
7
+ agentgraph_connector_web.egg-info/requires.txt
8
+ agentgraph_connector_web.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [agentgraph.connectors]
2
+ web = agentgraph_connector_web:WebConnector
@@ -0,0 +1,2 @@
1
+ agentgraph-server<0.6,>=0.5.0
2
+ httpx>=0.28.1
@@ -0,0 +1 @@
1
+ agentgraph_connector_web
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "agentgraph-connector-web"
3
+ version = "0.5.0"
4
+ description = "Generic web page connector for AgentGraph"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "agentgraph-server>=0.5.0,<0.6",
8
+ "httpx>=0.28.1",
9
+ ]
10
+
11
+ [project.entry-points."agentgraph.connectors"]
12
+ web = "agentgraph_connector_web:WebConnector"
13
+
14
+ [tool.uv]
15
+ package = true
16
+
17
+ [tool.uv.sources]
18
+ agentgraph-server = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+