Only show screenshot things if Playwright has chromium installed; use names instead of ids when ignoring Ruff errors
All checks were successful
Test and build Docker image / docker (push) Successful in 1m54s

This commit is contained in:
Joakim Hellsén 2026-07-18 07:04:00 +02:00
commit d61e8ccf10
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
15 changed files with 224 additions and 174 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import datetime
import functools
import hashlib
import json
import logging
@ -30,6 +31,7 @@ from httpx2 import HTTPError
from httpx2 import Response
from markdownify import markdownify
from playwright.sync_api import Browser
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import Page
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
@ -140,7 +142,7 @@ MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = (
)
def extract_domain(url: str) -> str: # noqa: PLR0911
def extract_domain(url: str) -> str: # ruff:ignore[too-many-return-statements]
"""Extract the domain name from a URL.
Args:
@ -153,7 +155,7 @@ def extract_domain(url: str) -> str: # noqa: PLR0911
if not url:
return "Other"
try: # noqa: PLW0717
try: # ruff:ignore[too-many-statements-in-try-clause]
# Special handling for YouTube feeds
if "youtube.com/feeds/videos.xml" in url:
return "YouTube"
@ -258,7 +260,7 @@ def extract_steam_app_id_from_url(url: str) -> str | 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
parsed_url,
)
@ -453,7 +455,7 @@ def get_feed_webhook_text_length_limit(reader: Reader, feed: Feed | str) -> int:
return coerce_webhook_text_length_limit(value)
def coerce_media_gallery_image_limit(value: JsonValue) -> int: # noqa: PLR0911
def coerce_media_gallery_image_limit(value: JsonValue) -> int: # ruff:ignore[too-many-return-statements]
"""Return the supported media gallery image limit for a stored tag value."""
if isinstance(value, bool):
return 1
@ -835,7 +837,7 @@ def get_webhook_query_params(
return clean_webhook_url, params
def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C901
def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # ruff:ignore[complex-structure]
"""Return files attached to a webhook object in a normalized shape."""
raw_files = getattr(webhook, "files", None)
files: list[WebhookFile] = []
@ -854,7 +856,7 @@ def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C9
files.append(file_value)
continue
if not isinstance(file_value, tuple) or len(file_value) < 2: # noqa: PLR2004
if not isinstance(file_value, tuple) or len(file_value) < 2: # ruff:ignore[magic-value-comparison]
continue
first, second = file_value[0], file_value[1]
@ -862,7 +864,7 @@ def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C9
files.append(WebhookFile(filename=first, content=second))
continue
if isinstance(second, tuple) and len(second) >= 2: # noqa: PLR2004
if isinstance(second, tuple) and len(second) >= 2: # ruff:ignore[magic-value-comparison]
nested_file = cast("tuple[object, ...]", second)
nested_filename, nested_content = nested_file[0], nested_file[1]
if isinstance(nested_filename, str) and isinstance(nested_content, bytes):
@ -916,7 +918,7 @@ def request_discord_webhook(
request_kwargs["json"] = payload
response: Response = httpx2.request(method, url, **request_kwargs)
if not rate_limit_retry or response.status_code != 429: # noqa: PLR2004
if not rate_limit_retry or response.status_code != 429: # ruff:ignore[magic-value-comparison]
return response
retry_after: float | None = get_retry_after_seconds(response)
@ -1183,7 +1185,7 @@ def update_sent_webhook_record_for_entry(
)
def update_sent_webhooks_for_modified_entries( # noqa: C901
def update_sent_webhooks_for_modified_entries( # ruff:ignore[complex-structure]
reader: Reader,
modified_entries: Iterable[tuple[str, str]],
) -> int:
@ -1253,7 +1255,7 @@ def create_text_webhook(
"""
webhook_message: str = ""
if get_custom_message(reader, entry.feed) != "": # noqa: PLC1901
if get_custom_message(reader, entry.feed) != "": # ruff:ignore[compare-to-empty-string]
webhook_message = replace_tags_in_text_message(entry=entry, reader=reader)
if not webhook_message and use_default_message_on_empty:
@ -1370,6 +1372,40 @@ def screenshot_filename_for_entry(entry: Entry, *, extension: str = "png") -> st
return f"{safe_name[:80]}.{safe_extension}"
@functools.lru_cache(maxsize=1)
def is_chromium_installed() -> bool:
"""Check if Playwright's Chromium browser is installed.
Uses a cached check so the browser is only probed once per process lifetime.
Offloads to a thread when called from an active event loop (e.g. FastAPI).
Returns:
bool: True if Chromium launched successfully, False otherwise.
"""
def _check() -> bool:
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=True,
args=["--disable-dev-shm-usage", "--no-sandbox"],
)
browser.close()
except (OSError, PlaywrightError):
return False
else:
return True
try:
asyncio.get_running_loop()
except RuntimeError:
return _check()
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future: concurrent.futures.Future[bool] = executor.submit(_check)
return future.result()
def capture_full_page_screenshot(
url: str,
*,
@ -1417,7 +1453,7 @@ def _capture_full_page_screenshot_sync(
Returns:
bytes | None: PNG bytes on success, otherwise None.
"""
try: # noqa: PLW0717
try: # ruff:ignore[too-many-statements-in-try-clause]
with sync_playwright() as playwright:
browser: Browser = playwright.chromium.launch(
headless=True,
@ -1537,7 +1573,7 @@ def set_description(
# Discord allows 2048, but we keep a small safety margin by default.
embed_description: str = custom_embed.description
if len(embed_description) > max_description_length:
if max_description_length <= 3: # noqa: PLR2004
if max_description_length <= 3: # ruff:ignore[magic-value-comparison]
embed_description = embed_description[:max_description_length]
else:
embed_description = f"{embed_description[: max_description_length - 3]}..."
@ -1635,7 +1671,7 @@ def get_ttvdrops_reward_description(drop: JsonObject, reward: JsonObject) -> str
return reward_name
def extract_ttvdrops_media_gallery_items(value: JsonValue, *, hide_paid: bool = False) -> list[JsonObject]: # noqa: C901
def extract_ttvdrops_media_gallery_items(value: JsonValue, *, hide_paid: bool = False) -> list[JsonObject]: # ruff:ignore[complex-structure]
"""Extract benefit/reward media gallery items from a ttvdrops API response.
Returns:
@ -1688,7 +1724,7 @@ def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]:
try:
response: Response = httpx2.get(api_url, follow_redirects=True, timeout=10.0)
if response.status_code != 200: # noqa: PLR2004
if response.status_code != 200: # ruff:ignore[magic-value-comparison]
logger.warning("Failed to fetch ttvdrops campaign data from %s: %s", api_url, response.text[:500])
return []
@ -1743,7 +1779,7 @@ def truncate_component_text(
"""
if len(content) <= max_text_display_length:
return content
if max_text_display_length <= 3: # noqa: PLR2004
if max_text_display_length <= 3: # ruff:ignore[magic-value-comparison]
return content[:max_text_display_length]
return f"{content[: max_text_display_length - 3]}..."
@ -1837,7 +1873,7 @@ def create_components_v2_webhook(
)
def create_embed_webhook( # noqa: C901, PLR0912
def create_embed_webhook( # ruff:ignore[complex-structure, too-many-branches]
webhook_url: str,
entry: Entry,
reader: Reader,
@ -2121,7 +2157,7 @@ def truncate_webhook_message(
"""
if len(webhook_message) <= max_content_length:
return webhook_message
if max_content_length <= 3: # noqa: PLR2004
if max_content_length <= 3: # ruff:ignore[magic-value-comparison]
return webhook_message[:max_content_length]
head_length = (max_content_length - 3) // 2
@ -2145,7 +2181,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: # noqa: C901, PLR0912
def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # ruff:ignore[complex-structure, too-many-branches]
"""Add a new feed, update it and mark every entry as read.
Args:

View file

@ -25,7 +25,7 @@ import json
import logging
import os
import shutil
import subprocess # noqa: S404
import subprocess # ruff:ignore[suspicious-subprocess-import]
from pathlib import Path
from typing import TYPE_CHECKING
@ -102,22 +102,22 @@ def setup_backup_repo(backup_path: Path) -> bool:
Returns:
``True`` if the repository is ready, ``False`` on any error.
"""
try: # noqa: PLW0717
try: # ruff:ignore[too-many-statements-in-try-clause]
backup_path.mkdir(parents=True, exist_ok=True)
git_dir: Path = backup_path / ".git"
if not git_dir.exists():
subprocess.run([GIT_EXECUTABLE, "init", str(backup_path)], check=True, capture_output=True) # noqa: S603
subprocess.run([GIT_EXECUTABLE, "init", str(backup_path)], check=True, capture_output=True) # ruff:ignore[subprocess-without-shell-equals-true]
logger.info("Initialized git backup repository at %s", backup_path)
# Ensure a local identity exists so that `git commit` always works.
for key, value in (("user.email", "discord-rss-bot@localhost"), ("user.name", "discord-rss-bot")):
result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603
result: subprocess.CompletedProcess[bytes] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key],
check=False,
capture_output=True,
)
if result.returncode != 0:
subprocess.run( # noqa: S603
subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key, value],
check=True,
capture_output=True,
@ -127,14 +127,14 @@ def setup_backup_repo(backup_path: Path) -> bool:
remote_url: str = get_backup_remote()
if remote_url:
# Check if remote "origin" already exists.
check_remote: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603
check_remote: subprocess.CompletedProcess[bytes] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "remote", "get-url", "origin"],
check=False,
capture_output=True,
)
if check_remote.returncode != 0:
# Remote doesn't exist, add it.
subprocess.run( # noqa: S603
subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "remote", "add", "origin", remote_url],
check=True,
capture_output=True,
@ -144,7 +144,7 @@ def setup_backup_repo(backup_path: Path) -> bool:
# Remote exists, update it if the URL has changed.
current_url: str = check_remote.stdout.decode().strip()
if current_url != remote_url:
subprocess.run( # noqa: S603
subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "remote", "set-url", "origin", remote_url],
check=True,
capture_output=True,
@ -169,7 +169,7 @@ def export_state(reader: Reader, backup_path: Path) -> None:
for tag in _FEED_TAGS:
try:
value: TagValue = reader.get_tag(feed, tag, None)
if value is not None and value != "": # noqa: PLC1901
if value is not None and value != "": # ruff:ignore[compare-to-empty-string]
feed_data[tag] = value
except Exception:
logger.exception("Failed to read tag '%s' for feed '%s' during state export", tag, feed.url)
@ -219,13 +219,13 @@ def commit_state_change(reader: Reader, message: str) -> None:
if not setup_backup_repo(backup_path):
return
try: # noqa: PLW0717
try: # ruff:ignore[too-many-statements-in-try-clause]
export_state(reader, backup_path)
subprocess.run([GIT_EXECUTABLE, "-C", str(backup_path), "add", "-A"], check=True, capture_output=True) # noqa: S603
subprocess.run([GIT_EXECUTABLE, "-C", str(backup_path), "add", "-A"], check=True, capture_output=True) # ruff:ignore[subprocess-without-shell-equals-true]
# Only create a commit if there are staged changes.
diff_result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603
diff_result: subprocess.CompletedProcess[bytes] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "diff", "--cached", "--exit-code"],
check=False,
capture_output=True,
@ -234,7 +234,7 @@ def commit_state_change(reader: Reader, message: str) -> None:
logger.debug("No state changes to commit for: %s", message)
return
subprocess.run( # noqa: S603
subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "commit", "-m", message],
check=True,
capture_output=True,
@ -243,7 +243,7 @@ def commit_state_change(reader: Reader, message: str) -> None:
# Push to remote if configured.
if get_backup_remote():
subprocess.run( # noqa: S603
subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true]
[GIT_EXECUTABLE, "-C", str(backup_path), "push", "origin", "HEAD"],
check=True,
capture_output=True,

