diff --git a/changedetectionio/blueprint/rss/_util.py b/changedetectionio/blueprint/rss/_util.py new file mode 100644 index 000000000..47c0ef885 --- /dev/null +++ b/changedetectionio/blueprint/rss/_util.py @@ -0,0 +1,95 @@ +""" +Utility functions for RSS feed generation. +""" + +from changedetectionio.jinja2_custom import render as jinja_render +from changedetectionio.notification.handler import apply_service_tweaks +from loguru import logger +import re + + +BAD_CHARS_REGEX = r'[\x00-\x08\x0B\x0C\x0E-\x1F]' + + +def scan_invalid_chars_in_rss(content): + """ + Scan for invalid characters in RSS content. + Returns True if invalid characters are found. + """ + for match in re.finditer(BAD_CHARS_REGEX, content): + i = match.start() + bad_char = content[i] + hex_value = f"0x{ord(bad_char):02x}" + # Grab context + start = max(0, i - 20) + end = min(len(content), i + 21) + context = content[start:end].replace('\n', '\\n').replace('\r', '\\r') + logger.warning(f"Invalid char {hex_value} at pos {i}: ...{context}...") + # First match is enough + return True + + return False + + +def clean_entry_content(content): + """ + Remove invalid characters from RSS content. + """ + cleaned = re.sub(BAD_CHARS_REGEX, '', content) + return cleaned + + +def generate_watch_guid(watch): + """ + Generate a unique GUID for a watch RSS entry. + """ + return f"{watch['uuid']}/{watch.last_changed}" + + +def generate_watch_diff_content(watch, dates, rss_content_format, datastore): + """ + Generate HTML diff content for a watch given its history dates. + Returns tuple of (content, watch_label). + + Args: + watch: The watch object + dates: List of history snapshot dates + rss_content_format: Format for RSS content (html or text) + datastore: The ChangeDetectionStore instance + + Returns: + Tuple of (content, watch_label) - the rendered HTML content and watch label + """ + from changedetectionio import diff + + # Same logic as watch-overview.html + if datastore.data['settings']['application']['ui'].get('use_page_title_in_list') or watch.get('use_page_title_in_list'): + watch_label = watch.label + else: + watch_label = watch.get('url') + + try: + html_diff = diff.render_diff( + previous_version_file_contents=watch.get_history_snapshot(timestamp=dates[-2]), + newest_version_file_contents=watch.get_history_snapshot(timestamp=dates[-1]), + include_equal=False + ) + + requested_output_format = datastore.data['settings']['application'].get('rss_content_format') + url, html_diff, n_title = apply_service_tweaks(url='', n_body=html_diff, n_title=None, requested_output_format=requested_output_format) + + except FileNotFoundError as e: + html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found." + + # @note: We use
 because nearly all RSS readers render only HTML (Thunderbird for example cant do just plaintext)
+    rss_template = "
{{watch_label}} had a change.\n\n{{html_diff}}\n
" + if 'html' in rss_content_format: + rss_template = "\n

{{watch_label}}

\n

{{html_diff}}

