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
+168
View File
@@ -0,0 +1,168 @@
"""Tests for scripts/lib/reddit_keyless.py — tiered keyless Reddit pipeline."""
from unittest import mock
from lib import reddit_keyless
def _post(i, date="2026-05-20", rel=0.0):
url = f"https://www.reddit.com/r/test/comments/{i:06d}/post_{i}/"
return {
"id": "", "title": f"Post {i}", "url": url, "score": 0, "num_comments": 0,
"subreddit": "test", "created_utc": None, "author": "u", "selftext": "",
"date": date, "engagement": {"score": 0, "num_comments": 0, "upvote_ratio": None},
"relevance": rel, "why_relevant": "Reddit RSS", "metadata": {},
}
def _scored(i, score, ncmt=0):
p = _post(i)
p["score"] = score
p["num_comments"] = ncmt
p["engagement"]["score"] = score
p["engagement"]["num_comments"] = ncmt
p["why_relevant"] = "Reddit listing"
p["metadata"] = {"post_id": f"{i:06d}"}
return p
class TestDiscoveryTierOrder:
"""Tier 0 (.json) is tried first; RSS + scored listings are the keyless path."""
def test_tier0_success_skips_keyless(self):
with mock.patch.object(reddit_keyless, "_tier0_json", return_value=[_post(1)]) as t0, \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss") as rss, \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings") as lst:
out = reddit_keyless._discover("topic", "default", None)
assert len(out) == 1
t0.assert_called_once()
rss.assert_not_called()
lst.assert_not_called()
def test_tier0_empty_falls_to_keyless(self):
with mock.patch.object(reddit_keyless, "_tier0_json", return_value=[]), \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
return_value=[_post(1), _post(2)]) as rss, \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
return_value=[]):
out = reddit_keyless._discover("topic", "default", ["test"])
assert len(out) == 2
rss.assert_called_once()
def test_listing_scores_backfill_rss_posts(self):
# RSS finds post 1 (no score); listing card for post 1 carries the score.
rss_post = _post(1)
listing_post = _scored(1, score=52692, ncmt=1743)
with mock.patch.object(reddit_keyless, "_tier0_json", return_value=[]), \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
return_value=[rss_post]), \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
return_value=[listing_post]):
out = reddit_keyless._discover("topic", "default", ["test"])
# listing post (scored) is kept; RSS dup of same url is dropped
assert len(out) == 1
assert out[0]["engagement"]["score"] == 52692
assert out[0]["num_comments"] == 1743
def test_scores_flow_to_distinct_rss_posts(self):
# Distinct RSS post whose id matches a listing card gets backfilled.
rss_post = _post(7) # url .../000007/...
listing_post = _scored(7, score=999)
listing_post["url"] = "https://www.reddit.com/r/test/comments/zzzzzz/other/"
with mock.patch.object(reddit_keyless, "_tier0_json", return_value=[]), \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
return_value=[rss_post]), \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
return_value=[listing_post]):
out = reddit_keyless._discover("topic", "default", ["test"])
backfilled = [p for p in out if p["url"] == rss_post["url"]][0]
assert backfilled["engagement"]["score"] == 999
def test_bare_query_does_not_merge_listing_discovery(self):
# No subreddits provided: derived-subreddit listings must NOT be added as
# results (avoids flooding with off-topic high-upvote posts) — only used
# to backfill scores onto the keyword-matched RSS posts.
rss_post = _post(1) # on-topic keyword match
offtopic_listing = _scored(99, score=88888) # high score, unrelated sub
offtopic_listing["url"] = "https://www.reddit.com/r/random/comments/zzz999/x/"
with mock.patch.object(reddit_keyless, "_tier0_json", return_value=[]), \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
return_value=[rss_post]), \
mock.patch.object(reddit_keyless, "_top_subreddits", return_value=["random"]), \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
return_value=[offtopic_listing]):
out = reddit_keyless._discover("topic", "default", None)
urls = [p["url"] for p in out]
assert rss_post["url"] in urls
assert offtopic_listing["url"] not in urls # not merged as discovery
def test_tier0_never_raises(self):
with mock.patch("lib.reddit_public.search", side_effect=Exception("boom")), \
mock.patch.object(reddit_keyless.reddit_rss, "search_rss", return_value=[]), \
mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", return_value=[]):
assert reddit_keyless._discover("t", "default", None) == []
class TestSearchAndEnrich:
"""Full pipeline: discover -> date filter -> rank -> enrich -> reindex."""
def _patch_enrich_passthrough(self):
return mock.patch.object(
reddit_keyless.reddit_shreddit, "fetch_comments",
return_value={"top_comments": [], "comment_insights": [], "num_comments": None},
)
def test_returns_empty_when_no_discovery(self):
with mock.patch.object(reddit_keyless, "_discover", return_value=[]):
assert reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") == []
def test_date_filter_keeps_in_range_and_unknown(self):
posts = [_post(1, date="2026-05-10"), _post(2, date="2020-01-01"),
_post(3, date=None)]
with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
self._patch_enrich_passthrough():
out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
titles = {p["title"] for p in out}
assert "Post 1" in titles and "Post 3" in titles
assert "Post 2" not in titles
def test_reindexes_ids(self):
posts = [_post(1), _post(2), _post(3)]
with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
self._patch_enrich_passthrough():
out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
assert [p["id"] for p in out] == ["R1", "R2", "R3"]
def test_enrichment_attaches_comments(self):
posts = [_post(1)]
enriched = {
"top_comments": [{"score": 9, "date": "2026-05-19", "author": "a",
"excerpt": "great", "url": "https://reddit.com/x"}],
"comment_insights": ["great point about X"],
"num_comments": 14,
}
with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
return_value=enriched):
out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
assert out[0]["top_comments"][0]["score"] == 9
assert out[0]["num_comments"] == 14
assert out[0]["engagement"]["num_comments"] == 14
def test_enrichment_failure_keeps_posts(self):
posts = [_post(i) for i in range(8)]
with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
side_effect=Exception("svc down")):
out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
assert len(out) == 8 # all posts retained despite enrichment failure
def test_only_top_n_enriched_by_depth(self):
posts = [_post(i, rel=1.0 - i / 100) for i in range(10)]
with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
return_value={"top_comments": [], "comment_insights": [],
"num_comments": None}) as fc:
reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31", depth="quick")
# quick depth enriches only top 3 posts
assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"]
+85
View File
@@ -0,0 +1,85 @@
"""Tests for scripts/lib/reddit_listing.py — keyless scored listing scrape."""
from pathlib import Path
from unittest import mock
from lib import reddit_listing as rl
FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "reddit_listing_cards_sample.html"
def _html():
return FIXTURE.read_text(encoding="utf-8")
class TestParseCards:
"""parse_cards reads <shreddit-post> cards into scored post dicts."""
def test_parses_five_cards(self):
posts = rl.parse_cards(_html(), query="netherlands")
assert len(posts) == 5
def test_real_score_and_count(self):
posts = rl.parse_cards(_html())
top = posts[0]
assert top["score"] == 52692 # the real upvote count
assert top["engagement"]["score"] == 52692
assert top["num_comments"] == 1743
assert top["engagement"]["num_comments"] == 1743
def test_normalized_shape(self):
post = rl.parse_cards(_html())[0]
required = {"id", "title", "url", "score", "num_comments", "subreddit",
"created_utc", "author", "selftext", "date",
"engagement", "relevance", "why_relevant", "metadata"}
assert required.issubset(set(post.keys()))
assert post["why_relevant"] == "Reddit listing"
assert post["metadata"]["post_id"] # post id captured for backfill
def test_fields_populated(self):
post = rl.parse_cards(_html())[0]
assert post["title"]
assert post["author"] == "AdSpecialist6598"
assert post["subreddit"] == "technology"
assert "/comments/" in post["url"]
assert post["date"] and len(post["date"]) == 10
def test_empty_html_returns_empty(self):
assert rl.parse_cards("") == []
assert rl.parse_cards("<div>no cards</div>") == []
class TestListingUrl:
def test_top_includes_timeframe(self):
u = rl._listing_url("technology", "top")
assert "community-more-posts/top/" in u and "name=technology" in u and "t=month" in u
def test_hot_no_timeframe(self):
u = rl._listing_url("r/technology", "hot")
assert "community-more-posts/hot/" in u and "name=technology" in u and "t=" not in u
assert ".json" not in u
class TestFetchListings:
def test_dedupes_across_sorts(self):
with mock.patch.object(rl.http, "get_text", return_value=_html()):
posts = rl.fetch_listings(["technology"], depth="default")
urls = [p["url"] for p in posts]
assert len(urls) == len(set(urls)) # top + hot return same cards -> deduped
def test_no_subreddits_returns_empty(self):
assert rl.fetch_listings([], depth="default") == []
def test_all_fetches_fail_returns_empty(self):
with mock.patch.object(rl.http, "get_text", return_value=None):
assert rl.fetch_listings(["technology"]) == []
class TestScoreIndex:
def test_builds_post_id_to_score_map(self):
with mock.patch.object(rl.http, "get_text", return_value=_html()):
idx = rl.score_index(["technology"], depth="quick")
assert idx # non-empty
first = next(iter(idx.values()))
assert set(first.keys()) == {"score", "num_comments"}
assert any(v["score"] == 52692 for v in idx.values())
+16 -75
View File
@@ -326,83 +326,24 @@ class TestMissingSubreddit:
assert results == []
# ---------------------------------------------------------------------------
# Tests for comment enrichment (Unit 2)
# search_reddit_public is now a thin shim over the keyless pipeline.
# Full discovery + enrichment behavior is covered in test_reddit_keyless.py.
# ---------------------------------------------------------------------------
class TestEnrichmentIntegration:
"""search_reddit_public enriches top posts with comments."""
class TestSearchRedditPublicDelegatesToKeyless:
"""search_reddit_public delegates to reddit_keyless.search_and_enrich."""
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_search_enriches_top_5_by_default(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = lambda item, timeout=10: item # pass-through
def test_delegates_with_all_args(self):
with mock.patch("lib.reddit_keyless.search_and_enrich") as mock_keyless:
mock_keyless.return_value = [{"id": "R1", "title": "x"}]
results = reddit_public.search_reddit_public(
"test", "2024-03-01", "2024-03-31",
depth="quick", subreddits=["ClaudeAI"],
)
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
assert len(results) == 10
# Default depth enriches top 5
assert mock_enrich.call_count == 5
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_enrichment_timeout_keeps_posts(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
# Some enrichments raise, some succeed
call_count = {"n": 0}
def _side_effect(item, timeout=10):
call_count["n"] += 1
if call_count["n"] % 2 == 0:
raise TimeoutError("enrichment timed out")
return item
mock_enrich.side_effect = _side_effect
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
# All 10 posts should still be returned
assert len(results) == 10
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_all_enrichment_fails_all_posts_returned(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = Exception("total failure")
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
# All posts returned despite enrichment failure
assert len(results) == 10
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_quick_depth_enriches_top_3(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = lambda item, timeout=10: item
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31", depth="quick")
assert len(results) == 10
# Quick depth enriches only top 3
assert mock_enrich.call_count == 3
assert results == [{"id": "R1", "title": "x"}]
mock_keyless.assert_called_once_with(
"test", "2024-03-01", "2024-03-31",
depth="quick", subreddits=["ClaudeAI"],
)
+96
View File
@@ -0,0 +1,96 @@
"""Tests for scripts/lib/reddit_rss.py — keyless Reddit RSS discovery."""
from pathlib import Path
from unittest import mock
from lib import reddit_rss
FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "reddit_search_rss_sample.xml"
def _feed_text():
return FIXTURE.read_text(encoding="utf-8")
class TestParseFeed:
"""_parse_feed turns Atom entries into normalized post dicts."""
def test_parses_entries(self):
posts = reddit_rss._parse_feed(_feed_text(), query="lifelock")
assert len(posts) == 5
for p in posts:
assert p["title"]
assert "/comments/" in p["url"]
assert p["url"].startswith("https://www.reddit.com/")
def test_normalized_shape_matches_scrapecreators(self):
post = reddit_rss._parse_feed(_feed_text(), query="x")[0]
required = {"id", "title", "url", "score", "num_comments", "subreddit",
"created_utc", "author", "selftext", "date",
"engagement", "relevance", "why_relevant", "metadata"}
assert required.issubset(set(post.keys()))
assert set(post["engagement"].keys()) == {"score", "num_comments", "upvote_ratio"}
assert post["why_relevant"] == "Reddit RSS"
def test_score_is_placeholder_zero(self):
# RSS carries no engagement score; it is backfilled during enrichment.
for p in reddit_rss._parse_feed(_feed_text(), query="x"):
assert p["score"] == 0
assert p["engagement"]["score"] == 0
def test_subreddit_derivation(self):
post = reddit_rss._parse_feed(_feed_text(), query="x")[0]
assert post["subreddit"] == "Rakuten"
def test_date_parsed_to_iso(self):
post = reddit_rss._parse_feed(_feed_text(), query="x")[0]
assert post["date"] and len(post["date"]) == 10 # YYYY-MM-DD
assert isinstance(post["created_utc"], float)
def test_author_strips_u_prefix(self):
authors = [p["author"] for p in reddit_rss._parse_feed(_feed_text(), query="x")]
assert all(not a.startswith("/u/") and not a.startswith("u/") for a in authors)
def test_empty_and_malformed_feed_never_raises(self):
assert reddit_rss._parse_feed("", query="x") == []
assert reddit_rss._parse_feed("<not xml", query="x") == []
assert reddit_rss._parse_feed("<feed></feed>", query="x") == []
def test_entry_without_comments_link_skipped(self):
feed = (
'<feed xmlns="http://www.w3.org/2005/Atom"><entry>'
'<title>Subreddit itself</title>'
'<link href="https://www.reddit.com/r/test/" />'
'<updated>2026-05-20T00:00:00+00:00</updated></entry></feed>'
)
assert reddit_rss._parse_feed(feed, query="x") == []
class TestSearchRss:
"""search_rss fans out, dedupes, assigns IDs, and honors depth limits."""
def test_dedupe_and_ids(self):
# Same feed returned for every URL -> deduped to 5 unique posts.
with mock.patch.object(reddit_rss.http, "get_text", return_value=_feed_text()):
posts = reddit_rss.search_rss("lifelock", depth="default",
subreddits=["Rakuten", "ConsumerAdvice"])
urls = [p["url"] for p in posts]
assert len(urls) == len(set(urls)) # no duplicates
assert [p["id"] for p in posts] == [f"R{i+1}" for i in range(len(posts))]
def test_depth_limit_quick(self):
with mock.patch.object(reddit_rss.http, "get_text", return_value=_feed_text()):
posts = reddit_rss.search_rss("lifelock", depth="quick")
assert len(posts) <= reddit_rss.DEPTH_LIMITS["quick"]
def test_all_feeds_fail_returns_empty(self):
with mock.patch.object(reddit_rss.http, "get_text", return_value=None):
posts = reddit_rss.search_rss("lifelock", subreddits=["Rakuten"])
assert posts == []
def test_builds_keyless_rss_urls(self):
urls = reddit_rss._build_urls("life lock", "default", ["Rakuten"])
assert any("search.rss?q=life+lock" in u and "/r/" not in u.split("?")[0] for u in urls)
assert any("/r/Rakuten/search.rss" in u and "restrict_sr=on" in u for u in urls)
assert any("/r/Rakuten/top.rss" in u for u in urls)
assert all(".json" not in u for u in urls) # never the dead endpoint
+103
View File
@@ -0,0 +1,103 @@
"""Tests for scripts/lib/reddit_shreddit.py — keyless shreddit comment scrape."""
from pathlib import Path
from unittest import mock
from lib import reddit_shreddit as rs
FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "reddit_shreddit_comments_sample.html"
def _html():
return FIXTURE.read_text(encoding="utf-8")
class TestExtractPostRef:
def test_extracts_sub_and_id(self):
ref = rs.extract_post_ref("https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/")
assert ref == ("Rakuten", "1taeiw0")
def test_non_thread_url_returns_none(self):
assert rs.extract_post_ref("https://www.reddit.com/r/Rakuten/") is None
assert rs.extract_post_ref("") is None
def test_svc_url_shape(self):
# sort=top guarantees the highest-scored comments land on page 1.
assert rs._svc_url("Rakuten", "1taeiw0") == (
"https://www.reddit.com/svc/shreddit/comments/r/Rakuten/t3_1taeiw0?sort=top"
)
class TestParseComments:
"""parse_comments reads <shreddit-comment> elements into scored dicts."""
def test_happy_path(self):
comments = rs.parse_comments(_html())
assert len(comments) >= 1
for c in comments:
assert isinstance(c["score"], int)
assert c["author"] and c["author"] not in ("[deleted]", "[removed]")
assert c["body"]
def test_sorted_by_score_desc(self):
scores = [c["score"] for c in rs.parse_comments(_html())]
assert scores == sorted(scores, reverse=True)
def test_deleted_and_removed_filtered(self):
authors = [c["author"] for c in rs.parse_comments(_html())]
assert "[deleted]" not in authors and "[removed]" not in authors
def test_negative_score_retained(self):
scores = [c["score"] for c in rs.parse_comments(_html())]
assert -7 in scores # synthetic downvoted-but-real comment
def test_limit_honored(self):
assert len(rs.parse_comments(_html(), limit=2)) == 2
def test_body_text_extracted(self):
bodies = [c["body"] for c in rs.parse_comments(_html())]
assert any("$750" in b or "pending" in b for b in bodies)
def test_comment_url_built(self):
for c in rs.parse_comments(_html()):
if c["url"]:
assert c["url"].startswith("https://reddit.com/r/")
def test_empty_html_returns_empty(self):
assert rs.parse_comments("") == []
assert rs.parse_comments("<html>no comments here</html>") == []
class TestTotalComments:
def test_reads_total(self):
assert rs._total_comments(_html()) == 14
def test_missing_returns_none(self):
assert rs._total_comments("<html></html>") is None
class TestFetchComments:
"""fetch_comments wires URL -> svc fetch -> parse, never raising."""
def test_happy_path(self):
url = "https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/"
with mock.patch.object(rs.http, "get_text", return_value=_html()) as m:
out = rs.fetch_comments(url)
# svc endpoint, not .json
assert "/svc/shreddit/comments/" in m.call_args[0][0]
assert ".json" not in m.call_args[0][0]
assert out["num_comments"] == 14
assert len(out["top_comments"]) >= 1
first = out["top_comments"][0]
assert {"score", "date", "author", "excerpt", "url"} <= set(first.keys())
assert isinstance(out["comment_insights"], list)
def test_bad_url_returns_empty(self):
out = rs.fetch_comments("https://www.reddit.com/r/Rakuten/")
assert out["top_comments"] == [] and out["num_comments"] is None
def test_fetch_failure_returns_empty(self):
url = "https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/"
with mock.patch.object(rs.http, "get_text", return_value=None):
out = rs.fetch_comments(url)
assert out["top_comments"] == [] and out["num_comments"] is None