"""Reddit search fetcher for ticker-specific discussion posts. Primary path is Reddit's public JSON search endpoint (``reddit.com/r/{sub}/search.json``), which carries the richest data (score, comment count, body). Reddit's WAF increasingly returns ``HTTP 403 Blocked`` on that endpoint (issue #862), so when the JSON request fails we transparently fall back to the public Atom/RSS search feed (``/search.rss``). The RSS feed is gated less aggressively and serves the same descriptive User-Agent we already send; the fallback lacks score / comment counts, so RSS-sourced posts are marked and the formatter omits those metrics rather than printing fake zeros. No API key required either way. Returns formatted plaintext blocks ready for prompt injection and degrades gracefully — returns a placeholder string rather than raising, so callers never special-case missing data. """ from __future__ import annotations import html import json import logging import re import time import xml.etree.ElementTree as ET from datetime import datetime from typing import Iterable, Optional from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen logger = logging.getLogger(__name__) _API = "https://www.reddit.com/r/{sub}/search.json?{qs}" _RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}" # A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit # blocks generic/anonymous tokens like bare "Mozilla/5.0" or "curl/…" but # serves this one on both endpoints; the RSS feed accepts it even when the # JSON search endpoint 403s, so no browser-spoofing is needed. _UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" _ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"} # Default subreddits ordered roughly by signal density for ticker-specific # discussion. wallstreetbets has the most volume but most noise; stocks / # investing trend more measured. Caller can override. DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing") def _search_qs(ticker: str, limit: int) -> str: return urlencode({ "q": ticker, "restrict_sr": "on", "sort": "new", "t": "week", # last 7 days "limit": limit, }) def _iso_to_timestamp(iso_str: Optional[str]) -> Optional[float]: """Parse an Atom ``published`` timestamp to a UTC epoch, or None.""" if not iso_str: return None try: normalized = iso_str[:-1] + "+00:00" if iso_str.endswith("Z") else iso_str return datetime.fromisoformat(normalized).timestamp() except (ValueError, TypeError): return None def _strip_html(content: str) -> str: """Reduce the HTML body Reddit embeds in an Atom entry to plain text.""" if not content: return "" # Reddit wraps the real selftext between SC_OFF / SC_ON markers. if "" in content and "" in content: content = content.split("")[1].split("")[0] text = re.sub(r"<[^>]+>", " ", content) return " ".join(html.unescape(text).split()) def _fetch_subreddit_rss( ticker: str, sub: str, limit: int, timeout: float, ) -> list[dict]: """Fallback path: parse the public Atom search feed for a subreddit. Carries no score / comment counts, so those fields are left None and the post is tagged ``source="rss"`` for honest display. """ url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit)) req = Request(url, headers={"User-Agent": _UA}) try: with urlopen(req, timeout=timeout) as resp: root = ET.fromstring(resp.read()) except (HTTPError, URLError, TimeoutError, ET.ParseError) as exc: logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc) return [] posts = [] for entry in root.findall("atom:entry", _ATOM_NS)[:limit]: title_el = entry.find("atom:title", _ATOM_NS) published_el = entry.find("atom:published", _ATOM_NS) content_el = entry.find("atom:content", _ATOM_NS) posts.append({ "title": (title_el.text if title_el is not None else "") or "", "score": None, "num_comments": None, "created_utc": _iso_to_timestamp( published_el.text if published_el is not None else None ), "selftext": _strip_html(content_el.text if content_el is not None else ""), "source": "rss", }) return posts def _fetch_subreddit( ticker: str, sub: str, limit: int, timeout: float, ) -> list[dict]: url = _API.format(sub=sub, qs=_search_qs(ticker, limit)) req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) try: with urlopen(req, timeout=timeout) as resp: payload = json.loads(resp.read()) children = (payload.get("data") or {}).get("children") or [] return [c.get("data", {}) for c in children if isinstance(c, dict)] except (HTTPError, URLError, json.JSONDecodeError, TimeoutError) as exc: logger.warning( "Reddit JSON fetch failed for r/%s · %s: %s — falling back to RSS feed.", sub, ticker, exc, ) return _fetch_subreddit_rss(ticker, sub, limit, timeout) def fetch_reddit_posts( ticker: str, subreddits: Iterable[str] = DEFAULT_SUBREDDITS, limit_per_sub: int = 5, timeout: float = 10.0, inter_request_delay: float = 0.4, ) -> str: """Fetch recent Reddit posts mentioning ``ticker`` across finance subreddits and return them as a formatted plaintext block. ``inter_request_delay`` keeps us under Reddit's public rate limit (~10 req/min per IP) even if the caller queries many subreddits. """ blocks = [] total_posts = 0 for i, sub in enumerate(subreddits): if i > 0: time.sleep(inter_request_delay) posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout) total_posts += len(posts) if not posts: blocks.append(f"r/{sub}: ") continue via_rss = any(p.get("source") == "rss" for p in posts) header = f"r/{sub} — {len(posts)} recent posts mentioning {ticker.upper()}" header += " (via RSS feed; scores/comments unavailable):" if via_rss else ":" lines = [header] for p in posts: title = (p.get("title") or "").replace("\n", " ").strip() score = p.get("score") comments = p.get("num_comments") created = p.get("created_utc") created_str = ( time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?" ) # Score / comment counts are absent on the RSS fallback path — # show them only when present rather than printing fake zeros. meta = created_str if score is not None and comments is not None: meta += f" · {score:>4}↑ · {comments:>3}c" selftext = (p.get("selftext") or "").replace("\n", " ").strip() if len(selftext) > 240: selftext = selftext[:240] + "…" lines.append( f" [{meta}] {title}" + (f"\n body excerpt: {selftext}" if selftext else "") ) blocks.append("\n".join(lines)) if total_posts == 0: return ( f"" ) return "\n\n".join(blocks)