\n\n" + + content = jinja_render(template_str=rss_template, watch_label=watch_label, html_diff=html_diff, watch_url=watch.link) + + # Out of range chars could also break feedgen + if scan_invalid_chars_in_rss(content): + content = clean_entry_content(content) + + return content, watch_label diff --git a/changedetectionio/blueprint/rss/main_feed.py b/changedetectionio/blueprint/rss/main_feed.py new file mode 100644 index 000000000..bb35240a6 --- /dev/null +++ b/changedetectionio/blueprint/rss/main_feed.py @@ -0,0 +1,101 @@ +from flask import make_response, request, url_for, redirect +from feedgen.feed import FeedGenerator +from loguru import logger +import datetime +import pytz +import time + +from ._util import generate_watch_guid, generate_watch_diff_content + + +def construct_main_feed_routes(rss_blueprint, datastore): + """ + Construct the main RSS feed routes. + + Args: + rss_blueprint: The Flask blueprint to add routes to + datastore: The ChangeDetectionStore instance + """ + + # Some RSS reader situations ended up with rss/ (forward slash after RSS) due + # to some earlier blueprint rerouting work, it should goto feed. + @rss_blueprint.route("/", methods=['GET']) + def extraslash(): + return redirect(url_for('rss.feed')) + + # Import the login decorator if needed + # from changedetectionio.auth_decorator import login_optionally_required + @rss_blueprint.route("", methods=['GET']) + def feed(): + now = time.time() + # Always requires token set + app_rss_token = datastore.data['settings']['application'].get('rss_access_token') + rss_url_token = request.args.get('token') + rss_content_format = datastore.data['settings']['application'].get('rss_content_format') + + if rss_url_token != app_rss_token: + return "Access denied, bad token", 403 + + limit_tag = request.args.get('tag', '').lower().strip() + # Be sure limit_tag is a uuid + for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): + if limit_tag == tag.get('title', '').lower().strip(): + limit_tag = uuid + + # Sort by last_changed and add the uuid which is usually the key.. + sorted_watches = [] + + # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away + for uuid, watch in datastore.data['watching'].items(): + # @todo tag notification_muted skip also (improve Watch model) + if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'): + continue + if limit_tag and not limit_tag in watch['tags']: + continue + watch['uuid'] = uuid + sorted_watches.append(watch) + + sorted_watches.sort(key=lambda x: x.last_changed, reverse=False) + + fg = FeedGenerator() + fg.title('changedetection.io') + fg.description('Feed description') + fg.link(href='https://changedetection.io') + + for watch in sorted_watches: + + dates = list(watch.history.keys()) + # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected. + if len(dates) < 2: + continue + + if not watch.viewed: + # Re #239 - GUID needs to be individual for each event + # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) + guid = generate_watch_guid(watch) + fe = fg.add_entry() + + # Include a link to the diff page, they will have to login here to see if password protection is enabled. + # Description is the page you watch, link takes you to the diff JS UI page + # Dict val base_url will get overriden with the env var if it is set. + ext_base_url = datastore.data['settings']['application'].get('active_base_url') + # @todo fix + + # Because we are called via whatever web server, flask should figure out the right path ( + diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)} + + fe.link(link=diff_link) + + content, watch_label = generate_watch_diff_content(watch, dates, rss_content_format, datastore) + + fe.title(title=watch_label) + fe.content(content=content, type='CDATA') + fe.guid(guid, permalink=False) + dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key)) + dt = dt.replace(tzinfo=pytz.UTC) + fe.pubDate(dt) + + response = make_response(fg.rss_str()) + response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8') + logger.trace(f"RSS generated in {time.time() - now:.3f}s") + return response diff --git a/changedetectionio/blueprint/rss/single_watch.py b/changedetectionio/blueprint/rss/single_watch.py new file mode 100644 index 000000000..84b7bcc85 --- /dev/null +++ b/changedetectionio/blueprint/rss/single_watch.py @@ -0,0 +1,71 @@ +from flask import make_response, request, url_for +from feedgen.feed import FeedGenerator +import datetime +import pytz + +from ._util import generate_watch_guid, generate_watch_diff_content + + +def construct_single_watch_routes(rss_blueprint, datastore): + """ + Construct RSS feed routes for single watches. + + Args: + rss_blueprint: The Flask blueprint to add routes to + datastore: The ChangeDetectionStore instance + """ + + @rss_blueprint.route("/watch/", methods=['GET']) + def rss_single_watch(uuid): + """ + Display the most recent change for a single watch as RSS feed. + Returns RSS XML with a single entry showing the diff between the last two snapshots. + """ + # Always requires token set + app_rss_token = datastore.data['settings']['application'].get('rss_access_token') + rss_url_token = request.args.get('token') + rss_content_format = datastore.data['settings']['application'].get('rss_content_format') + + if rss_url_token != app_rss_token: + return "Access denied, bad token", 403 + + # Get the watch by UUID + watch = datastore.data['watching'].get(uuid) + if not watch: + return f"Watch with UUID {uuid} not found", 404 + + # Check if watch has at least 2 history snapshots + dates = list(watch.history.keys()) + if len(dates) < 2: + return f"Watch {uuid} does not have enough history snapshots to show changes (need at least 2)", 400 + + # Add uuid to watch for proper functioning + watch['uuid'] = uuid + + # Generate the diff content using the shared helper function + content, watch_label = generate_watch_diff_content(watch, dates, rss_content_format, datastore) + + # Create RSS feed with single entry + fg = FeedGenerator() + fg.title(f'changedetection.io - {watch.label}') + fg.description('Changes') + fg.link(href='https://changedetection.io') + + # Add single entry for this watch + guid = generate_watch_guid(watch) + fe = fg.add_entry() + + # Include a link to the diff page + diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)} + fe.link(link=diff_link) + + fe.title(title=watch_label) + fe.content(content=content, type='CDATA') + fe.guid(guid, permalink=False) + dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key)) + dt = dt.replace(tzinfo=pytz.UTC) + fe.pubDate(dt) + + response = make_response(fg.rss_str()) + response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8') + return response diff --git a/changedetectionio/blueprint/rss/tag.py b/changedetectionio/blueprint/rss/tag.py new file mode 100644 index 000000000..0d6fa311d --- /dev/null +++ b/changedetectionio/blueprint/rss/tag.py @@ -0,0 +1,86 @@ +from flask import make_response, request, url_for +from feedgen.feed import FeedGenerator +import datetime +import pytz + + +from ._util import generate_watch_guid, generate_watch_diff_content + + +def construct_tag_routes(rss_blueprint, datastore): + """ + Construct RSS feed routes for tags. + + Args: + rss_blueprint: The Flask blueprint to add routes to + datastore: The ChangeDetectionStore instance + """ + + @rss_blueprint.route("/tag/", methods=['GET']) + def rss_tag_feed(tag_uuid): + """ + Display an RSS feed for all unviewed watches that belong to a specific tag. + Returns RSS XML with entries for each unviewed watch with sufficient history. + """ + # Always requires token set + app_rss_token = datastore.data['settings']['application'].get('rss_access_token') + rss_url_token = request.args.get('token') + rss_content_format = datastore.data['settings']['application'].get('rss_content_format') + + if rss_url_token != app_rss_token: + return "Access denied, bad token", 403 + + # Verify tag exists + tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid) + if not tag: + return f"Tag with UUID {tag_uuid} not found", 404 + + tag_title = tag.get('title', 'Unknown Tag') + + # Create RSS feed + fg = FeedGenerator() + fg.title(f'changedetection.io - {tag_title}') + fg.description(f'Changes for watches tagged with {tag_title}') + fg.link(href='https://changedetection.io') + + # Find all watches with this tag + for uuid, watch in datastore.data['watching'].items(): + # Skip if watch doesn't have this tag + if tag_uuid not in watch.get('tags', []): + continue + + # Skip muted watches if configured + if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'): + continue + + # Check if watch has at least 2 history snapshots + dates = list(watch.history.keys()) + if len(dates) < 2: + continue + + # Only include unviewed watches + if not watch.viewed: + # Add uuid to watch for proper functioning + watch['uuid'] = uuid + + # Generate GUID for this entry + guid = generate_watch_guid(watch) + fe = fg.add_entry() + + # Include a link to the diff page + diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)} + fe.link(link=diff_link) + + # Generate diff content + content, watch_label = generate_watch_diff_content(watch, dates, rss_content_format, datastore) + + fe.title(title=watch_label) + fe.content(content=content, type='CDATA') + fe.guid(guid, permalink=False) + dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key)) + dt = dt.replace(tzinfo=pytz.UTC) + fe.pubDate(dt) + + response = make_response(fg.rss_str()) + response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8') + return response diff --git a/changedetectionio/tests/test_rss_group.py b/changedetectionio/tests/test_rss_group.py new file mode 100644 index 000000000..12d453de4 --- /dev/null +++ b/changedetectionio/tests/test_rss_group.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 + +import time +from flask import url_for +from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches +import os + + +def set_original_response(datastore_path): + test_return_data = """ + + Some initial text
+

