feed-all 0.1.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,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: feed-all
3
+ Version: 0.1.0
4
+ Summary: All-in-one RSS/feed MCP Plugin — subscribe, fetch, browse, manage feeds, track webpage changes, health check, OPML import/export
5
+ Author-email: EntroFeed Team <wuyuezhang1984@gmail.com>
6
+ License: MIT
7
+ Keywords: rss,feed,mcp,claude-code,cline
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: feedparser>=6.0.0
10
+ Requires-Dist: httpx>=0.25.0
11
+ Requires-Dist: mcp>=1.0.0
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: beautifulsoup4>=4.12.0
14
+ Requires-Dist: lxml>=5.0.0
@@ -0,0 +1,17 @@
1
+ rss_plugin/__init__.py,sha256=MphEzKmeymA9Gl_lZYiiyQdtlR5PKVoX9-WPHhnoAeI,136
2
+ rss_plugin/__main__.py,sha256=dubPYx1yCEDu-nHC8qYwUYyqDDZi4tnJpYIrXuLEd2M,116
3
+ rss_plugin/discovery.py,sha256=QfdlX-Mk5onwTnTxCMo6TOBphop2RHu6CHaBgb9m0U8,5801
4
+ rss_plugin/fetcher.py,sha256=5C41HVFFyxTT-4p-bP5F6HPRhSeybLaTXxlQEhiP05k,8125
5
+ rss_plugin/models.py,sha256=IvcEroflRQYjnTgRtcP6G8lccOOyA8A90o4ntEledw4,3356
6
+ rss_plugin/server.py,sha256=O0blSiyqko4SeksYE5bIylL2evDy_87y1sG6coMu4vs,27084
7
+ rss_plugin/storage.py,sha256=r9QdntgOpjSQ-dJ6kJf0uwVjZCBG0HxSCqBvlE4LA9k,17461
8
+ rss_plugin/utils.py,sha256=vot0d5Fk7PCw-L0uEvNpGUg2PDXAgsCdmJR2JvN4MHE,4354
9
+ rss_plugin/webpage_parsers/__init__.py,sha256=bR7IY7xFfMrVrPM3stJgCQQ39PGfK8Iw4V7A2EVL0Ow,325
10
+ rss_plugin/webpage_parsers/base.py,sha256=9MFaHPpw-hVCWU969ugttCxnOhJX_6Bq1EYWwvVEOcI,740
11
+ rss_plugin/webpage_parsers/selector_parser.py,sha256=V5cn66mpi1KkKtrF_AqYCHBNys3o_SGSK4px0SFncAA,4819
12
+ rss_plugin/webpage_parsers/site_registry.py,sha256=vIEwnmjQ2lhPcaSpDtz0HpmNCfib_-fL2a7HXnHvVIo,1985
13
+ feed_all-0.1.0.dist-info/METADATA,sha256=UylSZaIlwvNCtLK-VFMT8Lfp1jcQSB_iAiIGue3-VPM,508
14
+ feed_all-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
15
+ feed_all-0.1.0.dist-info/entry_points.txt,sha256=GJKXRSndZqEyYTPjNDkTnLTy4W-w3c3QZxPa1cKUSgw,58
16
+ feed_all-0.1.0.dist-info/top_level.txt,sha256=7A1hA1z9pLknNfBOASpzYXzMTBrsAA_jimYjqurqUwk,11
17
+ feed_all-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ feed-all = rss_plugin.server:run_server
@@ -0,0 +1 @@
1
+ rss_plugin
rss_plugin/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Standalone RSS MCP Plugin — subscribe, fetch, and manage RSS feeds via MCP tools."""
3
+
4
+ __version__ = "0.1.0"
rss_plugin/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Run the RSS MCP plugin server."""
3
+
4
+ from rss_plugin.server import run_server
5
+
6
+ run_server()
@@ -0,0 +1,188 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Standalone RSS Plugin — feed discovery.
3
+
4
+ Supports three methods (from most to least preferred):
5
+ 1. Direct RSS check — URL is already an RSS/Atom feed
6
+ 2. Basic webpage scan — fetch HTML, extract <link> tags with feed MIME types
7
+ 3. Fuzzy URL check — common RSS path patterns
8
+ """
9
+
10
+ import logging
11
+ from typing import Any, Dict, Optional
12
+ from urllib.parse import urljoin, urlparse
13
+
14
+ import httpx
15
+
16
+ from rss_plugin.fetcher import check_feed as _check_feed
17
+ from rss_plugin.models import FeedDiscoveryResult
18
+ from rss_plugin.utils import extract_rss_links
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _USER_AGENT = (
23
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
24
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
25
+ "Chrome/131.0.6778.86 Safari/537.36"
26
+ )
27
+
28
+ # Common feed URL path segments
29
+ FEED_PATH_SEGMENTS = {"feed", "rss", "atom", "feeds", "xml"}
30
+ FEED_PATH_ENDINGS = (".rss", ".atom", "feed.xml", "rss.xml", "atom.xml", "index.xml")
31
+
32
+ # Common feed-like URLs to try if direct scan fails
33
+ COMMON_FEED_PATHS = [
34
+ "/feed",
35
+ "/feed/",
36
+ "/rss",
37
+ "/rss/",
38
+ "/atom.xml",
39
+ "/feed.xml",
40
+ "/rss.xml",
41
+ "/index.xml",
42
+ "/feeds/posts/default", # Blogger
43
+ "/posts/default", # Blogger
44
+ ]
45
+
46
+ # Actually import the real check function
47
+ async def _direct_rss_check(url: str) -> Optional[Dict[str, Any]]:
48
+ """Check if a URL is a valid RSS/Atom feed."""
49
+ try:
50
+ result = await _check_feed(url)
51
+ return result
52
+ except Exception as e:
53
+ logger.warning("Direct RSS check failed for %s: %s", url, e)
54
+ return None
55
+
56
+
57
+ async def _webpage_scan(url: str) -> Optional[Dict[str, Any]]:
58
+ """Fetch HTML page and extract RSS/Atom feed links."""
59
+ async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
60
+ try:
61
+ response = await client.get(
62
+ url,
63
+ headers={"User-Agent": _USER_AGENT},
64
+ )
65
+ response.raise_for_status()
66
+ except Exception as e:
67
+ logger.warning("Webpage scan failed for %s: %s", url, e)
68
+ return None
69
+
70
+ html_content = response.text
71
+ if not html_content:
72
+ return None
73
+
74
+ rss_links = extract_rss_links(html_content, str(response.url))
75
+ if not rss_links:
76
+ return None
77
+
78
+ for feed_url in rss_links:
79
+ try:
80
+ result = await _check_feed(feed_url)
81
+ if result and result.get("is_valid"):
82
+ result["feed_url"] = feed_url
83
+ return result
84
+ except Exception:
85
+ continue
86
+
87
+ return None
88
+
89
+
90
+ async def _try_common_feed_paths(base_url: str) -> Optional[Dict[str, Any]]:
91
+ """Try common RSS feed paths against a base URL."""
92
+ parsed = urlparse(base_url)
93
+ base = f"{parsed.scheme}://{parsed.netloc}"
94
+
95
+ for path in COMMON_FEED_PATHS:
96
+ feed_url = urljoin(base, path)
97
+ try:
98
+ result = await _check_feed(feed_url)
99
+ if result and result.get("is_valid"):
100
+ result["feed_url"] = feed_url
101
+ return result
102
+ except Exception:
103
+ continue
104
+
105
+ return None
106
+
107
+
108
+ def _is_likely_feed_url(url: str) -> bool:
109
+ """Heuristic check if a URL looks like a feed URL."""
110
+ url_lower = url.lower().strip()
111
+ parsed = urlparse(url_lower)
112
+ path = parsed.path
113
+
114
+ if any(path.endswith(e) for e in FEED_PATH_ENDINGS):
115
+ return True
116
+
117
+ segments = [s for s in path.split("/") if s]
118
+ if segments and any(s in FEED_PATH_SEGMENTS for s in segments):
119
+ return True
120
+
121
+ if "rss" in parsed.query or "atom" in parsed.query:
122
+ return True
123
+
124
+ return False
125
+
126
+
127
+ async def discover(url: str) -> FeedDiscoveryResult:
128
+ """Discover RSS feeds from a URL.
129
+
130
+ Priority:
131
+ 1. Direct RSS check
132
+ 2. Webpage scan (<link> tags)
133
+ 3. Common feed paths
134
+ 4. Fuzzy URL check (report if URL looks like a feed but wasn't validated)
135
+ """
136
+ # 1. Direct RSS check
137
+ if _is_likely_feed_url(url) or url.endswith(".xml"):
138
+ direct = await _direct_rss_check(url)
139
+ if direct and direct.get("is_valid"):
140
+ return FeedDiscoveryResult(
141
+ success=True,
142
+ feed_url=url,
143
+ source="direct_rss",
144
+ feed_title=direct.get("title", ""),
145
+ feed_type=direct.get("feed_type", "rss"),
146
+ article_count=direct.get("article_count", 0),
147
+ )
148
+
149
+ # 2. Try as webpage (extract <link> tags)
150
+ webpage = await _webpage_scan(url)
151
+ if webpage:
152
+ return FeedDiscoveryResult(
153
+ success=True,
154
+ feed_url=webpage.get("feed_url", url),
155
+ source="direct_rss",
156
+ feed_title=webpage.get("title", ""),
157
+ feed_type=webpage.get("feed_type", "rss"),
158
+ article_count=webpage.get("article_count", 0),
159
+ )
160
+
161
+ # 3. Try common feed paths
162
+ common = await _try_common_feed_paths(url)
163
+ if common:
164
+ return FeedDiscoveryResult(
165
+ success=True,
166
+ feed_url=common.get("feed_url", ""),
167
+ source="direct_rss",
168
+ feed_title=common.get("title", ""),
169
+ feed_type=common.get("feed_type", "rss"),
170
+ article_count=common.get("article_count", 0),
171
+ )
172
+
173
+ # 4. If URL looks like a feed but we couldn't validate, report it
174
+ if _is_likely_feed_url(url):
175
+ return FeedDiscoveryResult(
176
+ success=False,
177
+ feed_url=url,
178
+ source="unknown",
179
+ error_message="URL looks like a feed but could not be validated",
180
+ )
181
+
182
+ return FeedDiscoveryResult(
183
+ success=False,
184
+ error_message=(
185
+ "Could not discover an RSS feed. "
186
+ "Try providing a direct feed URL (e.g., .../feed.xml or .../rss)"
187
+ ),
188
+ )
rss_plugin/fetcher.py ADDED
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Standalone RSS Plugin — feed fetching and parsing.
3
+
4
+ Supports:
5
+ - RSS/Atom feed fetching via feedparser
6
+ - Webpage feed fetching (page_snapshot, list_extract, site_parser)
7
+ """
8
+
9
+ import json
10
+ from calendar import timegm
11
+ from datetime import datetime, timezone
12
+ from hashlib import md5
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import feedparser
16
+ import httpx
17
+
18
+ from rss_plugin.utils import clean_html_text, extract_readable_content, timestamp_now
19
+
20
+ _USER_AGENT = (
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
23
+ "Chrome/131.0.6778.86 Safari/537.36"
24
+ )
25
+
26
+
27
+ def _get_entry_time_raw(entry: Any) -> int:
28
+ """Get entry timestamp from feedparser parsed entry, with fallbacks."""
29
+ if hasattr(entry, "published_parsed") and entry.published_parsed:
30
+ return timegm(entry.published_parsed)
31
+ if hasattr(entry, "updated_parsed") and entry.updated_parsed:
32
+ return timegm(entry.updated_parsed)
33
+ return timestamp_now()
34
+
35
+
36
+ async def fetch_feed(url: str, timeout: int = 30) -> Dict[str, Any]:
37
+ """Fetch and parse an RSS/Atom feed URL.
38
+
39
+ Returns:
40
+ Dict with keys: success, title, description, feed_type, entry_count, entries, error
41
+ """
42
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
43
+ try:
44
+ response = await client.get(
45
+ url, headers={"User-Agent": _USER_AGENT},
46
+ )
47
+ response.raise_for_status()
48
+ except httpx.TimeoutException:
49
+ return {"success": False, "error": f"Request timed out after {timeout}s",
50
+ "title": "", "description": "", "feed_type": "", "entries": []}
51
+ except httpx.HTTPStatusError as e:
52
+ return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}",
53
+ "title": "", "description": "", "feed_type": "", "entries": []}
54
+ except httpx.RequestError as e:
55
+ return {"success": False, "error": f"Request failed: {e}",
56
+ "title": "", "description": "", "feed_type": "", "entries": []}
57
+
58
+ content = response.text
59
+ if not content or len(content.strip()) < 50:
60
+ return {"success": False, "error": "Empty or too short response",
61
+ "title": "", "description": "", "feed_type": "", "entries": []}
62
+
63
+ parsed = feedparser.parse(content)
64
+ if parsed.bozo and not parsed.entries:
65
+ bozo_exception = parsed.bozo_exception
66
+ return {"success": False, "error": f"Invalid feed: {bozo_exception}" if bozo_exception else "Invalid feed XML",
67
+ "title": "", "description": "", "feed_type": "", "entries": []}
68
+
69
+ feed_meta = parsed.feed
70
+ title = getattr(feed_meta, "title", "") or ""
71
+ description = getattr(feed_meta, "description", "") or ""
72
+
73
+ feed_type = "rss"
74
+ version = getattr(parsed, "version", "")
75
+ if version and "atom" in str(version).lower():
76
+ feed_type = "atom"
77
+
78
+ entries = []
79
+ for entry in parsed.entries:
80
+ entry_title = getattr(entry, "title", "") or ""
81
+ entry_link = getattr(entry, "link", "") or ""
82
+ published_time = _get_entry_time_raw(entry)
83
+
84
+ entry_content = ""
85
+ if hasattr(entry, "content") and entry.content:
86
+ entry_content = "".join(c.get("value", "") for c in entry.content if isinstance(c, dict))
87
+ elif hasattr(entry, "summary"):
88
+ entry_content = entry.summary or ""
89
+
90
+ summary = getattr(entry, "summary", "") or ""
91
+
92
+ authors = []
93
+ if hasattr(entry, "authors") and entry.authors:
94
+ authors = [a.get("name", "") for a in entry.authors if isinstance(a, dict)]
95
+ elif hasattr(entry, "author") and entry.author:
96
+ authors = [entry.author]
97
+
98
+ entries.append({
99
+ "title": entry_title,
100
+ "url": entry_link,
101
+ "published_at": published_time,
102
+ "updated_at": published_time,
103
+ "content": entry_content,
104
+ "preview": summary,
105
+ "authors": json.dumps(authors, ensure_ascii=False),
106
+ })
107
+
108
+ return {"success": True, "title": title, "description": description,
109
+ "feed_type": feed_type, "entry_count": len(entries),
110
+ "entries": entries, "error": ""}
111
+
112
+
113
+ async def check_feed(url: str, timeout: int = 30) -> Dict[str, Any]:
114
+ """Quick check if a URL is a valid RSS/Atom feed."""
115
+ result = await fetch_feed(url, timeout=timeout)
116
+ if not result["success"]:
117
+ return {"is_valid": False, "title": "", "feed_type": "", "article_count": 0, "error": result["error"]}
118
+ return {"is_valid": True, "title": result["title"], "feed_type": result["feed_type"],
119
+ "article_count": result["entry_count"], "error": ""}
120
+
121
+
122
+ # ── Webpage Feed Support ──
123
+
124
+
125
+ async def fetch_webpage_html(url: str, timeout: int = 30) -> Optional[str]:
126
+ """Fetch raw HTML from a URL using httpx."""
127
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
128
+ try:
129
+ response = await client.get(url, headers={"User-Agent": _USER_AGENT})
130
+ response.raise_for_status()
131
+ return response.text
132
+ except Exception:
133
+ return None
134
+
135
+
136
+ def classify_webpage_response(html_content: Optional[str]) -> Optional[str]:
137
+ """Classify a webpage response. Returns None if OK, or a classification string."""
138
+ if not html_content:
139
+ return "empty"
140
+ lowered = html_content.lower()
141
+ challenge_signals = (
142
+ "captcha", "verify you are human", "access denied",
143
+ "cloudflare", "security check", "challenge",
144
+ )
145
+ if any(signal in lowered for signal in challenge_signals):
146
+ return "challenge"
147
+ cleaned = clean_html_text(extract_readable_content(html_content))
148
+ if len(cleaned) < 200 and len(html_content) > 5000:
149
+ return "challenge"
150
+ if len(html_content) < 500:
151
+ return "thin"
152
+ return None
153
+
154
+
155
+ async def fetch_page_snapshot(url: str) -> Tuple[bool, Optional[str], Optional[str]]:
156
+ """Fetch a webpage and extract readable content as a snapshot.
157
+
158
+ Returns:
159
+ (changed, cleaned_text, entry_url)
160
+ changed: True if content differs from previous hash
161
+ """
162
+ html_content = await fetch_webpage_html(url)
163
+ if not html_content:
164
+ return False, None, None
165
+
166
+ readable = extract_readable_content(html_content)
167
+ cleaned = clean_html_text(readable)
168
+ if not cleaned:
169
+ return False, None, None
170
+
171
+ content_hash = md5(cleaned.encode("utf-8")).hexdigest()
172
+ entry_url = f"{url}#snapshot-{content_hash}"
173
+ return True, cleaned, entry_url
174
+
175
+
176
+ async def fetch_list_extract(
177
+ url: str,
178
+ parser_config: Dict[str, Any],
179
+ ) -> List[Dict[str, Any]]:
180
+ """Fetch a webpage and extract a list of items using CSS selectors.
181
+
182
+ Returns:
183
+ List of dicts with title, url, published_at, preview
184
+ """
185
+ from rss_plugin.webpage_parsers import extract_with_selectors, get_site_parser
186
+
187
+ html_content = await fetch_webpage_html(url)
188
+ if not html_content:
189
+ return []
190
+
191
+ strategy = parser_config.get("strategy", "list_extract")
192
+ items = []
193
+
194
+ if strategy == "site_parser":
195
+ parser = get_site_parser(parser_config.get("parser_id"))
196
+ if parser:
197
+ parsed = parser(html_content, url, parser_config)
198
+ else:
199
+ parsed = []
200
+ elif parser_config.get("selectors"):
201
+ parsed = extract_with_selectors(html_content, url, parser_config)
202
+ else:
203
+ parser = get_site_parser(parser_config.get("parser_id") or "generic_news_list")
204
+ parsed = parser(html_content, url, parser_config) if parser else []
205
+
206
+ for item in parsed:
207
+ items.append({
208
+ "title": item.title,
209
+ "url": item.url,
210
+ "published_at": item.published_at or timestamp_now(),
211
+ "updated_at": item.updated_at or timestamp_now(),
212
+ "content": item.content or "",
213
+ "preview": item.summary or "",
214
+ "authors": json.dumps(item.authors, ensure_ascii=False),
215
+ "entry_type": "webpage_list",
216
+ })
217
+
218
+ return items
rss_plugin/models.py ADDED
@@ -0,0 +1,114 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Standalone RSS Plugin — Pydantic models.
3
+
4
+ Feed model now includes health tracking fields (merged from poll_state).
5
+ """
6
+
7
+ from hashlib import md5
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from pydantic import BaseModel, field_validator
11
+
12
+
13
+ class Feed(BaseModel):
14
+ """RSS/webpage feed model with embedded health tracking."""
15
+
16
+ name: str
17
+ url: str
18
+ category: str = "uncategorized"
19
+ type: str = "rss" # "rss", "atom", "webpage", "crawl4ai"
20
+ refresh_enabled: bool = True
21
+ preview_only: bool = False
22
+ use_script: bool = False
23
+ retrieve_content: bool = True
24
+ check_interval_units: int = 1
25
+ parser_config: Optional[Dict[str, Any]] = None
26
+ authority_score: Optional[float] = None
27
+ source_category: Optional[str] = None
28
+ tags: List[str] = []
29
+ domains: List[str] = []
30
+ requires_vpn: bool = False
31
+ added_at: int = 0
32
+ # Health tracking (merged from poll_state)
33
+ last_polled_at: Optional[int] = None
34
+ start_ts: Optional[int] = None
35
+ consecutive_failures: int = 0
36
+ last_error: Optional[str] = None
37
+ auto_disabled: bool = False
38
+
39
+ @property
40
+ def id(self) -> str:
41
+ return md5(self.url.encode()).hexdigest()
42
+
43
+ def is_webpage(self) -> bool:
44
+ return self.type in {"webpage", "crawl4ai"}
45
+
46
+
47
+ class FeedEntry(BaseModel):
48
+ """RSS feed entry model."""
49
+
50
+ feed_id: str
51
+ title: str
52
+ url: str
53
+ published_at: int
54
+ updated_at: int = 0
55
+ content: Optional[str] = None
56
+ preview: Optional[str] = None
57
+ authors: str = "[]" # JSON-encoded list
58
+ is_read: bool = False
59
+ entry_type: str = "rss" # "rss", "webpage_snapshot", "webpage_list"
60
+
61
+ @field_validator("title")
62
+ @classmethod
63
+ def _title_not_blank(cls, v: str) -> str:
64
+ if not isinstance(v, str) or not v.strip():
65
+ raise ValueError("title must be a non-empty string")
66
+ return v
67
+
68
+ @field_validator("url")
69
+ @classmethod
70
+ def _url_must_be_http_like(cls, v: str) -> str:
71
+ if not isinstance(v, str) or not v.strip():
72
+ raise ValueError("url must be a non-empty string")
73
+ if not (v.startswith("http://") or v.startswith("https://")):
74
+ raise ValueError("url must start with http:// or https://")
75
+ return v
76
+
77
+ @field_validator("published_at", "updated_at", mode="before")
78
+ @classmethod
79
+ def _ts_must_be_non_negative_int(cls, v: Any) -> int:
80
+ if isinstance(v, bool) or not isinstance(v, int) or v < 0:
81
+ raise ValueError(f"timestamp must be a non-negative int (got {v!r})")
82
+ return v
83
+
84
+ @property
85
+ def id(self) -> str:
86
+ return md5(self.url.encode()).hexdigest()
87
+
88
+
89
+ class EntryContent(BaseModel):
90
+ """Full article content."""
91
+
92
+ entry_id: str
93
+ url: str
94
+ content: Optional[str] = None
95
+ summary: Optional[str] = None
96
+ unretrievable: bool = False
97
+
98
+ @property
99
+ def id(self) -> str:
100
+ return md5(self.url.encode()).hexdigest()
101
+
102
+
103
+ class FeedDiscoveryResult(BaseModel):
104
+ """Feed discovery result model."""
105
+
106
+ success: bool
107
+ feed_url: Optional[str] = None
108
+ source: str = "unknown" # "direct_rss", "webpage", "unknown"
109
+ feed_title: str = ""
110
+ feed_type: str = "" # "rss", "atom", "unknown"
111
+ article_count: int = 0
112
+ latest_article_date: Optional[str] = None
113
+ error_message: str = ""
114
+ attempts: List[Dict[str, Any]] = []