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>
This commit is contained in:
Matt Van Horn
2026-05-29 14:43:56 -05:00
committed by GitHub
parent 1e03af19e0
commit 8d3a9e4368
14 changed files with 1389 additions and 216 deletions
@@ -0,0 +1,214 @@
"""Keyless Reddit pipeline: tiered free search + comment enrichment.
Replaces the dead ``.json`` free path. Discovery tiers, cheapest/most-likely
first; enrichment then runs on whatever was discovered:
Tier 0 one-shot legacy ``.json`` search — demoted. Datacenter IPs get 403,
but a residential machine (where the skill usually runs) may still
get 200, so it is worth one cheap try. Honors the "brute-force .json"
intent without depending on it.
Tier 1 RSS discovery (reddit_rss) — keyless, robust, the load-bearing path.
Tier 2 shreddit comment + count enrichment (reddit_shreddit) for top posts.
Returns ``[]`` (never raises) so ``pipeline.py`` can fall through to the
ScrapeCreators backup when every keyless tier comes up empty.
"""
import concurrent.futures
import sys
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from collections import Counter
from . import reddit_rss, reddit_shreddit, reddit_listing
ENRICH_LIMITS = reddit_shreddit.ENRICH_LIMITS
ENRICH_BUDGET = 45 # seconds total across all enrichment threads
MAX_ENRICH_WORKERS = 4
MAX_DERIVED_SUBS = 5 # subreddits derived from RSS results for score backfill
def _log(msg: str) -> None:
sys.stderr.write(f"[RedditKeyless] {msg}\n")
sys.stderr.flush()
def _tier0_json(topic: str, depth: str) -> List[Dict[str, Any]]:
"""One cheap global ``.json`` discovery attempt. Returns [] on the 403 wall."""
try:
from . import reddit_public
return reddit_public.search(topic, depth=depth) or []
except Exception as e: # never let the demoted tier sink the run
_log(f"Tier 0 (.json) unavailable: {e}")
return []
def _top_subreddits(posts: List[Dict[str, Any]], limit: int = MAX_DERIVED_SUBS) -> List[str]:
"""Most frequent subreddits across discovered posts (for score backfill)."""
counts = Counter(p.get("subreddit", "") for p in posts if p.get("subreddit"))
return [sub for sub, _ in counts.most_common(limit)]
def _apply_scores(post: Dict[str, Any], scored: Dict[str, int]) -> None:
post["score"] = scored["score"]
post["num_comments"] = scored["num_comments"]
post.setdefault("engagement", {})["score"] = scored["score"]
post["engagement"]["num_comments"] = scored["num_comments"]
def _discover(topic: str, depth: str, subreddits: Optional[List[str]]) -> List[Dict[str, Any]]:
# Tier 0: demoted one-shot .json (dead for normal users too, but free to try).
posts = _tier0_json(topic, depth)
if posts:
_log(f"Tier 0 (.json) returned {len(posts)} posts")
return posts
# Tier 1: keyless discovery. RSS gives breadth (incl. global keyword search);
# the listing partials give real upvote scores.
rss_posts = reddit_rss.search_rss(topic, depth=depth, subreddits=subreddits)
if subreddits:
# Targeted run: the caller chose these subreddits, so their listing cards
# are on-topic — include them as scored discovery AND as a score source.
listing_posts = reddit_listing.fetch_listings(subreddits, depth=depth, query=topic)
score_source = listing_posts
else:
# Bare global run: subreddits derived from noisy RSS results are NOT
# reliably on-topic, so their listings are used ONLY to backfill scores
# onto the keyword-matched RSS posts — never merged as discovery, which
# would flood results with high-upvote but irrelevant posts.
listing_posts = []
derived = _top_subreddits(rss_posts)
score_source = reddit_listing.fetch_listings(derived, depth=depth, query=topic)
_log(
f"Tier 1 (RSS) {len(rss_posts)} posts; "
f"{'listing discovery ' + str(len(listing_posts)) if subreddits else 'score-only'}; "
f"{len(score_source)} scored cards"
)
# Score lookup by post id, from the scored listing cards.
score_map: Dict[str, Dict[str, int]] = {}
for p in score_source:
pid = p.get("metadata", {}).get("post_id", "")
if pid:
score_map[pid] = {"score": p["score"], "num_comments": p["num_comments"]}
# Merge: scored listing posts first (targeted only), then RSS breadth,
# backfilled with real scores where the post appears in a listing.
merged: List[Dict[str, Any]] = []
seen: set = set()
for p in listing_posts:
if p["url"] not in seen:
seen.add(p["url"])
merged.append(p)
for p in rss_posts:
if p["url"] in seen:
continue
pid = reddit_listing._post_id(p["url"])
if pid in score_map:
_apply_scores(p, score_map[pid])
seen.add(p["url"])
merged.append(p)
return merged
def _enrich_one(post: Dict[str, Any]) -> Dict[str, Any]:
"""Attach shreddit comments + real comment count. Never raises."""
try:
data = reddit_shreddit.fetch_comments(post.get("url", ""))
if data.get("top_comments"):
post["top_comments"] = data["top_comments"]
if data.get("comment_insights"):
post["comment_insights"] = data["comment_insights"]
num = data.get("num_comments")
if num is not None:
post["num_comments"] = num
post.setdefault("engagement", {})["num_comments"] = num
except Exception:
pass # keep the post with whatever discovery gave us
return post
def _enrich(posts: List[Dict[str, Any]], depth: str) -> List[Dict[str, Any]]:
"""Enrich the top N posts with comments under a total time budget."""
limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
to_enrich = posts[:limit]
rest = posts[limit:]
if not to_enrich:
return posts
result_map: Dict[int, Dict[str, Any]] = {}
try:
with ThreadPoolExecutor(max_workers=min(limit, MAX_ENRICH_WORKERS)) as executor:
futures = {
executor.submit(_enrich_one, post): i
for i, post in enumerate(to_enrich)
}
done, not_done = concurrent.futures.wait(futures, timeout=ENRICH_BUDGET)
for future in done:
idx = futures[future]
try:
result_map[idx] = future.result(timeout=0)
except Exception:
result_map[idx] = to_enrich[idx]
for future in not_done:
idx = futures[future]
result_map[idx] = to_enrich[idx]
future.cancel()
enriched = [result_map[i] for i in range(len(to_enrich))]
except Exception:
enriched = to_enrich
return enriched + rest
def search_and_enrich(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
subreddits: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Full keyless Reddit pipeline: discover (Tier 0/1) then enrich (Tier 2).
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
subreddits: Optional pre-resolved subreddit names (without r/)
Returns:
List of normalized item dicts matching the reddit_public output shape,
with top_comments/comment_insights attached on enriched posts.
Empty list when all keyless tiers fail (so SC backup can engage).
"""
posts = _discover(topic, depth, subreddits)
if not posts:
return []
# Date filter: keep posts in range or with unknown dates (mirrors reddit_public).
posts = [
p for p in posts
if p.get("date") is None or (from_date <= p["date"] <= to_date)
]
# Rank before enrichment by real upvote score (from listing cards / backfill),
# then query relevance, then recency. Posts without a recovered score sort by
# the latter two — same behavior as before scores were available.
posts.sort(
key=lambda p: (
p.get("engagement", {}).get("score", 0) or 0,
p.get("relevance", 0) or 0,
p.get("date") or "",
),
reverse=True,
)
posts = _enrich(posts, depth)
for i, post in enumerate(posts):
post["id"] = f"R{i + 1}"
return posts