Files
last30days-skill/skills/last30days/scripts/lib/reddit_rss.py
T
Matt Van Horn 8d3a9e4368 fix(reddit): restore free path via keyless RSS + shreddit scrape (.json is dead) (#457)
* test(reddit): add live RSS + shreddit comment fixtures

Captured from reddit.com on 2026-05-29 (search.rss listing + the
/svc/shreddit/comments partial), trimmed to a representative subset plus
two synthetic edge cases (deleted author, negative score) for offline
parser tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(http): add keyless get_text helper

Browser-UA text fetch for RSS/HTML endpoints; returns None on any HTTP or
network failure so tiered callers fall through cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reddit): keyless RSS discovery (search.rss + listing feeds)

Replaces the now-403 search.json with keyless Atom feeds, normalized to the
existing reddit_public post shape. Scores are placeholder zeros, backfilled
during shreddit enrichment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reddit): keyless shreddit comment scraper

Parses <shreddit-comment> elements from /svc/shreddit/comments/r/{sub}/t3_{id}
(score/author/created/permalink + thingId-anchored body) into top comments,
matching reddit_enrich output. Replaces the dead {thread}.json enrichment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reddit): tiered keyless orchestrator

Tier 0 one-shot .json (residential bonus) -> Tier 1 RSS discovery ->
Tier 2 shreddit enrichment. Returns [] never raises, so the SC backup
still engages when every keyless tier is empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reddit): route free path through keyless pipeline (.json is dead)

search_reddit_public is now a thin shim over reddit_keyless, so pipeline.py
and other callers need no change. Removes the dead .json enrichment helpers;
search/_parse_posts remain as the demoted Tier 0 attempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reddit): request sort=top so true top comments land on page 1

Guarantees the highest-scored comments are captured even on large threads,
independent of Reddit's default comment sort. Local score re-sort remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reddit): recover post upvote scores via keyless listing partials

The shreddit community-more-posts partial server-renders each post's score
and comment count (works for normal users, not IP-gated), unlike RSS or the
comments endpoint. Use it as a scored discovery source and to backfill scores
onto RSS-discovered posts (subreddits derived from results when not provided).
Ranking now uses real upvote score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reddit): listings backfill scores only on bare queries, not discovery

