Files

560 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Reddit adapter v2 for AI Research Oracle.
Uses Reddit RSS feeds (Atom XML) with proper filtering and scoring.
Improvements over v1 (proof of concept):
- Filters AutoModerator posts and sticky threads
- Proper User-Agent header
- Attempts old.reddit.com .json for real upvote/comment data
- Falls back to RSS with estimated scoring if JSON blocked
- Rate limit awareness with retry logic
Rate limits: Reddit aggressively 429s unauthenticated requests.
Strategy: 3s spacing between subreddits, retry with backoff.
"""
import json
import math
import os
import random
import re
import time
import urllib.request
import urllib.error
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from html import unescape
from adapters import SourceAdapter, browser_user_agent, jitter_sleep
from adapters._store import true_first_seen, upsert_entries
class RedditAdapter(SourceAdapter):
"""Reddit RSS + JSON adapter."""
# Default subreddits for AI content
DEFAULT_SUBREDDITS = [
"MachineLearning", "artificial", "LocalLLaMA", "Startups",
]
# Posts to filter out (sticky threads, recurring mod posts)
FILTER_TITLES = {
"self-promotion thread",
"monthly who's hiring",
"weekly self-promotion",
"discussion thread",
"show and tell",
"weekly show",
}
FILTER_AUTHORS = {
"automod",
"automoderator",
}
def __init__(self, subreddits=None, rate_limit=4, user_agent=None):
"""
Args:
subreddits: List of subreddit names.
rate_limit: Base seconds between subreddit requests (with jitter).
user_agent: Custom User-Agent header.
"""
self.subreddits = subreddits or self.DEFAULT_SUBREDDITS
self.rate_limit = rate_limit
self.user_agent = user_agent or browser_user_agent()
def name(self) -> str:
return "reddit"
def _sleep_with_jitter(self, base=None):
"""Sleep with ±30% jitter to avoid pattern detection."""
base = base or self.rate_limit
jitter = base * 0.3 * (2 * random.random() - 1) # ±30%
time.sleep(base + jitter)
def _clean_html(self, html: str) -> str:
"""Extract readable text from Reddit's HTML content."""
if not html:
return ""
text = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
text = re.sub(r"<div[^>]*>", "\n", text)
text = re.sub(r"</div>", "\n", text)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"<p[^>]*>", "\n", text)
text = re.sub(r"</p>", "\n", text)
text = re.sub(r"<a[^>]*href=\"([^\"]+)\"[^>]*>([^<]*)</a>", r"\2", text)
text = re.sub(r"<[^>]+>", "", text)
text = unescape(text)
text = re.sub(r"\n\s*\n+", "\n\n", text)
return text.strip()
def _is_sticky(self, entry: dict) -> bool:
"""Check if a post is a sticky/mod post that should be filtered."""
title_lower = entry.get("title", "").lower().strip()
author_lower = entry.get("author", "").lower().strip()
# Filter by author
if author_lower in self.FILTER_AUTHORS:
return True
# Filter by title patterns
for pattern in self.FILTER_TITLES:
if pattern in title_lower:
return True
# Filter very short posts (likely link-only with no content)
if len(entry.get("content", "")) < 20:
return True
return False
def _fetch_rss(self, subreddit: str) -> list[dict]:
"""Fetch RSS feed for a subreddit."""
url = f"https://www.reddit.com/r/{subreddit}/hot/.rss?limit=50"
req = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
backoff = [2, 5] # staggered backoff: 2s then 5s
for attempt in range(3): # max 3 attempts
try:
with urllib.request.urlopen(req, timeout=8) as resp:
xml_data = resp.read().decode("utf-8")
break
except urllib.error.HTTPError as e:
if e.code in (403,):
print(f" RSS blocked (HTTP {e.code}) for r/{subreddit}")
return []
if e.code == 429:
if attempt < len(backoff):
delay = backoff[attempt]
print(f" RSS 429 for r/{subreddit}, retrying in {delay}s")
jitter_sleep(delay)
continue
print(f" RSS rate-limited for r/{subreddit}, skip")
return []
print(f" RSS HTTP {e.code} for r/{subreddit}")
return []
except Exception as e:
print(f" RSS error r/{subreddit}: {e}")
return []
else:
print(f" r/{subreddit}: still rate limited after 3 attempts, skip")
return []
# Parse Atom XML
entries = []
root = ET.fromstring(xml_data)
for entry_el in root.iter():
tag = entry_el.tag.split("}")[-1] if "}" in entry_el.tag else entry_el.tag
if tag != "entry":
continue
data = {"title": "", "url": "", "author": "", "content": "", "published": "", "id": ""}
for child in entry_el:
ctag = child.tag.split("}")[-1]
if ctag == "title":
data["title"] = unescape((child.text or "").strip())
elif ctag == "link":
data["url"] = child.get("href", "")
elif ctag == "author":
for ac in child:
if ac.tag.split("}")[-1] == "name":
data["author"] = unescape((ac.text or "").strip())
elif ctag == "content":
data["content"] = child.text or ""
elif ctag == "published":
data["published"] = child.text or ""
elif ctag == "id":
data["id"] = child.text or ""
if data["title"] and data["url"]:
entries.append(data)
return entries
def _try_json(self, subreddit: str, sort: str = "hot") -> list[dict]:
"""Try to fetch JSON data from old.reddit.com for real scores."""
url = f"https://old.reddit.com/r/{subreddit}/{sort}.json?limit=25"
req = urllib.request.Request(url, headers={
"User-Agent": self.user_agent,
"Accept": "application/json",
})
for attempt in range(2):
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
posts = []
if isinstance(data, dict) and "data" in data:
for child in data["data"].get("children", []):
d = child.get("data", {})
if d.get("title"):
posts.append({
"title": d.get("title", ""),
"url": d.get("url", ""),
"author": d.get("author", ""),
"selftext": d.get("selftext", ""),
"score": d.get("score", 0),
"num_comments": d.get("num_comments", 0),
"ups": d.get("ups", 0),
"id": d.get("id", ""),
"created_utc": d.get("created_utc", 0),
"permalink": d.get("permalink", ""),
"link_flair_text": d.get("link_flair_text", ""),
"subreddit": subreddit,
"source": "json",
})
return posts
except urllib.error.HTTPError as e:
if e.code in (429, 403, 404):
return [] # JSON endpoint blocked, fall back to RSS
if attempt < 1:
jitter_sleep(5)
continue
return []
except Exception:
return []
return []
def _score(self, entry: dict, source: str) -> float:
"""Score based on available signals.
If source is 'json', use real upvote/comment counts.
If source is 'rss', use heuristics (content length, flair, etc.).
"""
if source == "json" and entry.get("score", 0) > 0:
import math
# Real upvote-based scoring
score = entry.get("score", 0)
comments = entry.get("num_comments", 0)
# Log scale on upvotes, bonus for engagement
star_score = min(math.log1p(score) / 2.0, 8.0)
engagement = min(comments / 50.0, 2.0)
return min(round(star_score + engagement, 2), 10.0)
else:
# RSS heuristic — no real scores available
content = entry.get("content", "")
title = entry.get("title", "")
# Base score
base = 3.0
# Content length bonus (substantive posts get higher scores)
content_len = len(self._clean_html(content))
if content_len > 5000:
base += 2.0
elif content_len > 2000:
base += 1.5
elif content_len > 1000:
base += 1.0
elif content_len > 500:
base += 0.6
elif content_len > 200:
base += 0.3
elif content_len > 50:
base += 0.1
# Post type from title markers
if " [R]" in title or " [r]" in title: # Research
base += 1.5
elif " [P]" in title or " [p]" in title: # Project
base += 1.2
elif " [N]" in title or " [n]" in title: # News
base += 0.8
elif " [D]" in title or " [d]" in title: # Discussion
base += 0.5
# Flair bonus
flair = entry.get("link_flair_text", "").lower()
if "research" in flair:
base += 1.0
elif "project" in flair:
base += 0.8
return min(round(base, 2), 10.0)
def _tags(self, entry: dict, source: str) -> list:
"""Generate category tags from Reddit metadata."""
tags = ["reddit"]
subreddit = entry.get("subreddit", "").lower()
if "machinelearning" in subreddit:
tags.append("machine-learning")
elif "artificial" in subreddit:
tags.append("ai-general")
elif "localllama" in subreddit:
tags.append("local-llm")
elif "startups" in subreddit:
tags.append("startups")
title = entry.get("title", "")
if " [P]" in title or " [p]" in title:
tags.append("project")
elif " [R]" in title or " [r]" in title:
tags.append("research")
elif " [D]" in title or " [d]" in title:
tags.append("discussion")
elif " [N]" in title or " [n]" in title:
tags.append("news")
else:
tags.append("general")
# Check for self-referential virality signal
content = entry.get("content", "").lower()
title_lower = title.lower()
if any(kw in title_lower or kw in content for kw in [
"stars", "viral", "trending", "blow up", "trending github",
]):
tags.append("meta:virality")
# Local-inference / on-device signal (suggested source: r/MachineLearning
# "I tried X on-device" posts — high builder signal → local-inference feed)
on_device_kw = [
"on-device", "on device", "local inference", "local llm",
"ran locally", "running locally", "in my pocket", "on my phone",
"edge device", "offline", "no gpu", "consumer gpu", "rtx",
"single gpu", "self-host", "self host",
]
if any(kw in title_lower or kw in content for kw in on_device_kw):
tags.append("local-inference")
return tags
def _fetch_rss(self, subreddit: str) -> list[dict]:
"""Fetch RSS feed for a subreddit."""
url = f"https://www.reddit.com/r/{subreddit}/hot/.rss?limit=50"
req = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
backoff = [2, 5] # staggered backoff: 2s then 5s
for attempt in range(3): # max 3 attempts
try:
with urllib.request.urlopen(req, timeout=8) as resp:
xml_data = resp.read().decode("utf-8")
break
except urllib.error.HTTPError as e:
if e.code in (403,):
print(f" RSS blocked (HTTP {e.code}) for r/{subreddit}")
return []
if e.code == 429:
if attempt < len(backoff):
delay = backoff[attempt]
print(f" RSS 429 for r/{subreddit}, retrying in {delay}s")
jitter_sleep(delay)
continue
print(f" RSS rate-limited for r/{subreddit}, skip")
return []
print(f" RSS HTTP {e.code} for r/{subreddit}")
return []
except Exception as e:
print(f" RSS error r/{subreddit}: {e}")
return []
else:
print(f" r/{subreddit}: still rate limited after 3 attempts, skip")
return []
# Parse Atom XML
entries = []
root = ET.fromstring(xml_data)
for entry_el in root.iter():
tag = entry_el.tag.split("}")[-1] if "}" in entry_el.tag else entry_el.tag
if tag != "entry":
continue
data = {"title": "", "url": "", "author": "", "content": "", "published": "", "id": ""}
for child in entry_el:
ctag = child.tag.split("}")[-1]
if ctag == "title":
data["title"] = unescape((child.text or "").strip())
elif ctag == "link":
data["url"] = child.get("href", "")
elif ctag == "author":
for ac in child:
if ac.tag.split("}")[-1] == "name":
data["author"] = unescape((ac.text or "").strip())
elif ctag == "content":
data["content"] = child.text or ""
elif ctag == "published":
data["published"] = child.text or ""
elif ctag == "id":
data["id"] = child.text or ""
if data["title"] and data["url"]:
entries.append(data)
return entries
def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
"""
Fetch Reddit posts.
Tries JSON first (real scores), falls back to RSS (heuristic scoring).
Filters AutoModerator and sticky posts.
"""
now = datetime.now(timezone.utc)
# Try JSON first — if it's blocked on the first subreddit, skip
# the test-RSS call (which would waste a request and risk rate-limiting)
# and go straight to the RSS loop
first_json = self._try_json(self.subreddits[0])
json_worked = bool(first_json)
all_entries = []
seen_ids = set()
# Add first JSON results if any
for p in first_json:
if p["id"] not in seen_ids:
seen_ids.add(p["id"])
all_entries.append(p)
# If JSON didn't work, fall back to RSS for all subreddits
if not json_worked:
print(" JSON endpoints blocked, using RSS fallback")
# Brief cooldown before RSS barrage
jitter_sleep(3)
for sub in self.subreddits:
entries = self._fetch_rss(sub)
for e in entries:
# Convert RSS format to unified format
entry = {
"title": e["title"],
"url": e["url"],
"author": e["author"],
"content": e["content"],
"published": e["published"],
"id": e["id"].replace("t3_", ""),
"score": 0,
"num_comments": 0,
"ups": 0,
"selftext": "",
"created_utc": 0,
"permalink": "",
"link_flair_text": "",
"subreddit": sub,
"source": "rss",
}
if entry["id"] not in seen_ids:
seen_ids.add(entry["id"])
all_entries.append(entry)
self._sleep_with_jitter()
# Filter sticky/mod posts
filtered = []
for entry in all_entries:
if not self._is_sticky(entry):
filtered.append(entry)
print(f" Filtered: {len(all_entries)}{len(filtered)} (removed {len(all_entries) - len(filtered)} sticky/mod)")
# Sort by score, take top limit
for entry in filtered:
entry["_score"] = self._score(entry, entry.get("source", "rss"))
filtered.sort(key=lambda e: e.get("_score", 0), reverse=True)
filtered = filtered[:limit]
# Convert to DB format
entries = []
for entry in filtered:
source_id = entry.get("id", "") or entry.get("url", "").split("/")[-1]
score = entry.pop("_score", 0)
# Clean title
title = entry.get("title", "")
# Extracted text: selftext (JSON) or content (RSS)
if entry.get("source") == "json":
extracted_text = entry.get("selftext", "")
else:
extracted_text = self._clean_html(entry.get("content", ""))
tags = self._tags(entry, entry.get("source", "rss"))
# Structured metadata
raw_meta = {
"subreddit": entry.get("subreddit", ""),
"author": entry.get("author", ""),
"published": entry.get("published", ""),
"score": entry.get("score", 0),
"num_comments": entry.get("num_comments", 0),
"ups": entry.get("ups", 0),
"link_flair_text": entry.get("link_flair_text", ""),
"source_type": entry.get("source", "rss"), # json or rss
"content_length": len(extracted_text),
"score_type": "actual" if entry.get("source") == "json" and entry.get("score", 0) > 0 else "estimated",
}
now_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
first_seen = true_first_seen(raw_meta, "reddit", now_str)
entries.append({
"source": "reddit",
"source_id": source_id,
"url": entry.get("url", ""),
"title": title,
"extracted_text": extracted_text,
"summary": None,
"category_tags": json.dumps(tags),
"signal_score": score,
"raw_metadata": json.dumps(raw_meta),
"first_seen": first_seen,
"last_updated": now_str,
})
return entries
if __name__ == "__main__":
import argparse
import sqlite3
parser = argparse.ArgumentParser(description="Reddit adapter v2 for AI Research Oracle")
parser.add_argument("--limit", type=int, default=20, help="Max entries")
parser.add_argument("--db", default=os.path.join(os.path.dirname(__file__), "..", "oracle.db"), help="SQLite DB")
parser.add_argument("--schema", default=os.path.join(os.path.dirname(__file__), "..", "schema.sql"), help="Schema file")
parser.add_argument("--dry-run", action="store_true", help="Don't store in DB")
args = parser.parse_args()
print(f"=== Reddit Adapter v2 ===")
print(f" Limit: {args.limit}")
print()
adapter = RedditAdapter()
entries = adapter.fetch(limit=args.limit)
print(f"\n Fetched {len(entries)} entries")
if not args.dry_run:
conn = sqlite3.connect(args.db)
if os.path.exists(args.schema):
with open(args.schema) as f:
conn.executescript(f.read())
conn.commit()
cur = conn.cursor()
stored = upsert_entries(conn, entries)
print(f"\n Stored {stored} entries")
conn.commit()
conn.close()
print(f" Stored {stored} entries")
# Print top 5
print(f"\n Top entries:")
for i, e in enumerate(entries[:5]):
meta = json.loads(e["raw_metadata"]) if isinstance(e["raw_metadata"], str) else e["raw_metadata"]
src = meta.get("source_type", "?")
ups = meta.get("score", "?")
comments = meta.get("num_comments", "?")
score_type = meta.get("score_type", "?")
print(f" [{i+1}] score={e['signal_score']:.2f} ({score_type}) ups={ups} comments={comments} src={src}")
print(f" {e['title'][:90]}")
print(f" r/{meta.get('subreddit', '?')} by {meta.get('author', '?')}")
print(f" text={meta.get('content_length', 0)}ch")
print(f"\n Done.")