Watch 1 content

+

Watch 2 content

+ + + """ + + with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f: + f.write(test_return_data) + return None + + +def set_modified_response(datastore_path): + test_return_data = """ + + Some initial text
+

Watch 1 content MODIFIED

+

Watch 2 content CHANGED

+ + + """ + + with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f: + f.write(test_return_data) + return None + + +def test_rss_group(client, live_server, measure_memory_usage, datastore_path): + """ + Test that RSS feed for a specific tag/group shows only watches in that group + and displays changes correctly. + """ + + set_original_response(datastore_path=datastore_path) + + # Create a tag/group + res = client.post( + url_for("tags.form_tag_add"), + data={"name": "test-rss-group"}, + follow_redirects=True + ) + assert b"Tag added" in res.data + assert b"test-rss-group" in res.data + + # Get the tag UUID + tag_uuid = get_UUID_for_tag_name(client, name="test-rss-group") + assert tag_uuid is not None + + # Add first watch with the tag + test_url_1 = url_for('test_endpoint', _external=True) + "?watch=1" + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url_1, "tags": 'test-rss-group'}, + follow_redirects=True + ) + assert b"Watch added" in res.data + + # Add second watch with the tag + test_url_2 = url_for('test_endpoint', _external=True) + "?watch=2" + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url_2, "tags": 'test-rss-group'}, + follow_redirects=True + ) + assert b"Watch added" in res.data + + # Add a third watch WITHOUT the tag (should not appear in RSS) + test_url_3 = url_for('test_endpoint', _external=True) + "?watch=3" + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url_3, "tags": 'other-tag'}, + follow_redirects=True + ) + assert b"Watch added" in res.data + + # Wait for initial checks to complete + wait_for_all_checks(client) + + # Trigger a change + set_modified_response(datastore_path=datastore_path) + + # Recheck all watches + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # Get RSS token + rss_token = extract_rss_token_from_UI(client) + assert rss_token is not None + + # Request RSS feed for the specific tag/group using the new endpoint + res = client.get( + url_for("rss.rss_tag_feed", tag_uuid=tag_uuid, token=rss_token, _external=True), + follow_redirects=True + ) + + # Verify response is successful + assert res.status_code == 200 + assert b"