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.
- agentgraph_connector_web-0.5.0/PKG-INFO +7 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web/__init__.py +381 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/PKG-INFO +7 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/SOURCES.txt +8 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/dependency_links.txt +1 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/entry_points.txt +2 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/requires.txt +2 -0
- agentgraph_connector_web-0.5.0/agentgraph_connector_web.egg-info/top_level.txt +1 -0
- agentgraph_connector_web-0.5.0/pyproject.toml +18 -0
- agentgraph_connector_web-0.5.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
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 }
|