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.
@@ -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,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,2 @@
1
+ [console_scripts]
2
+ feed-all = rss_plugin.server:run_server
@@ -0,0 +1,6 @@
1
+ feedparser>=6.0.0
2
+ httpx>=0.25.0
3
+ mcp>=1.0.0
4
+ pydantic>=2.0
5
+ beautifulsoup4>=4.12.0
6
+ lxml>=5.0.0
@@ -0,0 +1,2 @@
1
+ dist
2
+ rss_plugin
@@ -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,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"
@@ -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
+ )
@@ -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