View file

@ -18,7 +18,7 @@ def healthcheck() -> None:
sys.exit(0)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f"Healthcheck failed: {e}", file=sys.stderr) # noqa: T201
print(f"Healthcheck failed: {e}", file=sys.stderr) # ruff:ignore[print]
sys.exit(1)

View file

@ -76,7 +76,7 @@ def fetch_hoyolab_post(post_id: str) -> JsonObject | None:
return None
http_ok = 200
try: # noqa: PLW0717
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)
@ -94,7 +94,7 @@ def fetch_hoyolab_post(post_id: str) -> JsonObject | None:
return None
def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject) -> DiscordWebhook: # noqa: C901, PLR0912, PLR0914, PLR0915
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:
@ -184,8 +184,8 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject
# Only show Youtube URL if available
structured_content: str = str(post.get("structured_content", ""))
if structured_content: # noqa: PLR1702
try: # noqa: PLW0717
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)]

View file

@ -65,6 +65,7 @@ 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
@ -138,7 +139,7 @@ LOGGING_CONFIG = {
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s", # noqa: E501
"format": "%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s", # ruff:ignore[line-too-long]
},
},
"handlers": {
@ -1259,7 +1260,7 @@ async def get_embed_page(
@app.post("/embed", response_class=HTMLResponse)
async def post_embed( # noqa: C901
async def post_embed( # ruff:ignore[complex-structure]
feed_url: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
title: Annotated[str, Form()] = "",
@ -1272,6 +1273,7 @@ async def post_embed( # noqa: C901
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,
) -> RedirectResponse:
"""Set the embed settings.
@ -1778,7 +1780,7 @@ def get_add(
@app.get("/feed", response_class=HTMLResponse)
async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-many-locals, too-many-statements]
feed_url: str,
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
@ -1874,6 +1876,7 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
"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)
@ -1941,11 +1944,12 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
"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)
def create_html_for_feed( # noqa: C901, PLR0914
def create_html_for_feed( # ruff:ignore[complex-structure, too-many-locals]
reader: Reader,
entries: Iterable[Entry],
current_feed_url: str = "",
@ -2051,7 +2055,7 @@ def create_html_for_feed( # noqa: C901, PLR0914
{video_embed_html}
{image_html}
</div>
""" # noqa: E501
""" # ruff:ignore[line-too-long]
return html.strip()
@ -2152,7 +2156,7 @@ async def get_settings(
global_delivery_mode = "embed"
global_webhook_text_length_limit: int = coerce_webhook_text_length_limit(
reader.get_tag((), "webhook_text_length_limit", 4000)
reader.get_tag((), "webhook_text_length_limit", 4000),
)
# Get all feeds with their intervals
@ -2181,6 +2185,7 @@ async def get_settings(
"global_webhook_text_length_limit": global_webhook_text_length_limit,
"max_webhook_text_length_limit": 4000,
"feed_intervals": feed_intervals,
"chromium_installed": is_chromium_installed(),
}
return templates.TemplateResponse(request=request, name="settings.html", context=context)
@ -2603,8 +2608,8 @@ def create_webhook_feed_url_preview(
webhook_feeds: list[Feed],
replace_from: str,
replace_to: str,
resolve_urls: bool, # noqa: FBT001
force_update: bool = False, # noqa: FBT001, FBT002
resolve_urls: bool, # ruff:ignore[boolean-type-hint-positional-argument]
force_update: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
existing_feed_urls: set[str] | None = None,
) -> list[dict[str, str | bool | None]]:
"""Create preview rows for bulk feed URL replacement.
@ -2670,8 +2675,8 @@ def build_webhook_mass_update_context(
all_feeds: list[Feed],
replace_from: str,
replace_to: str,
resolve_urls: bool, # noqa: FBT001
force_update: bool = False, # noqa: FBT001, FBT002
resolve_urls: bool, # ruff:ignore[boolean-type-hint-positional-argument]
force_update: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
) -> dict[str, str | bool | int | list[dict[str, str | bool | None]] | dict[str, int]]:
"""Build context data used by the webhook mass URL update preview UI.
@ -2732,8 +2737,8 @@ async def get_webhook_entries_mass_update_preview(
reader: Annotated[Reader, Depends(get_reader_dependency)],
replace_from: str = "",
replace_to: str = "",
resolve_urls: bool = True, # noqa: FBT001, FBT002
force_update: bool = False, # noqa: FBT001, FBT002
resolve_urls: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
force_update: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
) -> HTMLResponse:
"""Render the mass-update preview fragment for a webhook using HTMX.
@ -2771,15 +2776,15 @@ async def get_webhook_entries_mass_update_preview(
@app.get("/webhook_entries", response_class=HTMLResponse)
async def get_webhook_entries( # noqa: C901, PLR0914
async def get_webhook_entries( # ruff:ignore[complex-structure, too-many-locals]
webhook_url: str,
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
starting_after: str = "",
replace_from: str = "",
replace_to: str = "",
resolve_urls: bool = True, # noqa: FBT001, FBT002
force_update: bool = False, # noqa: FBT001, FBT002
resolve_urls: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
force_update: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument]
message: str = "",
) -> HTMLResponse:
"""Get all latest entries from all feeds for a specific webhook.
@ -2901,13 +2906,13 @@ async def get_webhook_entries( # noqa: C901, PLR0914
@app.post("/bulk_change_feed_urls", response_class=HTMLResponse)
async def post_bulk_change_feed_urls( # noqa: C901, PLR0914, PLR0912, PLR0915
async def post_bulk_change_feed_urls( # ruff:ignore[complex-structure, too-many-locals, too-many-branches, too-many-statements]
webhook_url: Annotated[str, Form()],
replace_from: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
replace_to: Annotated[str, Form()] = "",
resolve_urls: Annotated[bool, Form()] = True, # noqa: FBT002
force_update: Annotated[bool, Form()] = False, # noqa: FBT002
resolve_urls: Annotated[bool, Form()] = True, # ruff:ignore[boolean-default-value-positional-argument]
force_update: Annotated[bool, Form()] = False, # ruff:ignore[boolean-default-value-positional-argument]
) -> RedirectResponse:
"""Bulk-change feed URLs attached to a webhook.
@ -3044,7 +3049,7 @@ if __name__ == "__main__":
uvicorn.run(
"main:app",
log_level="debug",
host="0.0.0.0", # noqa: S104
host="0.0.0.0", # ruff:ignore[hardcoded-bind-all-interfaces]
port=3000,
proxy_headers=True,
forwarded_allow_ips="*",

View file

@ -103,7 +103,7 @@
{% elif delivery_mode == "screenshot" %}
<li>Screenshot layout: {{ screenshot_layout }}.</li>
{% endif %}
<li>Webhook text</li> is truncated to {{ webhook_text_length_limit }} characters.</li>
<li>Webhook text is truncated to {{ webhook_text_length_limit }} characters.</li>
<li>
Filters:
{% if has_blacklist_filters and has_whitelist_filters %}
@ -173,10 +173,13 @@
<section class="mb-3">
<h4 class="h6 text-muted mb-2">Delivery Mode</h4>
<p class="text-muted small mb-2">
Choose between:<br>
Embed: a Discord embed with title, description, and images.<br>
Choose between:
<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>
Screenshot: full-page screenshot of the entry link.
<br>
{% endif %}
Text: plain message.
</p>
@ -184,39 +187,56 @@
{% if delivery_mode != "embed" %}
<form action="/use_embed" method="post" class="d-inline">
<button class="btn btn-outline-light btn-sm"
style="border-top-right-radius: 0; border-bottom-right-radius: 0;"
style="border-top-right-radius: 0;
border-bottom-right-radius: 0"
name="feed_url"
value="{{ feed.url }}">Embed</button>
</form>
{% else %}
<span class="btn btn-primary btn-sm disabled"
style="border-top-right-radius: 0; border-bottom-right-radius: 0;">Embed</span>
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 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>
{% 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 %}
{% else %}
<span class="btn btn-primary btn-sm disabled rounded-0">Screenshot</span>
<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 %}
{% endif %}
{% if delivery_mode != "text" %}
<form action="/use_text" method="post" class="d-inline">
<button class="btn btn-outline-light btn-sm"
style="border-top-left-radius: 0; border-bottom-left-radius: 0;"
style="border-top-left-radius: 0;
border-bottom-left-radius: 0"
name="feed_url"
value="{{ feed.url }}">Text</button>
</form>
{% else %}
<span class="btn btn-primary btn-sm disabled"
style="border-top-left-radius: 0; border-bottom-left-radius: 0;">Text</span>
style="border-top-left-radius: 0;
border-bottom-left-radius: 0">Text</span>
{% endif %}
</div>
{% if not chromium_installed and not "youtube.com/feeds/videos.xml" in feed.url %}
<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 not "youtube.com/feeds/videos.xml" in feed.url %}
{% if delivery_mode == "screenshot" and chromium_installed and not "youtube.com/feeds/videos.xml" in feed.url %}
<hr class="border-secondary" />
<section class="mb-3">
<h4 class="h6 text-muted mb-2">Screenshot Layout</h4>
@ -237,10 +257,6 @@
<span class="btn btn-primary btn-sm disabled">Desktop</span>
{% endif %}
</div>
<div class="mt-2 small text-muted">
Requires Chromium for Playwright. Run
<code class="text-light">uv run playwright install chromium</code> once.
</div>
</section>
{% endif %}
{% if not "youtube.com/feeds/videos.xml" in feed.url %}
@ -283,7 +299,7 @@
oninput="this.form.elements.image_limit_value.value = this.value" />
<div class="d-flex justify-content-between text-muted small">
<span>0</span>
</span> <span>{{ max_media_gallery_items }}</span>
<span>{{ max_media_gallery_items }}</span>
</div>
</div>
<button class="btn btn-outline-light btn-sm" type="submit">Save image limit</button>
@ -472,14 +488,10 @@
href="/blacklist?feed_url={{ feed.url|encode_url }}">Blacklist</a>
<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>
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>
href="/embed?feed_url={{ feed.url|encode_url }}">Customize embed</a>
{% endif %}
</div>
</div>

View file

@ -46,6 +46,11 @@
name="delivery_mode">
<option value="embed"
{% if global_delivery_mode == "embed" %}selected{% endif %}>Embed</option>
<option value="screenshot"
{% if global_delivery_mode == "screenshot" %}selected{% endif %}
{% if not chromium_installed %}disabled{% endif %}>
Screenshot{% if not chromium_installed %} (Chromium not installed){% endif %}
</option>
<option value="text"
{% if global_delivery_mode == "text" %}selected{% endif %}>Text</option>
</select>
@ -73,9 +78,12 @@
<div class="form-text text-muted mt-2">
New feeds inherit this value. Existing feeds keep their current screenshot layout.
</div>
<div class="form-text screenshot-requirement mt-1">
Screenshot mode requires Chromium to be installed for Playwright. Run <code>uv run playwright install chromium</code> once on this machine.
</div>
{% if not chromium_installed %}
<div class="form-text text-muted mt-1">
Screenshot mode requires Chromium to be installed for Playwright.
Run <code>uv run playwright install chromium</code> once on this machine.
</div>
{% endif %}
</div>
</form>
<form action="/set_global_webhook_text_length_limit"

View file

@ -19,7 +19,7 @@ class WebhookFile:
class DiscordEmbed:
"""Small Discord embed payload builder used by the webhook sender."""
def __init__(self) -> None: # noqa: D107
def __init__(self) -> None: # ruff:ignore[undocumented-public-init]
self._payload: JsonObject = {}
def to_dict(self) -> JsonObject:
@ -54,7 +54,7 @@ class DiscordEmbed:
def set_thumbnail(self, *, url: str) -> None:
self._payload["thumbnail"] = {"url": url}
def set_image(self, *, url: str, **_ignored: Any) -> None: # noqa: ANN401
def set_image(self, *, url: str, **_ignored: Any) -> None: # ruff:ignore[any-type]
self._payload["image"] = {"url": url}
def set_footer(self, *, text: str, icon_url: str | None = None) -> None:
@ -85,7 +85,7 @@ class DiscordWebhook:
while leaving the actual HTTP transport to `httpx2`.
"""
def __init__( # noqa: D107
def __init__( # ruff:ignore[undocumented-public-init]
self,
url: str,
*,
@ -99,7 +99,7 @@ class DiscordWebhook:
thread_id: str | None = None,
timeout: float | None = None,
rate_limit_retry: bool = False,
**_ignored: Any, # noqa: ANN401
**_ignored: Any, # ruff:ignore[any-type]
) -> None:
self.url: str = url
self.thread_id: str | None = thread_id