Add support for extensions
Some checks failed
Test and build Docker image / docker (push) Failing after 3s
Some checks failed
Test and build Docker image / docker (push) Failing after 3s
This commit is contained in:
parent
64a116d947
commit
793f67db42
39 changed files with 3670 additions and 1309 deletions
|
|
@ -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
|
||||
|
|
@ -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"<t:\d+(?::[tTdDfFrRsS])?>")
|
||||
|
||||
# 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(
|
||||
|
|
|
|||
157
discord_rss_bot/extensions/README.md
Normal file
157
discord_rss_bot/extensions/README.md
Normal file
|
|
@ -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)_ |
|
||||
32
discord_rss_bot/extensions/__init__.py
Normal file
32
discord_rss_bot/extensions/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
127
discord_rss_bot/extensions/base.py
Normal file
127
discord_rss_bot/extensions/base.py
Normal file
|
|
@ -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)
|
||||
191
discord_rss_bot/extensions/discovery.py
Normal file
191
discord_rss_bot/extensions/discovery.py
Normal file
|
|
@ -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
|
||||
331
discord_rss_bot/extensions/hoyolab.py
Normal file
331
discord_rss_bot/extensions/hoyolab.py
Normal file
|
|
@ -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"<t:{event_start}:R>")
|
||||
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"<t:{event_end}:R>")
|
||||
|
||||
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()
|
||||
285
discord_rss_bot/extensions/jwplayer_thumbnail.py
Normal file
285
discord_rss_bot/extensions/jwplayer_thumbnail.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""Extension: extract JWPlayer thumbnail/video URLs from WordPress sites.
|
||||
|
||||
`JWPlayer <https://www.jwplayer.com/>`_ is a commercial video player
|
||||
used by many sites to serve self-hosted or CDN-hosted video. The player
|
||||
is embedded via a ``<script>`` block with a ``jwplayer()`` setup call
|
||||
that contains ``image:`` (thumbnail) and ``file:`` (video URL) properties.
|
||||
|
||||
Feedparser strips ``<script>`` tags, so these URLs are invisible in the
|
||||
parsed feed content. This extension recovers them by either scanning the
|
||||
raw feed content directly or, for WordPress sites, fetching post HTML
|
||||
from the REST API in a single batched request (cached by slug).
|
||||
|
||||
Created in response to `issue #432
|
||||
<https://github.com/TheLovinator1/discord-rss-bot/issues/432>`_.
|
||||
|
||||
Currently auto-enabled for sites matching ``hentaigasm.com`` and
|
||||
``hgasm[0-9]*.com``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import ClassVar
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx2
|
||||
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Reader
|
||||
|
||||
from discord_rss_bot.feeds import JsonValue
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
_IMAGE_PATTERN: re.Pattern[str] = re.compile(
|
||||
r'image:\s*["\']([^"\']+)["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_FILE_PATTERN: re.Pattern[str] = re.compile(
|
||||
r'file:\s*["\']([^"\']+\.\w+)["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: Shared HTTP client — pooled connections, no TLS handshake per call.
|
||||
_HTTP_CLIENT: httpx2.Client | None = None
|
||||
|
||||
#: Cache of ``{slug: post_data}`` — populated by the first entry
|
||||
#: that needs it, then reused for all subsequent entries from the same site.
|
||||
#: Each post_data dict contains ``content``, ``excerpt``, and ``title`` keys.
|
||||
_SLUG_CACHE: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
_HTTP_OK: int = 200
|
||||
|
||||
|
||||
def _get_http() -> httpx2.Client:
|
||||
"""Return the shared ``httpx2.Client``, creating it on first call.
|
||||
|
||||
Returns:
|
||||
The shared ``httpx2.Client`` instance.
|
||||
"""
|
||||
global _HTTP_CLIENT # ruff:ignore[global-statement]
|
||||
if _HTTP_CLIENT is None:
|
||||
_HTTP_CLIENT = httpx2.Client(timeout=10)
|
||||
return _HTTP_CLIENT
|
||||
|
||||
|
||||
def _extract_slug(url: str) -> str | None:
|
||||
"""Extract the last non-numeric path segment as a potential post slug.
|
||||
|
||||
Args:
|
||||
url: The URL to extract the slug from.
|
||||
|
||||
Returns:
|
||||
The extracted slug, or ``None`` if no slug could be extracted.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
path: str = urlparse(url).path.rstrip("/")
|
||||
segments: list[str] = [s for s in path.split("/") if s]
|
||||
if not segments or segments[-1].isdigit():
|
||||
return None
|
||||
return segments[-1]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_slug_cache(base_url: str) -> dict[str, dict[str, str]]:
|
||||
"""Fetch all recent posts from the WordPress API and build the slug cache.
|
||||
|
||||
Fetches ``content``, ``excerpt``, and ``title`` for each post so that
|
||||
callers can access full post data without additional API requests.
|
||||
|
||||
Args:
|
||||
base_url: The site base URL (scheme + netloc).
|
||||
|
||||
Returns:
|
||||
A dict mapping slugs to ``{content: ..., excerpt: ..., title: ...}``.
|
||||
"""
|
||||
client: httpx2.Client = _get_http()
|
||||
fields: str = "slug,content,excerpt,title"
|
||||
api_url: str = f"{base_url}/wp-json/wp/v2/posts?per_page=100&orderby=date&order=desc&_fields={fields}"
|
||||
resp = client.get(api_url)
|
||||
if resp.status_code != _HTTP_OK:
|
||||
logger.warning("WordPress API returned %s for batch slug lookup", resp.status_code)
|
||||
return {}
|
||||
|
||||
data = resp.json()
|
||||
if not isinstance(data, list):
|
||||
return {}
|
||||
|
||||
fresh: dict[str, dict[str, str]] = {}
|
||||
for post in data:
|
||||
if not isinstance(post, dict):
|
||||
continue
|
||||
ps = post.get("slug")
|
||||
if not isinstance(ps, str):
|
||||
continue
|
||||
content_rendered: str = _extract_rendered(post.get("content"))
|
||||
excerpt_rendered: str = _extract_rendered(post.get("excerpt"))
|
||||
title_rendered: str = _extract_rendered(post.get("title"))
|
||||
fresh[ps] = {
|
||||
"content": content_rendered,
|
||||
"excerpt": excerpt_rendered,
|
||||
"title": title_rendered,
|
||||
}
|
||||
|
||||
logger.info("Batch-fetched %d posts from %s", len(fresh), base_url)
|
||||
return fresh
|
||||
|
||||
|
||||
def _extract_rendered(obj: JsonValue) -> str:
|
||||
"""Extract the ``rendered`` string from a WordPress API object.
|
||||
|
||||
Args:
|
||||
obj: A WordPress API object (typically a dict with a ``rendered`` key).
|
||||
|
||||
Returns:
|
||||
The rendered string, or ``""`` if the object is not a dict or
|
||||
has no ``rendered`` key.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
rendered = obj.get("rendered")
|
||||
if isinstance(rendered, str):
|
||||
return rendered
|
||||
return ""
|
||||
|
||||
|
||||
def _get_rendered_for_slug(slug: str, base_url: str) -> str | None:
|
||||
"""Return the content.rendered HTML for *slug*, fetching on first miss.
|
||||
|
||||
The first call for a given *base_url* triggers a **single** batch
|
||||
request (``?per_page=100``) that caches all recent posts. Every
|
||||
subsequent call for the same site is a dict lookup — zero network.
|
||||
|
||||
Args:
|
||||
slug: The post slug to look up.
|
||||
base_url: The site base URL (scheme + netloc).
|
||||
|
||||
Returns:
|
||||
The rendered content HTML for the post, or ``None`` if not found.
|
||||
"""
|
||||
post_data: dict[str, str] | None = _get_post_data_for_slug(slug, base_url)
|
||||
if post_data is None:
|
||||
return None
|
||||
return post_data.get("content") or None
|
||||
|
||||
|
||||
def _get_post_data_for_slug(slug: str, base_url: str) -> dict[str, str] | None:
|
||||
"""Return the full post data dict for *slug*, fetching on first miss.
|
||||
|
||||
The returned dict contains ``content``, ``excerpt``, and ``title`` keys.
|
||||
|
||||
The first call for a given *base_url* triggers a **single** batch
|
||||
request (``?per_page=100``) that caches all recent posts. Every
|
||||
subsequent call for the same site is a dict lookup — zero network.
|
||||
|
||||
Args:
|
||||
slug: The post slug to look up.
|
||||
base_url: The site base URL (scheme + netloc).
|
||||
|
||||
Returns:
|
||||
A dict with ``content``, ``excerpt``, ``title`` keys, or ``None``.
|
||||
"""
|
||||
site_cache: dict[str, dict[str, str]] | None = _SLUG_CACHE.get(base_url)
|
||||
if site_cache is not None:
|
||||
return site_cache.get(slug)
|
||||
|
||||
try:
|
||||
fresh: dict[str, dict[str, str]] = _build_slug_cache(base_url)
|
||||
except Exception:
|
||||
logger.exception("Failed to batch-fetch WordPress posts from %s", base_url)
|
||||
fresh = {}
|
||||
|
||||
_SLUG_CACHE[base_url] = fresh
|
||||
return fresh.get(slug)
|
||||
|
||||
|
||||
class JWPlayerThumbnailExtension(FeedExtension):
|
||||
"""Extract the ``image`` URL from a JWPlayer ``setup()`` call.
|
||||
|
||||
Uses a single batch WordPress API request per site to fetch all
|
||||
recent post content, then looks up entries by slug — zero API
|
||||
calls per entry.
|
||||
"""
|
||||
|
||||
name = "jwplayer_thumbnail"
|
||||
description = (
|
||||
"Extracts JWPlayer thumbnail and video URLs from entry content "
|
||||
"or the WordPress REST API (batched). "
|
||||
"Available as {{jwplayer_thumbnail}} and {{jwplayer_file}}."
|
||||
)
|
||||
provides_variables: ClassVar[list[str]] = ["jwplayer_thumbnail", "jwplayer_file"]
|
||||
auto_enable_url_patterns: ClassVar[list[str]] = [
|
||||
r"hentaigasm\.com",
|
||||
r"hgasm[0-9]*\.com",
|
||||
]
|
||||
|
||||
def process_entry(self, entry: Entry, _reader: Reader) -> dict[str, str]:
|
||||
"""Extract JWPlayer URLs from feed content or WordPress API.
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
|
||||
Returns:
|
||||
Dict with ``jwplayer_thumbnail`` and ``jwplayer_file``
|
||||
if found, otherwise empty dict.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
|
||||
# Phase 1: search feed content (fast).
|
||||
sources: list[str] = []
|
||||
if entry.content:
|
||||
sources.extend(item.value for item in entry.content if hasattr(item, "value") and item.value)
|
||||
entry_summary: str | None = getattr(entry, "summary", None)
|
||||
if entry_summary:
|
||||
sources.append(entry_summary)
|
||||
|
||||
for source in sources:
|
||||
self._search(source, result)
|
||||
if "jwplayer_thumbnail" in result and "jwplayer_file" in result:
|
||||
return result
|
||||
|
||||
# Phase 2: batch WordPress API lookup (one request per site).
|
||||
if not result:
|
||||
entry_link: str | None = getattr(entry, "link", None)
|
||||
if entry_link:
|
||||
slug: str | None = _extract_slug(entry_link)
|
||||
if slug:
|
||||
base_url: str | None = None
|
||||
try:
|
||||
parsed = urlparse(entry.feed.url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
except ValueError:
|
||||
return result
|
||||
|
||||
html: str | None = _get_rendered_for_slug(slug, base_url)
|
||||
if html:
|
||||
self._search(html, result)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _search(html: str, result: dict[str, str]) -> None:
|
||||
"""Search a single HTML source for JWPlayer patterns.
|
||||
|
||||
Args:
|
||||
html: The HTML content to search.
|
||||
result: The result dict to populate.
|
||||
"""
|
||||
if "jwplayer_thumbnail" not in result:
|
||||
match = _IMAGE_PATTERN.search(html)
|
||||
if match:
|
||||
result["jwplayer_thumbnail"] = quote(match.group(1).strip(), safe=":/")
|
||||
if "jwplayer_file" not in result:
|
||||
match = _FILE_PATTERN.search(html)
|
||||
if match:
|
||||
result["jwplayer_file"] = quote(match.group(1).strip(), safe=":/")
|
||||
239
discord_rss_bot/extensions/runner.py
Normal file
239
discord_rss_bot/extensions/runner.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Functions for running extensions against entries.
|
||||
|
||||
This module is separated from ``__init__.py`` to keep the package's
|
||||
public API as pure re-exports (Ruff rule: non-empty-init-module).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord_rss_bot.extensions.discovery import discover_plugins
|
||||
from discord_rss_bot.extensions.discovery import get_registry
|
||||
from discord_rss_bot.extensions.storage import EXTENSIONS_TAG
|
||||
from discord_rss_bot.extensions.storage import get_enabled_extensions_for_feed
|
||||
from discord_rss_bot.extensions.storage import set_enabled_extensions_for_feed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Reader
|
||||
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
from discord_rss_bot.webhook import DiscordWebhook
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# Auto-discover plugins on first import so the registry is ready.
|
||||
discover_plugins()
|
||||
|
||||
|
||||
def auto_enable_extensions_for_feed(reader: Reader, feed_url: str) -> list[str]:
|
||||
"""Enable extensions whose URL patterns match *feed_url*, if not already enabled.
|
||||
|
||||
Auto-enable only runs when the extensions tag has **never** been explicitly
|
||||
set for this feed. Once the user visits the Extensions page and saves
|
||||
(even with an empty list), auto-enable is skipped — the user's choice
|
||||
is respected.
|
||||
|
||||
This is called automatically when a feed is created, and lazily on first
|
||||
processing for existing feeds.
|
||||
|
||||
Args:
|
||||
reader: The reader instance.
|
||||
feed_url: The feed URL to match against.
|
||||
|
||||
Returns:
|
||||
The updated list of enabled extensions for this feed.
|
||||
"""
|
||||
# If the user has ever saved the Extensions page, respect their choice.
|
||||
if reader.get_tag(feed_url, EXTENSIONS_TAG, None) is not None:
|
||||
return get_enabled_extensions_for_feed(reader, feed_url)
|
||||
|
||||
registry: dict[str, type[FeedExtension]] = get_registry()
|
||||
already_enabled: list[str] = get_enabled_extensions_for_feed(reader, feed_url)
|
||||
|
||||
to_add: list[str] = []
|
||||
for name, cls in registry.items():
|
||||
if name in already_enabled:
|
||||
continue
|
||||
if not getattr(cls, "auto_enable_url_patterns", None):
|
||||
continue
|
||||
for pattern in cls.auto_enable_url_patterns:
|
||||
if re.search(pattern, feed_url):
|
||||
logger.info(
|
||||
"Auto-enabling extension %r for feed %s (matched pattern %r)",
|
||||
name,
|
||||
feed_url,
|
||||
pattern,
|
||||
)
|
||||
to_add.append(name)
|
||||
break
|
||||
|
||||
if not to_add:
|
||||
return already_enabled
|
||||
|
||||
updated: list[str] = already_enabled + [n for n in to_add if n not in already_enabled]
|
||||
set_enabled_extensions_for_feed(reader, feed_url, updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _get_enabled_instances(entry: Entry, reader: Reader) -> list[FeedExtension]:
|
||||
"""Return enabled extension instances for the given entry.
|
||||
|
||||
Auto-enables extensions whose URL patterns match the feed URL
|
||||
(lazy initialisation for feeds that were created before the
|
||||
extension system existed).
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
reader: The reader instance.
|
||||
|
||||
Returns:
|
||||
List of ``FeedExtension`` instances enabled for this entry's feed.
|
||||
Empty list if no extensions are enabled.
|
||||
"""
|
||||
feed_url: str = entry.feed.url
|
||||
enabled: list[str] = auto_enable_extensions_for_feed(reader, feed_url)
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
registry: dict[str, type[FeedExtension]] = get_registry()
|
||||
instances: list[FeedExtension] = []
|
||||
|
||||
for name in enabled:
|
||||
cls = registry.get(name)
|
||||
if cls is None:
|
||||
logger.warning(
|
||||
"Extension %r is enabled for feed %r but was not found in the registry",
|
||||
name,
|
||||
feed_url,
|
||||
)
|
||||
continue
|
||||
instances.append(cls())
|
||||
|
||||
return instances
|
||||
|
||||
|
||||
def _process_extension_instance(
|
||||
instance: FeedExtension,
|
||||
entry: Entry,
|
||||
reader: Reader,
|
||||
results: dict[str, str],
|
||||
) -> bool:
|
||||
"""Process a single extension and update *results* in place.
|
||||
|
||||
The ``try/except`` wrapping belongs in the caller so that this
|
||||
helper stays focused on happy-path logic.
|
||||
|
||||
Args:
|
||||
instance: The extension instance to process.
|
||||
entry: The feed entry to process.
|
||||
reader: The reader instance.
|
||||
results: Accumulator dict to update with variable values.
|
||||
|
||||
Returns:
|
||||
``True`` if the extension result was processed successfully,
|
||||
``False`` if the result was not a ``dict`` (and was skipped).
|
||||
"""
|
||||
extra: dict[str, str] = instance.process_entry(entry, reader)
|
||||
if not isinstance(extra, dict):
|
||||
logger.warning(
|
||||
"Extension %r returned %s, expected dict — skipping",
|
||||
instance.name,
|
||||
type(extra).__name__,
|
||||
)
|
||||
return False
|
||||
for var_name, var_value in extra.items():
|
||||
if not isinstance(var_value, str):
|
||||
logger.warning(
|
||||
"Extension %r returned non-string value for %r (%s) — coercing",
|
||||
instance.name,
|
||||
var_name,
|
||||
type(var_value).__name__,
|
||||
)
|
||||
results[var_name] = str(var_value)
|
||||
else:
|
||||
results[var_name] = var_value
|
||||
return True
|
||||
|
||||
|
||||
def run_extensions(entry: Entry, reader: Reader) -> dict[str, str]:
|
||||
"""Run all enabled extensions for the given entry.
|
||||
|
||||
For every enabled extension, all of its ``provides_variables`` are
|
||||
guaranteed to appear in the result dict. Variables that the
|
||||
extension did not produce (no match in the entry content) are set
|
||||
to an empty string so that ``{{variable}}`` tags are always
|
||||
replaced rather than left as literal text.
|
||||
|
||||
If an extension raises, its error is logged and processing
|
||||
continues with the next one.
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
reader: The reader instance (used to load per-feed config).
|
||||
|
||||
Returns:
|
||||
Flat dict of ``{variable_name: value}`` pairs. Always returns a
|
||||
dict (possibly empty if no extensions are enabled).
|
||||
"""
|
||||
results: dict[str, str] = {}
|
||||
|
||||
for instance in _get_enabled_instances(entry, reader):
|
||||
# Seed with empty strings so every declared variable is at
|
||||
# least present (prevents literal ``{{var}}`` in output).
|
||||
for var_name in getattr(type(instance), "provides_variables", []):
|
||||
results.setdefault(var_name, "")
|
||||
|
||||
try:
|
||||
_process_extension_instance(instance, entry, reader, results)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Extension %r failed while processing entry %s",
|
||||
instance.name,
|
||||
entry.id,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_modify_webhook(
|
||||
webhook: DiscordWebhook,
|
||||
entry: Entry,
|
||||
reader: Reader,
|
||||
) -> DiscordWebhook:
|
||||
"""Let enabled extensions modify the Discord webhook before sending.
|
||||
|
||||
Each enabled extension's ``modify_webhook()`` is called in order.
|
||||
If an extension raises, its error is logged and the webhook is
|
||||
passed to the next extension unchanged.
|
||||
|
||||
Args:
|
||||
webhook: The fully built webhook payload.
|
||||
entry: The feed entry being processed.
|
||||
reader: The reader instance.
|
||||
|
||||
Returns:
|
||||
The (possibly modified) webhook.
|
||||
"""
|
||||
current: DiscordWebhook = webhook
|
||||
|
||||
for instance in _get_enabled_instances(entry, reader):
|
||||
try:
|
||||
current = instance.modify_webhook(current, entry, reader)
|
||||
if current is None:
|
||||
logger.warning(
|
||||
"Extension %r returned None from modify_webhook — skipping",
|
||||
instance.name,
|
||||
)
|
||||
current = webhook
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Extension %r modify_webhook failed for entry %s",
|
||||
instance.name,
|
||||
entry.id,
|
||||
)
|
||||
|
||||
return current
|
||||
276
discord_rss_bot/extensions/steam.py
Normal file
276
discord_rss_bot/extensions/steam.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Steam feed extension: extracts app metadata and provides thumbnail URLs.
|
||||
|
||||
Detects feeds from ``store.steampowered.com`` and ``steamcommunity.com``,
|
||||
extracts the application ID, and exposes the game's capsule image URL
|
||||
as ``{{steam_thumbnail_url}}`` and the app ID as ``{{steam_app_id}}``.
|
||||
|
||||
Usage:
|
||||
1. Enable ``steam`` on the feed's Extensions page.
|
||||
2. In embed settings, set Thumbnail URL to ``{{steam_thumbnail_url}}``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import ClassVar
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from reader import ReaderError
|
||||
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
from discord_rss_bot.settings import data_dir
|
||||
from discord_rss_bot.webhook import DiscordWebhook
|
||||
from discord_rss_bot.webhook import WebhookFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Feed
|
||||
from reader import Reader
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
_STEAM_NETLOCS: frozenset[str] = frozenset({"store.steampowered.com", "steamcommunity.com"})
|
||||
|
||||
|
||||
def is_steam_url(url: str) -> bool:
|
||||
"""Return whether *url* belongs to Steam."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.netloc.lower().removeprefix("www.") in _STEAM_NETLOCS
|
||||
|
||||
|
||||
def _extract_store_app_id(segments: list[str]) -> str | None:
|
||||
"""Extract app ID from ``store.steampowered.com`` path segments.
|
||||
|
||||
Returns:
|
||||
The numeric app ID, or ``None`` if no match.
|
||||
"""
|
||||
for prefix in (
|
||||
("feeds", "news", "app"),
|
||||
("news", "app"),
|
||||
("app",),
|
||||
):
|
||||
if len(segments) > len(prefix) and tuple(segments[: len(prefix)]) == prefix:
|
||||
candidate: str = segments[len(prefix)]
|
||||
if candidate.isdigit():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _extract_community_app_id(segments: list[str]) -> str | None:
|
||||
"""Extract app ID from ``steamcommunity.com`` path segments.
|
||||
|
||||
Returns:
|
||||
The numeric app ID, or ``None`` if no match.
|
||||
"""
|
||||
if len(segments) > 1 and segments[0] in frozenset({"games", "app"}) and segments[1].isdigit():
|
||||
return segments[1]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_app_id_from_query(query: str) -> str | None:
|
||||
"""Extract app ID from query parameters as a fallback.
|
||||
|
||||
Args:
|
||||
query: The URL query string.
|
||||
|
||||
Returns:
|
||||
The numeric app ID, or ``None`` if no match.
|
||||
"""
|
||||
for key in ("appid", "app_id", "appids"):
|
||||
for value in parse_qs(query).get(key, []):
|
||||
if value.strip().isdigit():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_app_id(url: str) -> str | None:
|
||||
"""Extract a Steam application ID from a URL.
|
||||
|
||||
Args:
|
||||
url: The URL to inspect.
|
||||
|
||||
Returns:
|
||||
The numeric app ID if found, otherwise ``None``.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
netloc: str = parsed.netloc.lower().removeprefix("www.")
|
||||
segments: list[str] = [s for s in parsed.path.split("/") if s]
|
||||
|
||||
if netloc == "store.steampowered.com":
|
||||
return _extract_store_app_id(segments) or _extract_app_id_from_query(parsed.query)
|
||||
|
||||
if netloc == "steamcommunity.com":
|
||||
return _extract_community_app_id(segments) or _extract_app_id_from_query(parsed.query)
|
||||
|
||||
# Query parameter fallback for any other netloc
|
||||
return _extract_app_id_from_query(parsed.query)
|
||||
|
||||
|
||||
def _try_read_icon_file(app_id: str) -> WebhookFile | None:
|
||||
"""Read a Steam game icon from disk, returning a ``WebhookFile`` or ``None``.
|
||||
|
||||
Does not catch exceptions — the caller handles them.
|
||||
|
||||
Returns:
|
||||
A ``WebhookFile`` with the icon content, or ``None`` if no icon
|
||||
was found on disk.
|
||||
"""
|
||||
# Check the old project-relative icons/ directory first,
|
||||
# then fall back to the data directory.
|
||||
old_icon: Path = Path(__file__).resolve().parent.parent.parent / "icons" / f"{app_id}.png"
|
||||
icon_path = old_icon if old_icon.is_file() else Path(data_dir) / "steam_icons" / f"{app_id}.png"
|
||||
if not icon_path.is_file():
|
||||
return None
|
||||
icon_bytes: bytes = icon_path.read_bytes()
|
||||
if not icon_bytes:
|
||||
return None
|
||||
content_hash: str = hashlib.sha256(icon_bytes).hexdigest()[:12]
|
||||
return WebhookFile(
|
||||
filename=f"steam-app-{app_id}-{content_hash}.png",
|
||||
content=icon_bytes,
|
||||
)
|
||||
|
||||
|
||||
def get_icon_file_for_app(app_id: str) -> WebhookFile | None:
|
||||
"""Return a local Steam game icon file if one exists on disk."""
|
||||
try:
|
||||
return _try_read_icon_file(app_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to read local Steam icon for app %s", app_id)
|
||||
return None
|
||||
|
||||
|
||||
def _steam_thumbnail_enabled(reader: Reader, feed: Entry | Feed) -> bool:
|
||||
"""Return ``True`` if the "use Steam game icon" toggle is on for *feed*.
|
||||
|
||||
Reads the embed tag directly from the reader to avoid importing
|
||||
``custom_message`` (which would create a circular dependency).
|
||||
Defaults to ``True`` when the tag is missing or unparseable.
|
||||
"""
|
||||
try:
|
||||
embed_tag_raw = reader.get_tag(feed, "embed", None)
|
||||
except ReaderError:
|
||||
return True
|
||||
|
||||
raw_val = True
|
||||
if isinstance(embed_tag_raw, str) and embed_tag_raw.strip():
|
||||
try:
|
||||
embed_data = json.loads(embed_tag_raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return True
|
||||
if isinstance(embed_data, dict):
|
||||
raw_val = embed_data.get("show_steam_game_icon_in_thumbnail", True)
|
||||
elif isinstance(embed_tag_raw, dict):
|
||||
raw_val = embed_tag_raw.get("show_steam_game_icon_in_thumbnail", True)
|
||||
|
||||
return raw_val # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class SteamExtension(FeedExtension):
|
||||
"""Provide ``{{steam_thumbnail_url}}`` and ``{{steam_app_id}}``.
|
||||
|
||||
Extracts the app ID from Steam store and community feeds and makes
|
||||
the game's capsule image URL and app ID available as template variables.
|
||||
"""
|
||||
|
||||
name = "steam"
|
||||
description = (
|
||||
"Extracts the Steam app ID from feed URLs and exposes the game's "
|
||||
"capsule image as {{steam_thumbnail_url}}. "
|
||||
"Available as {{steam_app_id}}."
|
||||
)
|
||||
provides_variables: ClassVar[list[str]] = ["steam_thumbnail_url", "steam_app_id"]
|
||||
auto_enable_url_patterns: ClassVar[list[str]] = [
|
||||
r"store\.steampowered\.com",
|
||||
r"steamcommunity\.com",
|
||||
]
|
||||
|
||||
def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]: # ruff:ignore[unused-method-argument]
|
||||
"""Extract Steam app metadata from the entry.
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
reader: The reader instance.
|
||||
|
||||
Returns:
|
||||
Dict with ``steam_thumbnail_url`` and ``steam_app_id`` if
|
||||
this is a Steam feed, otherwise empty dict.
|
||||
"""
|
||||
feed_url: str = entry.feed.url or ""
|
||||
if not is_steam_url(feed_url):
|
||||
return {}
|
||||
|
||||
app_id: str | None = extract_app_id(feed_url) or extract_app_id(str(entry.link or ""))
|
||||
if not app_id:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"steam_app_id": app_id,
|
||||
"steam_thumbnail_url": f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_sm_120.jpg",
|
||||
}
|
||||
|
||||
def modify_webhook(
|
||||
self,
|
||||
webhook: DiscordWebhook,
|
||||
entry: Entry,
|
||||
reader: Reader,
|
||||
) -> DiscordWebhook:
|
||||
"""Set the embed thumbnail to the Steam game's capsule image.
|
||||
|
||||
Also attaches a locally cached icon file when available.
|
||||
Only applies when ``show_steam_game_icon_in_thumbnail`` is enabled
|
||||
in the feed's embed settings.
|
||||
|
||||
Args:
|
||||
webhook: The fully built webhook payload.
|
||||
entry: The feed entry being processed.
|
||||
reader: The reader instance.
|
||||
|
||||
Returns:
|
||||
The (possibly modified) webhook.
|
||||
"""
|
||||
feed_url: str = entry.feed.url or ""
|
||||
if not is_steam_url(feed_url):
|
||||
return webhook
|
||||
|
||||
if not _steam_thumbnail_enabled(reader, entry.feed):
|
||||
return webhook
|
||||
|
||||
app_id: str | None = extract_app_id(feed_url) or extract_app_id(str(entry.link or ""))
|
||||
if not app_id:
|
||||
return webhook
|
||||
|
||||
# Only modify embed thumbnail when there IS an embed (not in text mode).
|
||||
embeds = webhook.json.get("embeds", [])
|
||||
if not isinstance(embeds, list) or not embeds:
|
||||
return webhook
|
||||
|
||||
# Use a local icon file if available (better quality), otherwise CDN.
|
||||
icon_file: WebhookFile | None = get_icon_file_for_app(app_id)
|
||||
if icon_file:
|
||||
webhook.add_file(file=icon_file.content, filename=icon_file.filename)
|
||||
thumbnail_url: str = f"attachment://{icon_file.filename}"
|
||||
else:
|
||||
thumbnail_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_sm_120.jpg"
|
||||
|
||||
embed = embeds[0]
|
||||
if isinstance(embed, dict):
|
||||
embed["thumbnail"] = {"url": thumbnail_url}
|
||||
|
||||
return webhook
|
||||
68
discord_rss_bot/extensions/storage.py
Normal file
68
discord_rss_bot/extensions/storage.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Per-feed storage of enabled extension lists.
|
||||
|
||||
Enabled extensions for a feed are stored as a JSON list in the reader
|
||||
tag ``"extensions"``. An empty list or missing tag means no extensions
|
||||
are active for that feed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Reader
|
||||
from reader.types import JSONType
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
EXTENSIONS_TAG: str = "extensions"
|
||||
|
||||
|
||||
def get_enabled_extensions_for_feed(reader: Reader, feed_url: str) -> list[str]:
|
||||
"""Return the list of enabled extension names for the given feed.
|
||||
|
||||
Args:
|
||||
reader: The reader instance.
|
||||
feed_url: The feed URL.
|
||||
|
||||
Returns:
|
||||
List of extension names enabled for this feed. Never ``None``.
|
||||
"""
|
||||
raw = reader.get_tag(feed_url, EXTENSIONS_TAG, None)
|
||||
if raw is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw, list):
|
||||
return [str(name) for name in raw]
|
||||
|
||||
if isinstance(raw, str):
|
||||
if not raw.strip():
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [str(name) for name in parsed]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Failed to parse extensions tag for feed %r: %r", feed_url, raw)
|
||||
return []
|
||||
|
||||
logger.warning("Unexpected type for extensions tag on feed %r: %s (%s)", feed_url, type(raw).__name__, raw)
|
||||
return []
|
||||
|
||||
|
||||
def set_enabled_extensions_for_feed(
|
||||
reader: Reader,
|
||||
feed_url: str,
|
||||
extension_names: list[str],
|
||||
) -> None:
|
||||
"""Persist a list of enabled extension names for a feed.
|
||||
|
||||
Args:
|
||||
reader: The reader instance.
|
||||
feed_url: The feed URL.
|
||||
extension_names: Extension names to enable (empty = disable all).
|
||||
"""
|
||||
reader.set_tag(feed_url, EXTENSIONS_TAG, cast("JSONType", extension_names))
|
||||
116
discord_rss_bot/extensions/wordpress.py
Normal file
116
discord_rss_bot/extensions/wordpress.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""WordPress REST API extension: fetch post content with scripts intact.
|
||||
|
||||
Shares the batch cache with ``jwplayer_thumbnail`` so only one API call
|
||||
is made per site regardless of which extension is enabled.
|
||||
|
||||
Provides ``{{wp_content}}`` (formatted) / ``{{wp_content_raw}}`` (raw HTML),
|
||||
``{{wp_excerpt}}`` (formatted) / ``{{wp_excerpt_raw}}`` (raw HTML),
|
||||
``{{wp_jwplayer_thumbnail}}`` and ``{{wp_jwplayer_file}}``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import ClassVar
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
from discord_rss_bot.extensions.jwplayer_thumbnail import _extract_slug
|
||||
from discord_rss_bot.extensions.jwplayer_thumbnail import _get_post_data_for_slug
|
||||
from discord_rss_bot.html_format import format_entry_html_for_discord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Reader
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
_IMAGE_PATTERN: re.Pattern[str] = re.compile(
|
||||
r'image:\s*["\']([^"\']+)["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_FILE_PATTERN: re.Pattern[str] = re.compile(
|
||||
r'file:\s*["\']([^"\']+\.\w+)["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class WordPressExtension(FeedExtension):
|
||||
"""Fetch post content from the WordPress REST API.
|
||||
|
||||
Retrieves full post HTML so that ``<script>`` blocks survive.
|
||||
Shares the batch cache with ``jwplayer_thumbnail``.
|
||||
"""
|
||||
|
||||
name = "wordpress"
|
||||
description = (
|
||||
"Fetches post HTML from the WordPress REST API (batched). "
|
||||
"Extracts JWPlayer URLs. "
|
||||
"Available as {{wp_content}} (formatted) / {{wp_content_raw}} (raw HTML), "
|
||||
"{{wp_excerpt}} (formatted) / {{wp_excerpt_raw}} (raw HTML), "
|
||||
"{{wp_jwplayer_thumbnail}} and {{wp_jwplayer_file}}."
|
||||
)
|
||||
provides_variables: ClassVar[list[str]] = [
|
||||
"wp_content",
|
||||
"wp_content_raw",
|
||||
"wp_excerpt",
|
||||
"wp_excerpt_raw",
|
||||
"wp_jwplayer_file",
|
||||
"wp_jwplayer_thumbnail",
|
||||
]
|
||||
|
||||
def process_entry(self, entry: Entry, _reader: Reader) -> dict[str, str]:
|
||||
"""Return WordPress post data as template variables.
|
||||
|
||||
Uses the shared batch cache (single API call per site, not per entry).
|
||||
Exposes the full post content and excerpt, plus any JWPlayer URLs
|
||||
found within the content.
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
|
||||
Returns:
|
||||
Dict with ``wp_content`` (formatted) / ``wp_content_raw`` (raw), ``wp_excerpt``
|
||||
(formatted) / ``wp_excerpt_raw`` (raw), ``wp_jwplayer_thumbnail``
|
||||
and ``wp_jwplayer_file`` if found, otherwise empty dict.
|
||||
"""
|
||||
entry_link: str = entry.link or ""
|
||||
slug: str | None = _extract_slug(entry_link)
|
||||
if not slug:
|
||||
return {}
|
||||
|
||||
try:
|
||||
parsed = urlparse(entry.feed.url)
|
||||
base_url: str = f"{parsed.scheme}://{parsed.netloc}"
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
post_data: dict[str, str] | None = _get_post_data_for_slug(slug, base_url)
|
||||
if not post_data:
|
||||
return {}
|
||||
|
||||
result: dict[str, str] = {}
|
||||
|
||||
# Expose full content and excerpt from the WordPress REST API.
|
||||
content_html: str = post_data.get("content", "")
|
||||
if content_html:
|
||||
result["wp_content"] = format_entry_html_for_discord(content_html)
|
||||
result["wp_content_raw"] = content_html
|
||||
excerpt_html: str = post_data.get("excerpt", "")
|
||||
if excerpt_html:
|
||||
result["wp_excerpt"] = format_entry_html_for_discord(excerpt_html)
|
||||
result["wp_excerpt_raw"] = excerpt_html
|
||||
|
||||
# Extract JWPlayer URLs from the full content HTML.
|
||||
image_match = _IMAGE_PATTERN.search(content_html)
|
||||
if image_match:
|
||||
result["wp_jwplayer_thumbnail"] = quote(image_match.group(1).strip(), safe=":/")
|
||||
file_match = _FILE_PATTERN.search(content_html)
|
||||
if file_match:
|
||||
result["wp_jwplayer_file"] = quote(file_match.group(1).strip(), safe=":/")
|
||||
|
||||
return result
|
||||
110
discord_rss_bot/extensions/youtube.py
Normal file
110
discord_rss_bot/extensions/youtube.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""YouTube feed extension: extracts video IDs from YouTube video URLs.
|
||||
|
||||
Detects feeds from ``youtube.com/feeds/videos.xml`` and exposes the
|
||||
video ID as ``{{youtube_video_id}}`` and the full embeddable URL as
|
||||
``{{youtube_embed_url}}``.
|
||||
|
||||
Usage:
|
||||
1. Enable ``youtube`` on the feed's Extensions page.
|
||||
2. Use ``{{youtube_video_id}}`` or ``{{youtube_embed_url}}`` in
|
||||
your message template or embed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import ClassVar
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Reader
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# Various YouTube URL formats for video ID extraction.
|
||||
_VIDEO_ID_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"(?:v|vi|video)=([\w-]+)"),
|
||||
re.compile(r"(?:youtu\.be|youtube\.com/embed)/([\w-]+)"),
|
||||
re.compile(r"^([\w-]{11})$"),
|
||||
)
|
||||
|
||||
|
||||
def extract_youtube_video_id(url: str) -> str | None:
|
||||
"""Extract a YouTube video ID from a URL.
|
||||
|
||||
Args:
|
||||
url: The YouTube video URL.
|
||||
|
||||
Returns:
|
||||
The video ID if found, otherwise ``None``.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Try query parameter first
|
||||
for value in parse_qs(parsed.query).get("v", []):
|
||||
if value.strip():
|
||||
return value.strip()
|
||||
|
||||
# Try path-based patterns
|
||||
for pattern in _VIDEO_ID_PATTERNS:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_youtube_feed_url(feed_url: str) -> bool:
|
||||
"""Return whether a feed URL is a YouTube video feed."""
|
||||
return "youtube.com/feeds/videos.xml" in (feed_url or "")
|
||||
|
||||
|
||||
class YouTubeExtension(FeedExtension):
|
||||
"""Extract YouTube video IDs from entry links.
|
||||
|
||||
Makes ``{{youtube_video_id}}`` and ``{{youtube_embed_url}}``
|
||||
available for YouTube channel feeds.
|
||||
"""
|
||||
|
||||
name = "youtube"
|
||||
description = (
|
||||
"Extracts the YouTube video ID from entry links. Available as {{youtube_video_id}} and {{youtube_embed_url}}."
|
||||
)
|
||||
provides_variables: ClassVar[list[str]] = ["youtube_video_id", "youtube_embed_url"]
|
||||
auto_enable_url_patterns: ClassVar[list[str]] = [
|
||||
r"youtube\.com/feeds/videos\.xml",
|
||||
]
|
||||
|
||||
def process_entry(self, entry: Entry, _reader: Reader) -> dict[str, str]:
|
||||
"""Extract the video ID from the entry link.
|
||||
|
||||
Args:
|
||||
entry: The feed entry to process.
|
||||
|
||||
Returns:
|
||||
Dict with ``youtube_video_id`` and ``youtube_embed_url`` if
|
||||
this is a YouTube feed, otherwise empty dict.
|
||||
"""
|
||||
if not is_youtube_feed_url(entry.feed.url):
|
||||
return {}
|
||||
|
||||
entry_link: str = entry.link or ""
|
||||
video_id: str | None = extract_youtube_video_id(entry_link)
|
||||
if not video_id:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"youtube_video_id": video_id,
|
||||
"youtube_embed_url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import concurrent.futures
|
|||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -13,7 +14,6 @@ import re
|
|||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
|
@ -54,11 +54,12 @@ from discord_rss_bot.custom_message import get_validated_message_avatar_url
|
|||
from discord_rss_bot.custom_message import get_validated_message_username
|
||||
from discord_rss_bot.custom_message import replace_tags_in_embed
|
||||
from discord_rss_bot.custom_message import replace_tags_in_text_message
|
||||
from discord_rss_bot.extensions import auto_enable_extensions_for_feed
|
||||
from discord_rss_bot.extensions import run_modify_webhook
|
||||
from discord_rss_bot.extensions.steam import extract_app_id as steam_extract_app_id
|
||||
from discord_rss_bot.extensions.steam import get_icon_file_for_app as steam_get_icon_file
|
||||
from discord_rss_bot.extensions.steam import is_steam_url
|
||||
from discord_rss_bot.filter.evaluator import get_entry_filter_decision_from_reader
|
||||
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
|
||||
from discord_rss_bot.is_url_valid import is_url_valid
|
||||
from discord_rss_bot.settings import default_custom_embed
|
||||
from discord_rss_bot.settings import default_custom_message
|
||||
|
|
@ -92,7 +93,7 @@ class FeedUpdateError(HTTPException):
|
|||
*,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
autodiscover_links: object | None = None,
|
||||
autodiscover_links: JsonValue = None,
|
||||
) -> None:
|
||||
"""Initialize an initial-update error and preserve advertised feed links."""
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
|
|
@ -142,6 +143,27 @@ MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = (
|
|||
)
|
||||
|
||||
|
||||
def get_feed_display_name(feed: Feed) -> str:
|
||||
"""Return a human-readable display name for a feed.
|
||||
|
||||
For YouTube feeds whose title is just "Videos", shows
|
||||
``"{author}'s Videos"`` instead. Other feeds return the title
|
||||
as-is.
|
||||
"""
|
||||
url: str = feed.url or ""
|
||||
title: str = feed.title or ""
|
||||
|
||||
if "youtube.com/feeds/videos.xml" in url and (not title or title == "Videos"):
|
||||
author: str = feed.authors_str or ""
|
||||
if author:
|
||||
return f"{author}'s Videos"
|
||||
|
||||
try:
|
||||
return html.unescape(title) if title else url
|
||||
except (TypeError, AttributeError):
|
||||
return title or url
|
||||
|
||||
|
||||
def extract_domain(url: str) -> str: # ruff:ignore[too-many-return-statements]
|
||||
"""Extract the domain name from a URL.
|
||||
|
||||
|
|
@ -156,11 +178,10 @@ def extract_domain(url: str) -> str: # ruff:ignore[too-many-return-statements]
|
|||
return "Other"
|
||||
|
||||
try: # ruff:ignore[too-many-statements-in-try-clause]
|
||||
# Special handling for YouTube feeds
|
||||
# Special handling for YouTube and Reddit feeds
|
||||
if "youtube.com/feeds/videos.xml" in url:
|
||||
return "YouTube"
|
||||
|
||||
# Special handling for Reddit feeds
|
||||
if "reddit.com" in url and ".rss" in url:
|
||||
return "Reddit"
|
||||
|
||||
|
|
@ -191,123 +212,6 @@ def extract_domain(url: str) -> str: # ruff:ignore[too-many-return-statements]
|
|||
return "Other"
|
||||
|
||||
|
||||
STEAM_STORE_APP_ID_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
("feeds", "news", "app"),
|
||||
("news", "app"),
|
||||
("app",),
|
||||
)
|
||||
|
||||
|
||||
def is_steam_feed_url(url: str) -> bool:
|
||||
"""Return whether a feed URL belongs to Steam."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed_url: ParseResult = urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
normalized_netloc: str = parsed_url.netloc.lower().removeprefix("www.")
|
||||
return normalized_netloc in frozenset({"store.steampowered.com", "steamcommunity.com"})
|
||||
|
||||
|
||||
def _extract_steam_app_id_from_path(normalized_netloc: str, path_segments: list[str]) -> str | None:
|
||||
if normalized_netloc == "store.steampowered.com":
|
||||
for prefix in STEAM_STORE_APP_ID_PATH_PREFIXES:
|
||||
prefix_length: int = len(prefix)
|
||||
if len(path_segments) > prefix_length and tuple(path_segments[:prefix_length]) == prefix:
|
||||
app_id: str = path_segments[prefix_length]
|
||||
if app_id.isdigit():
|
||||
return app_id
|
||||
|
||||
if normalized_netloc == "steamcommunity.com" and len(path_segments) > 1:
|
||||
app_type: str = path_segments[0]
|
||||
app_id = path_segments[1]
|
||||
if app_type in frozenset({"games", "app"}) and app_id.isdigit():
|
||||
return app_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_steam_app_id_from_query(parsed_url: ParseResult) -> str | None:
|
||||
query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
|
||||
for key in ("appid", "app_id", "appids"):
|
||||
for value in query_params.get(key, []):
|
||||
cleaned_value: str = value.strip()
|
||||
if cleaned_value.isdigit():
|
||||
return cleaned_value
|
||||
return None
|
||||
|
||||
|
||||
def extract_steam_app_id_from_url(url: str) -> str | None:
|
||||
"""Extract a Steam application ID from a Steam URL when present.
|
||||
|
||||
Args:
|
||||
url: The Steam URL to inspect.
|
||||
|
||||
Returns:
|
||||
str | None: The application ID when present in the path or query string, otherwise None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_url: ParseResult = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
normalized_netloc: str = parsed_url.netloc.lower().removeprefix("www.")
|
||||
path_segments: list[str] = [segment for segment in parsed_url.path.split("/") if segment]
|
||||
return _extract_steam_app_id_from_path(normalized_netloc, path_segments) or _extract_steam_app_id_from_query(
|
||||
parsed_url,
|
||||
)
|
||||
|
||||
|
||||
def get_local_steam_game_icon_file(app_id: str) -> WebhookFile | None:
|
||||
"""Return a local Steam game icon file for Discord upload when available."""
|
||||
icon_path: Path = Path(__file__).resolve().parent.parent / "icons" / f"{app_id}.png"
|
||||
if not icon_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
icon_bytes: bytes = icon_path.read_bytes()
|
||||
except OSError:
|
||||
logger.exception("Failed to read local Steam icon for app %s from %s", app_id, icon_path)
|
||||
return None
|
||||
|
||||
if not icon_bytes:
|
||||
logger.warning("Local Steam icon file is empty for app %s: %s", app_id, icon_path)
|
||||
return None
|
||||
|
||||
content_hash: str = hashlib.sha256(icon_bytes).hexdigest()[:12]
|
||||
return WebhookFile(
|
||||
filename=f"steam-app-{app_id}-{content_hash}.png",
|
||||
content=icon_bytes,
|
||||
)
|
||||
|
||||
|
||||
def get_steam_game_thumbnail(entry: Entry) -> tuple[str | None, WebhookFile | None]:
|
||||
"""Return the preferred Steam thumbnail source for an entry.
|
||||
|
||||
Returns:
|
||||
tuple[str | None, WebhookFile | None]: Thumbnail URL and optional uploaded file.
|
||||
"""
|
||||
feed_url: str = str(getattr(entry.feed, "url", "") or "")
|
||||
if not is_steam_feed_url(feed_url):
|
||||
return None, None
|
||||
|
||||
app_id: str | None = extract_steam_app_id_from_url(feed_url) or extract_steam_app_id_from_url(str(entry.link or ""))
|
||||
if not app_id:
|
||||
return None, None
|
||||
|
||||
local_icon_file: WebhookFile | None = get_local_steam_game_icon_file(app_id)
|
||||
if local_icon_file:
|
||||
return f"attachment://{local_icon_file.filename}", local_icon_file
|
||||
|
||||
return f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_sm_120.jpg", None
|
||||
|
||||
|
||||
def send_entry_to_discord(entry: Entry, reader: Reader) -> str | None:
|
||||
"""Send a single entry to Discord.
|
||||
|
||||
|
|
@ -349,16 +253,12 @@ def get_entry_delivery_mode(reader: Reader, entry: Entry) -> DeliveryMode:
|
|||
"""Resolve the effective delivery mode for an entry.
|
||||
|
||||
Priority order:
|
||||
1. YouTube feeds are forced to text mode.
|
||||
2. New `delivery_mode` tag when valid.
|
||||
3. Legacy `should_send_embed` flag for backwards compatibility.
|
||||
1. New `delivery_mode` tag when valid.
|
||||
2. Legacy `should_send_embed` flag for backwards compatibility.
|
||||
|
||||
Returns:
|
||||
DeliveryMode: The effective delivery mode for this entry.
|
||||
"""
|
||||
if is_youtube_feed(entry.feed.url):
|
||||
return "text"
|
||||
|
||||
try:
|
||||
delivery_mode_raw: str = str(reader.get_tag(entry.feed, "delivery_mode", "")).strip().lower()
|
||||
except ReaderError:
|
||||
|
|
@ -385,9 +285,6 @@ def get_feed_delivery_mode(reader: Reader, feed: Feed) -> DeliveryMode:
|
|||
Returns:
|
||||
DeliveryMode: The effective delivery mode for this feed.
|
||||
"""
|
||||
if is_youtube_feed(feed.url):
|
||||
return "text"
|
||||
|
||||
try:
|
||||
delivery_mode_raw: str = str(reader.get_tag(feed, "delivery_mode", "")).strip().lower()
|
||||
except ReaderError:
|
||||
|
|
@ -973,11 +870,22 @@ def edit_sent_webhook_message(
|
|||
def apply_feed_webhook_identity(webhook: DiscordWebhook, entry: Entry, reader: Reader) -> DiscordWebhook:
|
||||
"""Apply per-feed custom username and avatar when valid; ignore blank/invalid values.
|
||||
|
||||
Falls back to the feed's author or title when no custom username is set.
|
||||
|
||||
Returns:
|
||||
The same webhook instance with optional identity overrides.
|
||||
"""
|
||||
username: str = get_validated_message_username(reader, entry.feed)
|
||||
avatar_url: str = get_validated_message_avatar_url(reader, entry.feed)
|
||||
feed: Feed = entry.feed
|
||||
username: str = get_validated_message_username(reader, feed)
|
||||
avatar_url: str = get_validated_message_avatar_url(reader, feed)
|
||||
|
||||
if not username:
|
||||
# Fall back to feed author or title so the webhook name is
|
||||
# identifiable rather than the generic Discord webhook default.
|
||||
feed_author: str = feed.authors_str or feed.title or ""
|
||||
if feed_author:
|
||||
username = html.unescape(feed_author)[:80]
|
||||
|
||||
if username:
|
||||
webhook.username = username
|
||||
if avatar_url:
|
||||
|
|
@ -999,22 +907,6 @@ def create_webhook_for_entry(
|
|||
"""
|
||||
delivery_mode: DeliveryMode = get_entry_delivery_mode(reader, entry)
|
||||
|
||||
if delivery_mode == "embed" and is_c3kay_feed(entry.feed.url):
|
||||
entry_link: str | None = entry.link
|
||||
if entry_link:
|
||||
post_id: str | None = extract_post_id_from_hoyolab_url(entry_link)
|
||||
if post_id:
|
||||
post_data = fetch_hoyolab_post(post_id)
|
||||
if post_data:
|
||||
webhook = create_hoyolab_webhook(webhook_url, entry, post_data)
|
||||
return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode
|
||||
logger.warning(
|
||||
"Failed to create Hoyolab webhook for feed %s, falling back to regular processing",
|
||||
entry.feed.url,
|
||||
)
|
||||
else:
|
||||
logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
|
||||
|
||||
if delivery_mode == "embed":
|
||||
webhook = create_embed_webhook(webhook_url, entry, reader=reader)
|
||||
return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode
|
||||
|
|
@ -1873,7 +1765,7 @@ def create_components_v2_webhook(
|
|||
)
|
||||
|
||||
|
||||
def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches]
|
||||
def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches, too-many-statements]
|
||||
webhook_url: str,
|
||||
entry: Entry,
|
||||
reader: Reader,
|
||||
|
|
@ -1895,10 +1787,6 @@ def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches]
|
|||
custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader)
|
||||
media_gallery_image_limit: int = get_feed_media_gallery_image_limit(reader, feed)
|
||||
webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, feed)
|
||||
steam_game_thumbnail_url: str | None = None
|
||||
steam_game_thumbnail_file: WebhookFile | None = None
|
||||
if custom_embed.show_steam_game_icon_in_thumbnail:
|
||||
steam_game_thumbnail_url, steam_game_thumbnail_file = get_steam_game_thumbnail(entry)
|
||||
if media_gallery_image_limit == 0:
|
||||
custom_embed.image_url = ""
|
||||
custom_embed.thumbnail_url = ""
|
||||
|
|
@ -1951,9 +1839,22 @@ def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches]
|
|||
icon_url=custom_embed.author_icon_url,
|
||||
)
|
||||
|
||||
embed_thumbnail_url: str = steam_game_thumbnail_url or custom_embed.thumbnail_url
|
||||
if embed_thumbnail_url:
|
||||
discord_embed.set_thumbnail(url=embed_thumbnail_url)
|
||||
if custom_embed.thumbnail_url:
|
||||
discord_embed.set_thumbnail(url=custom_embed.thumbnail_url)
|
||||
|
||||
# Steam feed: override thumbnail with the game's capsule image when enabled.
|
||||
steam_thumbnail_file: WebhookFile | None = None
|
||||
if custom_embed.show_steam_game_icon_in_thumbnail and is_steam_url(feed.url or ""):
|
||||
app_id: str | None = steam_extract_app_id(feed.url) or steam_extract_app_id(str(entry.link or ""))
|
||||
if app_id:
|
||||
icon_file: WebhookFile | None = steam_get_icon_file(app_id)
|
||||
if icon_file:
|
||||
steam_thumbnail_file = icon_file
|
||||
discord_embed.set_thumbnail(url=f"attachment://{icon_file.filename}")
|
||||
else:
|
||||
discord_embed.set_thumbnail(
|
||||
url=f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_sm_120.jpg",
|
||||
)
|
||||
|
||||
if custom_embed.image_url:
|
||||
discord_embed.set_image(url=custom_embed.image_url)
|
||||
|
|
@ -1967,8 +1868,8 @@ def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches]
|
|||
if custom_embed.footer_icon_url and not custom_embed.footer_text:
|
||||
discord_embed.set_footer(text="-", icon_url=custom_embed.footer_icon_url)
|
||||
|
||||
if steam_game_thumbnail_file:
|
||||
webhook.add_file(file=steam_game_thumbnail_file.content, filename=steam_game_thumbnail_file.filename)
|
||||
if steam_thumbnail_file:
|
||||
webhook.add_file(file=steam_thumbnail_file.content, filename=steam_thumbnail_file.filename)
|
||||
|
||||
webhook.add_embed(discord_embed)
|
||||
return webhook
|
||||
|
|
@ -2098,6 +1999,9 @@ def execute_webhook(
|
|||
logger.warning("Feed not found in reader, not sending entry to Discord: %s", entry_feed.url)
|
||||
return
|
||||
|
||||
# Let enabled extensions modify the webhook before it is sent.
|
||||
webhook = run_modify_webhook(webhook, entry, reader)
|
||||
|
||||
request_payload: JsonObject = get_webhook_request_payload(webhook)
|
||||
payload: JsonObject = get_webhook_message_payload(webhook)
|
||||
response: Response = send_webhook_message(webhook, request_payload)
|
||||
|
|
@ -2116,31 +2020,6 @@ def execute_webhook(
|
|||
upsert_sent_webhook_record(reader, entry, webhook_url, webhook, response, payload)
|
||||
|
||||
|
||||
def is_youtube_feed(feed_url: str) -> bool:
|
||||
"""Check if the feed is a YouTube feed.
|
||||
|
||||
Args:
|
||||
feed_url: The feed URL to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the feed is a YouTube feed, False otherwise.
|
||||
"""
|
||||
return "youtube.com/feeds/videos.xml" in feed_url
|
||||
|
||||
|
||||
def should_send_embed_check(reader: Reader, entry: Entry) -> bool:
|
||||
"""Check if we should send an embed to Discord.
|
||||
|
||||
Args:
|
||||
reader (Reader): The reader to use.
|
||||
entry (Entry): The entry to check.
|
||||
|
||||
Returns:
|
||||
bool: True if we should send an embed, False otherwise.
|
||||
"""
|
||||
return get_entry_delivery_mode(reader, entry) == "embed"
|
||||
|
||||
|
||||
def truncate_webhook_message(
|
||||
webhook_message: str,
|
||||
*,
|
||||
|
|
@ -2165,7 +2044,7 @@ def truncate_webhook_message(
|
|||
return f"{webhook_message[:head_length]}...{webhook_message[-tail_length:]}"
|
||||
|
||||
|
||||
def get_raw_autodiscover_links(reader: Reader, feed_url: str) -> object | None:
|
||||
def get_raw_autodiscover_links(reader: Reader, feed_url: str):
|
||||
"""Return advertised feed links stored after a failed initial update."""
|
||||
try:
|
||||
return reader.get_tag(feed_url, ".reader.autodiscover", None)
|
||||
|
|
@ -2181,7 +2060,7 @@ def remove_invalid_new_feed(reader: Reader, feed_url: str) -> None:
|
|||
logger.exception("Failed to remove invalid feed after initial update: %s", feed_url)
|
||||
|
||||
|
||||
def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # ruff:ignore[complex-structure, too-many-branches]
|
||||
def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # ruff:ignore[complex-structure, too-many-branches, too-many-statements]
|
||||
"""Add a new feed, update it and mark every entry as read.
|
||||
|
||||
Args:
|
||||
|
|
@ -2281,5 +2160,8 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None:
|
|||
# Set the default embed tag when creating the feed
|
||||
reader.set_tag(clean_feed_url, "embed", json.dumps(default_custom_embed)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
# Auto-enable extensions whose URL patterns match this feed URL.
|
||||
auto_enable_extensions_for_feed(reader, clean_feed_url)
|
||||
|
||||
# Update the full-text search index so our new feed is searchable.
|
||||
reader.update_search()
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord_rss_bot.filter.evaluator import find_filter_match
|
||||
from discord_rss_bot.filter.evaluator import get_filter_values_from_reader
|
||||
from discord_rss_bot.filter.evaluator import has_filter_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Feed
|
||||
from reader import Reader
|
||||
|
||||
|
||||
def feed_has_blacklist_tags(reader: Reader, feed: Feed) -> bool:
|
||||
"""Return True if the feed has blacklist tags.
|
||||
|
||||
The following tags are checked:
|
||||
- blacklist_author
|
||||
- blacklist_content
|
||||
- blacklist_summary
|
||||
- blacklist_title
|
||||
- regex_blacklist_author
|
||||
- regex_blacklist_content
|
||||
- regex_blacklist_summary
|
||||
- regex_blacklist_title
|
||||
|
||||
Args:
|
||||
reader: The reader.
|
||||
feed: The feed to check.
|
||||
|
||||
Returns:
|
||||
bool: If the feed has any of the tags.
|
||||
"""
|
||||
return has_filter_values(get_filter_values_from_reader(reader, feed, "blacklist"))
|
||||
|
||||
|
||||
def entry_should_be_skipped(reader: Reader, entry: Entry) -> bool:
|
||||
"""Return True if the entry is in the blacklist.
|
||||
|
||||
Args:
|
||||
reader: The reader.
|
||||
entry: The entry to check.
|
||||
|
||||
Returns:
|
||||
bool: If the entry is in the blacklist.
|
||||
"""
|
||||
return bool(find_filter_match(entry, get_filter_values_from_reader(reader, entry.feed, "blacklist"), "blacklist"))
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord_rss_bot.filter.utils import is_regex_match
|
||||
|
|
@ -259,3 +261,46 @@ def get_entry_decision_key(entry: Entry) -> str:
|
|||
str: A stable key based on feed URL and entry id.
|
||||
"""
|
||||
return f"{entry.feed.url}|{entry.id}"
|
||||
|
||||
|
||||
# ── Convenience wrappers (formerly in separate modules) ──────────────
|
||||
|
||||
|
||||
def feed_has_blacklist_tags(reader: Reader, feed: Feed) -> bool:
|
||||
"""Return True if the feed has any blacklist tags."""
|
||||
return has_filter_values(get_filter_values_from_reader(reader, feed, "blacklist"))
|
||||
|
||||
|
||||
def entry_should_be_skipped(reader: Reader, entry: Entry) -> bool:
|
||||
"""Return True if the entry matches a blacklist rule."""
|
||||
return bool(find_filter_match(entry, get_filter_values_from_reader(reader, entry.feed, "blacklist"), "blacklist"))
|
||||
|
||||
|
||||
def has_white_tags(reader: Reader, feed: Feed) -> bool:
|
||||
"""Return True if the feed has any whitelist tags."""
|
||||
return has_filter_values(get_filter_values_from_reader(reader, feed, "whitelist"))
|
||||
|
||||
|
||||
def should_be_sent(reader: Reader, entry: Entry) -> bool:
|
||||
"""Return True if the entry matches a whitelist rule."""
|
||||
return bool(find_filter_match(entry, get_filter_values_from_reader(reader, entry.feed, "whitelist"), "whitelist"))
|
||||
|
||||
|
||||
def entry_is_whitelisted(entry_to_check: Entry, reader: Reader) -> bool:
|
||||
"""Return True if the entry is whitelisted."""
|
||||
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:
|
||||
"""Return True if the entry is blacklisted."""
|
||||
return get_entry_filter_decision_from_reader(reader, entry_to_check).blacklist_match is not None
|
||||
|
||||
|
||||
@cache
|
||||
def encode_url(url_to_quote: str | None) -> str:
|
||||
"""%-escape a URL so it can be used in a URL query parameter.
|
||||
|
||||
Returns:
|
||||
str: The percent-encoded URL.
|
||||
"""
|
||||
return urllib.parse.quote(string=url_to_quote) if url_to_quote else ""
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord_rss_bot.filter.evaluator import find_filter_match
|
||||
from discord_rss_bot.filter.evaluator import get_filter_values_from_reader
|
||||
from discord_rss_bot.filter.evaluator import has_filter_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
from reader import Feed
|
||||
from reader import Reader
|
||||
|
||||
|
||||
def has_white_tags(reader: Reader, feed: Feed) -> bool:
|
||||
"""Return True if the feed has whitelist tags.
|
||||
|
||||
The following tags are checked:
|
||||
- regex_whitelist_author
|
||||
- regex_whitelist_content
|
||||
- regex_whitelist_summary
|
||||
- regex_whitelist_title
|
||||
- whitelist_author
|
||||
- whitelist_content
|
||||
- whitelist_summary
|
||||
- whitelist_title
|
||||
|
||||
Args:
|
||||
reader: The reader.
|
||||
feed: The feed to check.
|
||||
|
||||
Returns:
|
||||
bool: If the feed has any of the tags.
|
||||
"""
|
||||
return has_filter_values(get_filter_values_from_reader(reader, feed, "whitelist"))
|
||||
|
||||
|
||||
def should_be_sent(reader: Reader, entry: Entry) -> bool:
|
||||
"""Return True if the entry is in the whitelist.
|
||||
|
||||
Args:
|
||||
reader: The reader.
|
||||
entry: The entry to check.
|
||||
|
||||
Returns:
|
||||
bool: If the entry is in the whitelist.
|
||||
"""
|
||||
return bool(find_filter_match(entry, get_filter_values_from_reader(reader, entry.feed, "whitelist"), "whitelist"))
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
|
||||
from discord_rss_bot.webhook import DiscordEmbed
|
||||
from discord_rss_bot.webhook import DiscordWebhook
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Entry
|
||||
|
||||
|
||||
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(feed_url: str) -> bool:
|
||||
"""Check if the feed is from c3kay.de.
|
||||
|
||||
Args:
|
||||
feed_url: The feed URL to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the feed is from c3kay.de, False otherwise.
|
||||
"""
|
||||
return "feeds.c3kay.de" in feed_url
|
||||
|
||||
|
||||
def extract_post_id_from_hoyolab_url(url: str) -> str | None:
|
||||
"""Extract the post ID from a Hoyolab URL.
|
||||
|
||||
Args:
|
||||
url: The Hoyolab URL to extract the post ID from.
|
||||
For example: https://www.hoyolab.com/article/38588239
|
||||
|
||||
Returns:
|
||||
str | None: The post ID if found, None otherwise.
|
||||
"""
|
||||
try:
|
||||
match: re.Match[str] | None = re.search(r"/article/(\d+)", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except (ValueError, AttributeError, TypeError) as e:
|
||||
logger.warning("Error extracting post ID from Hoyolab URL %s: %s", url, e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def as_json_object(value: JsonValue) -> JsonObject:
|
||||
"""Return value as a JSON object when possible.
|
||||
|
||||
Returns:
|
||||
JsonObject: The input dict or an empty dict.
|
||||
"""
|
||||
return cast("JsonObject", value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def fetch_hoyolab_post(post_id: str) -> JsonObject | None:
|
||||
"""Fetch post data from the Hoyolab API.
|
||||
|
||||
Args:
|
||||
post_id: The post ID to fetch.
|
||||
|
||||
Returns:
|
||||
JsonObject | None: The post data if successful, None otherwise.
|
||||
"""
|
||||
if not post_id:
|
||||
return None
|
||||
|
||||
http_ok = 200
|
||||
try: # ruff:ignore[too-many-statements-in-try-clause]
|
||||
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 = as_json_object(data.get("data"))
|
||||
post_payload: JsonObject = as_json_object(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)
|
||||
except (requests.RequestException, ValueError):
|
||||
logger.exception("Error fetching Hoyolab post %s", post_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject) -> DiscordWebhook: # ruff:ignore[complex-structure, too-many-branches, too-many-locals, too-many-statements]
|
||||
"""Create a webhook with data from the Hoyolab API.
|
||||
|
||||
Args:
|
||||
webhook_url: The webhook URL.
|
||||
entry: The entry to send to Discord.
|
||||
post_data: The post data from the Hoyolab API.
|
||||
|
||||
Returns:
|
||||
DiscordWebhook: The webhook with the embed.
|
||||
"""
|
||||
entry_link: str = entry.link or entry.feed.url
|
||||
webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True)
|
||||
|
||||
# Extract relevant data from the post
|
||||
post: JsonObject = as_json_object(post_data.get("post"))
|
||||
subject: str = str(post.get("subject", ""))
|
||||
content: str = str(post.get("content", "{}"))
|
||||
|
||||
logger.debug("Post subject: %s", subject)
|
||||
logger.debug("Post content: %s", content)
|
||||
|
||||
content_data: JsonObject = {}
|
||||
with contextlib.suppress(json.JSONDecodeError, ValueError):
|
||||
loaded_content = cast("JsonValue", json.loads(content))
|
||||
content_data = as_json_object(loaded_content)
|
||||
|
||||
logger.debug("Content data: %s", content_data)
|
||||
|
||||
description: str = str(content_data.get("describe", ""))
|
||||
if not description:
|
||||
description = str(post.get("desc", ""))
|
||||
|
||||
# Create the embed
|
||||
discord_embed = DiscordEmbed()
|
||||
|
||||
# Set title and description
|
||||
discord_embed.set_title(subject)
|
||||
discord_embed.set_url(entry_link)
|
||||
|
||||
# Get post.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:
|
||||
image_url: str = str(image_list[0].get("url", ""))
|
||||
image_height: int = int(image_list[0].get("height", "1080")) # pyright: ignore[reportArgumentType]
|
||||
image_width: int = int(image_list[0].get("width", "1920")) # pyright: ignore[reportArgumentType]
|
||||
|
||||
logger.debug("Image URL: %s, Height: %s, Width: %s", image_url, image_height, image_width)
|
||||
discord_embed.set_image(url=image_url, height=image_height, width=image_width)
|
||||
|
||||
video: JsonObject = as_json_object(post_data.get("video"))
|
||||
if video and video.get("url"):
|
||||
video_url: str = str(video.get("url", ""))
|
||||
logger.debug("Video URL: %s", video_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",
|
||||
)
|
||||
|
||||
game: JsonObject = as_json_object(post_data.get("game"))
|
||||
|
||||
if game and game.get("color"):
|
||||
game_color = str(game.get("color", ""))
|
||||
discord_embed.set_color(game_color.removeprefix("#"))
|
||||
|
||||
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
|
||||
|
||||
classification: JsonObject = as_json_object(post_data.get("classification"))
|
||||
|
||||
if classification and classification.get("name"):
|
||||
footer = str(classification.get("name", ""))
|
||||
discord_embed.set_footer(text=footer)
|
||||
|
||||
webhook.add_embed(discord_embed)
|
||||
|
||||
# Only show Youtube URL if available
|
||||
structured_content: str = str(post.get("structured_content", ""))
|
||||
if structured_content: # ruff:ignore[too-many-nested-blocks]
|
||||
try: # ruff:ignore[too-many-statements-in-try-clause]
|
||||
loaded_structured_content = cast("JsonValue", json.loads(structured_content))
|
||||
structured_content_data: list[JsonObject] = (
|
||||
[cast("JsonObject", item) for item in loaded_structured_content if isinstance(item, dict)]
|
||||
if isinstance(loaded_structured_content, list)
|
||||
else []
|
||||
)
|
||||
for item in structured_content_data:
|
||||
insert: JsonObject = as_json_object(item.get("insert"))
|
||||
if insert:
|
||||
video_url: str = str(insert.get("video", ""))
|
||||
if video_url:
|
||||
video_id_match: re.Match[str] | None = re.search(r"embed/([a-zA-Z0-9_-]+)", video_url)
|
||||
if video_id_match:
|
||||
video_id: str = video_id_match.group(1)
|
||||
logger.debug("Video ID: %s", video_id)
|
||||
webhook.content = f"https://www.youtube.com/watch?v={video_id}"
|
||||
webhook.remove_embeds()
|
||||
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Error parsing structured content: %s", e)
|
||||
|
||||
event_start_date: str = str(post.get("event_start_date", ""))
|
||||
if event_start_date and event_start_date != "0":
|
||||
discord_embed.add_embed_field(name="Start", value=f"<t:{event_start_date}:R>")
|
||||
|
||||
event_end_date: str = str(post.get("event_end_date", ""))
|
||||
if event_end_date and event_end_date != "0":
|
||||
discord_embed.add_embed_field(name="End", value=f"<t:{event_end_date}:R>")
|
||||
|
||||
created_at: str = str(post.get("created_at", ""))
|
||||
if created_at and created_at != "0":
|
||||
discord_embed.set_timestamp(timestamp=created_at)
|
||||
|
||||
return webhook
|
||||
77
discord_rss_bot/html_format.py
Normal file
77
discord_rss_bot/html_format.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""HTML formatting utilities for Discord message content.
|
||||
|
||||
Converts entry HTML to Discord-friendly markdown while preserving
|
||||
Discord timestamp tags (``<t:12345:R>``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
from markdownify import markdownify
|
||||
|
||||
DISCORD_TIMESTAMP_TAG_RE: re.Pattern[str] = re.compile(r"<t:\d+(?::[tTdDfFrRsS])?>")
|
||||
|
||||
_REDUNDANT_LINK_PREFIX_RE: re.Pattern[str] = re.compile(r"\[https://(www\.)?")
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
formatted_text = _REDUNDANT_LINK_PREFIX_RE.sub("[", formatted_text)
|
||||
|
||||
return _restore_discord_timestamp_tags(formatted_text, replacements)
|
||||
|
|
@ -52,8 +52,6 @@ from reader import opml
|
|||
from starlette.responses import RedirectResponse
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from discord_rss_bot.custom_filters import entry_is_blacklisted
|
||||
from discord_rss_bot.custom_filters import entry_is_whitelisted
|
||||
from discord_rss_bot.custom_message import CustomEmbed
|
||||
from discord_rss_bot.custom_message import get_custom_message
|
||||
from discord_rss_bot.custom_message import get_embed
|
||||
|
|
@ -62,6 +60,14 @@ from discord_rss_bot.custom_message import get_message_avatar_url
|
|||
from discord_rss_bot.custom_message import get_message_username
|
||||
from discord_rss_bot.custom_message import replace_tags_in_text_message
|
||||
from discord_rss_bot.custom_message import save_embed
|
||||
from discord_rss_bot.extensions import FeedExtension as FeedExtensionABC
|
||||
from discord_rss_bot.extensions import get_registry as get_extension_registry
|
||||
from discord_rss_bot.extensions import run_extensions
|
||||
from discord_rss_bot.extensions.steam import is_steam_url as is_steam_feed_url
|
||||
from discord_rss_bot.extensions.storage import get_enabled_extensions_for_feed as get_enabled_extensions
|
||||
from discord_rss_bot.extensions.storage import set_enabled_extensions_for_feed as set_enabled_extensions
|
||||
from discord_rss_bot.extensions.youtube import extract_youtube_video_id
|
||||
from discord_rss_bot.extensions.youtube import is_youtube_feed_url
|
||||
from discord_rss_bot.feeds import FeedUpdateError
|
||||
from discord_rss_bot.feeds import JsonValue
|
||||
from discord_rss_bot.feeds import SentWebhookRecord
|
||||
|
|
@ -71,12 +77,12 @@ from discord_rss_bot.feeds import create_feed
|
|||
from discord_rss_bot.feeds import extract_domain
|
||||
from discord_rss_bot.feeds import feed_saves_sent_webhooks
|
||||
from discord_rss_bot.feeds import get_feed_delivery_mode
|
||||
from discord_rss_bot.feeds import get_feed_display_name
|
||||
from discord_rss_bot.feeds import get_feed_media_gallery_image_limit
|
||||
from discord_rss_bot.feeds import get_feed_webhook_text_length_limit
|
||||
from discord_rss_bot.feeds import get_screenshot_layout
|
||||
from discord_rss_bot.feeds import get_sent_webhook_records
|
||||
from discord_rss_bot.feeds import is_chromium_installed
|
||||
from discord_rss_bot.feeds import is_steam_feed_url
|
||||
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 update_feed_and_collect_modified_entries
|
||||
|
|
@ -85,6 +91,8 @@ from discord_rss_bot.filter.evaluator import FILTER_FIELDS
|
|||
from discord_rss_bot.filter.evaluator import EntryFilterDecision
|
||||
from discord_rss_bot.filter.evaluator import FilterMatch
|
||||
from discord_rss_bot.filter.evaluator import coerce_filter_values
|
||||
from discord_rss_bot.filter.evaluator import entry_is_blacklisted
|
||||
from discord_rss_bot.filter.evaluator import entry_is_whitelisted
|
||||
from discord_rss_bot.filter.evaluator import evaluate_entry_filters
|
||||
from discord_rss_bot.filter.evaluator import get_entry_decision_key
|
||||
from discord_rss_bot.filter.evaluator import get_entry_fields
|
||||
|
|
@ -97,6 +105,7 @@ from discord_rss_bot.search import create_search_context
|
|||
from discord_rss_bot.settings import data_dir
|
||||
from discord_rss_bot.settings import default_custom_embed
|
||||
from discord_rss_bot.settings import default_custom_message
|
||||
from discord_rss_bot.settings import extensions_dir
|
||||
from discord_rss_bot.settings import get_reader
|
||||
from discord_rss_bot.settings import make_app_reader
|
||||
|
||||
|
|
@ -283,6 +292,7 @@ templates: Jinja2Templates = Jinja2Templates(directory="discord_rss_bot/template
|
|||
templates.env.filters["encode_url"] = lambda url: urllib.parse.quote(str(url)) if url else ""
|
||||
templates.env.filters["discord_markdown"] = markdownify # pyright: ignore[reportArgumentType]
|
||||
templates.env.filters["relative_time"] = relative_time
|
||||
templates.env.filters["feed_display_name"] = get_feed_display_name
|
||||
templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[reportArgumentType]
|
||||
templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
|
@ -493,7 +503,7 @@ def get_global_delivery_mode(reader: Reader) -> str:
|
|||
def get_autodiscover_links(
|
||||
reader: Reader,
|
||||
feed_url: str,
|
||||
stored_links: object | None = None,
|
||||
stored_links: JsonValue = None,
|
||||
) -> list[AutodiscoverLink]:
|
||||
"""Return valid autodiscovered links stored for a failed feed update.
|
||||
|
||||
|
|
@ -1402,18 +1412,41 @@ async def get_custom(
|
|||
"""
|
||||
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
|
||||
|
||||
context: dict[str, Request | Feed | str | Entry] = {
|
||||
# Collect extension variable names for the template.
|
||||
ext_registry: dict[str, type] = get_extension_registry()
|
||||
enabled_ext_names: list[str] = get_enabled_extensions(reader, feed.url)
|
||||
extension_variables: list[str] = FeedExtensionABC.get_enabled_variables(ext_registry, enabled_ext_names)
|
||||
|
||||
# Try to resolve extension values from the first entry.
|
||||
extension_values: dict[str, str] = {}
|
||||
first_entry: Entry | None = None
|
||||
for entry in reader.get_entries(feed=feed, limit=1):
|
||||
first_entry = entry
|
||||
break
|
||||
if first_entry is not None:
|
||||
try:
|
||||
extension_values = run_extensions(first_entry, reader)
|
||||
except Exception:
|
||||
logger.exception("Failed to run extensions for preview")
|
||||
|
||||
# Compute first_image for template preview.
|
||||
first_image: str = ""
|
||||
if first_entry is not None:
|
||||
content_str: str = first_entry.content[0].value if first_entry.content else ""
|
||||
first_image = get_first_image(first_entry.summary, content_str)
|
||||
|
||||
context: dict[str, object] = {
|
||||
"request": request,
|
||||
"feed": feed,
|
||||
"custom_message": get_custom_message(reader, feed),
|
||||
"message_username": get_message_username(reader, feed),
|
||||
"message_avatar_url": get_message_avatar_url(reader, feed),
|
||||
"extension_variables": extension_variables,
|
||||
"extension_values": extension_values,
|
||||
"entry": first_entry,
|
||||
"first_image": first_image,
|
||||
}
|
||||
|
||||
# Get the first entry, this is used to show the user what the custom message will look like.
|
||||
for entry in reader.get_entries(feed=feed, limit=1):
|
||||
context["entry"] = entry
|
||||
|
||||
return templates.TemplateResponse(request=request, name="custom.html", context=context)
|
||||
|
||||
|
||||
|
|
@ -1431,34 +1464,58 @@ async def get_embed_page(
|
|||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The embed page.
|
||||
HTMLResponse: The custom message page.
|
||||
"""
|
||||
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
|
||||
|
||||
# Get previous data, this is used when creating the form.
|
||||
embed: CustomEmbed = get_embed(reader, feed)
|
||||
|
||||
context: dict[str, Request | Feed | str | Entry | CustomEmbed | bool] = {
|
||||
# Collect extension variables so the template can list them.
|
||||
ext_registry: dict[str, type] = get_extension_registry()
|
||||
enabled_ext_names: list[str] = get_enabled_extensions(reader, feed.url)
|
||||
extension_variables: list[str] = FeedExtensionABC.get_enabled_variables(ext_registry, enabled_ext_names)
|
||||
|
||||
# Try to resolve extension values from the first entry.
|
||||
extension_values: dict[str, str] = {}
|
||||
first_entry: Entry | None = None
|
||||
for entry in reader.get_entries(feed=feed, limit=1):
|
||||
first_entry = entry
|
||||
break
|
||||
if first_entry is not None:
|
||||
try:
|
||||
extension_values = run_extensions(first_entry, reader)
|
||||
except Exception:
|
||||
logger.exception("Failed to run extensions for preview")
|
||||
|
||||
# Compute first_image for template preview.
|
||||
first_image: str = ""
|
||||
if first_entry is not None:
|
||||
content_str: str = first_entry.content[0].value if first_entry.content else ""
|
||||
first_image = get_first_image(first_entry.summary, content_str)
|
||||
|
||||
context: dict[str, object] = {
|
||||
"request": request,
|
||||
"feed": feed,
|
||||
"title": embed.title,
|
||||
"description": embed.description,
|
||||
"color": embed.color,
|
||||
"image_url": embed.image_url,
|
||||
"thumbnail_url": embed.thumbnail_url,
|
||||
"author_name": embed.author_name,
|
||||
"author_url": embed.author_url,
|
||||
"author_icon_url": embed.author_icon_url,
|
||||
"image_url": embed.image_url,
|
||||
"thumbnail_url": embed.thumbnail_url,
|
||||
"footer_text": embed.footer_text,
|
||||
"footer_icon_url": embed.footer_icon_url,
|
||||
"show_steam_game_icon_in_thumbnail": embed.show_steam_game_icon_in_thumbnail,
|
||||
"is_steam_feed": is_steam_feed_url(feed.url or ""),
|
||||
"extension_variables": extension_variables,
|
||||
"extension_values": extension_values,
|
||||
"entry": first_entry,
|
||||
"first_image": first_image,
|
||||
}
|
||||
if custom_embed := get_embed(reader, feed):
|
||||
context["custom_embed"] = custom_embed
|
||||
|
||||
for entry in reader.get_entries(feed=feed, limit=1):
|
||||
# Append to context.
|
||||
context["entry"] = entry
|
||||
return templates.TemplateResponse(request=request, name="embed.html", context=context)
|
||||
|
||||
|
||||
|
|
@ -1476,8 +1533,7 @@ async def post_embed( # ruff:ignore[complex-structure]
|
|||
author_icon_url: Annotated[str, Form()] = "",
|
||||
footer_text: Annotated[str, Form()] = "",
|
||||
footer_icon_url: Annotated[str, Form()] = "",
|
||||
*,
|
||||
show_steam_game_icon_in_thumbnail: Annotated[bool, Form()] = False,
|
||||
show_steam_game_icon_in_thumbnail: Annotated[str, Form()] = "",
|
||||
) -> RedirectResponse:
|
||||
"""Set the embed settings.
|
||||
|
||||
|
|
@ -1493,7 +1549,7 @@ async def post_embed( # ruff:ignore[complex-structure]
|
|||
author_icon_url: The author icon url of the embed.
|
||||
footer_text: The footer text of the embed.
|
||||
footer_icon_url: The footer icon url of the embed.
|
||||
show_steam_game_icon_in_thumbnail: Whether to use the Steam game icon as the embed thumbnail.
|
||||
show_steam_game_icon_in_thumbnail: Whether to use Steam game icon as thumbnail.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
|
|
@ -1524,8 +1580,11 @@ async def post_embed( # ruff:ignore[complex-structure]
|
|||
custom_embed.footer_text = footer_text
|
||||
if footer_icon_url != custom_embed.footer_icon_url:
|
||||
custom_embed.footer_icon_url = footer_icon_url
|
||||
if show_steam_game_icon_in_thumbnail != custom_embed.show_steam_game_icon_in_thumbnail:
|
||||
custom_embed.show_steam_game_icon_in_thumbnail = show_steam_game_icon_in_thumbnail
|
||||
|
||||
# Handle the steam thumbnail toggle (checkbox: "true" when checked, "" when unchecked).
|
||||
new_steam_icon_toggle: bool = show_steam_game_icon_in_thumbnail.strip().lower() in {"true", "on", "1"}
|
||||
if new_steam_icon_toggle != custom_embed.show_steam_game_icon_in_thumbnail:
|
||||
custom_embed.show_steam_game_icon_in_thumbnail = new_steam_icon_toggle
|
||||
|
||||
# Save the data.
|
||||
save_embed(reader, feed, custom_embed)
|
||||
|
|
@ -1534,6 +1593,60 @@ async def post_embed( # ruff:ignore[complex-structure]
|
|||
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/extensions", response_class=HTMLResponse)
|
||||
async def get_extensions(
|
||||
feed_url: str,
|
||||
request: Request,
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
):
|
||||
"""Show the extensions configuration page for a feed.
|
||||
|
||||
Args:
|
||||
feed_url: The feed to configure extensions for.
|
||||
request: The request object.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: The extensions configuration page.
|
||||
"""
|
||||
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
|
||||
registry: dict[str, type] = get_extension_registry()
|
||||
enabled: list[str] = get_enabled_extensions(reader, feed.url)
|
||||
|
||||
context: dict[str, object] = {
|
||||
"request": request,
|
||||
"feed": feed,
|
||||
"discovered_extensions": registry,
|
||||
"enabled_extensions": enabled,
|
||||
"extensions_dir": extensions_dir,
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="extensions.html", context=context)
|
||||
|
||||
|
||||
@app.post("/extensions")
|
||||
async def post_extensions(
|
||||
feed_url: Annotated[str, Form()],
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
enabled_extensions: Annotated[list[str] | None, Form()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""Save the enabled extensions for a feed.
|
||||
|
||||
Args:
|
||||
feed_url: The feed URL.
|
||||
reader: The Reader instance.
|
||||
enabled_extensions: List of extension names to enable.
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to the feed page.
|
||||
"""
|
||||
if enabled_extensions is None:
|
||||
enabled_extensions = []
|
||||
clean_feed_url: str = feed_url.strip()
|
||||
set_enabled_extensions(reader, clean_feed_url, enabled_extensions)
|
||||
commit_state_change(reader, f"Update extensions for {clean_feed_url}")
|
||||
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/use_embed")
|
||||
async def post_use_embed(
|
||||
feed_url: Annotated[str, Form()],
|
||||
|
|
@ -2078,7 +2191,6 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
|
|||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
"is_steam_feed": is_steam_feed_url(feed.url),
|
||||
"chromium_installed": is_chromium_installed(),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
|
@ -2146,7 +2258,6 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
|
|||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
"is_steam_feed": is_steam_feed_url(feed.url),
|
||||
"chromium_installed": is_chromium_installed(),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
|
@ -2229,10 +2340,9 @@ def create_html_for_feed( # ruff:ignore[complex-structure, too-many-locals]
|
|||
)
|
||||
|
||||
# Check if this is a YouTube feed entry and the entry has a link
|
||||
is_youtube_feed = "youtube.com/feeds/videos.xml" in entry.feed.url
|
||||
video_embed_html = ""
|
||||
|
||||
if is_youtube_feed and entry.link:
|
||||
if is_youtube_feed_url(entry.feed.url) and entry.link:
|
||||
# Extract the video ID and create an embed if possible
|
||||
video_id: str | None = extract_youtube_video_id(entry.link)
|
||||
if video_id:
|
||||
|
|
@ -3213,29 +3323,6 @@ def modify_webhook(
|
|||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
def extract_youtube_video_id(url: str) -> str | None:
|
||||
"""Extract YouTube video ID from a YouTube video URL.
|
||||
|
||||
Args:
|
||||
url: The YouTube video URL.
|
||||
|
||||
Returns:
|
||||
The video ID if found, None otherwise.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
# Handle standard YouTube URLs (youtube.com/watch?v=VIDEO_ID)
|
||||
if "youtube.com/watch" in url and "v=" in url:
|
||||
return url.split("v=")[1].split("&", maxsplit=1)[0]
|
||||
|
||||
# Handle shortened YouTube URLs (youtu.be/VIDEO_ID)
|
||||
if "youtu.be/" in url:
|
||||
return url.split("youtu.be/")[1].split("?", maxsplit=1)[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_final_feed_url(url: str) -> tuple[str, str | None]:
|
||||
"""Resolve a feed URL by following redirects.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ data_dir: str = os.getenv("DISCORD_RSS_BOT_DATA_DIR", "").strip() or user_data_d
|
|||
ensure_exists=True,
|
||||
)
|
||||
|
||||
#: Directory where user-supplied Python extension plugins live.
|
||||
#: Override with the ``EXTENSIONS_DIR`` environment variable.
|
||||
extensions_dir: str = os.getenv("EXTENSIONS_DIR", "").strip() or str(Path.cwd() / "extensions")
|
||||
|
||||
|
||||
# TODO(TheLovinator): Add default things to the database and make the edible.
|
||||
default_custom_message: JSONType | str = "{{entry_title}}\n{{entry_link}}"
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
{% raw %}
|
||||
{{entry_text}}
|
||||
{% endraw %}
|
||||
</code> Same as entry_content if it exists, otherwise entry_summary
|
||||
</code>{{ entry.content[0].value|discord_markdown if entry and entry.content else (entry.summary|discord_markdown if entry and entry.summary else '') }}
|
||||
</li>
|
||||
<li>
|
||||
<code>
|
||||
|
|
@ -217,18 +217,22 @@
|
|||
{% raw %}
|
||||
{{image_1}}
|
||||
{% endraw %}
|
||||
</code>First image in the entry if it exists
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="list-inline">
|
||||
<li>Examples:</li>
|
||||
<li>
|
||||
<code>
|
||||
{% raw %}
|
||||
{{feed_title}}\n{{entry_content}}
|
||||
{% endraw %}
|
||||
</code>
|
||||
</code>{{ first_image }}
|
||||
</li>
|
||||
{% if extension_variables %}
|
||||
<br />
|
||||
<li>Extension variables (from enabled extensions):</li>
|
||||
{% for var_name in extension_variables %}
|
||||
<li>
|
||||
<code>{% raw %}{{{% endraw %}{{ var_name }}{% raw %}}}{% endraw %}</code>
|
||||
{% if var_name in extension_values and extension_values[var_name] %}
|
||||
{{ extension_values[var_name] }}
|
||||
{% else %}
|
||||
(empty)
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
Something went wrong, there was no entry found. If this feed has entries and you still see this
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@
|
|||
{% raw %}
|
||||
{{entry_text}}
|
||||
{% endraw %}
|
||||
</code> Same as entry_content if it exists, otherwise entry_summary
|
||||
</code>{{ entry.content[0].value|discord_markdown if entry and entry.content else (entry.summary|discord_markdown if entry and entry.summary else '') }}
|
||||
</li>
|
||||
<li>
|
||||
<code>
|
||||
|
|
@ -210,13 +210,27 @@
|
|||
{% raw %}
|
||||
{{image_1}}
|
||||
{% endraw %}
|
||||
</code>First image in the entry if it exists
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
Something went wrong, there was no entry found. If this feed has entries and you still see this
|
||||
message, please contact the developer.
|
||||
{% endif %}
|
||||
</code>{{ first_image }}
|
||||
</li>
|
||||
{% if extension_variables %}
|
||||
<br />
|
||||
<li>Extension variables (from enabled extensions):</li>
|
||||
{% for var_name in extension_variables %}
|
||||
<li>
|
||||
<code>{% raw %}{{{% endraw %}{{ var_name }}{% raw %}}}{% endraw %}</code>
|
||||
{% if var_name in extension_values and extension_values[var_name] %}
|
||||
{{ extension_values[var_name] }}
|
||||
{% else %}
|
||||
(empty)
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
Something went wrong, there was no entry found. If this feed has entries and you still see this
|
||||
message, please contact the developer.
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<ul class="list-inline">
|
||||
|
|
@ -251,17 +265,16 @@
|
|||
<label for="thumbnail_url" class="col-sm-6 col-form-label">Thumbnail</label>
|
||||
<input name="thumbnail_url" type="text" class="form-control bg-dark border-dark text-muted"
|
||||
id="thumbnail_url" {% if thumbnail_url %} value="{{- thumbnail_url -}}" {% endif %} />
|
||||
{% if is_steam_feed %}
|
||||
<div class="form-check mt-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="show_steam_game_icon_in_thumbnail"
|
||||
name="show_steam_game_icon_in_thumbnail"
|
||||
value="true"
|
||||
{% if show_steam_game_icon_in_thumbnail %}checked{% endif %}
|
||||
/>
|
||||
<label class="form-check-label" for="show_steam_game_icon_in_thumbnail"> Use Steam game icon as thumbnail when available </label>
|
||||
<input class="form-check-input" type="checkbox" name="show_steam_game_icon_in_thumbnail"
|
||||
id="show_steam_game_icon_in_thumbnail" value="true"
|
||||
{% if show_steam_game_icon_in_thumbnail %}checked{% endif %}>
|
||||
<label class="form-check-label" for="show_steam_game_icon_in_thumbnail">
|
||||
Use Steam game icon as thumbnail override
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<label for="footer_text" class="col-sm-6 col-form-label">Footer text</label>
|
||||
<input name="footer_text" type="text" class="form-control bg-dark border-dark text-muted"
|
||||
id="footer_text" {% if footer_text %} value="{{- footer_text -}}" {% endif %} />
|
||||
|
|
|
|||
68
discord_rss_bot/templates/extensions.html
Normal file
68
discord_rss_bot/templates/extensions.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
Extensions: {{ feed.title if feed.title else feed.url }} | discord-rss-bot
|
||||
{% endblock title %}
|
||||
{% block description %}
|
||||
Enable or disable content extension plugins for {{ feed.title if feed.title else feed.url }}.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
<div class="p-2 border border-dark">
|
||||
<form action="/extensions" method="post">
|
||||
<div class="row pb-2">
|
||||
<div class="col-sm-12">
|
||||
<div class="form-text">
|
||||
<ul class="list-inline">
|
||||
<li>Extensions extract additional variables from feed entries that you can use in your message template or embed.</li>
|
||||
<li>Each extension creates a template variable like <code>{% raw %}{{ extension_variable_name }}{% endraw %}</code>.</li>
|
||||
<li>Enable the ones you need and use their variables in the <a href="/custom?feed_url={{ feed.url|encode_url }}">message template</a> or <a href="/embed?feed_url={{ feed.url|encode_url }}">embed settings</a>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if not discovered_extensions %}
|
||||
<div class="alert alert-info mt-3" role="alert">
|
||||
<p class="mb-0">
|
||||
No extension plugins were found.
|
||||
Place <code>.py</code> files containing <code>FeedExtension</code> subclasses
|
||||
in the extensions directory
|
||||
(<code>{{ extensions_dir }}</code>).
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-3">
|
||||
<p class="text-muted small mb-2">
|
||||
Discovered extensions in <code>{{ extensions_dir }}</code>:
|
||||
</p>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for name, cls in discovered_extensions|dictsort %}
|
||||
<div class="list-group-item bg-transparent border-secondary text-light px-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="ext-{{ name }}"
|
||||
name="enabled_extensions"
|
||||
value="{{ name }}"
|
||||
{% if name in enabled_extensions %}checked{% endif %} />
|
||||
<label class="form-check-label flex-grow-1" for="ext-{{ name }}">
|
||||
<code>{{ name }}</code>
|
||||
{% if cls.description %}
|
||||
<br />
|
||||
<span class="text-muted small">{{ cls.description }}</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add a hidden feed_url field to the form -->
|
||||
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
|
||||
<!-- Submit button -->
|
||||
<div class="d-md-flex mt-3">
|
||||
<button class="btn btn-dark btn-sm"
|
||||
{% if not discovered_extensions %}disabled{% endif %}>Save extensions</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="feed-page__content">
|
||||
<h2 class="h3 mb-1">
|
||||
<a class="text-muted text-decoration-none feed-page__wrap"
|
||||
href="{{ feed.url }}">{{ feed.title }}</a>
|
||||
href="{{ feed.url }}">{{ feed | feed_display_name }}</a>
|
||||
</h2>
|
||||
<p class="text-muted mb-0">{{ total_entries }} entries</p>
|
||||
</div>
|
||||
|
|
@ -30,18 +30,16 @@
|
|||
Text
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
<span class="badge status-chip bg-secondary">
|
||||
Images:
|
||||
{% if media_gallery_image_limit == 0 %}
|
||||
No images
|
||||
{% elif media_gallery_image_limit == 1 %}
|
||||
First image only
|
||||
{% else %}
|
||||
Up to {{ media_gallery_image_limit }} images
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge status-chip bg-secondary">
|
||||
Images:
|
||||
{% if media_gallery_image_limit == 0 %}
|
||||
No images
|
||||
{% elif media_gallery_image_limit == 1 %}
|
||||
First image only
|
||||
{% else %}
|
||||
Up to {{ media_gallery_image_limit }} images
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if delivery_mode == "screenshot" %}
|
||||
<span class="badge status-chip bg-secondary">
|
||||
Screenshot layout:
|
||||
|
|
@ -177,10 +175,8 @@
|
|||
<br>
|
||||
Embed: a Discord embed with title, description, and images.
|
||||
<br>
|
||||
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
Screenshot: full-page screenshot of the entry link.
|
||||
<br>
|
||||
{% endif %}
|
||||
Screenshot: full-page screenshot of the entry link.
|
||||
<br>
|
||||
Text: plain message.
|
||||
</p>
|
||||
<div class="d-flex" role="group" aria-label="Delivery mode">
|
||||
|
|
@ -197,23 +193,21 @@
|
|||
style="border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0">Embed</span>
|
||||
{% endif %}
|
||||
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
{% if chromium_installed %}
|
||||
{% if delivery_mode != "screenshot" %}
|
||||
<form action="/use_screenshot" method="post" class="d-inline">
|
||||
<button class="btn btn-outline-light btn-sm rounded-0"
|
||||
name="feed_url"
|
||||
value="{{ feed.url }}">Screenshot</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="btn btn-primary btn-sm disabled rounded-0">Screenshot</span>
|
||||
{% endif %}
|
||||
{% if chromium_installed %}
|
||||
{% if delivery_mode != "screenshot" %}
|
||||
<form action="/use_screenshot" method="post" class="d-inline">
|
||||
<button class="btn btn-outline-light btn-sm rounded-0"
|
||||
name="feed_url"
|
||||
value="{{ feed.url }}">Screenshot</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="btn btn-outline-light btn-sm rounded-0"
|
||||
title="Playwright Chromium is not installed. Run 'uv run playwright install chromium' to enable."
|
||||
style="opacity: 0.5;
|
||||
cursor: not-allowed">Screenshot</span>
|
||||
<span class="btn btn-primary btn-sm disabled rounded-0">Screenshot</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="btn btn-outline-light btn-sm rounded-0"
|
||||
title="Playwright Chromium is not installed. Run 'uv run playwright install chromium' to enable."
|
||||
style="opacity: 0.5;
|
||||
cursor: not-allowed">Screenshot</span>
|
||||
{% endif %}
|
||||
{% if delivery_mode != "text" %}
|
||||
<form action="/use_text" method="post" class="d-inline">
|
||||
|
|
@ -229,14 +223,14 @@
|
|||
border-bottom-left-radius: 0">Text</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not chromium_installed and not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
{% if not chromium_installed %}
|
||||
<div class="mt-2 small text-muted">
|
||||
Screenshot mode requires Chromium for Playwright.
|
||||
Run <code class="text-light">uv run playwright install chromium</code> to enable it.
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if delivery_mode == "screenshot" and chromium_installed and not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
{% if delivery_mode == "screenshot" and chromium_installed %}
|
||||
<hr class="border-secondary" />
|
||||
<section class="mb-3">
|
||||
<h4 class="h6 text-muted mb-2">Screenshot Layout</h4>
|
||||
|
|
@ -259,54 +253,52 @@
|
|||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
<hr class="border-secondary" />
|
||||
<section class="mb-3">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<h4 class="h6 text-muted mb-0">Image Delivery</h4>
|
||||
<span class="badge {{ 'bg-info' if media_gallery_image_limit < max_media_gallery_items else 'bg-secondary' }}">
|
||||
{% if media_gallery_image_limit == 0 %}
|
||||
No images
|
||||
{% elif media_gallery_image_limit == 1 %}
|
||||
First image only
|
||||
{% else %}
|
||||
Up to {{ media_gallery_image_limit }} images
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p id="imageDeliveryHelp" class="text-muted small mb-2">
|
||||
How many images to include per entry. Only applies to embed mode.
|
||||
</p>
|
||||
<form action="/set_feed_media_gallery_image_limit"
|
||||
method="post"
|
||||
class="mb-0">
|
||||
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
|
||||
<label class="form-label small text-muted mb-2" for="image_limit">
|
||||
Images per entry:
|
||||
<output name="image_limit_value" for="image_limit">{{ media_gallery_image_limit }}</output>
|
||||
</label>
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="flex-grow-1 image-limit-control">
|
||||
<input id="image_limit"
|
||||
class="form-range"
|
||||
type="range"
|
||||
name="image_limit"
|
||||
min="0"
|
||||
max="{{ max_media_gallery_items }}"
|
||||
step="1"
|
||||
value="{{ media_gallery_image_limit }}"
|
||||
aria-describedby="imageDeliveryHelp"
|
||||
oninput="this.form.elements.image_limit_value.value = this.value" />
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span>0</span>
|
||||
<span>{{ max_media_gallery_items }}</span>
|
||||
</div>
|
||||
<hr class="border-secondary" />
|
||||
<section class="mb-3">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<h4 class="h6 text-muted mb-0">Image Delivery</h4>
|
||||
<span class="badge {{ 'bg-info' if media_gallery_image_limit < max_media_gallery_items else 'bg-secondary' }}">
|
||||
{% if media_gallery_image_limit == 0 %}
|
||||
No images
|
||||
{% elif media_gallery_image_limit == 1 %}
|
||||
First image only
|
||||
{% else %}
|
||||
Up to {{ media_gallery_image_limit }} images
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p id="imageDeliveryHelp" class="text-muted small mb-2">
|
||||
How many images to include per entry. Only applies to embed mode.
|
||||
</p>
|
||||
<form action="/set_feed_media_gallery_image_limit"
|
||||
method="post"
|
||||
class="mb-0">
|
||||
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
|
||||
<label class="form-label small text-muted mb-2" for="image_limit">
|
||||
Images per entry:
|
||||
<output name="image_limit_value" for="image_limit">{{ media_gallery_image_limit }}</output>
|
||||
</label>
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="flex-grow-1 image-limit-control">
|
||||
<input id="image_limit"
|
||||
class="form-range"
|
||||
type="range"
|
||||
name="image_limit"
|
||||
min="0"
|
||||
max="{{ max_media_gallery_items }}"
|
||||
step="1"
|
||||
value="{{ media_gallery_image_limit }}"
|
||||
aria-describedby="imageDeliveryHelp"
|
||||
oninput="this.form.elements.image_limit_value.value = this.value" />
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span>0</span>
|
||||
<span>{{ max_media_gallery_items }}</span>
|
||||
</div>
|
||||
<button class="btn btn-outline-light btn-sm" type="submit">Save image limit</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-light btn-sm" type="submit">Save image limit</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<hr class="border-secondary" />
|
||||
<section>
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
|
|
@ -489,15 +481,12 @@
|
|||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a class="btn {{ 'btn-primary' if delivery_mode == 'text' else 'btn-outline-light' }}"
|
||||
href="/custom?feed_url={{ feed.url|encode_url }}">Customize message</a>
|
||||
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
<a class="btn {{ 'btn-primary' if delivery_mode == 'embed' else 'btn-outline-light' }}"
|
||||
href="/embed?feed_url={{ feed.url|encode_url }}">Customize embed</a>
|
||||
{% endif %}
|
||||
<a class="btn {{ 'btn-primary' if delivery_mode == 'embed' else 'btn-outline-light' }}"
|
||||
href="/embed?feed_url={{ feed.url|encode_url }}">Customize embed</a>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-light"
|
||||
href="/extensions?feed_url={{ feed.url|encode_url }}">Extensions</a>
|
||||
</div>
|
||||
{% if is_steam_feed %}
|
||||
<div class="form-text small mt-2">Steam feeds can enable thumbnails in embed settings.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,11 +73,7 @@
|
|||
{% for feed in domain_feeds %}
|
||||
<li>
|
||||
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">
|
||||
{% if feed.title %}
|
||||
{{ feed.title }}
|
||||
{% else %}
|
||||
{{ feed.url }}
|
||||
{% endif %}
|
||||
{{ feed | feed_display_name }}
|
||||
</a>
|
||||
{% if not feed.updates_enabled %}<span class="text-warning">Disabled</span>{% endif %}
|
||||
{% if feed.last_exception %}<span class="text-danger">({{ feed.last_exception.value_str }})</span>{% endif %}
|
||||
|
|
@ -116,18 +112,7 @@
|
|||
{% for broken_feed in broken_feeds %}
|
||||
<a class="text-muted"
|
||||
href="/feed?feed_url={{ broken_feed.url|encode_url }}">
|
||||
{# Display username@youtube for YouTube feeds #}
|
||||
{% if "youtube.com/feeds/videos.xml" in broken_feed.url %}
|
||||
{% if "user=" in broken_feed.url %}
|
||||
{{ broken_feed.url.split("user=")[1] }}@youtube
|
||||
{% elif "channel_id=" in broken_feed.url %}
|
||||
{{ broken_feed.title if broken_feed.title else broken_feed.url.split("channel_id=")[1] }}@youtube
|
||||
{% else %}
|
||||
{{ broken_feed.url }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ broken_feed.url }}
|
||||
{% endif %}
|
||||
{{ broken_feed | feed_display_name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
@ -142,18 +127,7 @@
|
|||
<li class="list-group-item bg-dark border-dark text-danger">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">
|
||||
{# Display username@youtube for YouTube feeds #}
|
||||
{% if "youtube.com/feeds/videos.xml" in feed.url %}
|
||||
{% if "user=" in feed.url %}
|
||||
{{ feed.url.split("user=")[1] }}@youtube
|
||||
{% elif "channel_id=" in feed.url %}
|
||||
{{ feed.title if feed.title else feed.url.split("channel_id=")[1] }}@youtube
|
||||
{% else %}
|
||||
{{ feed.url }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ feed.url }}
|
||||
{% endif %}
|
||||
{{ feed | feed_display_name }}
|
||||
</a>
|
||||
{% if webhooks %}
|
||||
<form action="/attach_feed_webhook"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue