mirror of
https://github.com/tanrax/org-social-relay
synced 2026-07-25 02:34:58 +02:00
Expose external accounts as virtual Org Social feeds that any client
can follow with a plain #+FOLLOW: line:
- /bridge/activitypub/@{user}@{instance}/ bridges a Mastodon or any
ActivityPub account (WebFinger, actor and paginated outbox; only
public top-level notes, with CW, hashtags, language and attachments)
- /bridge/rss/?url={feed} bridges any RSS/Atom feed (entry title as
*** sub-heading, converted body and link to the original article)
Registration is implicit on first GET. Bridged data is stored in the
existing Profile/Post tables so /profile/, /search/ and the rest of
the API work on bridged feeds. Active bridges are refreshed every 15
minutes; bridges unrequested for 90 days are cleaned up.
Remote HTML is converted to Org text, escaping headline-like lines so
external content cannot inject posts. Fetches enforce the Webmention
SSRF protections, a 10s timeout and a 5MB size cap. Bridged posts
never queue Webmentions nor publish notifications.
140 lines
4.0 KiB
Python
140 lines
4.0 KiB
Python
"""
|
|
RSS/Atom fetching for the bridge, normalized into the same structure
|
|
used by the ActivityPub bridge.
|
|
|
|
Each entry becomes a post whose body is the entry title as an Org
|
|
sub-heading (***), the converted entry content, and a link to the
|
|
original article.
|
|
"""
|
|
|
|
import calendar
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timezone as dt_timezone
|
|
from urllib.parse import urlparse
|
|
|
|
import feedparser
|
|
|
|
from .fetching import BridgeError, safe_get
|
|
from .html_to_org import html_to_org
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_POSTS = 40
|
|
|
|
_NICK_ALLOWED_RE = re.compile(r"[^A-Za-z0-9_-]+")
|
|
|
|
|
|
def _derive_nick(title, source_url):
|
|
"""Build a spaces-free nick from the feed title or the feed host."""
|
|
candidate = _NICK_ALLOWED_RE.sub("_", (title or "").strip()).strip("_")
|
|
if candidate:
|
|
return candidate
|
|
host = urlparse(source_url).hostname or "feed"
|
|
return host.replace(".", "_")
|
|
|
|
|
|
def _entry_post_id(entry):
|
|
"""Org Social post ID (UTC RFC 3339) from the entry date, or None."""
|
|
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
|
|
if not parsed_time:
|
|
return None
|
|
timestamp = calendar.timegm(parsed_time)
|
|
return datetime.fromtimestamp(timestamp, dt_timezone.utc).strftime(
|
|
"%Y-%m-%dT%H:%M:%S+00:00"
|
|
)
|
|
|
|
|
|
def _next_free_id(post_id, seen_ids):
|
|
"""
|
|
Entries sharing the same timestamp (e.g. date-only feeds) get IDs
|
|
shifted forward one second at a time so no post is lost.
|
|
"""
|
|
while post_id in seen_ids:
|
|
parsed = datetime.strptime(post_id, "%Y-%m-%dT%H:%M:%S+00:00")
|
|
shifted = parsed.timestamp() + 1
|
|
post_id = datetime.fromtimestamp(shifted, dt_timezone.utc).strftime(
|
|
"%Y-%m-%dT%H:%M:%S+00:00"
|
|
)
|
|
return post_id
|
|
|
|
|
|
def _entry_body(entry):
|
|
contents = entry.get("content") or []
|
|
if contents and contents[0].get("value"):
|
|
html = contents[0]["value"]
|
|
else:
|
|
html = entry.get("summary") or ""
|
|
return html_to_org(html)
|
|
|
|
|
|
def _entry_to_post(entry, seen_ids):
|
|
post_id = _entry_post_id(entry)
|
|
if post_id is None:
|
|
logger.warning(
|
|
f"Skipping RSS entry without date: {entry.get('link', '(no link)')}"
|
|
)
|
|
return None
|
|
post_id = _next_free_id(post_id, seen_ids)
|
|
|
|
parts = []
|
|
title = " ".join((entry.get("title") or "").split())
|
|
if title:
|
|
parts.append(f"*** {title}")
|
|
body = _entry_body(entry)
|
|
if body:
|
|
parts.append(body)
|
|
link = (entry.get("link") or "").strip()
|
|
if link and "]" not in link:
|
|
parts.append(f"[[{link}]]")
|
|
|
|
tags = " ".join(
|
|
tag.get("term", "").strip().replace(" ", "-")
|
|
for tag in entry.get("tags", [])
|
|
if tag.get("term")
|
|
)
|
|
|
|
return {
|
|
"id": post_id,
|
|
"content": "\n\n".join(parts),
|
|
"tags": tags,
|
|
"language": "",
|
|
}
|
|
|
|
|
|
def fetch_rss_feed(source_url):
|
|
"""
|
|
Fetch an RSS/Atom feed and normalize it for the bridge.
|
|
|
|
Returns a dict with metadata and posts.
|
|
Raises BridgeError when the feed cannot be fetched or parsed.
|
|
"""
|
|
raw = safe_get(source_url)
|
|
document = feedparser.parse(raw)
|
|
|
|
feed_info = document.get("feed", {})
|
|
if not document.get("entries") and not feed_info.get("title"):
|
|
raise BridgeError(f"Not a valid RSS/Atom feed: {source_url}")
|
|
|
|
title = " ".join((feed_info.get("title") or "").split())
|
|
image = feed_info.get("image") or {}
|
|
metadata = {
|
|
"title": title or source_url,
|
|
"nick": _derive_nick(title, source_url),
|
|
"description": " ".join((feed_info.get("subtitle") or "").split()),
|
|
"avatar": image.get("href", ""),
|
|
"link": feed_info.get("link") or source_url,
|
|
"language": (feed_info.get("language") or "").split("-")[0],
|
|
}
|
|
|
|
posts = []
|
|
seen_ids = set()
|
|
for entry in document.get("entries", [])[:MAX_POSTS]:
|
|
post = _entry_to_post(entry, seen_ids)
|
|
if post is None:
|
|
continue
|
|
seen_ids.add(post["id"])
|
|
posts.append(post)
|
|
|
|
return {"metadata": metadata, "posts": posts}
|