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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [agentgraph.connectors]
2
+ rss = agentgraph_connector_rss:RssConnector
@@ -0,0 +1 @@
1
+ agentgraph_connector_rss