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.
- feed_all-0.1.0.dist-info/METADATA +14 -0
- feed_all-0.1.0.dist-info/RECORD +17 -0
- feed_all-0.1.0.dist-info/WHEEL +5 -0
- feed_all-0.1.0.dist-info/entry_points.txt +2 -0
- feed_all-0.1.0.dist-info/top_level.txt +1 -0
- rss_plugin/__init__.py +4 -0
- rss_plugin/__main__.py +6 -0
- rss_plugin/discovery.py +188 -0
- rss_plugin/fetcher.py +218 -0
- rss_plugin/models.py +114 -0
- rss_plugin/server.py +805 -0
- rss_plugin/storage.py +442 -0
- rss_plugin/utils.py +132 -0
- rss_plugin/webpage_parsers/__init__.py +8 -0
- rss_plugin/webpage_parsers/base.py +25 -0
- rss_plugin/webpage_parsers/selector_parser.py +142 -0
- rss_plugin/webpage_parsers/site_registry.py +59 -0
|
@@ -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 @@
|
|
|
1
|
+
rss_plugin
|
rss_plugin/__init__.py
ADDED
rss_plugin/__main__.py
ADDED
rss_plugin/discovery.py
ADDED
|
@@ -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]] = []
|