Adding 'rss reader mode'

This commit is contained in:
dgtlmoon
2025-10-10 16:28:20 +02:00
parent b59ce190ac
commit a72b13964d
4 changed files with 152 additions and 2 deletions
@@ -75,6 +75,9 @@
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }}
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.rss_reader_mode) }}
</div>
<div class="pure-control-group">
{{ render_field(form.application.form.rss_content_format) }}
<span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span>
+4
View File
@@ -940,6 +940,10 @@ class globalSettingsApplicationForm(commonSettingsForm):
strip_ignored_lines = BooleanField('Strip ignored lines')
rss_hide_muted_watches = BooleanField('Hide muted watches from RSS feed', default=True,
validators=[validators.Optional()])
rss_reader_mode = BooleanField('RSS Reader mode - Format contents of RSS feeds', default=False,
validators=[validators.Optional()])
filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification',
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
@@ -228,8 +228,21 @@ class ContentProcessor:
self.datastore = datastore
def preprocess_rss(self, content):
"""Convert CDATA/comments in RSS to usable text."""
return cdata_in_document_to_text(html_content=content)
"""
Convert CDATA/comments in RSS to usable text.
Supports two RSS processing modes:
- 'default': Inline CDATA replacement (original behavior)
- 'formatted': Format RSS items with title, link, guid, pubDate, and description (CDATA unmarked)
"""
from changedetectionio import rss_tools
rss_mode = self.datastore.data["settings"]["application"].get("rss_reader_mode")
if rss_mode:
# Format RSS items nicely with CDATA content unmarked and converted to text
return rss_tools.format_rss_items(content)
else:
# Default: Original inline CDATA replacement
return cdata_in_document_to_text(html_content=content)
def preprocess_pdf(self, raw_content):
"""Convert PDF to HTML using external tool."""
+130
View File
@@ -0,0 +1,130 @@
"""
RSS/Atom feed processing tools for changedetection.io
"""
from loguru import logger
import re
def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
"""
Process CDATA sections in HTML/XML content - inline replacement.
Args:
html_content: The HTML/XML content to process
render_anchor_tag_content: Whether to render anchor tag content
Returns:
Processed HTML/XML content with CDATA sections replaced inline
"""
from xml.sax.saxutils import escape as xml_escape
from .html_tools import html_to_text
pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>'
def repl(m):
text = m.group(1)
return xml_escape(html_to_text(html_content=text, render_anchor_tag_content=render_anchor_tag_content)).strip()
return re.sub(pattern, repl, html_content)
def format_rss_items(rss_content: str, render_anchor_tag_content=False) -> str:
"""
Format RSS/Atom feed items in a readable text format.
Converts RSS <item> or Atom <entry> elements to formatted text with:
- <title> → <h1>Title</h1>
- <link> → Link: [url]
- <guid> → Guid: [id]
- <pubDate> → PubDate: [date]
- <description> or <content> → Full html_to_text conversion
Args:
rss_content: The RSS/Atom feed content
render_anchor_tag_content: Whether to render anchor tag content in descriptions
Returns:
Formatted HTML content ready for html_to_text conversion
"""
from lxml import etree
from xml.sax.saxutils import escape as xml_escape
try:
# Parse with XMLParser to preserve CDATA
parser = etree.XMLParser(strip_cdata=False)
root = etree.fromstring(rss_content.encode('utf-8'), parser=parser)
formatted_items = []
# Handle both RSS (<item>) and Atom (<entry>) formats
items = root.xpath('//item | //entry')
for item in items:
item_parts = []
# Extract title
title_elem = item.find('title')
if title_elem is not None and title_elem.text:
# Convert CDATA in title if present
title_text = etree.tostring(title_elem, encoding='unicode', method='html')
title_text = cdata_in_document_to_text(title_text)
# Strip the title tags and get just the content
title_clean = re.sub(r'</?title[^>]*>', '', title_text).strip()
if title_clean:
item_parts.append(f'<h1>{xml_escape(title_clean)}</h1>')
# Extract link
link_elem = item.find('link')
if link_elem is not None:
link_text = link_elem.text if link_elem.text else link_elem.get('href', '')
if link_text:
item_parts.append(f'Link: {xml_escape(link_text.strip())}')
# Extract guid/id
guid_elem = item.find('guid')
if guid_elem is None:
guid_elem = item.find('id')
if guid_elem is not None and guid_elem.text:
item_parts.append(f'Guid: {xml_escape(guid_elem.text.strip())}')
# Extract pubDate/published/updated
date_elem = item.find('pubDate')
if date_elem is None:
date_elem = item.find('published')
if date_elem is None:
date_elem = item.find('updated')
if date_elem is not None and date_elem.text:
item_parts.append(f'PubDate: {xml_escape(date_elem.text.strip())}')
# Extract description/content/summary
desc_elem = item.find('description')
if desc_elem is None:
desc_elem = item.find('content')
if desc_elem is None:
desc_elem = item.find('summary')
if desc_elem is not None:
# Get the full element as string to preserve CDATA and nested HTML
desc_html = etree.tostring(desc_elem, encoding='unicode', method='html')
# First process CDATA sections
desc_processed = cdata_in_document_to_text(desc_html, render_anchor_tag_content=render_anchor_tag_content)
# Strip the outer description/content/summary tags
desc_processed = re.sub(r'^<(description|content|summary)[^>]*>', '', desc_processed)
desc_processed = re.sub(r'</(description|content|summary)>$', '', desc_processed)
if desc_processed.strip():
item_parts.append(desc_processed)
# Join all parts of this item
if item_parts:
formatted_items.append('\n'.join(item_parts))
# Join all items with <br><br><hr>
return '<br><br><hr>'.join(formatted_items)
except Exception as e:
logger.warning(f"Error formatting RSS items: {str(e)}")
# Fall back to original content
return rss_content