Caught running the full pipeline on a bare topic: deriving subreddits from
noisy RSS results and merging their top/hot listings flooded results with
high-upvote off-topic posts. Now derived-subreddit listings are used only to
backfill scores onto keyword-matched RSS posts; listing cards are merged as
discovery only when the caller explicitly provides subreddits (on-topic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:43:56 -05:00

225 lines
7.6 KiB
Python

"""Keyless Reddit discovery via public RSS/Atom feeds.
Reddit's ``.json`` search endpoints now return HTTP 403 (shreddit anti-bot).
RSS feeds still serve HTTP 200 with no API key, so this module uses them for
post discovery, replacing ``reddit_public.search`` as the free search path.
Two feed families are combined and deduped:
- search: /search.rss?q=... and /r/{sub}/search.rss?q=...&restrict_sr=on
- listing: /r/{sub}/{top,hot}.rss?t=month
RSS entries carry no engagement score, so ``score``/``num_comments`` start at 0
and are backfilled during shreddit enrichment (see reddit_shreddit.py). Output
dicts match the normalized shape emitted by ``reddit_public._parse_posts`` so
downstream code (pipeline, renderer) is unaffected.
"""
import sys
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import quote_plus
from . import http
from .relevance import token_overlap_relevance
ATOM = "{http://www.w3.org/2005/Atom}"
# Mirror reddit_public depth-aware limits so the two free paths behave alike.
DEPTH_LIMITS = {
"quick": 10,
"default": 25,
"deep": 50,
}
# Listing sorts pulled per subreddit (in addition to search), for volume.
LISTING_SORTS = {
"quick": ["top"],
"default": ["top", "hot"],
"deep": ["top", "hot", "new"],
}
MAX_WORKERS = 4
FEED_TIMEOUT = 15
def _log(msg: str) -> None:
sys.stderr.write(f"[RedditRSS] {msg}\n")
sys.stderr.flush()
def _iso_to_date(value: Optional[str]) -> Optional[str]:
"""Parse an ISO-8601 timestamp (e.g. 2026-05-20T18:48:31+00:00) to YYYY-MM-DD."""
if not value:
return None
try:
dt = datetime.fromisoformat(value.strip())
return dt.date().isoformat()
except (ValueError, TypeError):
return None
def _iso_to_epoch(value: Optional[str]) -> Optional[float]:
if not value:
return None
try:
dt = datetime.fromisoformat(value.strip())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except (ValueError, TypeError):
return None
def _subreddit_from(category: str, url: str) -> str:
"""Derive subreddit name from the entry category or, failing that, the URL."""
if category:
return category
# URL form: https://www.reddit.com/r/{sub}/comments/{id}/...
parts = url.split("/r/", 1)
if len(parts) == 2:
return parts[1].split("/", 1)[0]
return ""
def _parse_feed(xml_text: str, query: str = "") -> List[Dict[str, Any]]:
"""Parse an Atom feed string into normalized post dicts. Never raises."""
if not xml_text:
return []
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
_log(f"feed parse error: {e}")
return []
posts: List[Dict[str, Any]] = []
for entry in root.iter(f"{ATOM}entry"):
link_el = entry.find(f"{ATOM}link")
url = link_el.get("href", "").strip() if link_el is not None else ""
if not url or "/comments/" not in url:
continue
title_el = entry.find(f"{ATOM}title")
title = (title_el.text or "").strip() if title_el is not None else ""
author = ""
author_el = entry.find(f"{ATOM}author/{ATOM}name")
if author_el is not None and author_el.text:
author = author_el.text.strip().removeprefix("/u/").removeprefix("u/")
if author in ("[deleted]", "[removed]", ""):
author = "[deleted]"
cat_el = entry.find(f"{ATOM}category")
category = cat_el.get("term", "").strip() if cat_el is not None else ""
subreddit = _subreddit_from(category, url)
updated_el = entry.find(f"{ATOM}updated")
updated = (updated_el.text or "").strip() if updated_el is not None else ""
content_el = entry.find(f"{ATOM}content")
selftext = ""
if content_el is not None and content_el.text:
# Strip the simplest HTML; renderer only needs an excerpt.
import re as _re
selftext = _re.sub(r"<[^>]+>", " ", content_el.text)
selftext = _re.sub(r"\s+", " ", selftext).strip()[:500]
relevance = round(token_overlap_relevance(query, title), 3) if query else 0.0
posts.append({
"id": "", # assigned after dedup
"title": title,
"url": url,
"score": 0, # backfilled by shreddit enrichment
"num_comments": 0, # backfilled by shreddit enrichment
"subreddit": subreddit,
"created_utc": _iso_to_epoch(updated),
"author": author,
"selftext": selftext,
"date": _iso_to_date(updated),
"engagement": {
"score": 0,
"num_comments": 0,
"upvote_ratio": None,
},
"relevance": relevance,
"why_relevant": "Reddit RSS",
"metadata": {},
})
return posts
def _build_urls(query: str, depth: str, subreddits: Optional[List[str]]) -> List[str]:
"""Build the keyless RSS feed URLs to fan out across."""
q = quote_plus(query)
urls: List[str] = [
f"https://www.reddit.com/search.rss?q={q}&sort=relevance&t=month"
]
for raw_sub in (subreddits or []):
sub = raw_sub.removeprefix("r/").strip()
if not sub:
continue
urls.append(
f"https://www.reddit.com/r/{sub}/search.rss"
f"?q={q}&restrict_sr=on&sort=relevance&t=month"
)
for sort in LISTING_SORTS.get(depth, LISTING_SORTS["default"]):
urls.append(f"https://www.reddit.com/r/{sub}/{sort}.rss?t=month")
return urls
def _fetch_feed(url: str, query: str) -> List[Dict[str, Any]]:
"""Fetch and parse one feed. Never raises."""
try:
text = http.get_text(url, timeout=FEED_TIMEOUT, accept="application/atom+xml")
return _parse_feed(text, query) if text else []
except Exception as e: # defensive: a single bad feed must not sink the run
_log(f"feed fetch failed for {url}: {e}")
return []
def search_rss(
query: str,
depth: str = "default",
subreddits: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Discover Reddit posts for a query via keyless RSS feeds.
Args:
query: Search query string
depth: 'quick', 'default', or 'deep' — controls result limit and feeds
subreddits: Optional pre-resolved subreddit names (without r/) to target
Returns:
List of normalized post dicts (deduped by URL, capped by depth),
with placeholder scores to be backfilled during enrichment.
Empty list on any failure.
"""
limit = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
urls = _build_urls(query, depth, subreddits)
all_posts: List[Dict[str, Any]] = []
workers = min(MAX_WORKERS, len(urls)) or 1
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(_fetch_feed, url, query): url for url in urls}
for future in futures:
try:
all_posts.extend(future.result(timeout=FEED_TIMEOUT + 5))
except (Exception, FuturesTimeoutError) as e:
_log(f"feed future failed: {e}")
# Dedupe by URL (first occurrence wins).
seen: set = set()
unique: List[Dict[str, Any]] = []
for post in all_posts:
if post["url"] not in seen:
seen.add(post["url"])
unique.append(post)
for i, post in enumerate(unique):
post["id"] = f"R{i + 1}"
return unique[:limit]