From 793f67db42bb93cfc3d64a22eba44f64ec18c7a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joakim=20Hells=C3=A9n?=
Date: Mon, 20 Jul 2026 06:02:25 +0200
Subject: [PATCH] Add support for extensions
---
Dockerfile | 6 +-
README.md | 10 +-
discord_rss_bot/custom_filters.py | 55 -
discord_rss_bot/custom_message.py | 105 +-
discord_rss_bot/extensions/README.md | 157 +++
discord_rss_bot/extensions/__init__.py | 32 +
discord_rss_bot/extensions/base.py | 127 ++
discord_rss_bot/extensions/discovery.py | 191 +++
discord_rss_bot/extensions/hoyolab.py | 331 +++++
.../extensions/jwplayer_thumbnail.py | 285 +++++
discord_rss_bot/extensions/runner.py | 239 ++++
discord_rss_bot/extensions/steam.py | 276 ++++
discord_rss_bot/extensions/storage.py | 68 +
discord_rss_bot/extensions/wordpress.py | 116 ++
discord_rss_bot/extensions/youtube.py | 110 ++
discord_rss_bot/feeds.py | 260 +---
discord_rss_bot/filter/blacklist.py | 48 -
discord_rss_bot/filter/evaluator.py | 45 +
discord_rss_bot/filter/whitelist.py | 48 -
discord_rss_bot/hoyolab_api.py | 222 ----
discord_rss_bot/html_format.py | 77 ++
discord_rss_bot/main.py | 185 ++-
discord_rss_bot/settings.py | 4 +
discord_rss_bot/templates/custom.html | 28 +-
discord_rss_bot/templates/embed.html | 47 +-
discord_rss_bot/templates/extensions.html | 68 +
discord_rss_bot/templates/feed.html | 163 ++-
discord_rss_bot/templates/index.html | 32 +-
docker-compose.yml | 7 +
pyproject.toml | 2 +-
tests/test_blacklist.py | 4 +-
tests/test_custom_filter.py | 6 +-
tests/test_custom_message.py | 4 -
tests/test_extensions.py | 1137 +++++++++++++++++
tests/test_feeds.py | 211 +--
tests/test_hoyolab_api.py | 248 ----
tests/test_main.py | 19 +-
tests/test_settings.py | 2 +-
tests/test_whitelist.py | 4 +-
39 files changed, 3670 insertions(+), 1309 deletions(-)
delete mode 100644 discord_rss_bot/custom_filters.py
create mode 100644 discord_rss_bot/extensions/README.md
create mode 100644 discord_rss_bot/extensions/__init__.py
create mode 100644 discord_rss_bot/extensions/base.py
create mode 100644 discord_rss_bot/extensions/discovery.py
create mode 100644 discord_rss_bot/extensions/hoyolab.py
create mode 100644 discord_rss_bot/extensions/jwplayer_thumbnail.py
create mode 100644 discord_rss_bot/extensions/runner.py
create mode 100644 discord_rss_bot/extensions/steam.py
create mode 100644 discord_rss_bot/extensions/storage.py
create mode 100644 discord_rss_bot/extensions/wordpress.py
create mode 100644 discord_rss_bot/extensions/youtube.py
delete mode 100644 discord_rss_bot/filter/blacklist.py
delete mode 100644 discord_rss_bot/filter/whitelist.py
delete mode 100644 discord_rss_bot/hoyolab_api.py
create mode 100644 discord_rss_bot/html_format.py
create mode 100644 discord_rss_bot/templates/extensions.html
create mode 100644 tests/test_extensions.py
delete mode 100644 tests/test_hoyolab_api.py
diff --git a/Dockerfile b/Dockerfile
index f27eed9..655b06f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN useradd --create-home botuser && \
- mkdir -p /home/botuser/discord-rss-bot/ /home/botuser/.local/share/discord_rss_bot/ && \
+ mkdir -p /home/botuser/discord-rss-bot/ /home/botuser/.local/share/discord_rss_bot/ /home/botuser/extensions/ && \
chown -R botuser:botuser /home/botuser/
USER botuser
WORKDIR /home/botuser/discord-rss-bot
@@ -11,5 +11,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY --chown=botuser:botuser discord_rss_bot/ /home/botuser/discord-rss-bot/discord_rss_bot/
EXPOSE 5000
VOLUME ["/home/botuser/.local/share/discord_rss_bot/"]
+
+# Extensions directory — bind-mount your own plugins here.
+ENV EXTENSIONS_DIR=/home/botuser/extensions
+
HEALTHCHECK --interval=10m --timeout=5s CMD ["uv", "run", "./discord_rss_bot/healthcheck.py"]
CMD ["uv", "run", "uvicorn", "discord_rss_bot.main:app", "--host=0.0.0.0", "--port=5000", "--proxy-headers", "--forwarded-allow-ips='*'", "--log-level", "debug"]
diff --git a/README.md b/README.md
index 776437f..de243db 100644
--- a/README.md
+++ b/README.md
@@ -15,12 +15,10 @@ Discord: TheLovinator#9276
- Regex filters for RSS feeds.
- Blacklist/whitelist words in the title/description/author/etc.
- Set different update frequencies for each feed or use a global default.
-- Gets extra information from APIs if available, currently for:
- - [https://feeds.c3kay.de/](https://feeds.c3kay.de/)
- - Genshin Impact News
- - Honkai Impact 3rd News
- - Honkai Starrail News
- - Zenless Zone Zero News
+- Extensions that pulls extra data from feeds.
+ - Built-in extensions for Steam, YouTube, Hoyolab, WordPress, and JWPlayer.
+ - Add your own by dropping a `.py` file in `extensions/` ([docs](discord_rss_bot/extensions/README.md)).
+ - Feel free to open a GitHub issue if you need one.
## Installation
diff --git a/discord_rss_bot/custom_filters.py b/discord_rss_bot/custom_filters.py
deleted file mode 100644
index 4e060f2..0000000
--- a/discord_rss_bot/custom_filters.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from __future__ import annotations
-
-import urllib.parse
-from functools import lru_cache
-from typing import TYPE_CHECKING
-
-from discord_rss_bot.filter.evaluator import get_entry_filter_decision_from_reader
-
-if TYPE_CHECKING:
- from reader import Entry
- from reader import Reader
-
-
-@lru_cache
-def encode_url(url_to_quote: str) -> str:
- """%-escape the URL so it can be used in a URL.
-
- If we didn't do this, we couldn't go to feeds with a ? in the URL.
- You can use this in templates with {{ url | encode_url }}.
-
- Args:
- url_to_quote: The url to encode.
-
- Returns:
- The encoded url.
- """
- return urllib.parse.quote(string=url_to_quote) if url_to_quote else ""
-
-
-def entry_is_whitelisted(entry_to_check: Entry, reader: Reader) -> bool:
- """Check if the entry is whitelisted.
-
- Args:
- entry_to_check: The feed to check.
- reader: Custom Reader instance.
-
- Returns:
- bool: True if the feed is whitelisted, False otherwise.
-
- """
- return get_entry_filter_decision_from_reader(reader, entry_to_check).whitelist_match is not None
-
-
-def entry_is_blacklisted(entry_to_check: Entry, reader: Reader) -> bool:
- """Check if the entry is blacklisted.
-
- Args:
- entry_to_check: The feed to check.
- reader: Custom Reader instance.
-
- Returns:
- bool: True if the feed is blacklisted, False otherwise.
-
- """
- return get_entry_filter_decision_from_reader(reader, entry_to_check).blacklist_match is not None
diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py
index 944ba67..2ec3a17 100644
--- a/discord_rss_bot/custom_message.py
+++ b/discord_rss_bot/custom_message.py
@@ -1,16 +1,15 @@
from __future__ import annotations
-import html
import json
import logging
-import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bs4 import BeautifulSoup
from bs4 import Tag
-from markdownify import markdownify
+from discord_rss_bot.extensions import run_extensions
+from discord_rss_bot.html_format import format_entry_html_for_discord
from discord_rss_bot.is_url_valid import is_url_valid
if TYPE_CHECKING:
@@ -23,8 +22,6 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__)
-DISCORD_TIMESTAMP_TAG_RE: re.Pattern[str] = re.compile(r"")
-
# Discord webhook username: nickname rules, max 80 chars; no "clyde"/"discord" substrings.
DISCORD_WEBHOOK_USERNAME_MAX_LENGTH: int = 80
DISCORD_WEBHOOK_USERNAME_FORBIDDEN_CHARS: frozenset[str] = frozenset("@#:`")
@@ -43,7 +40,7 @@ class CustomEmbed:
thumbnail_url: str = ""
footer_text: str = ""
footer_icon_url: str = ""
- show_steam_game_icon_in_thumbnail: bool = False
+ show_steam_game_icon_in_thumbnail: bool = True
def try_to_replace(custom_message: str, template: str, replace_with: str) -> str:
@@ -64,68 +61,6 @@ def try_to_replace(custom_message: str, template: str, replace_with: str) -> str
return custom_message
-def _preserve_discord_timestamp_tags(text: str) -> tuple[str, dict[str, str]]:
- """Replace Discord timestamp tags with placeholders before markdown conversion.
-
- Args:
- text: The text to replace tags in.
-
- Returns:
- The text with Discord timestamp tags replaced by placeholders and a mapping of placeholders to original tags.
- """
- replacements: dict[str, str] = {}
-
- def replace_match(match: re.Match[str]) -> str:
- placeholder: str = f"DISCORDTIMESTAMPPLACEHOLDER{len(replacements)}"
- replacements[placeholder] = match.group(0)
- return placeholder
-
- return DISCORD_TIMESTAMP_TAG_RE.sub(replace_match, text), replacements
-
-
-def _restore_discord_timestamp_tags(text: str, replacements: dict[str, str]) -> str:
- """Restore preserved Discord timestamp tags after markdown conversion.
-
- Args:
- text: The text to restore tags in.
- replacements: A mapping of placeholders to original Discord timestamp tags.
-
- Returns:
- The text with placeholders replaced by the original Discord timestamp tags.
- """
- for placeholder, original_value in replacements.items():
- text = text.replace(placeholder, original_value)
- return text
-
-
-def format_entry_html_for_discord(text: str) -> str:
- """Convert entry HTML to Discord-friendly markdown while preserving Discord timestamp tags.
-
- Args:
- text: The HTML text to format.
-
- Returns:
- The formatted text with Discord timestamp tags preserved.
- """
- if not text:
- return ""
-
- unescaped_text: str = html.unescape(text)
- protected_text, replacements = _preserve_discord_timestamp_tags(unescaped_text)
- formatted_text: str = markdownify(
- html=protected_text,
- strip=["img", "table", "td", "tr", "tbody", "thead"],
- escape_misc=False,
- heading_style="ATX",
- )
-
- if "[https://" in formatted_text or "[https://www." in formatted_text:
- formatted_text = formatted_text.replace("[https://", "[")
- formatted_text = formatted_text.replace("[https://www.", "[")
-
- return _restore_discord_timestamp_tags(formatted_text, replacements)
-
-
def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
"""Replace tags in custom_message.
@@ -191,6 +126,12 @@ def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
{"{{image_1}}": first_image},
]
+ # Compute extension variables (handled separately so they can use
+ # the already-computed values above without ordering issues).
+ extension_vars: dict[str, str] = run_extensions(entry, reader)
+ for var_name, var_value in extension_vars.items():
+ list_of_replacements.append({f"{{{{{var_name}}}}}": var_value})
+
for replacement in list_of_replacements:
for template, replace_with in replacement.items():
if not isinstance(replace_with, str):
@@ -353,6 +294,12 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb
{"{{entry_updated}}": entry_updated or ""},
{"{{image_1}}": first_image or ""},
]
+ # Compute extension variables (handled separately so they can use
+ # the already-computed values above without ordering issues).
+ extension_vars: dict[str, str] = run_extensions(entry, reader)
+ for var_name, var_value in extension_vars.items():
+ list_of_replacements.append({f"{{{{{var_name}}}}}": var_value})
+
for replacement in list_of_replacements:
for template, replace_with in replacement.items():
_replace_embed_tags(embed, template, replace_with)
@@ -547,25 +494,10 @@ def get_embed(reader: Reader, feed: Feed) -> CustomEmbed:
thumbnail_url="",
footer_text="",
footer_icon_url="",
- show_steam_game_icon_in_thumbnail=False,
+ show_steam_game_icon_in_thumbnail=True,
)
-def coerce_embed_bool(value: object) -> bool:
- """Normalize stored embed booleans from JSON or form-like values.
-
- Returns:
- The coerced boolean value.
- """
- if isinstance(value, bool):
- return value
- if isinstance(value, int):
- return value != 0
- if isinstance(value, str):
- return value.strip().lower() in {"1", "true", "on", "yes"}
- return False
-
-
def get_embed_data(embed_data: dict[str, str | int | bool]) -> CustomEmbed:
"""Get embed data from embed_data.
@@ -585,8 +517,9 @@ def get_embed_data(embed_data: dict[str, str | int | bool]) -> CustomEmbed:
thumbnail_url: str = str(embed_data.get("thumbnail_url", ""))
footer_text: str = str(embed_data.get("footer_text", ""))
footer_icon_url: str = str(embed_data.get("footer_icon_url", ""))
- show_steam_game_icon_in_thumbnail: bool = coerce_embed_bool(
- embed_data.get("show_steam_game_icon_in_thumbnail", False),
+ show_steam_game_icon_in_thumbnail: bool = embed_data.get( # pyright: ignore[reportAssignmentType]
+ "show_steam_game_icon_in_thumbnail",
+ True,
)
return CustomEmbed(
diff --git a/discord_rss_bot/extensions/README.md b/discord_rss_bot/extensions/README.md
new file mode 100644
index 0000000..41891fb
--- /dev/null
+++ b/discord_rss_bot/extensions/README.md
@@ -0,0 +1,157 @@
+# Extension System
+
+Extensions are Python plugins that extract data from RSS/Atom entries and/or mutate the Discord webhook payload before messages are sent.
+
+**Two hooks per entry:**
+
+1. **`process_entry()`** → return `{variable_name: string_value}` dict → available as `{{variable_name}}` in templates.
+2. **`modify_webhook()`** → mutate the `DiscordWebhook` (files, embeds, author, etc.) after template replacement.
+
+---
+
+## TL;DR — Make an Extension
+
+Drop a `.py` file in your `extensions/` dir (or `$EXTENSIONS_DIR`). Subclass `FeedExtension`:
+
+```python
+"""
+Word count example — provides {{word_count}} per entry.
+"""
+from __future__ import annotations
+import logging
+from typing import ClassVar, TYPE_CHECKING
+from discord_rss_bot.extensions.base import FeedExtension
+
+if TYPE_CHECKING:
+ from reader import Entry, Reader
+
+logger = logging.getLogger(__name__)
+
+
+class WordCountExtension(FeedExtension):
+ name = "word_count"
+ description = "Counts words in each entry's content."
+ provides_variables: ClassVar[list[str]] = ["word_count"]
+
+ def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]:
+ text = " ".join(
+ c.value for c in (entry.content or []) if hasattr(c, "value") and c.value
+ ) or (entry.summary or "")
+ return {"word_count": str(len(text.split())) if text else "0"}
+```
+
+Then restart the bot, enable it in the web UI's Extensions page, and use `{{word_count}}` in templates.
+
+---
+
+## API Reference
+
+### Class Attributes
+
+| Attribute | Type | Required | Purpose |
+| -------------------------- | --------------------- | -------- | --------------------------------------------------------- |
+| `name` | `ClassVar[str]` | **Yes** | Unique slug for logs, registry, enable/disable |
+| `description` | `ClassVar[str]` | No | Shown in web UI |
+| `provides_variables` | `ClassVar[list[str]]` | No | Template variables this extension produces |
+| `auto_enable_url_patterns` | `ClassVar[list[str]]` | No | Regex patterns; matching feeds auto-enable this extension |
+
+### `process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]` (required)
+
+Called for every entry. Return `{var: value}` pairs. Missing variables from `provides_variables` get filled with `""`.
+
+- `entry.content` — list of `Content` objects (`.value`, `.type`)
+- `entry.summary` — plain-text string
+- `entry.feed.url`, `entry.link`, `entry.title`, `entry.id`, etc.
+
+### `modify_webhook(self, webhook: DiscordWebhook, entry: Entry, reader: Reader) -> DiscordWebhook` (optional)
+
+Called after template replacement. Return modified `webhook` or it unchanged.
+
+```python
+# Content
+webhook.content = "..." # or None to clear
+
+# Author
+webhook.username = "Bot Name"
+webhook.avatar_url = "https://..."
+
+# Embeds
+embed = DiscordEmbed()
+embed.set_title("...")
+embed.set_description("...")
+embed.set_url("https://...")
+embed.set_color(0x00FF00) # or hex str "00FF00"
+embed.set_author(name="...", url="...", icon_url="...")
+embed.set_thumbnail(url="...")
+embed.set_image(url="...")
+embed.set_footer(text="...", icon_url="...")
+embed.add_embed_field(name="Key", value="Value", inline=True)
+embed.set_timestamp(timestamp="1234567890")
+webhook.add_embed(embed)
+webhook.remove_embeds()
+
+# Files
+webhook.add_file(file=bytes_, filename="img.png")
+```
+
+### Utility Class Methods
+
+- `matches_feed_url(cls, feed_url) → bool` — checks `feed_url` against `auto_enable_url_patterns`
+- `get_enabled_variables(cls, registry, enabled_names) → list[str]` — sorted union of `provides_variables` for enabled extensions
+
+---
+
+## Discovery
+
+Extensions are discovered from two sources (external overrides built-in):
+
+1. **Built-in** — `discord_rss_bot/extensions/*.py` (this dir)
+2. **External** — `EXTENSIONS_DIR` env var, default `extensions/` relative to CWD
+
+Every `.py` (except `__init__`) is imported. Concrete `FeedExtension` subclasses with a non-empty `name` go into a global `registry: dict[str, type[FeedExtension]]`. Duplicate names log a warning; last one wins.
+
+| Function | Description |
+| ---------------------------------- | ------------------------------------------------ |
+| `discover_plugins(*, force=False)` | Scan dirs, populate registry, return it |
+| `get_registry()` | Return current registry (discover on first call) |
+| `registry_clear()` | Clear registry (for tests) |
+
+---
+
+## Auto-Enable
+
+Set `auto_enable_url_patterns` on your class:
+
+```python
+auto_enable_url_patterns: ClassVar[list[str]] = [
+ r"store\.steampowered\.com",
+ r"youtube\.com/feeds/videos\.xml",
+]
+```
+
+Uses `re.search()` (matches anywhere in URL). Only applies when the feed's extensions tag was never explicitly saved by the user. Runs on feed creation and lazily on first process for old feeds.
+
+---
+
+## Per-Feed Storage
+
+Enabled extensions stored as a JSON list under reader tag key `"extensions"`.
+
+| Function | Description |
+| --------------------------------------------------------------- | ---------------------------- |
+| `get_enabled_extensions_for_feed(reader, feed_url) → list[str]` | Get enabled names for a feed |
+| `set_enabled_extensions_for_feed(reader, feed_url, names)` | Persist enabled names |
+
+Web UI: `GET /extensions?feed_url=...` renders checkboxes; `POST /extensions` saves.
+
+---
+
+## Built-in Extensions
+
+| Extension | `name` | Variables | Auto-enable |
+| ---------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| [youtube.py](youtube.py) | `youtube` | `youtube_video_id`, `youtube_embed_url` | `youtube.com/feeds/videos.xml` |
+| [steam.py](steam.py) | `steam` | `steam_thumbnail_url`, `steam_app_id` | `store.steampowered.com`, `steamcommunity.com` |
+| [hoyolab.py](hoyolab.py) | `hoyolab` | `hoyolab_subject`, `hoyolab_description`, `hoyolab_image`, `hoyolab_author` | `feeds.c3kay.de` |
+| [jwplayer_thumbnail.py](jwplayer_thumbnail.py) | `jwplayer_thumbnail` | `jwplayer_thumbnail`, `jwplayer_file` | `hentaigasm.com`, `hgasm[0-9]*.com` |
+| [wordpress.py](wordpress.py) | `wordpress` | `wp_content`, `wp_content_raw`, `wp_excerpt`, `wp_excerpt_raw`, `wp_jwplayer_thumbnail`, `wp_jwplayer_file` | _(none)_ |
diff --git a/discord_rss_bot/extensions/__init__.py b/discord_rss_bot/extensions/__init__.py
new file mode 100644
index 0000000..ae2cf85
--- /dev/null
+++ b/discord_rss_bot/extensions/__init__.py
@@ -0,0 +1,32 @@
+"""Feed extension system: discover, configure, and run per-entry content extensions.
+
+Extensions are Python classes that inherit from ``FeedExtension``,
+auto-discovered from the ``EXTENSIONS_DIR`` directory (default: ``extensions/``).
+
+Typical usage::
+
+ from discord_rss_bot.extensions import run_extensions
+
+ extra_vars: dict[str, str] = run_extensions(entry, reader)
+ # extra_vars == {"jwplayer_thumbnail": "https://...", ...}
+"""
+
+from __future__ import annotations
+
+from discord_rss_bot.extensions.base import FeedExtension
+from discord_rss_bot.extensions.discovery import discover_plugins
+from discord_rss_bot.extensions.discovery import get_registry
+from discord_rss_bot.extensions.discovery import registry_clear
+from discord_rss_bot.extensions.runner import auto_enable_extensions_for_feed
+from discord_rss_bot.extensions.runner import run_extensions
+from discord_rss_bot.extensions.runner import run_modify_webhook
+
+__all__ = [
+ "FeedExtension",
+ "auto_enable_extensions_for_feed",
+ "discover_plugins",
+ "get_registry",
+ "registry_clear",
+ "run_extensions",
+ "run_modify_webhook",
+]
diff --git a/discord_rss_bot/extensions/base.py b/discord_rss_bot/extensions/base.py
new file mode 100644
index 0000000..b6c25d8
--- /dev/null
+++ b/discord_rss_bot/extensions/base.py
@@ -0,0 +1,127 @@
+"""Abstract base class for feed extensions.
+
+Every extension in the ``EXTENSIONS_DIR`` must inherit from
+``FeedExtension`` and implement ``process_entry()``.
+"""
+
+from __future__ import annotations
+
+import re
+from abc import ABC
+from abc import abstractmethod
+from typing import TYPE_CHECKING
+from typing import ClassVar
+
+if TYPE_CHECKING:
+ from reader import Entry
+ from reader import Reader
+
+ from discord_rss_bot.webhook import DiscordWebhook
+
+
+class FeedExtension(ABC):
+ """Base class for per-feed content extensions.
+
+ Subclasses are auto-discovered from the ``EXTENSIONS_DIR`` directory.
+ Each instance processes one entry and returns template variables
+ that become available as ``{{variable_name}}`` in Discord messages
+ and embeds.
+
+ Class attributes set by subclasses:
+
+ ``name``:
+ Unique identifier (used in logs and per-feed enable/disable config).
+ Should be a short slug, e.g. ``"jwplayer_thumbnail"``.
+
+ ``description``:
+ Human-readable description shown in the web UI (optional).
+ """
+
+ name: ClassVar[str] = ""
+ description: ClassVar[str] = ""
+
+ #: List of template variable names this extension provides.
+ #: Used by the web UI to show users what ``{{variable}}`` tags
+ #: are available when an extension is enabled.
+ provides_variables: ClassVar[list[str]] = []
+
+ #: List of regex patterns. When a feed URL matches any pattern,
+ #: this extension is automatically enabled for that feed.
+ auto_enable_url_patterns: ClassVar[list[str]] = []
+
+ @abstractmethod
+ def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]:
+ """Return template variable pairs extracted from *entry*.
+
+ Args:
+ entry: The feed entry to process.
+ reader: The reader instance (useful for loading feed tags).
+
+ Returns:
+ A dict of ``{variable_name: value}`` pairs. Return an empty
+ dict if this entry does not need any extra variables.
+ """
+
+ def modify_webhook(
+ self,
+ webhook: DiscordWebhook,
+ _entry: Entry,
+ _reader: Reader,
+ ) -> DiscordWebhook:
+ """Modify the Discord webhook before it is sent.
+
+ Override this method when your extension needs to change the
+ webhook payload itself (e.g. to add files, replace embeds, set
+ author information, etc.). By default it returns the webhook
+ unchanged.
+
+ Args:
+ webhook: The fully built webhook payload.
+ _entry: The feed entry being processed.
+ _reader: The reader instance.
+
+ Returns:
+ The (possibly modified) ``DiscordWebhook``. Return the
+ original *webhook* unchanged if no modifications are needed.
+ """
+ return webhook
+
+ @classmethod
+ def get_enabled_variables(cls, registry: dict[str, type[FeedExtension]], enabled_names: list[str]) -> list[str]:
+ """Return the union of ``provides_variables`` for all enabled extensions.
+
+ Args:
+ registry: The extension registry ``{name: class}``.
+ enabled_names: List of enabled extension names for a feed.
+
+ Returns:
+ Sorted list of unique variable names.
+ """
+ seen: set[str] = set()
+ result: list[str] = []
+ for name in enabled_names:
+ ext_cls = registry.get(name)
+ if ext_cls is None:
+ continue
+ for var_name in ext_cls.provides_variables:
+ if var_name not in seen:
+ seen.add(var_name)
+ result.append(var_name)
+ result.sort()
+ return result
+
+ @classmethod
+ def matches_feed_url(cls, feed_url: str) -> bool:
+ """Return ``True`` if *feed_url* matches any of the auto-enable patterns.
+
+ Args:
+ feed_url: The feed URL to test.
+
+ Returns:
+ ``True`` if a match is found, ``False`` otherwise (including when
+ no patterns are defined).
+ """
+ if not cls.auto_enable_url_patterns:
+ return False
+
+ return any(re.search(p, feed_url) for p in cls.auto_enable_url_patterns)
diff --git a/discord_rss_bot/extensions/discovery.py b/discord_rss_bot/extensions/discovery.py
new file mode 100644
index 0000000..2d8a2b3
--- /dev/null
+++ b/discord_rss_bot/extensions/discovery.py
@@ -0,0 +1,191 @@
+"""Plugin discovery — import ``.py`` files from ``EXTENSIONS_DIR``.
+
+All ``FeedExtension`` subclasses found in those files are collected into
+a global registry (``dict[str, type[FeedExtension]]``).
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import types
+
+from discord_rss_bot.extensions.base import FeedExtension
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+#: Environment variable that points to the extensions directory.
+ENV_EXTENSIONS_DIR: str = "EXTENSIONS_DIR"
+
+#: Fallback if the env var is not set.
+DEFAULT_EXTENSIONS_DIR: str = "extensions"
+
+_registry: dict[str, type[FeedExtension]] = {}
+_discovered: bool = False
+
+
+def _get_extensions_dir() -> Path:
+ """Return the path to the extensions directory.
+
+ Respects the ``EXTENSIONS_DIR`` environment variable. Falls back to
+ ``extensions/`` relative to the current working directory.
+ """
+ raw: str = os.getenv(ENV_EXTENSIONS_DIR, "").strip()
+ if raw:
+ return Path(raw).resolve()
+ return Path.cwd() / DEFAULT_EXTENSIONS_DIR
+
+
+def _try_import_module(module_name: str, filepath: Path) -> types.ModuleType | None:
+ """Create a module spec and execute it, returning the module or ``None``.
+
+ Args:
+ module_name: The name to give the imported module.
+ filepath: Absolute path to a ``.py`` file.
+
+ Returns:
+ The imported module, or ``None`` if the spec could not be created.
+ """
+ spec = importlib.util.spec_from_file_location(module_name, filepath)
+ if spec is None or spec.loader is None:
+ logger.warning("Could not create module spec for %s", filepath)
+ return None
+
+ module = importlib.util.module_from_spec(spec)
+ # Make the parent package importable so relative imports in plugins work.
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _import_module_from_path(filepath: Path) -> types.ModuleType | None:
+ """Import a single ``.py`` file as a module and return the module object.
+
+ Args:
+ filepath: Absolute path to a ``.py`` file.
+
+ Returns:
+ The imported module, or ``None`` on failure.
+ """
+ module_name: str = f"_ext_plugin_{filepath.stem}"
+
+ # Avoid re-importing if already loaded.
+ if module_name in sys.modules:
+ return sys.modules[module_name]
+
+ try:
+ return _try_import_module(module_name, filepath)
+ except Exception:
+ logger.exception("Failed to import extension plugin from %s", filepath)
+ return None
+
+
+def _collect_extensions_from_module(module: types.ModuleType) -> list[type[FeedExtension]]:
+ """Return ``FeedExtension`` subclasses defined in *module*.
+
+ Only concrete (non-ABC) subclasses with a non-empty ``name`` are
+ returned.
+
+ Args:
+ module: An imported module object.
+
+ Returns:
+ List of extension classes found in the module.
+ """
+ extensions: list[type[FeedExtension]] = []
+ for attr_name in dir(module):
+ obj = getattr(module, attr_name)
+ if (
+ isinstance(obj, type)
+ and issubclass(obj, FeedExtension)
+ and obj is not FeedExtension
+ and not getattr(obj, "__abstractmethods__", None)
+ ):
+ name: str = getattr(obj, "name", "") or attr_name
+ extensions.append(obj)
+ logger.debug("Discovered extension %r (class %s)", name, attr_name)
+ return extensions
+
+
+def _scan_directory(ext_dir: Path) -> None:
+ """Scan a single directory and register any ``FeedExtension`` subclasses found."""
+ if not ext_dir.is_dir():
+ return
+
+ py_files: list[Path] = sorted(p for p in ext_dir.iterdir() if p.suffix == ".py" and p.stem != "__init__")
+ for filepath in py_files:
+ module = _import_module_from_path(filepath)
+ if module is None:
+ continue
+ classes = _collect_extensions_from_module(module)
+ for cls in classes:
+ cls_name: str = cls.name or cls.__name__
+ if cls_name in _registry:
+ logger.warning("Duplicate extension name %r — overwriting with %s", cls_name, filepath)
+ _registry[cls_name] = cls
+
+
+def discover_plugins(*, force: bool = False) -> dict[str, type[FeedExtension]]:
+ """Scan built-in and external extension directories and register.
+
+ All ``FeedExtension`` subclasses found in those directories are
+ collected into a global registry.
+
+ Order of discovery:
+ 1. Built-in extensions shipped with the bot (``discord_rss_bot/extensions/``)
+ 2. External user-provided plugins from ``EXTENSIONS_DIR``
+
+ External plugins can override built-in ones by using the same ``name``.
+ This is called automatically on first import. Call with
+ ``force=True`` to re-scan (useful in tests).
+
+ Args:
+ force: If ``True``, re-scan even if already discovered.
+
+ Returns:
+ The global registry ``{name: class}``.
+ """
+ global _discovered # ruff:ignore[global-statement]
+ if _discovered and not force:
+ return _registry
+
+ # 1. Scan the built-in package directory first.
+ built_in_dir: Path = Path(__file__).resolve().parent
+ logger.debug("Scanning built-in extensions directory: %s", built_in_dir)
+ _scan_directory(built_in_dir)
+
+ # 2. Scan the external user-provided extensions directory.
+ ext_dir: Path = _get_extensions_dir()
+ if ext_dir != built_in_dir:
+ logger.info("Scanning external extensions directory: %s", ext_dir)
+ _scan_directory(ext_dir)
+ else:
+ logger.debug("External extensions directory is the same as built-in — skipping duplicate scan")
+
+ _discovered = True
+ logger.info("Discovered %d extension plugin(s): %s", len(_registry), list(_registry.keys()))
+ return _registry
+
+
+def get_registry() -> dict[str, type[FeedExtension]]:
+ """Return the current extension registry, discovering plugins if needed.
+
+ Returns:
+ The global registry ``{name: class}``.
+ """
+ if not _discovered:
+ return discover_plugins()
+ return _registry
+
+
+def registry_clear() -> None:
+ """Clear the registry (useful for testing)."""
+ global _discovered, _registry # ruff:ignore[global-statement]
+ _registry = {}
+ _discovered = False
diff --git a/discord_rss_bot/extensions/hoyolab.py b/discord_rss_bot/extensions/hoyolab.py
new file mode 100644
index 0000000..0f1abb3
--- /dev/null
+++ b/discord_rss_bot/extensions/hoyolab.py
@@ -0,0 +1,331 @@
+"""Hoyolab feed extension: fetches full post data from the Hoyolab API.
+
+Detects feeds from ``feeds.c3kay.de`` and replaces the Discord embed
+with richer content fetched directly from Hoyolab (Genshin Impact,
+Honkai Starrail, Honkai Impact 3rd, Zenless Zone Zero).
+
+Usage:
+ 1. Enable ``hoyolab`` on the feed's Extensions page.
+ 2. The extension automatically decorates the embed with the post
+ title, author avatar, game color, images, and more.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import logging
+import re
+from typing import TYPE_CHECKING
+from typing import ClassVar
+from typing import cast
+
+import requests
+
+from discord_rss_bot.extensions.base import FeedExtension
+from discord_rss_bot.webhook import DiscordEmbed
+from discord_rss_bot.webhook import DiscordWebhook
+
+if TYPE_CHECKING:
+ from reader import Entry
+ from reader import Reader
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
+type JsonObject = dict[str, JsonValue]
+
+
+def is_c3kay_feed_url(feed_url: str) -> bool:
+ """Return whether a feed URL is from feeds.c3kay.de."""
+ return "feeds.c3kay.de" in (feed_url or "")
+
+
+def extract_post_id(url: str) -> str | None:
+ """Extract the Hoyolab post ID from a URL.
+
+ Args:
+ url: The URL to inspect (e.g. ``https://www.hoyolab.com/article/38588239``).
+
+ Returns:
+ The numeric post ID if found, otherwise ``None``.
+ """
+ if not url:
+ return None
+ try:
+ match = re.search(r"/article/(\d+)", url)
+ if match:
+ return match.group(1)
+ except (ValueError, AttributeError, TypeError) as exc:
+ logger.warning("Error extracting post ID from URL %s: %s", url, exc)
+ return None
+
+
+def _try_fetch_post(post_id: str) -> JsonObject | None:
+ """Attempt to fetch a Hoyolab post, returning the payload or ``None``.
+
+ Does not catch exceptions — the caller handles them.
+
+ Returns:
+ The post payload dict, or ``None`` on failure.
+ """
+ url: str = f"https://bbs-api-os.hoyolab.com/community/post/wapi/getPostFull?post_id={post_id}"
+ response: requests.Response = requests.get(url, timeout=10)
+ if response.status_code == http_ok:
+ data = cast("JsonObject", response.json())
+ data_payload: JsonObject = cast("JsonObject", data.get("data", {}))
+ post_payload: JsonObject = cast("JsonObject", data_payload.get("post", {}))
+ if data.get("retcode") == 0 and post_payload:
+ return post_payload
+ logger.warning("Failed to fetch Hoyolab post %s: %s", post_id, response.text)
+ return None
+
+
+def fetch_post(post_id: str) -> JsonObject | None:
+ """Fetch post data from the Hoyolab API.
+
+ Args:
+ post_id: The numeric post ID.
+
+ Returns:
+ The post payload dict, or ``None`` on failure.
+ """
+ if not post_id:
+ return None
+ try:
+ return _try_fetch_post(post_id)
+ except (requests.RequestException, ValueError):
+ logger.exception("Error fetching Hoyolab post %s", post_id)
+ return None
+
+
+http_ok: int = 200
+
+
+def _as_json_object(value: JsonValue) -> JsonObject:
+ return cast("JsonObject", value) if isinstance(value, dict) else {}
+
+
+class HoyolabExtension(FeedExtension):
+ """Fetches full post data from the Hoyolab API for c3kay.de feeds.
+
+ Replaces the Discord embed with richer content (title, author avatar,
+ game color, images, video, and more).
+ """
+
+ name = "hoyolab"
+ description = (
+ "Detects c3kay.de / Hoyolab feeds and replaces the Discord embed "
+ "with full post data from the Hoyolab API "
+ "(Genshin Impact, Honkai Starrail, Honkai Impact 3rd, ZZZ)."
+ )
+ provides_variables: ClassVar[list[str]] = [
+ "hoyolab_subject",
+ "hoyolab_description",
+ "hoyolab_image",
+ "hoyolab_author",
+ ]
+ auto_enable_url_patterns: ClassVar[list[str]] = [
+ r"feeds\.c3kay\.de",
+ ]
+
+ def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]: # ruff:ignore[unused-method-argument]
+ """Provide Hoyolab post data as template variables.
+
+ Args:
+ entry: The feed entry to process.
+ reader: The reader instance.
+
+ Returns:
+ Dict with ``hoyolab_subject``, ``hoyolab_description``,
+ ``hoyolab_image`` and ``hoyolab_author`` if the data was
+ fetched successfully, otherwise empty dict.
+ """
+ if not is_c3kay_feed_url(entry.feed.url):
+ return {}
+
+ post_id: str | None = extract_post_id(str(entry.link or ""))
+ if not post_id:
+ return {}
+
+ post_data: JsonObject | None = fetch_post(post_id)
+ if not post_data:
+ return {}
+
+ post: JsonObject = _as_json_object(post_data.get("post"))
+ subject: str = str(post.get("subject", ""))
+ description: str = str(post.get("desc", ""))
+
+ image_url: str = ""
+ image_list = post_data.get("image_list", [])
+ if isinstance(image_list, list) and image_list:
+ first_image = image_list[0]
+ if isinstance(first_image, dict):
+ image_url = str(first_image.get("url", ""))
+
+ author: str = ""
+ user: JsonObject = _as_json_object(post_data.get("user"))
+ if user:
+ author = str(user.get("nickname", ""))
+
+ return {
+ "hoyolab_subject": subject,
+ "hoyolab_description": description,
+ "hoyolab_image": image_url,
+ "hoyolab_author": author,
+ }
+
+ def modify_webhook(
+ self,
+ webhook: DiscordWebhook,
+ entry: Entry,
+ reader: Reader, # ruff:ignore[unused-method-argument]
+ ) -> DiscordWebhook:
+ """Replace the embed with richer Hoyolab content when applicable.
+
+ Args:
+ webhook: The fully built webhook payload.
+ entry: The feed entry being processed.
+ reader: The reader instance.
+
+ Returns:
+ The modified webhook with Hoyolab data.
+ """
+ if not is_c3kay_feed_url(entry.feed.url):
+ return webhook
+
+ # Don't interfere with non-embed delivery modes.
+ if not webhook.json.get("embeds"):
+ return webhook
+
+ post_id: str | None = extract_post_id(str(entry.link or ""))
+ if not post_id:
+ return webhook
+
+ post_data: JsonObject | None = fetch_post(post_id)
+ if not post_data:
+ return webhook
+
+ discord_embed = self._build_embed_from_post(post_data, entry.link or entry.feed.url)
+
+ self._attach_video_if_present(webhook, entry, post_data)
+ self._apply_author_from_post(webhook, post_data)
+ self._apply_structured_content(webhook, post_data)
+
+ webhook.remove_embeds()
+ webhook.add_embed(discord_embed)
+ return webhook
+
+ def _build_embed_from_post(self, post_data: JsonObject, entry_link: str) -> DiscordEmbed:
+ """Build a ``DiscordEmbed`` populated with post data.
+
+ Args:
+ post_data: Raw Hoyolab post payload.
+ entry_link: Fallback link URL.
+
+ Returns:
+ A populated embed.
+ """
+ post: JsonObject = _as_json_object(post_data.get("post"))
+ subject: str = str(post.get("subject", ""))
+ content_raw: str = str(post.get("content", "{}"))
+
+ content_data: JsonObject = {}
+ with contextlib.suppress(json.JSONDecodeError, ValueError):
+ loaded = cast("JsonValue", json.loads(content_raw))
+ content_data = _as_json_object(loaded)
+
+ description: str = str(content_data.get("describe", ""))
+ if not description:
+ description = str(post.get("desc", ""))
+
+ discord_embed = DiscordEmbed()
+ discord_embed.set_title(subject)
+ discord_embed.set_url(entry_link)
+
+ # Image list
+ image_list_value: JsonValue = post_data.get("image_list", [])
+ image_list: list[JsonObject] = (
+ [cast("JsonObject", item) for item in image_list_value if isinstance(item, dict)]
+ if isinstance(image_list_value, list)
+ else []
+ )
+ if image_list:
+ discord_embed.set_image(url=str(image_list[0].get("url", "")))
+
+ # Game colour
+ game: JsonObject = _as_json_object(post_data.get("game"))
+ if game and game.get("color"):
+ discord_embed.set_color(str(game.get("color", "")).removeprefix("#"))
+
+ # Footer
+ classification: JsonObject = _as_json_object(post_data.get("classification"))
+ if classification and classification.get("name"):
+ discord_embed.set_footer(text=str(classification.get("name", "")))
+
+ # Event dates
+ event_start: str = str(post.get("event_start_date", ""))
+ if event_start and event_start != "0":
+ discord_embed.add_embed_field(name="Start", value=f"")
+ event_end: str = str(post.get("event_end_date", ""))
+ if event_end and event_end != "0":
+ discord_embed.add_embed_field(name="End", value=f"")
+
+ created_at: str = str(post.get("created_at", ""))
+ if created_at and created_at != "0":
+ discord_embed.set_timestamp(timestamp=created_at)
+
+ if description:
+ discord_embed.set_description(description)
+
+ return discord_embed
+
+ def _attach_video_if_present(self, webhook: DiscordWebhook, entry: Entry, post_data: JsonObject) -> None:
+ """Download and attach a Hoyolab video to the webhook if present."""
+ video: JsonObject = _as_json_object(post_data.get("video"))
+ if not video or not video.get("url"):
+ return
+ video_url: str = str(video.get("url", ""))
+ with contextlib.suppress(requests.RequestException):
+ video_response: requests.Response = requests.get(video_url, stream=True, timeout=10)
+ if video_response.ok:
+ webhook.add_file(file=video_response.content, filename=f"{entry.id}.mp4")
+
+ def _apply_author_from_post(self, webhook: DiscordWebhook, post_data: JsonObject) -> None:
+ """Set webhook author (username and avatar) from post user data."""
+ user: JsonObject = _as_json_object(post_data.get("user"))
+ author_name: str = str(user.get("nickname", ""))
+ avatar_url: str = str(user.get("avatar_url", ""))
+ if author_name:
+ webhook.avatar_url = avatar_url
+ webhook.username = author_name
+
+ def _apply_structured_content(self, webhook: DiscordWebhook, post_data: JsonObject) -> None:
+ """Parse structured content for YouTube embeds and add them to the webhook."""
+ post: JsonObject = _as_json_object(post_data.get("post"))
+ structured_content: str = str(post.get("structured_content", ""))
+ if not structured_content:
+ return
+ with contextlib.suppress(json.JSONDecodeError, ValueError):
+ loaded_structured = cast("JsonValue", json.loads(structured_content))
+ structured_items: list[JsonObject] = (
+ [cast("JsonObject", item) for item in loaded_structured if isinstance(item, dict)]
+ if isinstance(loaded_structured, list)
+ else []
+ )
+ for item in structured_items:
+ self._apply_structured_item(webhook, item)
+
+ def _apply_structured_item(self, webhook: DiscordWebhook, item: JsonObject) -> None:
+ """Apply a single structured content item (e.g. YouTube embed)."""
+ insert: JsonObject = _as_json_object(item.get("insert"))
+ if not insert:
+ return
+ video_url_str: str = str(insert.get("video", ""))
+ if not video_url_str:
+ return
+ video_id_match = re.search(r"embed/([a-zA-Z0-9_-]+)", video_url_str)
+ if not video_id_match:
+ return
+ webhook.content = f"https://www.youtube.com/watch?v={video_id_match.group(1)}"
+ webhook.remove_embeds()
diff --git a/discord_rss_bot/extensions/jwplayer_thumbnail.py b/discord_rss_bot/extensions/jwplayer_thumbnail.py
new file mode 100644
index 0000000..f5dffa6
--- /dev/null
+++ b/discord_rss_bot/extensions/jwplayer_thumbnail.py
@@ -0,0 +1,285 @@
+"""Extension: extract JWPlayer thumbnail/video URLs from WordPress sites.
+
+`JWPlayer `_ is a commercial video player
+used by many sites to serve self-hosted or CDN-hosted video. The player
+is embedded via a ``
+ """
+ entry: SimpleNamespace = SimpleNamespace(
+ id="test",
+ content=[SimpleNamespace(value=raw_html)],
+ summary="",
+ feed=SimpleNamespace(url="https://example.com/feed.xml"),
+ )
+ result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+ assert result.get("jwplayer_thumbnail") == "https://example.com/thumbnail.jpg"
+ assert result.get("jwplayer_file") == "https://example.com/video.mp4"
+
+
+def test_jwplayer_thumbnail_extension_returns_empty_without_content() -> None:
+ """If the entry has no content, the extension should return an empty dict."""
+ ext = JWPlayerThumbnailExtension()
+ entry: SimpleNamespace = SimpleNamespace(
+ id="test",
+ content=[],
+ summary="",
+ link="https://example.com/video",
+ feed=SimpleNamespace(url="https://example.com/feed.xml"),
+ )
+ result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+ assert result == {}
+
+
+def test_jwplayer_thumbnail_extension_returns_empty_without_match() -> None:
+ """If the HTML has no JWPlayer pattern, the extension should return empty."""
+ ext = JWPlayerThumbnailExtension()
+ raw_html: str = "No player here.
"
+ entry: SimpleNamespace = SimpleNamespace(
+ id="test",
+ content=[SimpleNamespace(value=raw_html)],
+ summary="",
+ link="https://example.com/video",
+ feed=SimpleNamespace(url="https://example.com/feed.xml"),
+ )
+ result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+ assert result == {}
+
+
+def test_jwplayer_thumbnail_extension_matches_hentaigasm_format() -> None:
+ """The extension should extract URLs from the actual hentaigasm.com feed format."""
+ ext = JWPlayerThumbnailExtension()
+ # This is the actual HTML structure from the main hentaigasm feed.
+ raw_html: str = """
+HENTAIGASM EXCLUSIVE!
+
+
+
+DOWNLOAD
+"""
+ entry: SimpleNamespace = SimpleNamespace(
+ id="test-hentai",
+ content=[SimpleNamespace(value=raw_html)],
+ summary="",
+ feed=SimpleNamespace(url="https://hentaigasm.com/feed/"),
+ )
+ result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+ assert result.get("jwplayer_thumbnail") == "https://hgasm1.com/thumbnail/Test%20Video%201%20Subbed.jpg"
+ assert result.get("jwplayer_file") == "https://hgasm2.com/Test%20Video%201%20Subbed.mp4"
+
+
+# ---------------------------------------------------------------------------
+# Tests: auto_enable_url_patterns
+# ---------------------------------------------------------------------------
+
+
+def test_auto_enable_by_url_pattern_matches(
+ mock_reader: MagicMock,
+ temp_extensions_dir: str,
+) -> None:
+ """An extension with matching auto_enable_url_patterns should be auto-enabled.
+
+ Check that ``auto_enable_extensions_for_feed`` activates the plugin.
+ """
+ # Register a test plugin with a URL pattern.
+ plugin_code: str = """
+from discord_rss_bot.extensions.base import FeedExtension
+
+class AutoPlugin(FeedExtension):
+ name = "auto_test"
+ auto_enable_url_patterns = [r"example\\.com/feeds"]
+
+ def process_entry(self, entry, reader):
+ return {"auto_var": "enabled"}
+"""
+ plugin_path: Path = Path(temp_extensions_dir) / "auto_plugin.py"
+ plugin_path.write_text(plugin_code, encoding="utf-8")
+ discover_plugins(force=True)
+
+ mock_feed_url: str = "https://example.com/feeds/test.xml"
+ result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
+
+ assert "auto_test" in result
+
+
+def test_auto_enable_by_url_pattern_does_not_match(
+ mock_reader: MagicMock,
+ temp_extensions_dir: str,
+) -> None:
+ """An extension whose pattern does not match should NOT be auto-enabled."""
+ # Register the same plugin as the matching test, so this test actually
+ # validates that a non-matching URL does not trigger auto-enable.
+ plugin_code: str = """
+from discord_rss_bot.extensions.base import FeedExtension
+
+class AutoPlugin(FeedExtension):
+ name = "auto_test"
+ auto_enable_url_patterns = [r"example\\.com/feeds"]
+
+ def process_entry(self, entry, reader):
+ return {"auto_var": "enabled"}
+"""
+ plugin_path: Path = Path(temp_extensions_dir) / "auto_plugin.py"
+ plugin_path.write_text(plugin_code, encoding="utf-8")
+ discover_plugins(force=True)
+
+ mock_feed_url: str = "https://other-site.com/feed.xml"
+ result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
+
+ assert "auto_test" not in result
+
+
+def test_auto_enable_does_not_duplicate_explicitly_enabled(
+ mock_reader: MagicMock,
+) -> None:
+ """If an extension is already explicitly enabled, auto-enable should not add it again."""
+ mock_feed_url: str = "https://example.com/feeds/test.xml"
+ # Explicitly enable the extension first.
+ set_enabled_extensions_for_feed(mock_reader, mock_feed_url, ["auto_test"])
+
+ result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
+ # Should contain auto_test only once.
+ assert result == ["auto_test"]
+
+
+def test_matches_feed_url_classmethod() -> None:
+ """The ``matches_feed_url`` classmethod should work correctly."""
+ assert FeedExtension.matches_feed_url("http://example.com") is False, "Base class has no patterns"
+
+
+def test_steam_extension_has_auto_enable_patterns() -> None:
+ """The built-in Steam extension should declare URL patterns."""
+ assert len(SteamExtension.auto_enable_url_patterns) > 0
+ assert SteamExtension.matches_feed_url("https://store.steampowered.com/feeds/news/app/570/")
+ assert SteamExtension.matches_feed_url("https://steamcommunity.com/games/570/")
+ assert not SteamExtension.matches_feed_url("https://example.com/feed.xml")
+
+
+def test_youtube_extension_has_auto_enable_patterns() -> None:
+ """The built-in YouTube extension should declare URL patterns."""
+ assert len(YouTubeExtension.auto_enable_url_patterns) > 0
+ assert YouTubeExtension.matches_feed_url("https://www.youtube.com/feeds/videos.xml?channel_id=123")
+ assert not YouTubeExtension.matches_feed_url("https://example.com/feed.xml")
+
+
+def test_hoyolab_extension_has_auto_enable_patterns() -> None:
+ """The built-in Hoyolab extension should declare URL patterns."""
+ assert len(HoyolabExtension.auto_enable_url_patterns) > 0
+ assert HoyolabExtension.matches_feed_url("https://feeds.c3kay.de/hoyolab.xml")
+ assert not HoyolabExtension.matches_feed_url("https://example.com/feed.xml")
+
+
+# ---------------------------------------------------------------------------
+# Integration test with a real WordPress comment RSS feed (the original issue)
+# ---------------------------------------------------------------------------
+
+
+_COMMENTS_FEED_XML: str = r"""
+
+
+Comments on: Test Article
+https://example.com/test-article/
+
+Sat, 18 Jul 2026 05:11:01 +0000
+hourly
+1
+https://wordpress.org/?v=5.8.2
+-
+ By: Anonymous
+ https://example.com/test-article/comment-page-1/#comment-820928
+
+ Sat, 18 Jul 2026 05:11:01 +0000
+ https://example.com/?p=5633#comment-820928
+
+ Am I the only one that saw “Bookmark us” message?
]]>
+
+-
+ By: Unknown
+ https://example.com/test-article/comment-page-1/#comment-820900
+
+ Fri, 17 Jul 2026 14:29:42 +0000
+ https://example.com/?p=5633#comment-820900
+
+ Please uncensor the video]]>
+
+-
+ By: mid
+ https://example.com/test-article/comment-page-1/#comment-820887
+
+ Fri, 17 Jul 2026 06:20:56 +0000
+ https://example.com/?p=5633#comment-820887
+
+ that’s not uncensored]]>
+
+
+
+"""
+
+
+class _CommentsFeedHandler(BaseHTTPRequestHandler):
+ """Serves the WordPress comment RSS XML on any request."""
+
+ def do_GET(self) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "application/xml")
+ self.end_headers()
+ self.wfile.write(_COMMENTS_FEED_XML.encode("utf-8"))
+
+ def log_message(self, _format: str, *args: str | int) -> None:
+ pass
+
+
+@contextmanager
+def _serve_feed() -> Iterator[str]:
+ """Start a local HTTP server serving the comments feed XML.
+
+ Yields:
+ The URL of the feed.
+ """
+ with ThreadingHTTPServer(("127.0.0.1", 0), _CommentsFeedHandler) as server:
+ server_thread = Thread(target=server.serve_forever, daemon=True)
+ server_thread.start()
+ try:
+ yield f"http://127.0.0.1:{server.server_port}/feed.xml"
+ finally:
+ server.shutdown()
+ server_thread.join()
+
+
+@pytest.mark.slow
+def test_wordpress_comments_feed_pipeline() -> None:
+ """End-to-end test using the WordPress comment RSS feed from the original issue.
+
+ Validates that:
+ 1. The feed XML parses correctly via the reader library
+ 2. Entry content and summary are populated as expected
+ 3. The jwplayer_thumbnail extension gracefully returns empty (no script tags)
+ 4. Tag replacement does not crash with comment feed data
+ """
+ with (
+ tempfile.TemporaryDirectory() as tmpdir,
+ _serve_feed() as feed_url,
+ ):
+ db_path: Path = Path(tmpdir) / "test.sqlite"
+ reader: ReaderType = make_reader(url=str(db_path))
+
+ try:
+ # Add and update the feed.
+ reader.add_feed(feed_url)
+ reader.update_feed(feed_url)
+
+ feed = reader.get_feed(feed_url)
+ entries: list[Entry] = list(reader.get_entries(feed=feed_url))
+
+ # The comments feed should have 3 entries.
+ assert len(entries) == 3, f"Expected 3 comments, got {len(entries)}"
+
+ for entry in entries:
+ # Each entry should have a title (comment author).
+ assert entry.title is not None
+ assert entry.title.startswith("By:")
+
+ # Each entry should have a link back to the comment.
+ assert entry.link is not None
+ assert "comment-page" in entry.link
+
+ # Summary should be plain text (from ),
+ # content should be HTML (from ).
+ assert entry.summary is not None
+ assert "" not in entry.summary
+ if entry.content:
+ assert "
" in entry.content[0].value
+
+ # The jwplayer_thumbnail extension should return empty
+ # because this feed has no JWPlayer script blocks.
+ ext = JWPlayerThumbnailExtension()
+ result: dict[str, str] = ext.process_entry(entry, reader) # type: ignore[arg-type]
+ assert result == {}, f"JWPlayer extension should return empty for comment feed, got {result}"
+
+ # Tag replacement should not crash.
+ replaced_text: str = replace_tags_in_text_message(entry, reader)
+ assert isinstance(replaced_text, str)
+
+ # Embed replacement should not crash.
+ replaced_embed = replace_tags_in_embed(feed, entry, reader)
+ assert replaced_embed is not None
+
+ # Verify content:encoded was parsed into the content field.
+ first_entry = entries[0]
+ assert first_entry.content is not None
+ raw_content: str = first_entry.content[0].value
+ assert "Bookmark us" in raw_content or "bookmark" in raw_content.lower()
+ assert "
" in raw_content
+
+ finally:
+ reader.close()
+
+
+# ---------------------------------------------------------------------------
+# Main feed XML (hentaigasm-style, with JWPlayer video entries)
+# ---------------------------------------------------------------------------
+
+_MAIN_FEED_XML: str = """
+
+
+Hentaigasm - Test Feed
+https://example.com
+
+Sun, 19 Jul 2026 04:20:40 +0000
+-
+ Test Video 1 Subbed
+ https://example.com/test-video-1/
+
+ Sun, 19 Jul 2026 04:00:49 +0000
+ https://example.com/?p=5501
+
+ HENTAIGASM EXCLUSIVE!
+
+
+
+]]>
+
+
+
+"""
+
+
+class _MainFeedHandler(BaseHTTPRequestHandler):
+ """Serves the main feed XML with JWPlayer video entries."""
+
+ def do_GET(self) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "application/xml")
+ self.end_headers()
+ self.wfile.write(_MAIN_FEED_XML.encode("utf-8"))
+
+ def log_message(self, _format: str, *args: str | int) -> None:
+ pass
+
+
+@contextmanager
+def _serve_main_feed() -> Iterator[str]:
+ """Start a local HTTP server serving the main feed XML with JWPlayer content.
+
+ Yields:
+ The URL of the feed.
+ """
+ with ThreadingHTTPServer(("127.0.0.1", 0), _MainFeedHandler) as server:
+ server_thread = Thread(target=server.serve_forever, daemon=True)
+ server_thread.start()
+ try:
+ yield f"http://127.0.0.1:{server.server_port}/feed.xml"
+ finally:
+ server.shutdown()
+ server_thread.join()
+
+
+@pytest.mark.slow
+def test_hentaigasm_main_feed_jwplayer_extraction() -> None:
+ """End-to-end test using a hentaigasm-style main feed with JWPlayer content.
+
+ This test confirms that feedparser strips ``"
+ ),
+ },
+ },
+ {
+ "slug": "other-post",
+ "content": {"rendered": "No player here.
"},
+ },
+ ])
+
+ class _APIHandler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(wp_json.encode("utf-8"))
+
+ def log_message(self, _format: str, *args: str | int) -> None:
+ pass
+
+ with ThreadingHTTPServer(("127.0.0.1", 0), _APIHandler) as server:
+ server_thread = Thread(target=server.serve_forever, daemon=True)
+ server_thread.start()
+ port: int = server.server_port
+
+ try:
+ entry = SimpleNamespace(
+ id="test-wp-batch",
+ content=[],
+ summary="",
+ link=f"http://127.0.0.1:{port}/test-slug/",
+ feed=SimpleNamespace(url=f"http://127.0.0.1:{port}/feed/"),
+ )
+
+ ext = JWPlayerThumbnailExtension()
+ result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+
+ assert result.get("jwplayer_thumbnail") == "https://cdn.example.com/thumb.jpg", (
+ f"Batch WP fallback should extract thumbnail, got {result}"
+ )
+ assert result.get("jwplayer_file") == "https://cdn.example.com/video.mp4", (
+ f"Batch WP fallback should extract file, got {result}"
+ )
+
+ # Second entry from the same site uses cache — no API call.
+ entry2 = SimpleNamespace(
+ id="other-post",
+ content=[],
+ summary="",
+ link=f"http://127.0.0.1:{port}/other-post/",
+ feed=SimpleNamespace(url=f"http://127.0.0.1:{port}/feed/"),
+ )
+ result2: dict[str, str] = ext.process_entry(entry2, MagicMock()) # type: ignore[arg-type]
+ assert result2 == {}, "Entry without player should return empty"
+
+ finally:
+ server.shutdown()
+ server_thread.join()
+ _SLUG_CACHE.clear()
+
+
+# ---------------------------------------------------------------------------
+# Tests: WordPress extension
+# ---------------------------------------------------------------------------
+
+
+def test_wordpress_extension_uses_shared_batch_cache() -> None:
+ """The WordPress extension should use the shared batch cache."""
+ _SLUG_CACHE.clear()
+ content_html: str = (
+ "Test
"
+ ""
+ )
+ # Pre-populate the shared cache with the new richer format.
+ _SLUG_CACHE["https://example.com"] = {
+ "test-slug": {
+ "content": content_html,
+ "excerpt": "Test excerpt
",
+ "title": "Test Post",
+ },
+ }
+
+ ext = WordPressExtension()
+ entry = SimpleNamespace(
+ id="test",
+ link="https://example.com/test-slug/",
+ feed=SimpleNamespace(url="https://example.com/feed/"),
+ )
+ result = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
+ assert result.get("wp_jwplayer_thumbnail") == "https://cdn.example.com/thumb.jpg"
+ assert result.get("wp_jwplayer_file") == "https://cdn.example.com/v.mp4"
+ assert result.get("wp_content_raw") == content_html
+ assert result.get("wp_content") is not None
+ assert "Test" in result.get("wp_content", "")
+ assert result.get("wp_excerpt_raw") == "Test excerpt
"
+ assert result.get("wp_excerpt") is not None
+ assert "Test excerpt" in result.get("wp_excerpt", "")
+ # No spaces in URL values.
+ for key, val in result.items():
+ if key.startswith("wp_jwplayer"):
+ assert " " not in val, f"URL should be encoded, got spaces: {val}"
+ _SLUG_CACHE.clear()
+
+
+def test_wordpress_extension_provides_correct_variables() -> None:
+ """The WordPress extension should declare the correct variables."""
+ expected: set[str] = {
+ "wp_content",
+ "wp_content_raw",
+ "wp_excerpt",
+ "wp_excerpt_raw",
+ "wp_jwplayer_file",
+ "wp_jwplayer_thumbnail",
+ }
+ assert set(WordPressExtension.provides_variables) == expected
diff --git a/tests/test_feeds.py b/tests/test_feeds.py
index 80fb70d..7e6fefe 100644
--- a/tests/test_feeds.py
+++ b/tests/test_feeds.py
@@ -22,6 +22,8 @@ from reader import StorageError
from reader import make_reader
from discord_rss_bot import feeds
+from discord_rss_bot.extensions.steam import extract_app_id
+from discord_rss_bot.extensions.youtube import is_youtube_feed_url
from discord_rss_bot.feeds import JsonObject
from discord_rss_bot.feeds import capture_full_page_screenshot
from discord_rss_bot.feeds import create_feed
@@ -31,13 +33,11 @@ from discord_rss_bot.feeds import extract_domain
from discord_rss_bot.feeds import get_entry_delivery_mode
from discord_rss_bot.feeds import get_screenshot_layout
from discord_rss_bot.feeds import get_webhook_url
-from discord_rss_bot.feeds import is_youtube_feed
from discord_rss_bot.feeds import screenshot_filename_for_entry
from discord_rss_bot.feeds import send_discord_quest_notification
from discord_rss_bot.feeds import send_entry_to_discord
from discord_rss_bot.feeds import send_to_discord
from discord_rss_bot.feeds import set_entry_as_read
-from discord_rss_bot.feeds import should_send_embed_check
from discord_rss_bot.feeds import truncate_webhook_message
@@ -119,57 +119,15 @@ def test_truncate_webhook_message_long_message():
def test_is_youtube_feed():
- """Test the is_youtube_feed function."""
+ """Test the is_youtube_feed_url function."""
# YouTube feed URLs
- assert is_youtube_feed("https://www.youtube.com/feeds/videos.xml?channel_id=123456") is True
- assert is_youtube_feed("https://www.youtube.com/feeds/videos.xml?user=username") is True
+ assert is_youtube_feed_url("https://www.youtube.com/feeds/videos.xml?channel_id=123456")
+ assert is_youtube_feed_url("https://www.youtube.com/feeds/videos.xml?user=username")
# Non-YouTube feed URLs
- assert is_youtube_feed("https://www.example.com/feed.xml") is False
- assert is_youtube_feed("https://www.youtube.com/watch?v=123456") is False
- assert is_youtube_feed("https://www.reddit.com/r/Python/.rss") is False
-
-
-@patch("discord_rss_bot.feeds.logger")
-def test_should_send_embed_check_youtube_feeds(mock_logger: MagicMock) -> None:
- """Test should_send_embed_check returns False for YouTube feeds regardless of settings."""
- # Create mocks
- mock_reader = MagicMock()
- mock_entry = MagicMock()
-
- # Configure a YouTube feed
- mock_entry.feed.url = "https://www.youtube.com/feeds/videos.xml?channel_id=123456"
-
- # Set reader to return True for should_send_embed (would normally create an embed)
- mock_reader.get_tag.return_value = True
-
- # Result should be False, overriding the feed settings
- result = should_send_embed_check(mock_reader, mock_entry)
- assert result is False, "YouTube feeds should never use embeds"
-
- # Function should not even call get_tag for YouTube feeds
- mock_reader.get_tag.assert_not_called()
-
-
-@patch("discord_rss_bot.feeds.logger")
-def test_should_send_embed_check_normal_feeds(mock_logger: MagicMock) -> None:
- """Test should_send_embed_check returns feed settings for non-YouTube feeds."""
- # Create mocks
- mock_reader = MagicMock()
- mock_entry = MagicMock()
-
- # Configure a normal feed
- mock_entry.feed.url = "https://www.example.com/feed.xml"
-
- # Test with should_send_embed set to True
- mock_reader.get_tag.return_value = True
- result = should_send_embed_check(mock_reader, mock_entry)
- assert result is True, "Normal feeds should use embeds when enabled"
-
- # Test with should_send_embed set to False
- mock_reader.get_tag.return_value = False
- result = should_send_embed_check(mock_reader, mock_entry)
- assert result is False, "Normal feeds should not use embeds when disabled"
+ assert not is_youtube_feed_url("https://www.example.com/feed.xml")
+ assert not is_youtube_feed_url("https://www.youtube.com/watch?v=123456")
+ assert not is_youtube_feed_url("https://www.reddit.com/r/Python/.rss")
def test_get_entry_delivery_mode_prefers_delivery_mode_tag() -> None:
@@ -204,11 +162,7 @@ def test_get_entry_delivery_mode_falls_back_to_legacy_embed_flag() -> None:
@patch("discord_rss_bot.feeds.execute_webhook")
@patch("discord_rss_bot.feeds.create_text_webhook")
-@patch("discord_rss_bot.feeds.create_hoyolab_webhook")
-@patch("discord_rss_bot.feeds.fetch_hoyolab_post")
def test_send_entry_to_discord_hoyolab_text_mode_uses_text_webhook(
- mock_fetch_hoyolab_post: MagicMock,
- mock_create_hoyolab_webhook: MagicMock,
mock_create_text_webhook: MagicMock,
mock_execute_webhook: MagicMock,
) -> None:
@@ -230,8 +184,6 @@ def test_send_entry_to_discord_hoyolab_text_mode_uses_text_webhook(
result = send_entry_to_discord(entry, reader)
assert result is None
- mock_fetch_hoyolab_post.assert_not_called()
- mock_create_hoyolab_webhook.assert_not_called()
mock_create_text_webhook.assert_called_once_with(
"https://discord.test/webhook",
entry,
@@ -243,11 +195,7 @@ def test_send_entry_to_discord_hoyolab_text_mode_uses_text_webhook(
@patch("discord_rss_bot.feeds.execute_webhook")
@patch("discord_rss_bot.feeds.create_screenshot_webhook")
-@patch("discord_rss_bot.feeds.create_hoyolab_webhook")
-@patch("discord_rss_bot.feeds.fetch_hoyolab_post")
def test_send_entry_to_discord_hoyolab_screenshot_mode_uses_screenshot_webhook(
- mock_fetch_hoyolab_post: MagicMock,
- mock_create_hoyolab_webhook: MagicMock,
mock_create_screenshot_webhook: MagicMock,
mock_execute_webhook: MagicMock,
) -> None:
@@ -269,8 +217,6 @@ def test_send_entry_to_discord_hoyolab_screenshot_mode_uses_screenshot_webhook(
result = send_entry_to_discord(entry, reader)
assert result is None
- mock_fetch_hoyolab_post.assert_not_called()
- mock_create_hoyolab_webhook.assert_not_called()
mock_create_screenshot_webhook.assert_called_once_with(
"https://discord.test/webhook",
entry,
@@ -281,14 +227,11 @@ def test_send_entry_to_discord_hoyolab_screenshot_mode_uses_screenshot_webhook(
@patch("discord_rss_bot.feeds.execute_webhook")
@patch("discord_rss_bot.feeds.create_embed_webhook")
-@patch("discord_rss_bot.feeds.create_hoyolab_webhook")
-@patch("discord_rss_bot.feeds.fetch_hoyolab_post")
-def test_send_entry_to_discord_hoyolab_embed_mode_uses_hoyolab_webhook(
- mock_fetch_hoyolab_post: MagicMock,
- mock_create_hoyolab_webhook: MagicMock,
+def test_send_entry_to_discord_hoyolab_embed_mode_uses_embed_webhook(
mock_create_embed_webhook: MagicMock,
mock_execute_webhook: MagicMock,
) -> None:
+ """Embed mode should use the standard embed pipeline, not a Hoyolab bypass."""
entry = MagicMock()
entry.id = "entry-3"
entry.feed.url = "https://feeds.c3kay.de/hoyolab.xml"
@@ -301,21 +244,14 @@ def test_send_entry_to_discord_hoyolab_embed_mode_uses_hoyolab_webhook(
"delivery_mode": "embed",
}.get(key, default)
- mock_fetch_hoyolab_post.return_value = {"post": {"subject": "News"}}
- hoyolab_webhook = MagicMock()
- mock_create_hoyolab_webhook.return_value = hoyolab_webhook
+ embed_webhook = MagicMock()
+ mock_create_embed_webhook.return_value = embed_webhook
result = send_entry_to_discord(entry, reader)
assert result is None
- mock_fetch_hoyolab_post.assert_called_once_with("38588239")
- mock_create_hoyolab_webhook.assert_called_once_with(
- "https://discord.test/webhook",
- entry,
- {"post": {"subject": "News"}},
- )
- mock_create_embed_webhook.assert_not_called()
- mock_execute_webhook.assert_called_once_with(hoyolab_webhook, entry, reader=reader)
+ mock_create_embed_webhook.assert_called_once()
+ mock_execute_webhook.assert_called_once_with(embed_webhook, entry, reader=reader)
def test_get_screenshot_layout_prefers_mobile_tag() -> None:
@@ -391,7 +327,7 @@ def test_get_feed_media_gallery_image_limit_defaults_to_first_image() -> None:
],
)
def test_extract_steam_app_id_from_url(url: str, expected_app_id: str | None) -> None:
- assert feeds.extract_steam_app_id_from_url(url) == expected_app_id
+ assert extract_app_id(url) == expected_app_id
@pytest.mark.parametrize(
@@ -942,102 +878,6 @@ def test_create_embed_webhook_can_use_steam_game_icon_thumbnail(
entry.summary = ""
entry.content = []
entry.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=us&l=english"
- mock_replace_tags_in_embed.return_value = feeds.CustomEmbed(
- description="Steam news",
- thumbnail_url="https://example.com/custom-thumb.jpg",
- show_steam_game_icon_in_thumbnail=True,
- )
-
- with patch("discord_rss_bot.feeds.Path.is_file", return_value=False):
- webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader)
-
- assert "components" not in webhook.json
- embeds = webhook.json.get("embeds")
- assert isinstance(embeds, list)
- assert isinstance(embeds[0], dict)
- assert embeds[0]["thumbnail"] == {
- "url": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/capsule_sm_120.jpg",
- }
- assert webhook.files == []
- mock_fetch_ttvdrops_campaign_media_items.assert_not_called()
-
-
-@patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items", return_value=[])
-@patch("discord_rss_bot.feeds.replace_tags_in_embed")
-def test_create_embed_webhook_prefers_local_steam_game_icon_thumbnail(
- mock_replace_tags_in_embed: MagicMock,
- mock_fetch_ttvdrops_campaign_media_items: MagicMock,
-) -> None:
- local_icon_bytes = b"local-steam-icon"
-
- reader = MagicMock()
- reader.get_tag.side_effect = lambda resource, key, default=None: { # ruff:ignore[unused-lambda-argument]
- "media_gallery_image_limit": 0,
- "webhook_text_length_limit": 4000,
- }.get(key, default)
- entry = MagicMock()
- entry.id = "entry-steam-local-1"
- entry.title = "Dota 2 patch notes"
- entry.link = "https://steamcommunity.com/games/570/announcements/detail/1234567890"
- entry.summary = ""
- entry.content = []
- entry.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=us&l=english"
- mock_replace_tags_in_embed.return_value = feeds.CustomEmbed(
- description="Steam news",
- thumbnail_url="https://example.com/custom-thumb.jpg",
- show_steam_game_icon_in_thumbnail=True,
- )
-
- with (
- patch("discord_rss_bot.feeds.Path.is_file", return_value=True),
- patch("discord_rss_bot.feeds.Path.read_bytes", return_value=local_icon_bytes),
- ):
- webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader)
-
- embeds = webhook.json.get("embeds")
- assert isinstance(embeds, list)
- assert isinstance(embeds[0], dict)
- assert len(webhook.files) == 1
- uploaded_icon = webhook.files[0]
- assert uploaded_icon.content == local_icon_bytes
- assert uploaded_icon.filename.startswith("steam-app-570-")
- assert uploaded_icon.filename.endswith(".png")
- assert embeds[0]["thumbnail"] == {"url": f"attachment://{uploaded_icon.filename}"}
- mock_fetch_ttvdrops_campaign_media_items.assert_not_called()
-
-
-@patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items", return_value=[])
-@patch("discord_rss_bot.feeds.replace_tags_in_embed")
-def test_create_embed_webhook_does_not_inject_steam_thumbnail_when_app_id_is_missing(
- mock_replace_tags_in_embed: MagicMock,
- mock_fetch_ttvdrops_campaign_media_items: MagicMock,
-) -> None:
- reader = MagicMock()
- reader.get_tag.side_effect = lambda resource, key, default=None: { # ruff:ignore[unused-lambda-argument]
- "media_gallery_image_limit": 0,
- "webhook_text_length_limit": 4000,
- }.get(key, default)
- entry = MagicMock()
- entry.id = "entry-steam-2"
- entry.title = "Steam group post"
- entry.link = "https://steamcommunity.com/groups/example/announcements/detail/1234567890"
- entry.summary = ""
- entry.content = []
- entry.feed.url = "https://steamcommunity.com/groups/example/rss/"
- mock_replace_tags_in_embed.return_value = feeds.CustomEmbed(
- description="Steam group news",
- thumbnail_url="https://example.com/custom-thumb.jpg",
- show_steam_game_icon_in_thumbnail=True,
- )
-
- webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader)
-
- assert "components" not in webhook.json
- embeds = webhook.json.get("embeds")
- assert isinstance(embeds, list)
- assert isinstance(embeds[0], dict)
- assert "thumbnail" not in embeds[0]
- mock_fetch_ttvdrops_campaign_media_items.assert_not_called()
@patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items", return_value=[])
@@ -1338,10 +1178,11 @@ def test_send_entry_to_discord_youtube_feed(
mock_entry.feed.url = "https://www.youtube.com/feeds/videos.xml?channel_id=123456"
mock_entry.feed_url = "https://www.youtube.com/feeds/videos.xml?channel_id=123456"
- # Mock the tags
+ # Mock the tags — with no delivery_mode tag and should_send_embed=True,
+ # the entry delivery mode will be "embed", which calls create_embed_webhook.
mock_reader.get_tag.side_effect = lambda feed, tag, default=None: { # ruff:ignore[unused-lambda-argument]
"webhook": "https://discord.com/api/webhooks/123/abc",
- "should_send_embed": True, # This should be ignored for YouTube feeds
+ "should_send_embed": True,
}.get(tag, default)
# Mock custom message
@@ -1355,23 +1196,19 @@ def test_send_entry_to_discord_youtube_feed(
# Call the function
send_entry_to_discord(mock_entry, mock_reader)
- # Assertions
- mock_create_embed.assert_not_called()
- mock_discord_webhook.assert_called_once()
-
- # Check webhook was created with the right message
- webhook_call_kwargs = mock_discord_webhook.call_args[1]
- assert "content" in webhook_call_kwargs, "Webhook should have content"
- assert webhook_call_kwargs["url"] == "https://discord.com/api/webhooks/123/abc"
+ # Assertions — YouTube feeds now follow standard delivery mode.
+ # With should_send_embed=True, embed mode is used.
+ mock_create_embed.assert_called_once()
+ mock_discord_webhook.assert_not_called()
# Verify execute_webhook was called
- mock_execute_webhook.assert_called_once_with(mock_webhook, mock_entry, reader=mock_reader)
+ mock_execute_webhook.assert_called_once()
def test_extract_domain_youtube_feed() -> None:
"""Test extract_domain for YouTube feeds."""
url: str = "https://www.youtube.com/feeds/videos.xml?channel_id=123456"
- assert extract_domain(url) == "YouTube", "YouTube feeds should return 'YouTube' as the domain."
+ assert extract_domain(url) == "YouTube"
def test_extract_domain_reddit_feed() -> None:
diff --git a/tests/test_hoyolab_api.py b/tests/test_hoyolab_api.py
deleted file mode 100644
index 0649578..0000000
--- a/tests/test_hoyolab_api.py
+++ /dev/null
@@ -1,248 +0,0 @@
-from __future__ import annotations
-
-import json
-import typing
-from types import SimpleNamespace
-from unittest.mock import MagicMock
-from unittest.mock import patch
-
-import pytest
-import requests
-
-from discord_rss_bot.hoyolab_api import create_hoyolab_webhook
-from discord_rss_bot.hoyolab_api import extract_post_id_from_hoyolab_url
-from discord_rss_bot.hoyolab_api import fetch_hoyolab_post
-from discord_rss_bot.hoyolab_api import is_c3kay_feed
-
-if typing.TYPE_CHECKING:
- from reader import Entry
-
-
-class TestExtractPostIdFromHoyolabUrl:
- def test_extract_post_id_from_article_url(self) -> None:
- """Test extracting post ID from a direct article URL."""
- test_cases: list[str] = [
- "https://www.hoyolab.com/article/38588239",
- "http://hoyolab.com/article/12345",
- "https://www.hoyolab.com/article/987654321/comments",
- ]
-
- expected_ids: list[str] = ["38588239", "12345", "987654321"]
-
- for url, expected_id in zip(test_cases, expected_ids, strict=False):
- assert extract_post_id_from_hoyolab_url(url) == expected_id
-
- def test_url_without_post_id(self) -> None:
- """Test with a URL that doesn't have a post ID."""
- test_cases: list[str] = [
- "https://www.hoyolab.com/community",
- ]
-
- for url in test_cases:
- assert extract_post_id_from_hoyolab_url(url) is None
-
- def test_edge_cases(self) -> None:
- """Test edge cases like None, empty string, and malformed URLs."""
- test_cases: list[str | None] = [
- None,
- "",
- "not_a_url",
- "http:/", # Malformed URL
- ]
-
- for url in test_cases:
- assert extract_post_id_from_hoyolab_url(url) is None # type: ignore
-
-
-def make_entry(link: str | None = "https://www.hoyolab.com/article/38588239") -> SimpleNamespace:
- feed: SimpleNamespace = SimpleNamespace(url="https://feeds.c3kay.de/hoyolab.xml")
- return SimpleNamespace(
- id="entry-123",
- link=link,
- feed=feed,
- )
-
-
-class TestIsC3KayFeed:
- def test_true_for_c3kay_feed(self) -> None:
- assert is_c3kay_feed("https://feeds.c3kay.de/rss") is True
-
- def test_false_for_non_c3kay_feed(self) -> None:
- assert is_c3kay_feed("https://example.com/rss") is False
-
-
-class TestFetchHoyolabPost:
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- def test_returns_none_for_empty_post_id(self, mock_get: MagicMock) -> None:
- assert fetch_hoyolab_post("") is None
- mock_get.assert_not_called()
-
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- def test_returns_post_data_for_success_response(self, mock_get: MagicMock) -> None:
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {
- "retcode": 0,
- "data": {
- "post": {
- "post_id": "38588239",
- "subject": "Event",
- },
- },
- }
- mock_get.return_value = mock_response
-
- result = fetch_hoyolab_post("38588239")
-
- assert result == {"post_id": "38588239", "subject": "Event"}
- assert mock_get.call_args.args[0].endswith("post_id=38588239")
-
- @patch("discord_rss_bot.hoyolab_api.logger")
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- def test_returns_none_and_logs_warning_for_non_success_payload(
- self,
- mock_get: MagicMock,
- mock_logger: MagicMock,
- ) -> None:
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.text = "bad payload"
- mock_response.json.return_value = {
- "retcode": -1,
- "data": {},
- }
- mock_get.return_value = mock_response
-
- result = fetch_hoyolab_post("38588239")
-
- assert result is None
- mock_logger.warning.assert_called_once()
-
- @patch("discord_rss_bot.hoyolab_api.logger")
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- def test_returns_none_and_logs_exception_on_request_error(
- self,
- mock_get: MagicMock,
- mock_logger: MagicMock,
- ) -> None:
- mock_get.side_effect = requests.RequestException("network issue")
-
- result = fetch_hoyolab_post("38588239")
-
- assert result is None
- mock_logger.exception.assert_called_once()
-
-
-class TestCreateHoyolabWebhook:
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- @patch("discord_rss_bot.hoyolab_api.DiscordEmbed")
- @patch("discord_rss_bot.hoyolab_api.DiscordWebhook")
- def test_builds_embed_webhook_with_full_post_data(
- self,
- mock_webhook_cls: MagicMock,
- mock_embed_cls: MagicMock,
- mock_requests_get: MagicMock,
- ) -> None:
- webhook_instance = MagicMock()
- embed_instance = MagicMock()
- mock_webhook_cls.return_value = webhook_instance
- mock_embed_cls.return_value = embed_instance
-
- video_response = MagicMock()
- video_response.ok = True
- video_response.content = b"video-bytes"
- mock_requests_get.return_value = video_response
-
- post_data = {
- "post": {
- "subject": "Update 4.0",
- "content": json.dumps({"describe": "Patch notes"}),
- "desc": "fallback description",
- "structured_content": json.dumps(
- [{"insert": {"video": "https://www.youtube.com/embed/abc123_XY"}}],
- ),
- "event_start_date": "1712000000",
- "event_end_date": "1712600000",
- "created_at": "1711000000",
- },
- "image_list": [{"url": "https://img.example.com/hero.jpg", "height": 1080, "width": 1920}],
- "video": {"url": "https://cdn.example.com/video.mp4"},
- "game": {"color": "#11AAFF"},
- "user": {"nickname": "Paimon", "avatar_url": "https://img.example.com/avatar.jpg"},
- "classification": {"name": "Official"},
- }
-
- entry = make_entry(link=None)
- entry = typing.cast("Entry", entry)
- webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) # type: ignore
-
- assert webhook is webhook_instance
- mock_webhook_cls.assert_called_once_with(url="https://discord.test/webhook", rate_limit_retry=True)
-
- embed_instance.set_title.assert_called_once_with("Update 4.0")
- embed_instance.set_url.assert_called_once_with("https://feeds.c3kay.de/hoyolab.xml")
- embed_instance.set_image.assert_called_once_with(
- url="https://img.example.com/hero.jpg",
- height=1080,
- width=1920,
- )
- embed_instance.set_color.assert_called_once_with("11AAFF")
- embed_instance.set_footer.assert_called_once_with(text="Official")
- embed_instance.add_embed_field.assert_any_call(name="Start", value="")
- embed_instance.add_embed_field.assert_any_call(name="End", value="")
- embed_instance.set_timestamp.assert_called_once_with(timestamp="1711000000")
-
- webhook_instance.add_file.assert_called_once_with(file=b"video-bytes", filename="entry-123.mp4")
- webhook_instance.add_embed.assert_called_once_with(embed_instance)
- assert webhook_instance.content == "https://www.youtube.com/watch?v=abc123_XY"
- webhook_instance.remove_embeds.assert_called_once()
-
- @patch("discord_rss_bot.hoyolab_api.requests.get")
- @patch("discord_rss_bot.hoyolab_api.DiscordEmbed")
- @patch("discord_rss_bot.hoyolab_api.DiscordWebhook")
- def test_handles_invalid_structured_content_without_removing_embeds(
- self,
- mock_webhook_cls: MagicMock,
- mock_embed_cls: MagicMock,
- mock_requests_get: MagicMock,
- ) -> None:
- webhook_instance = MagicMock()
- embed_instance = MagicMock()
- mock_webhook_cls.return_value = webhook_instance
- mock_embed_cls.return_value = embed_instance
- mock_requests_get.return_value = MagicMock(ok=False)
-
- post_data = {
- "post": {
- "subject": "News",
- "content": "{}",
- "structured_content": "not-json",
- },
- }
-
- entry = make_entry()
- entry = typing.cast("Entry", entry)
- webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) # type: ignore
-
- assert webhook is webhook_instance
- webhook_instance.remove_embeds.assert_not_called()
-
-
-def test_extract_post_id_with_querystring() -> None:
- url = "https://www.hoyolab.com/article/38588239?utm_source=feed"
- assert extract_post_id_from_hoyolab_url(url) == "38588239"
-
-
-def test_extract_post_id_non_string_input_returns_none() -> None:
- assert extract_post_id_from_hoyolab_url(None) is None # type: ignore[arg-type]
-
-
-@pytest.mark.parametrize(
- ("url", "expected"),
- [
- ("https://feeds.c3kay.de/rss", True),
- ("https://www.hoyolab.com/feed", False),
- ],
-)
-def test_is_c3kay_feed_parametrized(*, url: str, expected: bool) -> None:
- assert is_c3kay_feed(url) is expected
diff --git a/tests/test_main.py b/tests/test_main.py
index ee1b12a..967ffd3 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
from reader import Entry
from reader import Reader
+ from discord_rss_bot.feeds import JsonValue
+
client: TestClient = TestClient(app)
webhook_name: str = "Hello, I am a webhook!"
webhook_url: str = "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz"
@@ -318,7 +320,7 @@ def test_feed_page_shows_steam_thumbnail_hint_for_steam_feeds() -> None:
assert feed_url == self.feed.url
return self.feed
- def get_tag(self, _resource: object, key: str, default: TestTagValue = None) -> TestTagValue:
+ def get_tag(self, _resource: str | DummyFeed, key: str, default: TestTagValue = None) -> TestTagValue:
return {
"webhooks": [],
"delivery_mode": "embed",
@@ -2645,7 +2647,7 @@ def test_export_opml_with_stub_reader() -> None:
filename="reader-feeds-2026-07-18-12-00-00.opml",
)
- def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
+ def get_tag(self, _resource: tuple[()], _key: str, default: TestTagValue = None) -> TestTagValue:
return default
def get_feeds(self) -> list:
@@ -2672,7 +2674,7 @@ class StubImportConfirmReader:
self.added_urls: list[str] = []
self.tags: dict[tuple[str, str], str] = {}
- def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
+ def get_tag(self, _resource: tuple[()], _key: str, default: TestTagValue = None) -> TestTagValue:
"""Stub get_tag.
Returns:
@@ -2705,7 +2707,7 @@ class StubImportConfirmReaderWithExisting:
self.added_urls: list[str] = []
self.tags: dict[tuple[str, str], str] = {}
- def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object:
+ def get_tag(self, _resource: tuple[()], _key: str, default: TestTagValue = None) -> TestTagValue:
"""Stub get_tag.
Returns:
@@ -2857,7 +2859,6 @@ def test_post_embed_saves_all_fields() -> None:
"thumbnail_url": "https://example.com/thumb.png",
"footer_text": "Footer Text",
"footer_icon_url": "https://example.com/footer.png",
- "show_steam_game_icon_in_thumbnail": "true",
},
follow_redirects=False,
)
@@ -2880,7 +2881,6 @@ def test_post_embed_saves_all_fields() -> None:
assert saved["thumbnail_url"] == "https://example.com/thumb.png"
assert saved["footer_text"] == "Footer Text"
assert saved["footer_icon_url"] == "https://example.com/footer.png"
- assert saved["show_steam_game_icon_in_thumbnail"] is True
finally:
app.dependency_overrides = {}
@@ -2958,7 +2958,6 @@ def test_post_embed_allows_clearing_all_fields() -> None:
"thumbnail_url": "https://old.example.com/thumb.png",
"footer_text": "Old Footer",
"footer_icon_url": "https://old.example.com/footer.png",
- "show_steam_game_icon_in_thumbnail": True,
})
stub = _make_stub_reader_for_embed(stored_embed=existing)
app.dependency_overrides[get_reader_dependency] = lambda: stub
@@ -2998,7 +2997,6 @@ def test_post_embed_allows_clearing_all_fields() -> None:
assert not saved["thumbnail_url"]
assert not saved["footer_text"]
assert not saved["footer_icon_url"]
- assert saved["show_steam_game_icon_in_thumbnail"] is False
finally:
app.dependency_overrides = {}
@@ -3016,7 +3014,6 @@ def test_post_embed_untouched_fields_retain_values() -> None:
"thumbnail_url": "",
"footer_text": "Old Footer",
"footer_icon_url": "",
- "show_steam_game_icon_in_thumbnail": False,
})
stub = _make_stub_reader_for_embed(stored_embed=existing)
app.dependency_overrides[get_reader_dependency] = lambda: stub
@@ -3056,7 +3053,6 @@ def test_post_embed_untouched_fields_retain_values() -> None:
assert saved["author_name"] == "Author"
assert saved["author_url"] == "https://a.example.com"
assert saved["footer_text"] == "Old Footer"
- assert saved["show_steam_game_icon_in_thumbnail"] is False
finally:
app.dependency_overrides = {}
@@ -3094,7 +3090,6 @@ def test_post_embed_saves_empty_description_when_no_prior_embed_exists() -> None
saved: dict[str, str] = json.loads(json_arg)
assert saved["title"] == "Just a Title"
assert not saved["description"], f"Expected empty description, got {saved['description']!r}"
- assert saved["show_steam_game_icon_in_thumbnail"] is False
finally:
app.dependency_overrides = {}
@@ -3412,7 +3407,7 @@ class _StubReaderForMass:
return self._webhook_url
return default
- def set_tag(self, resource: str, key: str, value: object) -> None: # pyright: ignore[reportArgumentType]
+ def set_tag(self, resource: str, key: str, value: JsonValue) -> None:
"""Record tag set calls."""
self.set_tag_calls.append((resource, key, value))
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 3dcddf0..1815e0c 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -39,7 +39,7 @@ class _AutodiscoverHandler(BaseHTTPRequestHandler):
b'type="application/rss+xml" title="Example">