feed-all 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feed_all-0.1.0/PKG-INFO +14 -0
- feed_all-0.1.0/README.md +111 -0
- feed_all-0.1.0/feed_all.egg-info/PKG-INFO +14 -0
- feed_all-0.1.0/feed_all.egg-info/SOURCES.txt +20 -0
- feed_all-0.1.0/feed_all.egg-info/dependency_links.txt +1 -0
- feed_all-0.1.0/feed_all.egg-info/entry_points.txt +2 -0
- feed_all-0.1.0/feed_all.egg-info/requires.txt +6 -0
- feed_all-0.1.0/feed_all.egg-info/top_level.txt +2 -0
- feed_all-0.1.0/pyproject.toml +34 -0
- feed_all-0.1.0/rss_plugin/__init__.py +4 -0
- feed_all-0.1.0/rss_plugin/__main__.py +6 -0
- feed_all-0.1.0/rss_plugin/discovery.py +188 -0
- feed_all-0.1.0/rss_plugin/fetcher.py +218 -0
- feed_all-0.1.0/rss_plugin/models.py +114 -0
- feed_all-0.1.0/rss_plugin/server.py +805 -0
- feed_all-0.1.0/rss_plugin/storage.py +442 -0
- feed_all-0.1.0/rss_plugin/utils.py +132 -0
- feed_all-0.1.0/rss_plugin/webpage_parsers/__init__.py +8 -0
- feed_all-0.1.0/rss_plugin/webpage_parsers/base.py +25 -0
- feed_all-0.1.0/rss_plugin/webpage_parsers/selector_parser.py +142 -0
- feed_all-0.1.0/rss_plugin/webpage_parsers/site_registry.py +59 -0
- feed_all-0.1.0/setup.cfg +4 -0
feed_all-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
feed_all-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Feed All - RSS/Feed MCP Plugin
|
|
2
|
+
|
|
3
|
+
All-in-one RSS feed management plugin using the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).
|
|
4
|
+
|
|
5
|
+
Works with **Claude Code**, **Cline**, **OpenClaw**, **Cursor**, and any MCP-compatible client.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# One line, via uvx
|
|
11
|
+
uvx feed-all
|
|
12
|
+
|
|
13
|
+
# Or install globally
|
|
14
|
+
pip install feed-all
|
|
15
|
+
feed-all
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Claude Code / Cline / Cursor Configuration
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"feed-all": {
|
|
24
|
+
"command": "uvx",
|
|
25
|
+
"args": ["feed-all"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Available Tools (13 tools)
|
|
32
|
+
|
|
33
|
+
| Tool | Description |
|
|
34
|
+
|------|-------------|
|
|
35
|
+
| `rss_subscribe` | Subscribe to a feed (RSS/Atom/webpage) with auto-discovery |
|
|
36
|
+
| `rss_fetch` | **Preview** a feed without subscribing (two-pass: titles first, content on demand) |
|
|
37
|
+
| `rss_list_feeds` | List all feeds with entry counts **and health status** |
|
|
38
|
+
| `rss_unsubscribe` | Remove a feed and all its entries |
|
|
39
|
+
| `rss_refresh` | Refresh feeds — respects intervals, tracks health, auto-disables after 10 failures |
|
|
40
|
+
| `rss_list_entries` | Browse entries (filter by feed, unread, limit) |
|
|
41
|
+
| `rss_get_entry` | Get full entry content (auto-marks read) |
|
|
42
|
+
| `rss_discover` | Discover RSS feeds from a website URL |
|
|
43
|
+
| `rss_check_health` | **Check health** of all feeds — failures, errors, auto-disabled status + optional live revalidation |
|
|
44
|
+
| `rss_export_opml` | Export all feeds to OPML |
|
|
45
|
+
| `rss_import_opml` | Import feeds from OPML |
|
|
46
|
+
| `rss_mark_read` | Mark entry as read |
|
|
47
|
+
| `rss_update_feed` | Update feed settings (name, category, refresh, tags) |
|
|
48
|
+
|
|
49
|
+
## Key Capabilities
|
|
50
|
+
|
|
51
|
+
### 📡 RSS/Atom Feed Tracking
|
|
52
|
+
- Subscribe, refresh, browse RSS/Atom feeds
|
|
53
|
+
- Auto-discovery: finds feed URLs from website URLs
|
|
54
|
+
- OPML import/export for migration
|
|
55
|
+
|
|
56
|
+
### 🌐 Webpage Feed Tracking
|
|
57
|
+
- **Page snapshot**: Fetch HTML, extract readable content, detect changes by content hash
|
|
58
|
+
- **List extraction**: CSS selectors to extract article lists from news sites
|
|
59
|
+
- **Site parser**: Named parsers for specific websites (extensible)
|
|
60
|
+
|
|
61
|
+
### ❤️ Health Tracking (merged into feed record)
|
|
62
|
+
- `consecutive_failures`: auto-incremented on each failed fetch
|
|
63
|
+
- `last_error`: stores the last error message
|
|
64
|
+
- `auto_disabled`: feed auto-disabled after 10 consecutive failures
|
|
65
|
+
- `rss_check_health`: inspect + optional live revalidation
|
|
66
|
+
- `rss_list_feeds`: health status visible in the feed list
|
|
67
|
+
|
|
68
|
+
### 💾 Storage
|
|
69
|
+
- SQLite at `~/.feed-all/data.db`
|
|
70
|
+
- 3 tables: `feeds` (with health fields), `entries`, `entry_content`
|
|
71
|
+
- Deduplication by URL hash — no duplicate entries
|
|
72
|
+
|
|
73
|
+
## Comparison with Other RSS MCP Servers
|
|
74
|
+
|
|
75
|
+
| Feature | **feed-all** (this) | feed-mcp (Go) | rss-mcp (TS) | mcp_rss (TS) |
|
|
76
|
+
|---------|:-------------------:|:-------------:|:------------:|:------------:|
|
|
77
|
+
| RSS/Atom | ✅ | ✅ | ✅ | ✅ |
|
|
78
|
+
| Webpage snapshot | **✅** | ❌ | ❌ | ❌ |
|
|
79
|
+
| Webpage list extraction | **✅** | ❌ | ❌ | ❌ |
|
|
80
|
+
| Health tracking + auto-disable | **✅** | ❌ | ❌ | ❌ |
|
|
81
|
+
| Persistent SQLite storage | **✅** | cache only | none | MySQL |
|
|
82
|
+
| OPML import/export | ✅ | ✅ | ❌ | ✅ |
|
|
83
|
+
| Two-pass fetch (preview) | **✅** | ✅ | ❌ | ❌ |
|
|
84
|
+
| Mark read / unread | **✅** | ❌ | ❌ | ❌ |
|
|
85
|
+
| Health check tool | **✅** | ❌ | ❌ | ❌ |
|
|
86
|
+
| Update feed settings | **✅** | ❌ | ❌ | ❌ |
|
|
87
|
+
| Language | Python | Go | TypeScript | TypeScript |
|
|
88
|
+
| Install | `uvx` / `pip` | Docker / brew | `npx` | `npx` |
|
|
89
|
+
|
|
90
|
+
## Dependencies
|
|
91
|
+
|
|
92
|
+
- `feedparser` — RSS/Atom parsing
|
|
93
|
+
- `httpx` — Async HTTP client
|
|
94
|
+
- `mcp` — Model Context Protocol server
|
|
95
|
+
- `pydantic` — Data validation
|
|
96
|
+
- `beautifulsoup4` + `lxml` — HTML parsing (webpage feeds)
|
|
97
|
+
|
|
98
|
+
## Storage
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
~/.feed-all/data.db (SQLite)
|
|
102
|
+
|
|
103
|
+
feeds (id, name, url, type, refresh_enabled, check_interval_units,
|
|
104
|
+
last_polled_at, consecutive_failures, last_error, auto_disabled, ...)
|
|
105
|
+
entries (id, feed_id, title, url, published_at, content, preview, is_read, entry_type)
|
|
106
|
+
entry_content (id, entry_id, url, content, summary)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -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,20 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
feed_all.egg-info/PKG-INFO
|
|
4
|
+
feed_all.egg-info/SOURCES.txt
|
|
5
|
+
feed_all.egg-info/dependency_links.txt
|
|
6
|
+
feed_all.egg-info/entry_points.txt
|
|
7
|
+
feed_all.egg-info/requires.txt
|
|
8
|
+
feed_all.egg-info/top_level.txt
|
|
9
|
+
rss_plugin/__init__.py
|
|
10
|
+
rss_plugin/__main__.py
|
|
11
|
+
rss_plugin/discovery.py
|
|
12
|
+
rss_plugin/fetcher.py
|
|
13
|
+
rss_plugin/models.py
|
|
14
|
+
rss_plugin/server.py
|
|
15
|
+
rss_plugin/storage.py
|
|
16
|
+
rss_plugin/utils.py
|
|
17
|
+
rss_plugin/webpage_parsers/__init__.py
|
|
18
|
+
rss_plugin/webpage_parsers/base.py
|
|
19
|
+
rss_plugin/webpage_parsers/selector_parser.py
|
|
20
|
+
rss_plugin/webpage_parsers/site_registry.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "feed-all"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "All-in-one RSS/feed MCP Plugin — subscribe, fetch, browse, manage feeds, track webpage changes, health check, OPML import/export"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["rss", "feed", "mcp", "claude-code", "cline"]
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "EntroFeed Team", email = "wuyuezhang1984@gmail.com"}
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"feedparser>=6.0.0",
|
|
19
|
+
"httpx>=0.25.0",
|
|
20
|
+
"mcp>=1.0.0",
|
|
21
|
+
"pydantic>=2.0",
|
|
22
|
+
"beautifulsoup4>=4.12.0",
|
|
23
|
+
"lxml>=5.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
feed-all = "rss_plugin.server:run_server"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["."]
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
python_version = "3.11"
|
|
34
|
+
ignore_missing_imports = true
|
|
@@ -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
|
+
)
|
|
@@ -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
|