agentgraph-connector-rss 0.5.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.
- agentgraph_connector_rss/__init__.py +807 -0
- agentgraph_connector_rss/auth.py +521 -0
- agentgraph_connector_rss-0.5.0.dist-info/METADATA +11 -0
- agentgraph_connector_rss-0.5.0.dist-info/RECORD +7 -0
- agentgraph_connector_rss-0.5.0.dist-info/WHEEL +5 -0
- agentgraph_connector_rss-0.5.0.dist-info/entry_points.txt +2 -0
- agentgraph_connector_rss-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
"""RSS and Atom connector."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from email.utils import parsedate_to_datetime
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from agentgraph.connectors.base import (
|
|
21
|
+
BaseConnector,
|
|
22
|
+
ConnectorAccount,
|
|
23
|
+
ConnectorCommandEffects,
|
|
24
|
+
EdgeRecord,
|
|
25
|
+
EntityBatch,
|
|
26
|
+
EntityRecord,
|
|
27
|
+
FetchPolicy,
|
|
28
|
+
PersonRecord,
|
|
29
|
+
ResourceType,
|
|
30
|
+
SourceReference,
|
|
31
|
+
)
|
|
32
|
+
from agentgraph.core.context import get_backend
|
|
33
|
+
from agentgraph_connector_rss.auth import (
|
|
34
|
+
add_feed_urls,
|
|
35
|
+
import_opml_feeds,
|
|
36
|
+
load_rss_settings,
|
|
37
|
+
preview_feed,
|
|
38
|
+
remove_feed_urls,
|
|
39
|
+
resolve_feed_sources,
|
|
40
|
+
run_rss_flow,
|
|
41
|
+
verify_rss_auth,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_STALE_AFTER = 30 * 60
|
|
45
|
+
_FEED_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
46
|
+
_MAX_FEED_BYTES = 5_000_000
|
|
47
|
+
_MAX_OBSERVATION_ENTRIES_PER_FEED = 8
|
|
48
|
+
_MAX_OBSERVATION_PATTERNS_PER_FEED = 5
|
|
49
|
+
_TRACKING_QUERY_KEYS = {
|
|
50
|
+
"fbclid",
|
|
51
|
+
"gclid",
|
|
52
|
+
"dclid",
|
|
53
|
+
"msclkid",
|
|
54
|
+
"mc_cid",
|
|
55
|
+
"mc_eid",
|
|
56
|
+
"_hsenc",
|
|
57
|
+
"_hsmi",
|
|
58
|
+
}
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RssConnector(BaseConnector):
|
|
63
|
+
source = "rss"
|
|
64
|
+
fetch_policy = FetchPolicy(stale_after_seconds=_STALE_AFTER)
|
|
65
|
+
poll_interval: timedelta | None = timedelta(minutes=15) # type: ignore[assignment]
|
|
66
|
+
url_patterns = []
|
|
67
|
+
auth_label = "rss"
|
|
68
|
+
auth_description = "RSS/Atom feeds: feed URLs are fetched directly and entries are indexed as Document entities."
|
|
69
|
+
onboard_prompt = "Set up RSS feeds?"
|
|
70
|
+
appears_in_auth_status = False
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._observation_patterns: list[str] | None = None
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def run_auth_flow(
|
|
77
|
+
cls,
|
|
78
|
+
account_id: str | None = None,
|
|
79
|
+
add: bool = False,
|
|
80
|
+
args: list[str] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
if args:
|
|
83
|
+
raise ValueError(f"RSS setup does not accept auth options: {' '.join(args)}")
|
|
84
|
+
run_rss_flow(account_id=account_id, add=add)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_authenticated_user(cls) -> str | None:
|
|
88
|
+
try:
|
|
89
|
+
settings = load_rss_settings()
|
|
90
|
+
except RuntimeError:
|
|
91
|
+
return None
|
|
92
|
+
return "RSS" if settings.feed_urls else None
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def list_accounts(cls) -> list[ConnectorAccount]:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
async def verify_auth(cls, account_id: str | None = None) -> tuple[str, str | None]:
|
|
100
|
+
return await verify_rss_auth(account_id)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def run_cli_command(cls, args: list[str]) -> dict[str, Any]:
|
|
104
|
+
if not args:
|
|
105
|
+
raise ValueError(_rss_usage())
|
|
106
|
+
command, *rest = args
|
|
107
|
+
if command == "add":
|
|
108
|
+
sources = _parse_feed_url_args(rest, command="add")
|
|
109
|
+
feed_urls = resolve_feed_sources(sources)
|
|
110
|
+
config = add_feed_urls(feed_urls, validate=False)
|
|
111
|
+
return {
|
|
112
|
+
"status": "ok",
|
|
113
|
+
"source": cls.source,
|
|
114
|
+
"feed_urls": config.feed_urls,
|
|
115
|
+
"added": feed_urls,
|
|
116
|
+
}
|
|
117
|
+
if command == "remove":
|
|
118
|
+
feed_urls = _parse_feed_url_args(rest, command="remove")
|
|
119
|
+
config, removed_feed_urls = remove_feed_urls(feed_urls)
|
|
120
|
+
return {
|
|
121
|
+
"status": "ok",
|
|
122
|
+
"source": cls.source,
|
|
123
|
+
"feed_urls": config.feed_urls,
|
|
124
|
+
"removed": removed_feed_urls,
|
|
125
|
+
}
|
|
126
|
+
if command == "import-opml":
|
|
127
|
+
options = _parse_import_opml_args(rest)
|
|
128
|
+
config, feeds, selected_feeds = import_opml_feeds(**options)
|
|
129
|
+
selected_feed_urls = [feed.feed_url for feed in selected_feeds]
|
|
130
|
+
return {
|
|
131
|
+
"status": "ok",
|
|
132
|
+
"source": cls.source,
|
|
133
|
+
"feed_urls": config.feed_urls,
|
|
134
|
+
"imported_feed_count": len(feeds),
|
|
135
|
+
"selected_feed_count": len(selected_feeds),
|
|
136
|
+
"added": selected_feed_urls,
|
|
137
|
+
}
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Unknown rss connector command '{command}'. Available: add, remove, import-opml"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def cli_help(cls) -> str:
|
|
144
|
+
return _rss_help()
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def format_cli_result(cls, result: dict[str, Any]) -> str:
|
|
148
|
+
return _format_rss_cli_result(result)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def command_effects(
|
|
152
|
+
cls,
|
|
153
|
+
args: list[str],
|
|
154
|
+
result: dict[str, Any],
|
|
155
|
+
) -> ConnectorCommandEffects:
|
|
156
|
+
_ = result
|
|
157
|
+
command = args[0] if args else None
|
|
158
|
+
return ConnectorCommandEffects(poll=command in {"add", "import-opml"})
|
|
159
|
+
|
|
160
|
+
def can_handle(self, url: str) -> bool:
|
|
161
|
+
return self.resolve_url(url) is not None
|
|
162
|
+
|
|
163
|
+
def resolve_url(self, url: str) -> SourceReference | None:
|
|
164
|
+
try:
|
|
165
|
+
settings = load_rss_settings()
|
|
166
|
+
except RuntimeError:
|
|
167
|
+
return None
|
|
168
|
+
if url not in settings.feed_urls:
|
|
169
|
+
return None
|
|
170
|
+
return SourceReference(
|
|
171
|
+
source=self.source,
|
|
172
|
+
resource_type="document",
|
|
173
|
+
resource_id=url,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def resolve_observation_url(self, url: str) -> SourceReference | None:
|
|
177
|
+
configured_feed = self.resolve_url(url)
|
|
178
|
+
if configured_feed is not None:
|
|
179
|
+
return configured_feed
|
|
180
|
+
|
|
181
|
+
normalised_url = normalise_article_url(url)
|
|
182
|
+
if normalised_url is None:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
backend = get_backend()
|
|
186
|
+
except RuntimeError:
|
|
187
|
+
return None
|
|
188
|
+
entries = await backend.query_by_filter(
|
|
189
|
+
"Document",
|
|
190
|
+
{"platform": self.source, "web_url": normalised_url},
|
|
191
|
+
1,
|
|
192
|
+
"updated_at",
|
|
193
|
+
None,
|
|
194
|
+
None,
|
|
195
|
+
)
|
|
196
|
+
if not entries:
|
|
197
|
+
return None
|
|
198
|
+
entry = entries[0]
|
|
199
|
+
platform_entity_id = entry.get("platform_entity_id")
|
|
200
|
+
metadata = entry.get("metadata")
|
|
201
|
+
if not isinstance(platform_entity_id, str) or not isinstance(metadata, Mapping):
|
|
202
|
+
return None
|
|
203
|
+
return SourceReference(
|
|
204
|
+
source=self.source,
|
|
205
|
+
resource_type="document",
|
|
206
|
+
resource_id=platform_entity_id,
|
|
207
|
+
fetch_meta=_string_metadata(metadata),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def observation_url_patterns(self) -> list[str]:
|
|
211
|
+
if self._observation_patterns is not None:
|
|
212
|
+
return self._observation_patterns
|
|
213
|
+
try:
|
|
214
|
+
settings = load_rss_settings()
|
|
215
|
+
except RuntimeError:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
links_by_feed: dict[str, list[str]] = {}
|
|
219
|
+
backend = get_backend()
|
|
220
|
+
for feed_url in settings.feed_urls:
|
|
221
|
+
entries = await backend.query_by_filter(
|
|
222
|
+
"Document",
|
|
223
|
+
{"platform": self.source, "feed_url": feed_url},
|
|
224
|
+
_MAX_OBSERVATION_ENTRIES_PER_FEED,
|
|
225
|
+
"updated_at",
|
|
226
|
+
None,
|
|
227
|
+
None,
|
|
228
|
+
)
|
|
229
|
+
links_by_feed[feed_url] = _entry_links(entries)
|
|
230
|
+
self._observation_patterns = derive_observation_url_patterns(links_by_feed)
|
|
231
|
+
return self._observation_patterns
|
|
232
|
+
|
|
233
|
+
async def ingest(self, account_id: str | None = None) -> EntityBatch:
|
|
234
|
+
settings = load_rss_settings(account_id)
|
|
235
|
+
combined = EntityBatch()
|
|
236
|
+
for feed_url in settings.feed_urls:
|
|
237
|
+
try:
|
|
238
|
+
batch = await _fetch_feed(feed_url, hydrate_documents=True)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"Skipping RSS feed %s (%s: %s)",
|
|
242
|
+
feed_url,
|
|
243
|
+
type(exc).__name__,
|
|
244
|
+
exc,
|
|
245
|
+
)
|
|
246
|
+
logger.debug("RSS feed fetch failure", exc_info=True)
|
|
247
|
+
continue
|
|
248
|
+
combined.entities.extend(batch.entities)
|
|
249
|
+
combined.edges.extend(batch.edges)
|
|
250
|
+
combined.persons.extend(batch.persons)
|
|
251
|
+
self._observation_patterns = derive_observation_url_patterns(
|
|
252
|
+
_entry_links_by_feed(combined.entities)
|
|
253
|
+
)
|
|
254
|
+
return combined
|
|
255
|
+
|
|
256
|
+
async def poll(
|
|
257
|
+
self,
|
|
258
|
+
cursor: dict[str, Any],
|
|
259
|
+
account_id: str | None = None,
|
|
260
|
+
) -> tuple[EntityBatch, dict[str, Any]]:
|
|
261
|
+
_ = cursor
|
|
262
|
+
batch = await self.ingest(account_id=account_id)
|
|
263
|
+
return batch, {"last_polled_at": datetime.now(UTC).isoformat()}
|
|
264
|
+
|
|
265
|
+
async def preview_feed(self, feed_url: str, *, count: int = 3) -> dict[str, Any]:
|
|
266
|
+
return await preview_feed(feed_url, count=count)
|
|
267
|
+
|
|
268
|
+
async def fetch(
|
|
269
|
+
self,
|
|
270
|
+
resource_type: ResourceType,
|
|
271
|
+
resource_id: str,
|
|
272
|
+
meta: dict[str, str] | None = None,
|
|
273
|
+
account_id: str | None = None,
|
|
274
|
+
) -> EntityBatch:
|
|
275
|
+
_ = account_id
|
|
276
|
+
feed_url = resource_id
|
|
277
|
+
if resource_type == "folder" and meta and meta.get("feed_url"):
|
|
278
|
+
feed_url = meta["feed_url"]
|
|
279
|
+
if feed_url.startswith(("http://", "https://")):
|
|
280
|
+
return await _fetch_feed(
|
|
281
|
+
feed_url,
|
|
282
|
+
hydrate_documents=True,
|
|
283
|
+
new_documents_only=True,
|
|
284
|
+
)
|
|
285
|
+
if resource_type == "document" and meta and meta.get("web_url"):
|
|
286
|
+
entity = await _fetch_entry_document(resource_id, meta)
|
|
287
|
+
return EntityBatch(entities=[entity])
|
|
288
|
+
return EntityBatch()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _fetch_feed(
|
|
292
|
+
feed_url: str,
|
|
293
|
+
*,
|
|
294
|
+
hydrate_documents: bool = False,
|
|
295
|
+
new_documents_only: bool = False,
|
|
296
|
+
) -> EntityBatch:
|
|
297
|
+
parsed = await _parse_feed(feed_url)
|
|
298
|
+
feed_title = str(cast(dict[str, Any], parsed.feed).get("title") or feed_url)
|
|
299
|
+
feed_id = _feed_id(feed_url)
|
|
300
|
+
feed_entity_id = f"feed/{feed_id}"
|
|
301
|
+
entities = [
|
|
302
|
+
EntityRecord(
|
|
303
|
+
entity_type="Folder",
|
|
304
|
+
platform="rss",
|
|
305
|
+
platform_entity_id=feed_entity_id,
|
|
306
|
+
title=feed_title,
|
|
307
|
+
content=f"RSS feed: {feed_title}\n{feed_url}",
|
|
308
|
+
metadata={"feed_url": feed_url, "web_url": feed_url},
|
|
309
|
+
)
|
|
310
|
+
]
|
|
311
|
+
edges: list[EdgeRecord] = []
|
|
312
|
+
persons: dict[str, PersonRecord] = {}
|
|
313
|
+
batch = EntityBatch()
|
|
314
|
+
feed_authors = _parse_authors(cast(dict[str, Any], parsed.feed))
|
|
315
|
+
for author in feed_authors:
|
|
316
|
+
persons.setdefault(author.user_id, author.to_person())
|
|
317
|
+
|
|
318
|
+
for raw_entry in cast(list[Any], parsed.entries):
|
|
319
|
+
entry = cast(dict[str, Any], raw_entry)
|
|
320
|
+
# RFC 4287 §4.2.1: feed-level authors apply to entries that declare none.
|
|
321
|
+
authors = _parse_authors(entry) or feed_authors
|
|
322
|
+
entity = _entry_to_entity(feed_url, feed_entity_id, entry, authors)
|
|
323
|
+
include_entity = True
|
|
324
|
+
if new_documents_only:
|
|
325
|
+
existing = await get_backend().get_entity_by_platform(
|
|
326
|
+
"rss",
|
|
327
|
+
entity.platform_entity_id,
|
|
328
|
+
)
|
|
329
|
+
include_entity = existing is None
|
|
330
|
+
if include_entity:
|
|
331
|
+
if hydrate_documents:
|
|
332
|
+
entity = await _hydrate_entry_document(entity)
|
|
333
|
+
entities.append(entity)
|
|
334
|
+
edges.append(
|
|
335
|
+
EdgeRecord(
|
|
336
|
+
edge_type="posted_in",
|
|
337
|
+
source_platform_entity_id=entity.platform_entity_id,
|
|
338
|
+
target_platform_entity_id=feed_entity_id,
|
|
339
|
+
platform="rss",
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
for author in authors:
|
|
343
|
+
persons.setdefault(author.user_id, author.to_person())
|
|
344
|
+
edges.append(
|
|
345
|
+
EdgeRecord(
|
|
346
|
+
edge_type="authored",
|
|
347
|
+
source_platform_user_id=author.user_id,
|
|
348
|
+
target_platform_entity_id=entity.platform_entity_id,
|
|
349
|
+
platform="rss",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
if include_entity:
|
|
353
|
+
batch.add_stubs_from(entity)
|
|
354
|
+
|
|
355
|
+
edges.extend(
|
|
356
|
+
EdgeRecord(
|
|
357
|
+
edge_type="authored",
|
|
358
|
+
source_platform_user_id=platform_user_id,
|
|
359
|
+
target_platform_entity_id=feed_entity_id,
|
|
360
|
+
platform="rss",
|
|
361
|
+
)
|
|
362
|
+
for platform_user_id in persons
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
batch.entities = [*entities, *batch.entities]
|
|
366
|
+
batch.edges = [*edges, *batch.edges]
|
|
367
|
+
batch.persons = [*persons.values(), *batch.persons]
|
|
368
|
+
return batch
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def _parse_feed(feed_url: str) -> Any:
|
|
372
|
+
import asyncio
|
|
373
|
+
|
|
374
|
+
import feedparser # type: ignore[import-untyped]
|
|
375
|
+
|
|
376
|
+
async with httpx.AsyncClient(
|
|
377
|
+
follow_redirects=True,
|
|
378
|
+
max_redirects=5,
|
|
379
|
+
timeout=_FEED_TIMEOUT,
|
|
380
|
+
headers={
|
|
381
|
+
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*"
|
|
382
|
+
},
|
|
383
|
+
) as client:
|
|
384
|
+
response = await client.get(feed_url)
|
|
385
|
+
response.raise_for_status()
|
|
386
|
+
content = response.content
|
|
387
|
+
if len(content) > _MAX_FEED_BYTES:
|
|
388
|
+
raise ValueError(f"RSS feed response too large: limit is {_MAX_FEED_BYTES} bytes")
|
|
389
|
+
return await asyncio.to_thread(feedparser.parse, content)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _rss_usage() -> str:
|
|
393
|
+
return (
|
|
394
|
+
"Usage: agentgraph connector rss add <feed-url> [feed-url...]\n"
|
|
395
|
+
" or: agentgraph connector rss remove <feed-url> [feed-url...]\n"
|
|
396
|
+
" or: agentgraph connector rss import-opml <file.opml> [--all | --select <indexes>]"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _rss_help() -> str:
|
|
401
|
+
return "\n".join(
|
|
402
|
+
[
|
|
403
|
+
"RSS connector commands:",
|
|
404
|
+
"",
|
|
405
|
+
_rss_usage(),
|
|
406
|
+
"",
|
|
407
|
+
"Commands:",
|
|
408
|
+
" add <feed-url> [feed-url...]",
|
|
409
|
+
" Validate and add one or more RSS/Atom feeds, then queue an RSS poll.",
|
|
410
|
+
" remove <feed-url> [feed-url...]",
|
|
411
|
+
" Remove one or more exact RSS/Atom feed URLs from the configured feed list.",
|
|
412
|
+
" import-opml <file.opml> [--all | --select <indexes>]",
|
|
413
|
+
" Import RSS/Atom feed URLs from an OPML file. Omit flags for checkbox selection.",
|
|
414
|
+
"",
|
|
415
|
+
"Options:",
|
|
416
|
+
" --all Import every feed from the OPML file.",
|
|
417
|
+
" --select <indexes> Import selected feed numbers, e.g. 1,3-5.",
|
|
418
|
+
" --json Output command results as JSON.",
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _format_rss_cli_result(result: dict[str, Any]) -> str:
|
|
424
|
+
added = [str(item) for item in cast(list[object], result.get("added") or [])]
|
|
425
|
+
removed = [str(item) for item in cast(list[object], result.get("removed") or [])]
|
|
426
|
+
feed_urls = cast(list[object], result.get("feed_urls") or [])
|
|
427
|
+
|
|
428
|
+
if "imported_feed_count" in result:
|
|
429
|
+
imported_count = int(result.get("imported_feed_count") or 0)
|
|
430
|
+
selected_count = int(result.get("selected_feed_count") or len(added))
|
|
431
|
+
lines = [f"Imported {selected_count} of {imported_count} feed(s)."]
|
|
432
|
+
elif "removed" in result:
|
|
433
|
+
lines = [f"Removed {len(removed)} feed(s)."]
|
|
434
|
+
else:
|
|
435
|
+
lines = [f"Added {len(added)} feed(s)."]
|
|
436
|
+
|
|
437
|
+
if added:
|
|
438
|
+
lines.append("Added feeds:")
|
|
439
|
+
lines.extend(f" - {feed_url}" for feed_url in added[:20])
|
|
440
|
+
if len(added) > 20:
|
|
441
|
+
lines.append(f" ... {len(added) - 20} more")
|
|
442
|
+
if removed:
|
|
443
|
+
lines.append("Removed feeds:")
|
|
444
|
+
lines.extend(f" - {feed_url}" for feed_url in removed[:20])
|
|
445
|
+
if len(removed) > 20:
|
|
446
|
+
lines.append(f" ... {len(removed) - 20} more")
|
|
447
|
+
lines.append(f"Total configured feeds: {len(feed_urls)}")
|
|
448
|
+
poll = result.get("poll")
|
|
449
|
+
if isinstance(poll, Mapping):
|
|
450
|
+
status = str(poll.get("status") or "unknown").replace("_", " ")
|
|
451
|
+
lines.append(f"Poll: {status}.")
|
|
452
|
+
return "\n".join(lines)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _parse_feed_url_args(args: list[str], *, command: str) -> list[str]:
|
|
456
|
+
for arg in args:
|
|
457
|
+
if arg.startswith("--"):
|
|
458
|
+
raise ValueError(f"Unknown {command} option: {arg}")
|
|
459
|
+
return args
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _parse_import_opml_args(args: list[str]) -> dict[str, Any]:
|
|
463
|
+
path: str | None = None
|
|
464
|
+
include_all = False
|
|
465
|
+
selection: str | None = None
|
|
466
|
+
index = 0
|
|
467
|
+
while index < len(args):
|
|
468
|
+
arg = args[index]
|
|
469
|
+
if arg == "--all":
|
|
470
|
+
include_all = True
|
|
471
|
+
index += 1
|
|
472
|
+
continue
|
|
473
|
+
if arg == "--select":
|
|
474
|
+
if index + 1 >= len(args):
|
|
475
|
+
raise ValueError("--select requires a value")
|
|
476
|
+
selection = args[index + 1]
|
|
477
|
+
index += 2
|
|
478
|
+
continue
|
|
479
|
+
if arg.startswith("--"):
|
|
480
|
+
raise ValueError(f"Unknown import-opml option: {arg}")
|
|
481
|
+
if path is not None:
|
|
482
|
+
raise ValueError(_rss_usage())
|
|
483
|
+
path = arg
|
|
484
|
+
index += 1
|
|
485
|
+
if path is None:
|
|
486
|
+
raise ValueError(_rss_usage())
|
|
487
|
+
if include_all and selection is not None:
|
|
488
|
+
raise ValueError("Use either --all or --select, not both")
|
|
489
|
+
return {
|
|
490
|
+
"path": path,
|
|
491
|
+
"include_all": include_all,
|
|
492
|
+
"selection": selection,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass(frozen=True)
|
|
497
|
+
class FeedAuthor:
|
|
498
|
+
"""An author declared by a feed or one of its entries."""
|
|
499
|
+
|
|
500
|
+
user_id: str
|
|
501
|
+
display_name: str | None
|
|
502
|
+
email: str | None
|
|
503
|
+
|
|
504
|
+
def to_person(self) -> PersonRecord:
|
|
505
|
+
return PersonRecord(
|
|
506
|
+
platform="rss",
|
|
507
|
+
platform_user_id=self.user_id,
|
|
508
|
+
display_name=self.display_name,
|
|
509
|
+
canonical_email=self.email,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def label(self) -> str:
|
|
514
|
+
return self.display_name or self.user_id
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _author_details(container: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
518
|
+
"""Return feedparser author dicts for a feed or entry, newest API first."""
|
|
519
|
+
raw_authors = container.get("authors")
|
|
520
|
+
if isinstance(raw_authors, list):
|
|
521
|
+
details = [item for item in cast(list[Any], raw_authors) if isinstance(item, Mapping)]
|
|
522
|
+
if details:
|
|
523
|
+
return cast(list[Mapping[str, Any]], details)
|
|
524
|
+
detail = container.get("author_detail")
|
|
525
|
+
if isinstance(detail, Mapping):
|
|
526
|
+
return [cast(Mapping[str, Any], detail)]
|
|
527
|
+
name = container.get("author")
|
|
528
|
+
if isinstance(name, str) and name.strip():
|
|
529
|
+
return [{"name": name}]
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _parse_authors(container: Mapping[str, Any]) -> list[FeedAuthor]:
|
|
534
|
+
"""Map feedparser author metadata onto identity-bearing FeedAuthor records.
|
|
535
|
+
|
|
536
|
+
RSS ``<author>`` carries an email address, Atom ``<author>`` a name plus
|
|
537
|
+
optional email, and ``<dc:creator>`` a bare name; feedparser normalises all
|
|
538
|
+
three into ``{"name": ..., "email": ...}`` dicts with either key optional.
|
|
539
|
+
"""
|
|
540
|
+
authors: list[FeedAuthor] = []
|
|
541
|
+
seen: set[str] = set()
|
|
542
|
+
for detail in _author_details(container):
|
|
543
|
+
name = _clean_author_field(detail.get("name"))
|
|
544
|
+
email = _clean_author_field(detail.get("email"))
|
|
545
|
+
if email is None and name is not None and "@" in name and " " not in name:
|
|
546
|
+
name, email = None, name
|
|
547
|
+
if email is not None:
|
|
548
|
+
email = email.lower()
|
|
549
|
+
user_id = email or name
|
|
550
|
+
if user_id is None or user_id in seen:
|
|
551
|
+
continue
|
|
552
|
+
seen.add(user_id)
|
|
553
|
+
authors.append(FeedAuthor(user_id=user_id, display_name=name, email=email))
|
|
554
|
+
return authors
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _clean_author_field(value: object) -> str | None:
|
|
558
|
+
if not isinstance(value, str):
|
|
559
|
+
return None
|
|
560
|
+
return value.strip() or None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _entry_to_entity(
|
|
564
|
+
feed_url: str,
|
|
565
|
+
feed_entity_id: str,
|
|
566
|
+
entry: dict[str, Any],
|
|
567
|
+
authors: Sequence[FeedAuthor] = (),
|
|
568
|
+
) -> EntityRecord:
|
|
569
|
+
link = normalise_article_url(str(entry.get("link") or "")) or ""
|
|
570
|
+
external_id = str(entry.get("id") or entry.get("guid") or link or entry.get("title") or "")
|
|
571
|
+
entity_id = f"entry/{_hash_ref(feed_url + ':' + external_id)}"
|
|
572
|
+
title = str(entry.get("title") or link or "(untitled)")
|
|
573
|
+
summary = _entry_text(entry)
|
|
574
|
+
published = _parse_entry_datetime(entry.get("published") or entry.get("updated"))
|
|
575
|
+
metadata: dict[str, str | int | float | bool | None] = {
|
|
576
|
+
"feed_url": feed_url,
|
|
577
|
+
"feed_entity_id": feed_entity_id,
|
|
578
|
+
"link": link or None,
|
|
579
|
+
"web_url": link or None,
|
|
580
|
+
"author": authors[0].label if authors else None,
|
|
581
|
+
"authors": ", ".join(author.label for author in authors) or None,
|
|
582
|
+
}
|
|
583
|
+
content_parts = [title]
|
|
584
|
+
if summary:
|
|
585
|
+
content_parts.append(summary)
|
|
586
|
+
if link:
|
|
587
|
+
content_parts.append(link)
|
|
588
|
+
return EntityRecord(
|
|
589
|
+
entity_type="Document",
|
|
590
|
+
platform="rss",
|
|
591
|
+
platform_entity_id=entity_id,
|
|
592
|
+
title=title,
|
|
593
|
+
content="\n\n".join(content_parts),
|
|
594
|
+
created_at=published,
|
|
595
|
+
updated_at=_parse_entry_datetime(entry.get("updated")) or published,
|
|
596
|
+
metadata=metadata,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def _fetch_entry_document(
|
|
601
|
+
platform_entity_id: str,
|
|
602
|
+
metadata: Mapping[str, object],
|
|
603
|
+
*,
|
|
604
|
+
fallback: EntityRecord | None = None,
|
|
605
|
+
) -> EntityRecord:
|
|
606
|
+
web_url = _metadata_str(metadata, "web_url")
|
|
607
|
+
if web_url is None:
|
|
608
|
+
raise ValueError(f"RSS document {platform_entity_id} has no web_url")
|
|
609
|
+
existing = await get_backend().get_entity_by_platform("rss", platform_entity_id)
|
|
610
|
+
http_existing = _http_existing_entity(existing, web_url)
|
|
611
|
+
fetched = await _fetch_http_document(web_url, existing_entity=http_existing)
|
|
612
|
+
fetched_metadata = dict(fetched.metadata)
|
|
613
|
+
rss_metadata: dict[str, str | int | float | bool | None] = {
|
|
614
|
+
**_entity_record_metadata(metadata),
|
|
615
|
+
**fetched_metadata,
|
|
616
|
+
"web_url": str(fetched_metadata.get("web_url") or web_url),
|
|
617
|
+
"link": _metadata_str(metadata, "link") or web_url,
|
|
618
|
+
}
|
|
619
|
+
not_modified = fetched_metadata.get("status_code") == 304
|
|
620
|
+
return EntityRecord(
|
|
621
|
+
entity_type="Document",
|
|
622
|
+
platform="rss",
|
|
623
|
+
platform_entity_id=platform_entity_id,
|
|
624
|
+
title=fetched.title or (fallback.title if fallback else None),
|
|
625
|
+
content=fetched.content or (fallback.content if fallback else None),
|
|
626
|
+
created_at=fallback.created_at if fallback else None,
|
|
627
|
+
updated_at=(
|
|
628
|
+
None
|
|
629
|
+
if not_modified
|
|
630
|
+
else fetched.updated_at or (fallback.updated_at if fallback else None)
|
|
631
|
+
),
|
|
632
|
+
metadata=rss_metadata,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
async def _hydrate_entry_document(entity: EntityRecord) -> EntityRecord:
|
|
637
|
+
if _metadata_str(entity.metadata, "web_url") is None:
|
|
638
|
+
return entity
|
|
639
|
+
try:
|
|
640
|
+
return await _fetch_entry_document(
|
|
641
|
+
entity.platform_entity_id,
|
|
642
|
+
entity.metadata,
|
|
643
|
+
fallback=entity,
|
|
644
|
+
)
|
|
645
|
+
except Exception as exc:
|
|
646
|
+
logger.warning(
|
|
647
|
+
"Skipping RSS article hydration for %s (%s: %s)",
|
|
648
|
+
entity.platform_entity_id,
|
|
649
|
+
type(exc).__name__,
|
|
650
|
+
exc,
|
|
651
|
+
)
|
|
652
|
+
logger.debug("RSS article hydration failure", exc_info=True)
|
|
653
|
+
return entity
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _http_existing_entity(
|
|
657
|
+
existing: dict[str, Any] | None,
|
|
658
|
+
web_url: str,
|
|
659
|
+
) -> dict[str, object] | None:
|
|
660
|
+
if existing is None:
|
|
661
|
+
return None
|
|
662
|
+
http_existing = dict(existing)
|
|
663
|
+
http_existing["platform_entity_id"] = web_url
|
|
664
|
+
return http_existing
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
async def _fetch_http_document(
|
|
668
|
+
url: str,
|
|
669
|
+
*,
|
|
670
|
+
existing_entity: dict[str, object] | None,
|
|
671
|
+
) -> EntityRecord:
|
|
672
|
+
module: Any = import_module("agentgraph_connector_web")
|
|
673
|
+
result = await module.fetch_http_document(url, existing_entity=existing_entity)
|
|
674
|
+
return cast(EntityRecord, result)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _entity_record_metadata(
|
|
678
|
+
metadata: Mapping[str, object],
|
|
679
|
+
) -> dict[str, str | int | float | bool | None]:
|
|
680
|
+
result: dict[str, str | int | float | bool | None] = {}
|
|
681
|
+
for key, value in metadata.items():
|
|
682
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
683
|
+
result[key] = value
|
|
684
|
+
return result
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _metadata_str(metadata: Mapping[str, object], key: str) -> str | None:
|
|
688
|
+
value = metadata.get(key)
|
|
689
|
+
return value if isinstance(value, str) and value else None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _string_metadata(metadata: Mapping[str, object]) -> dict[str, str]:
|
|
693
|
+
return {key: value for key, value in metadata.items() if isinstance(value, str)}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def normalise_article_url(url: str) -> str | None:
|
|
697
|
+
"""Canonicalise an RSS entry URL for exact observation matching."""
|
|
698
|
+
parsed = urlsplit(url)
|
|
699
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
700
|
+
return None
|
|
701
|
+
query_items = sorted(
|
|
702
|
+
(key, value)
|
|
703
|
+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
704
|
+
if not _is_tracking_query_key(key)
|
|
705
|
+
)
|
|
706
|
+
query = urlencode(query_items, doseq=True)
|
|
707
|
+
path = parsed.path.rstrip("/") or "/"
|
|
708
|
+
return urlunsplit((parsed.scheme.lower(), parsed.netloc.lower(), path, query, ""))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def derive_observation_url_patterns(links_by_feed: Mapping[str, list[str]]) -> list[str]:
|
|
712
|
+
"""Build bounded Chrome patterns from previously indexed article links."""
|
|
713
|
+
patterns: list[str] = []
|
|
714
|
+
seen: set[str] = set()
|
|
715
|
+
for links in links_by_feed.values():
|
|
716
|
+
candidates: dict[str, set[str]] = {}
|
|
717
|
+
for raw_link in links:
|
|
718
|
+
link = normalise_article_url(raw_link)
|
|
719
|
+
if link is None:
|
|
720
|
+
continue
|
|
721
|
+
parsed = urlsplit(link)
|
|
722
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
723
|
+
segments = [segment for segment in parsed.path.split("/") if segment]
|
|
724
|
+
for depth in range(1, len(segments)):
|
|
725
|
+
prefix = f"{origin}/{'/'.join(segments[:depth])}"
|
|
726
|
+
candidates.setdefault(prefix, set()).add(link)
|
|
727
|
+
candidates.setdefault(origin, set()).add(link)
|
|
728
|
+
|
|
729
|
+
shared = [
|
|
730
|
+
(prefix, urls)
|
|
731
|
+
for prefix, urls in candidates.items()
|
|
732
|
+
if len(urls) >= 2 and prefix.count("/") > 2
|
|
733
|
+
]
|
|
734
|
+
useful = shared or [(prefix, urls) for prefix, urls in candidates.items() if len(urls) >= 1]
|
|
735
|
+
useful.sort(key=lambda item: (item[0].count("/"), -len(item[1]), item[0]))
|
|
736
|
+
selected_prefixes: list[str] = []
|
|
737
|
+
for prefix, _urls in useful:
|
|
738
|
+
if any(
|
|
739
|
+
prefix.startswith(f"{selected}/") or prefix == selected
|
|
740
|
+
for selected in selected_prefixes
|
|
741
|
+
):
|
|
742
|
+
continue
|
|
743
|
+
selected_prefixes.append(prefix)
|
|
744
|
+
if len(selected_prefixes) == _MAX_OBSERVATION_PATTERNS_PER_FEED:
|
|
745
|
+
break
|
|
746
|
+
|
|
747
|
+
for prefix in selected_prefixes:
|
|
748
|
+
pattern = f"{prefix}/*"
|
|
749
|
+
if pattern not in seen:
|
|
750
|
+
patterns.append(pattern)
|
|
751
|
+
seen.add(pattern)
|
|
752
|
+
return patterns
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _is_tracking_query_key(key: str) -> bool:
|
|
756
|
+
return key.lower().startswith("utm_") or key.lower() in _TRACKING_QUERY_KEYS
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _entry_links(entries: Sequence[Mapping[str, object]]) -> list[str]:
|
|
760
|
+
links: list[str] = []
|
|
761
|
+
for entry in entries:
|
|
762
|
+
metadata = entry.get("metadata")
|
|
763
|
+
if not isinstance(metadata, Mapping):
|
|
764
|
+
continue
|
|
765
|
+
link = _metadata_str(metadata, "web_url") or _metadata_str(metadata, "link")
|
|
766
|
+
if link is not None:
|
|
767
|
+
links.append(link)
|
|
768
|
+
return links
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _entry_links_by_feed(entities: list[EntityRecord]) -> dict[str, list[str]]:
|
|
772
|
+
links_by_feed: dict[str, list[str]] = {}
|
|
773
|
+
for entity in entities:
|
|
774
|
+
if entity.entity_type != "Document":
|
|
775
|
+
continue
|
|
776
|
+
feed_url = _metadata_str(entity.metadata, "feed_url")
|
|
777
|
+
link = _metadata_str(entity.metadata, "web_url") or _metadata_str(entity.metadata, "link")
|
|
778
|
+
if feed_url is not None and link is not None:
|
|
779
|
+
links_by_feed.setdefault(feed_url, []).append(link)
|
|
780
|
+
return links_by_feed
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _entry_text(entry: dict[str, Any]) -> str:
|
|
784
|
+
content = entry.get("content")
|
|
785
|
+
if isinstance(content, list) and content:
|
|
786
|
+
first = content[0]
|
|
787
|
+
if isinstance(first, dict) and first.get("value"):
|
|
788
|
+
return str(first["value"])
|
|
789
|
+
return str(entry.get("summary") or entry.get("description") or "")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _parse_entry_datetime(value: object) -> datetime | None:
|
|
793
|
+
if not isinstance(value, str) or not value:
|
|
794
|
+
return None
|
|
795
|
+
try:
|
|
796
|
+
dt = parsedate_to_datetime(value)
|
|
797
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
798
|
+
except Exception:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _feed_id(feed_url: str) -> str:
|
|
803
|
+
return _hash_ref(feed_url)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _hash_ref(value: str) -> str:
|
|
807
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:24]
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""RSS feed configuration flow."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
from urllib.parse import unquote, urlparse
|
|
13
|
+
from xml.etree import ElementTree
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RssConfig(BaseModel):
|
|
20
|
+
feed_urls: list[str] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpmlFeed(BaseModel):
|
|
24
|
+
title: str
|
|
25
|
+
feed_url: str
|
|
26
|
+
html_url: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RSS_CONFIG_BEGIN = "# BEGIN AgentGraph managed RSS config"
|
|
30
|
+
_RSS_CONFIG_END = "# END AgentGraph managed RSS config"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_rss_settings(account_id: str | None = None) -> RssConfig:
|
|
34
|
+
_ = account_id
|
|
35
|
+
data = load_rss_config()
|
|
36
|
+
if data is None:
|
|
37
|
+
raise RuntimeError("RSS feeds not configured. Run: agentgraph connector rss add <feed-url>")
|
|
38
|
+
return RssConfig(**data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_rss_config() -> dict[str, Any] | None:
|
|
42
|
+
"""Return RSS feed config."""
|
|
43
|
+
return _load_rss_config()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_rss_config(data: Any) -> None:
|
|
47
|
+
payload = data.model_dump(mode="json") if hasattr(data, "model_dump") else dict(data)
|
|
48
|
+
_write_rss_config(_normalise_rss_config(payload))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_rss_config() -> dict[str, Any] | None:
|
|
52
|
+
from agentgraph.config import CONFIG_FILE, CONFIG_YAML_FILE
|
|
53
|
+
|
|
54
|
+
yaml_config = _load_rss_yaml_config(CONFIG_YAML_FILE)
|
|
55
|
+
if yaml_config is not None:
|
|
56
|
+
return yaml_config
|
|
57
|
+
toml_config = _load_rss_toml_config(CONFIG_FILE)
|
|
58
|
+
if toml_config is not None:
|
|
59
|
+
return toml_config
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_rss_toml_config(config_file: Path) -> dict[str, Any] | None:
|
|
64
|
+
if not config_file.exists():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
raw = tomllib.loads(config_file.read_text(encoding="utf-8"))
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
return _extract_rss_config(raw)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_rss_yaml_config(config_file: Path) -> dict[str, Any] | None:
|
|
74
|
+
if not config_file.exists():
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
return None
|
|
82
|
+
return _extract_rss_config(raw)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_rss_config(raw: dict[Any, Any]) -> dict[str, Any] | None:
|
|
86
|
+
connectors = raw.get("connectors")
|
|
87
|
+
if not isinstance(connectors, dict):
|
|
88
|
+
return None
|
|
89
|
+
rss = connectors.get("rss")
|
|
90
|
+
if not isinstance(rss, dict):
|
|
91
|
+
return None
|
|
92
|
+
feed_urls = rss.get("feed_urls")
|
|
93
|
+
if isinstance(feed_urls, list):
|
|
94
|
+
return _normalise_rss_config({"feed_urls": feed_urls})
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_rss_config(config: dict[str, Any]) -> None:
|
|
99
|
+
from agentgraph.config import CONFIG_DIR, CONFIG_FILE, CONFIG_YAML_FILE
|
|
100
|
+
|
|
101
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
if CONFIG_YAML_FILE.exists():
|
|
103
|
+
_write_rss_yaml_config(CONFIG_YAML_FILE, config)
|
|
104
|
+
return
|
|
105
|
+
existing = CONFIG_FILE.read_text(encoding="utf-8") if CONFIG_FILE.exists() else ""
|
|
106
|
+
prefix = _strip_managed_rss_config(existing).rstrip()
|
|
107
|
+
block = _format_rss_config_block(config)
|
|
108
|
+
content = f"{prefix}\n\n{block}" if prefix else block
|
|
109
|
+
CONFIG_FILE.write_text(content, encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rss_config_write_path() -> Path:
|
|
113
|
+
from agentgraph.config import CONFIG_FILE, CONFIG_YAML_FILE
|
|
114
|
+
|
|
115
|
+
return CONFIG_YAML_FILE if CONFIG_YAML_FILE.exists() else CONFIG_FILE
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write_rss_yaml_config(
|
|
119
|
+
config_file: Path,
|
|
120
|
+
config: dict[str, Any],
|
|
121
|
+
) -> None:
|
|
122
|
+
raw: dict[str, Any]
|
|
123
|
+
if config_file.exists():
|
|
124
|
+
loaded = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
125
|
+
raw = loaded if isinstance(loaded, dict) else {}
|
|
126
|
+
else:
|
|
127
|
+
raw = {}
|
|
128
|
+
connectors = raw.get("connectors")
|
|
129
|
+
if not isinstance(connectors, dict):
|
|
130
|
+
connectors = {}
|
|
131
|
+
connectors["rss"] = _normalise_rss_config(config)
|
|
132
|
+
raw["connectors"] = connectors
|
|
133
|
+
config_file.write_text(
|
|
134
|
+
yaml.safe_dump(raw, sort_keys=False, allow_unicode=False),
|
|
135
|
+
encoding="utf-8",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _normalise_rss_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
return {
|
|
141
|
+
"feed_urls": [
|
|
142
|
+
str(feed_url)
|
|
143
|
+
for feed_url in config.get("feed_urls", [])
|
|
144
|
+
if isinstance(feed_url, str) and feed_url
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _strip_managed_rss_config(content: str) -> str:
|
|
150
|
+
begin = content.find(_RSS_CONFIG_BEGIN)
|
|
151
|
+
if begin == -1:
|
|
152
|
+
return content
|
|
153
|
+
end = content.find(_RSS_CONFIG_END, begin)
|
|
154
|
+
if end == -1:
|
|
155
|
+
return content[:begin]
|
|
156
|
+
return f"{content[:begin]}{content[end + len(_RSS_CONFIG_END) :]}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format_rss_config_block(config: dict[str, Any]) -> str:
|
|
160
|
+
lines = [
|
|
161
|
+
_RSS_CONFIG_BEGIN,
|
|
162
|
+
"[connectors.rss]",
|
|
163
|
+
]
|
|
164
|
+
feed_urls = [
|
|
165
|
+
str(feed_url)
|
|
166
|
+
for feed_url in config.get("feed_urls", [])
|
|
167
|
+
if isinstance(feed_url, str) and feed_url
|
|
168
|
+
]
|
|
169
|
+
lines.append(f"feed_urls = [{', '.join(_toml_string(url) for url in feed_urls)}]")
|
|
170
|
+
lines.append(_RSS_CONFIG_END)
|
|
171
|
+
return "\n".join(lines) + "\n"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _toml_string(value: str) -> str:
|
|
175
|
+
return json.dumps(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def add_feed_urls(
|
|
179
|
+
feed_urls: list[str],
|
|
180
|
+
*,
|
|
181
|
+
validate: bool = True,
|
|
182
|
+
) -> RssConfig:
|
|
183
|
+
"""Add feed URLs to the configured RSS feed list and return the updated config."""
|
|
184
|
+
selected_feed_urls = [part.strip() for part in feed_urls if part.strip()]
|
|
185
|
+
if not selected_feed_urls:
|
|
186
|
+
raise ValueError("Usage: agentgraph connector rss add <feed-url> [feed-url...]")
|
|
187
|
+
if validate:
|
|
188
|
+
selected_feed_urls = resolve_feed_sources(selected_feed_urls)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
existing = load_rss_settings()
|
|
192
|
+
merged = [*existing.feed_urls]
|
|
193
|
+
except RuntimeError:
|
|
194
|
+
merged = []
|
|
195
|
+
|
|
196
|
+
for feed_url in selected_feed_urls:
|
|
197
|
+
if feed_url not in merged:
|
|
198
|
+
merged.append(feed_url)
|
|
199
|
+
|
|
200
|
+
config = RssConfig(feed_urls=merged)
|
|
201
|
+
save_rss_config(config)
|
|
202
|
+
return config
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def remove_feed_urls(
|
|
206
|
+
feed_urls: list[str],
|
|
207
|
+
) -> tuple[RssConfig, list[str]]:
|
|
208
|
+
"""Remove exact feed URLs from the configured RSS feed list."""
|
|
209
|
+
selected_feed_urls = [part.strip() for part in feed_urls if part.strip()]
|
|
210
|
+
if not selected_feed_urls:
|
|
211
|
+
raise ValueError("Usage: agentgraph connector rss remove <feed-url> [feed-url...]")
|
|
212
|
+
|
|
213
|
+
existing = load_rss_settings()
|
|
214
|
+
remove_set = set(selected_feed_urls)
|
|
215
|
+
updated_feed_urls = [feed_url for feed_url in existing.feed_urls if feed_url not in remove_set]
|
|
216
|
+
removed_feed_urls = [feed_url for feed_url in existing.feed_urls if feed_url in remove_set]
|
|
217
|
+
if not removed_feed_urls:
|
|
218
|
+
raise ValueError("No matching RSS feed URLs are configured for removal")
|
|
219
|
+
|
|
220
|
+
config = RssConfig(feed_urls=updated_feed_urls)
|
|
221
|
+
save_rss_config(config)
|
|
222
|
+
return config, removed_feed_urls
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def resolve_feed_sources(sources: list[str]) -> list[str]:
|
|
226
|
+
"""Validate user-provided RSS/Atom feed URLs and deduplicate them."""
|
|
227
|
+
resolved: list[str] = []
|
|
228
|
+
seen: set[str] = set()
|
|
229
|
+
for source in sources:
|
|
230
|
+
feed_url = resolve_feed_source(source)
|
|
231
|
+
if feed_url not in seen:
|
|
232
|
+
seen.add(feed_url)
|
|
233
|
+
resolved.append(feed_url)
|
|
234
|
+
return resolved
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_feed_source(source: str) -> str:
|
|
238
|
+
"""Return the supplied source if it is a valid RSS/Atom feed."""
|
|
239
|
+
candidate = source.strip()
|
|
240
|
+
if not candidate:
|
|
241
|
+
raise ValueError("RSS feed URL cannot be empty")
|
|
242
|
+
|
|
243
|
+
parsed = _parse_feed(candidate)
|
|
244
|
+
if _is_valid_feed(parsed):
|
|
245
|
+
return candidate
|
|
246
|
+
raise ValueError(f"Not a valid RSS/Atom feed: {candidate}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_feed(feed_url: str) -> Any:
|
|
250
|
+
import feedparser # type: ignore[import-untyped]
|
|
251
|
+
|
|
252
|
+
return feedparser.parse(feed_url)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_valid_feed(parsed: Any) -> bool:
|
|
256
|
+
version = str(getattr(parsed, "version", "") or "")
|
|
257
|
+
if not version:
|
|
258
|
+
return False
|
|
259
|
+
feed = cast(dict[str, Any], getattr(parsed, "feed", {}) or {})
|
|
260
|
+
entries = cast(list[Any], getattr(parsed, "entries", []) or [])
|
|
261
|
+
return bool(feed or entries)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_opml_feeds(path: str | Path) -> list[OpmlFeed]:
|
|
265
|
+
"""Parse feed outlines from an OPML file."""
|
|
266
|
+
source_path = _opml_source_path(path)
|
|
267
|
+
if not source_path.exists():
|
|
268
|
+
raise FileNotFoundError(f"OPML file not found: {source_path}")
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
root = ElementTree.parse(source_path).getroot()
|
|
272
|
+
except ElementTree.ParseError as exc:
|
|
273
|
+
raise ValueError(f"Could not parse OPML file: {exc}") from exc
|
|
274
|
+
|
|
275
|
+
feeds: list[OpmlFeed] = []
|
|
276
|
+
seen: set[str] = set()
|
|
277
|
+
for outline in root.iter("outline"):
|
|
278
|
+
feed_url = (outline.attrib.get("xmlUrl") or outline.attrib.get("xmlurl") or "").strip()
|
|
279
|
+
if not feed_url or feed_url in seen:
|
|
280
|
+
continue
|
|
281
|
+
seen.add(feed_url)
|
|
282
|
+
title = (
|
|
283
|
+
outline.attrib.get("title")
|
|
284
|
+
or outline.attrib.get("text")
|
|
285
|
+
or outline.attrib.get("description")
|
|
286
|
+
or feed_url
|
|
287
|
+
)
|
|
288
|
+
html_url = (outline.attrib.get("htmlUrl") or outline.attrib.get("htmlurl") or "").strip()
|
|
289
|
+
feeds.append(OpmlFeed(title=title.strip(), feed_url=feed_url, html_url=html_url or None))
|
|
290
|
+
return feeds
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _opml_source_path(path: str | Path) -> Path:
|
|
294
|
+
return _local_file_path(path, kind="OPML")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _local_file_path(path: str | Path, *, kind: str) -> Path:
|
|
298
|
+
raw_path = str(path)
|
|
299
|
+
parsed = urlparse(raw_path)
|
|
300
|
+
if parsed.scheme == "file":
|
|
301
|
+
if parsed.netloc not in ("", "localhost"):
|
|
302
|
+
raise ValueError(f"Unsupported {kind} file URI host: {parsed.netloc}")
|
|
303
|
+
return Path(unquote(parsed.path)).expanduser()
|
|
304
|
+
return Path(path).expanduser()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def select_opml_feeds(
|
|
308
|
+
feeds: list[OpmlFeed],
|
|
309
|
+
*,
|
|
310
|
+
include_all: bool = False,
|
|
311
|
+
selection: str | None = None,
|
|
312
|
+
configured_feed_urls: list[str] | None = None,
|
|
313
|
+
) -> list[OpmlFeed]:
|
|
314
|
+
if include_all:
|
|
315
|
+
return feeds
|
|
316
|
+
if selection is not None:
|
|
317
|
+
selected_indexes = _parse_feed_selection(selection, len(feeds))
|
|
318
|
+
return [feeds[index - 1] for index in selected_indexes]
|
|
319
|
+
return _prompt_for_opml_feeds(feeds, configured_feed_urls=configured_feed_urls or [])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def import_opml_feeds(
|
|
323
|
+
path: str | Path,
|
|
324
|
+
*,
|
|
325
|
+
include_all: bool = False,
|
|
326
|
+
selection: str | None = None,
|
|
327
|
+
) -> tuple[RssConfig, list[OpmlFeed], list[OpmlFeed]]:
|
|
328
|
+
feeds = parse_opml_feeds(path)
|
|
329
|
+
if not feeds:
|
|
330
|
+
raise ValueError("No RSS/Atom feeds found in OPML file")
|
|
331
|
+
|
|
332
|
+
configured_feed_urls: list[str] = []
|
|
333
|
+
try:
|
|
334
|
+
configured_feed_urls = load_rss_settings().feed_urls
|
|
335
|
+
except RuntimeError:
|
|
336
|
+
configured_feed_urls = []
|
|
337
|
+
|
|
338
|
+
selected_feeds = select_opml_feeds(
|
|
339
|
+
feeds,
|
|
340
|
+
include_all=include_all,
|
|
341
|
+
selection=selection,
|
|
342
|
+
configured_feed_urls=configured_feed_urls,
|
|
343
|
+
)
|
|
344
|
+
if not selected_feeds:
|
|
345
|
+
raise ValueError("No feeds selected")
|
|
346
|
+
|
|
347
|
+
config = add_feed_urls([feed.feed_url for feed in selected_feeds])
|
|
348
|
+
return config, feeds, selected_feeds
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _parse_feed_selection(selection: str, feed_count: int) -> list[int]:
|
|
352
|
+
indexes: list[int] = []
|
|
353
|
+
seen: set[int] = set()
|
|
354
|
+
for raw_part in selection.split(","):
|
|
355
|
+
part = raw_part.strip()
|
|
356
|
+
if not part:
|
|
357
|
+
continue
|
|
358
|
+
if "-" in part:
|
|
359
|
+
start_raw, end_raw = part.split("-", 1)
|
|
360
|
+
try:
|
|
361
|
+
start = int(start_raw.strip())
|
|
362
|
+
end = int(end_raw.strip())
|
|
363
|
+
except ValueError as exc:
|
|
364
|
+
raise ValueError(f"Invalid feed selection: {selection}") from exc
|
|
365
|
+
if start > end:
|
|
366
|
+
raise ValueError(f"Invalid feed selection range: {part}")
|
|
367
|
+
candidates = range(start, end + 1)
|
|
368
|
+
else:
|
|
369
|
+
try:
|
|
370
|
+
candidates = range(int(part), int(part) + 1)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
raise ValueError(f"Invalid feed selection: {selection}") from exc
|
|
373
|
+
for index in candidates:
|
|
374
|
+
if index < 1 or index > feed_count:
|
|
375
|
+
raise ValueError(f"Feed selection {index} is out of range 1-{feed_count}")
|
|
376
|
+
if index not in seen:
|
|
377
|
+
seen.add(index)
|
|
378
|
+
indexes.append(index)
|
|
379
|
+
return indexes
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _prompt_for_opml_feeds(
|
|
383
|
+
feeds: list[OpmlFeed],
|
|
384
|
+
*,
|
|
385
|
+
configured_feed_urls: list[str],
|
|
386
|
+
) -> list[OpmlFeed]:
|
|
387
|
+
import sys
|
|
388
|
+
|
|
389
|
+
if not sys.stdin.isatty():
|
|
390
|
+
raise ValueError("Use --all or --select <indexes> when importing OPML non-interactively")
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
return _checkbox_select_opml_feeds(feeds, configured_feed_urls=configured_feed_urls)
|
|
394
|
+
except ImportError:
|
|
395
|
+
return _prompt_for_opml_feeds_numeric(feeds)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _checkbox_select_opml_feeds(
|
|
399
|
+
feeds: list[OpmlFeed],
|
|
400
|
+
*,
|
|
401
|
+
configured_feed_urls: list[str],
|
|
402
|
+
) -> list[OpmlFeed]:
|
|
403
|
+
import questionary # type: ignore[import-untyped]
|
|
404
|
+
|
|
405
|
+
configured = set(configured_feed_urls)
|
|
406
|
+
choices = [
|
|
407
|
+
questionary.Choice(
|
|
408
|
+
title=f"{feed.title} ({feed.feed_url})",
|
|
409
|
+
value=feed.feed_url,
|
|
410
|
+
checked=feed.feed_url in configured,
|
|
411
|
+
)
|
|
412
|
+
for feed in feeds
|
|
413
|
+
]
|
|
414
|
+
selected_urls = questionary.checkbox(
|
|
415
|
+
f"Select feeds to add ({len(feeds)} found):",
|
|
416
|
+
choices=choices,
|
|
417
|
+
).ask()
|
|
418
|
+
if selected_urls is None:
|
|
419
|
+
raise ValueError("No feeds selected")
|
|
420
|
+
selected = set(cast(list[str], selected_urls))
|
|
421
|
+
return [feed for feed in feeds if feed.feed_url in selected]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _prompt_for_opml_feeds_numeric(feeds: list[OpmlFeed]) -> list[OpmlFeed]:
|
|
425
|
+
import typer
|
|
426
|
+
|
|
427
|
+
typer.echo(f"Found {len(feeds)} feed(s) in OPML:")
|
|
428
|
+
for index, feed in enumerate(feeds, start=1):
|
|
429
|
+
typer.echo(f" {index}. {feed.title} - {feed.feed_url}")
|
|
430
|
+
if typer.confirm("Add all feeds?", default=True):
|
|
431
|
+
return feeds
|
|
432
|
+
selection = typer.prompt("Feed numbers to add (comma-separated, ranges allowed)").strip()
|
|
433
|
+
return select_opml_feeds(feeds, selection=selection)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def preview_feed(feed_url: str, *, count: int = 3) -> dict[str, Any]:
|
|
437
|
+
"""Fetch and parse a small preview from an RSS/Atom feed URL."""
|
|
438
|
+
import asyncio
|
|
439
|
+
|
|
440
|
+
import feedparser # type: ignore[import-untyped]
|
|
441
|
+
|
|
442
|
+
parsed: Any = await asyncio.to_thread(feedparser.parse, feed_url)
|
|
443
|
+
entries = [
|
|
444
|
+
{
|
|
445
|
+
"title": str(
|
|
446
|
+
entry.get("title") or entry.get("id") or entry.get("link") or "(untitled)"
|
|
447
|
+
),
|
|
448
|
+
"link": str(entry.get("link") or ""),
|
|
449
|
+
}
|
|
450
|
+
for entry in cast(list[Any], parsed.entries)[: max(1, min(count, 50))]
|
|
451
|
+
]
|
|
452
|
+
feed = cast(dict[str, Any], parsed.feed)
|
|
453
|
+
return {
|
|
454
|
+
"title": str(feed.get("title") or feed_url),
|
|
455
|
+
"feed_url": feed_url,
|
|
456
|
+
"entries": entries,
|
|
457
|
+
"bozo": bool(parsed.bozo),
|
|
458
|
+
"bozo_exception": str(parsed.bozo_exception) if parsed.bozo else None,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def verify_rss_auth(account_id: str | None = None) -> tuple[str, str | None]:
|
|
463
|
+
try:
|
|
464
|
+
settings = load_rss_settings(account_id)
|
|
465
|
+
except RuntimeError:
|
|
466
|
+
return ("missing", None)
|
|
467
|
+
|
|
468
|
+
if not settings.feed_urls:
|
|
469
|
+
return ("invalid", "No RSS feed URLs configured")
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
preview = await preview_feed(settings.feed_urls[0], count=1)
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
return ("invalid", str(exc))
|
|
475
|
+
|
|
476
|
+
entry_count = len(cast(list[object], preview.get("entries") or []))
|
|
477
|
+
if preview.get("bozo") and entry_count == 0:
|
|
478
|
+
return ("invalid", str(preview.get("bozo_exception") or "Feed could not be parsed"))
|
|
479
|
+
return ("ok", f"{len(settings.feed_urls)} feed(s), sample returned {entry_count} article(s)")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def run_rss_flow(
|
|
483
|
+
account_id: str | None = None,
|
|
484
|
+
add: bool = False,
|
|
485
|
+
) -> None:
|
|
486
|
+
_ = (account_id, add)
|
|
487
|
+
import asyncio
|
|
488
|
+
|
|
489
|
+
import typer
|
|
490
|
+
|
|
491
|
+
typer.echo(
|
|
492
|
+
"\n"
|
|
493
|
+
"RSS setup does not need authentication. Provide one or more RSS/Atom feed URLs,\n"
|
|
494
|
+
"and AgentGraph will fetch those feeds directly.\n"
|
|
495
|
+
)
|
|
496
|
+
raw_feeds: str = typer.prompt("RSS/Atom feed URLs (comma-separated)").strip()
|
|
497
|
+
selected_feed_urls = resolve_feed_sources(
|
|
498
|
+
[part.strip() for part in raw_feeds.split(",") if part.strip()]
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
config = RssConfig(feed_urls=selected_feed_urls)
|
|
502
|
+
save_rss_config(config)
|
|
503
|
+
|
|
504
|
+
typer.echo(f"\nRSS feeds saved to {_rss_config_write_path()}")
|
|
505
|
+
if selected_feed_urls:
|
|
506
|
+
typer.echo("Checking configured feed(s)...")
|
|
507
|
+
|
|
508
|
+
async def _check() -> None:
|
|
509
|
+
status, detail = await verify_rss_auth()
|
|
510
|
+
if status == "ok":
|
|
511
|
+
typer.echo(f"RSS feed check passed: {detail}")
|
|
512
|
+
preview = await preview_feed(selected_feed_urls[0], count=3)
|
|
513
|
+
entries = cast(list[dict[str, str]], preview.get("entries") or [])
|
|
514
|
+
if entries:
|
|
515
|
+
typer.echo("Sample articles from first feed:")
|
|
516
|
+
for item in entries[:3]:
|
|
517
|
+
typer.echo(f" - {item['title']}")
|
|
518
|
+
else:
|
|
519
|
+
typer.echo(f"RSS feed check failed: {detail or status}")
|
|
520
|
+
|
|
521
|
+
asyncio.run(_check())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentgraph-connector-rss
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: RSS and Atom connector for AgentGraph
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: agentgraph-server<0.6,>=0.5.0
|
|
7
|
+
Requires-Dist: agentgraph-connector-web<0.6,>=0.5.0
|
|
8
|
+
Requires-Dist: feedparser>=6.0.11
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: questionary>=2.1.1
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
agentgraph_connector_rss/__init__.py,sha256=-7kXcAl5zmknz6zP031f-e1S4VDEHTbskCEJ4_8mwcE,28129
|
|
2
|
+
agentgraph_connector_rss/auth.py,sha256=uk97Ka3fC_ognY3Sf-RQEREYGESB4g0VYJM_29vv8FI,16899
|
|
3
|
+
agentgraph_connector_rss-0.5.0.dist-info/METADATA,sha256=_QVJjNaVR-FKlDnM5AiWuCIrAI8i_8iI2JaoRze7xFE,362
|
|
4
|
+
agentgraph_connector_rss-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
agentgraph_connector_rss-0.5.0.dist-info/entry_points.txt,sha256=1lY1JBweReupuaRXl1iEo3as6fym1v5XjszuJ3vUXiA,68
|
|
6
|
+
agentgraph_connector_rss-0.5.0.dist-info/top_level.txt,sha256=SYROHVGUSvNTdW_lVwjOun_MWBGkI-4iFg6Yc52f3wo,25
|
|
7
|
+
agentgraph_connector_rss-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentgraph_connector_rss
|