From dc3314d49988b6d5789ba9ffaa9a84050a93557e Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Tue, 22 Sep 2026 19:36:18 +0530 Subject: [PATCH 1/6] Kodexis basic configuration --- .env.example | 2 + voucherbot/config/settings.py | 3 + voucherbot/providers/Kodexis/__init__.py | 5 + voucherbot/providers/Kodexis/client.py | 60 ++++++++ voucherbot/providers/reddit/collector.py | 172 +++++++++++++++++++++-- voucherbot/services/scheduler.py | 7 +- 6 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 voucherbot/providers/Kodexis/__init__.py create mode 100644 voucherbot/providers/Kodexis/client.py diff --git a/.env.example b/.env.example index eac9a77..9b6bbd5 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ REDDIT_CLIENT_ID="your_client_id" REDDIT_CLIENT_SECRET="your_client_secret" REDDIT_USER_AGENT="VoucherBot/0.1.0" REDDIT_INGESTION_ENABLED="false" # false = collect via public RSS feeds, never call the OAuth API +# Kodexis API (used when REDDIT_INGESTION_ENABLED=false) +KODEXIS_API_KEY="kp_live_example_key" # Email Automation # See docs/setup.md for how to get your Resend API key and configure your email. diff --git a/voucherbot/config/settings.py b/voucherbot/config/settings.py index d90867e..780b53d 100644 --- a/voucherbot/config/settings.py +++ b/voucherbot/config/settings.py @@ -138,6 +138,9 @@ class Settings(BaseSettings): reddit_fetch_limit: int = 25 reddit_ingestion_enabled: bool = False + # Kodexis API (fallback when REDDIT_INGESTION_ENABLED=false) + kodexis_api_key: Optional[str] = None + # Scraping policy (see deep-research-report) # Identifying UA; empty → built from email contact. Do not spoof browsers. scraper_user_agent: Optional[str] = None diff --git a/voucherbot/providers/Kodexis/__init__.py b/voucherbot/providers/Kodexis/__init__.py new file mode 100644 index 0000000..b127bcb --- /dev/null +++ b/voucherbot/providers/Kodexis/__init__.py @@ -0,0 +1,5 @@ +"""Kodexis Reddit provider for fetching posts via Kodexis API.""" +from .client import KodexisRedditClient + + +__all__ = ["KodexisRedditClient"] \ No newline at end of file diff --git a/voucherbot/providers/Kodexis/client.py b/voucherbot/providers/Kodexis/client.py new file mode 100644 index 0000000..11560d9 --- /dev/null +++ b/voucherbot/providers/Kodexis/client.py @@ -0,0 +1,60 @@ +import httpx +from typing import Any, Dict, List, Optional +import structlog + +logger = structlog.get_logger(__name__) + + +KODEXIS_API_BASE = "https://api.kodexis.com/v1" +KODEXIS_REDDIT_ENDPOINT = "/reddit/posts" + + +class KodexisRedditClient: + def __init__(self, api_key: str) -> None: + self.api_key = api_key + self.base_url = KODEXIS_API_BASE + self.http_client = httpx.AsyncClient( + base_url=self.base_url, + headers={"X-API-Key": self.api_key}, + timeout=30.0, + ) + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + await self.http_client.aclose() + + async def fetch_latest_posts( + self, subreddit: str, limit: int = 10 + ) -> List[Dict[str, Any]]: + """Fetch latest posts from a subreddit using Kodexis API.""" + try: + response = await self.http_client.get( + KODEXIS_REDDIT_ENDPOINT, + params={ + "subreddit": subreddit, + "sort": "new", + "limit": limit, + }, + ) + response.raise_for_status() + data = response.json() + return data.get("posts", []) + except httpx.HTTPStatusError as e: + logger.error( + "Kodexis: HTTP error fetching posts from {subreddit}", + subreddit=subreddit, + error=str(e), + ) + raise + except Exception as e: + logger.error( + "Kodexis: error fetching posts from {subreddit}", + subreddit=subreddit, + error=str(e), + ) + raise + + async def close(self) -> None: + await self.http_client.aclose() \ No newline at end of file diff --git a/voucherbot/providers/reddit/collector.py b/voucherbot/providers/reddit/collector.py index 3bc6664..0bfa442 100644 --- a/voucherbot/providers/reddit/collector.py +++ b/voucherbot/providers/reddit/collector.py @@ -9,6 +9,7 @@ from voucherbot.config.settings import settings from voucherbot.providers.base import BaseCollector, NormalizedPost +from voucherbot.providers.Kodexis.client import KodexisRedditClient from voucherbot.providers.reddit.client import RedditClient from voucherbot.providers.http_policy import ( RobotsDisallowedError, @@ -20,10 +21,11 @@ class RedditCollector(BaseCollector): - """Collects posts from a subreddit via asyncpraw.""" + """Collects posts from a subreddit via asyncpraw or Kodexis fallback.""" - def __init__(self, client: RedditClient) -> None: - self.client = client + def __init__(self, reddit_client: RedditClient, kodexis_client: KodexisRedditClient | None = None) -> None: + self.client = reddit_client + self.kodexis_client = kodexis_client async def collect( self, source_config: dict[str, Any], limit: int = 25 @@ -35,23 +37,165 @@ async def collect( ) return [] - if not settings.reddit_ingestion_enabled or not self.client.is_configured: - return await self._collect_via_rss(source_config, limit) + if settings.reddit_ingestion_enabled and self.client.is_configured: + query_terms = source_config.get("query_terms") or [] + if query_terms: + query = " OR ".join( + f'"{term}"' if " " in term else term for term in query_terms + ) + raw_posts = await self.client.search_posts( + query=query, + subreddit_name=subreddit_name, + limit=limit, + ) + else: + raw_posts = await self.client.fetch_new_posts(subreddit_name, limit=limit) + + return self._normalize_praw_posts(subreddit_name, raw_posts) + + # Fallback to Kodexis API when REDDIT_INGESTION_ENABLED is false + if self.kodexis_client: + return await self._collect_via_kodexis(source_config, limit) + + # Fallback to RSS when Kodexis is not configured + return await self._collect_via_rss(source_config, limit) + + async def _collect_via_kodexis( + self, source_config: dict[str, Any], limit: int + ) -> list[NormalizedPost]: + subreddit_name = source_config.get("subreddit", "") + if not subreddit_name: + return [] + + try: + raw_posts = await self.kodexis_client.fetch_latest_posts( + subreddit=subreddit_name, limit=limit + ) + except Exception as e: + logger.error( + "Kodexis fallback failed for {subreddit}", subreddit=subreddit_name, error=str(e) + ) + return [] + + return self._normalize_kodexis_posts(subreddit_name, raw_posts) + + def _normalize_kodexis_posts( + self, subreddit_name: str, raw_posts: list[dict[str, Any]] + ) -> list[NormalizedPost]: + results: list[NormalizedPost] = [] + + for post in raw_posts[:limit]: + results.append( + NormalizedPost( + url=f"https://www.reddit.com{post.get('permalink', '')}", + title=post.get("title", "(no title)"), + content=post.get("selftext") or None, + summary=None, + author=None, + published_at=datetime.fromtimestamp( + post.get("created_utc", 0), tz=timezone.utc + ), + raw_data={ + "score": post.get("score", 0), + "num_comments": post.get("num_comments", 0), + "url": post.get("url", ""), + "subreddit": subreddit_name, + "auth_mode": "kodexis", + }, + ) + ) + + return results + async def _collect_via_rss( + self, + source_config: dict[str, Any], + limit: int, + ) -> list[NormalizedPost]: + subreddit_name = source_config["subreddit"] query_terms = source_config.get("query_terms") or [] if query_terms: - query = " OR ".join( - f'"{term}"' if " " in term else term for term in query_terms + query = quote_plus(" OR ".join(query_terms)) + url = f"https://www.reddit.com/r/{subreddit_name}/search.rss?q={query}&restrict_sr=on&sort=new" + else: + url = f"https://www.reddit.com/r/{subreddit_name}/new.rss" + + headers = { + "User-Agent": scraper_user_agent(), + } + logger.info("RedditCollector: fetching RSS fallback", subreddit=subreddit_name) + + try: + response = await polite_get( + url, + accept="application/rss+xml, application/atom+xml, application/xml, text/xml, */*", + timeout=15, + extra_headers=headers, ) - raw_posts = await self.client.search_posts( - query=query, - subreddit_name=subreddit_name, - limit=limit, + except RobotsDisallowedError: + logger.info( + "RedditCollector: RSS fallback blocked by robots.txt", + subreddit=subreddit_name, + ) + return [] + except Exception as exc: + logger.error( + "RedditCollector: RSS fallback failed", + subreddit=subreddit_name, + error=str(exc), + ) + return [] + + feed = await asyncio.to_thread(feedparser.parse, response.content) + results: list[NormalizedPost] = [] + for entry in feed.entries[:limit]: + link = entry.get("link", "") + title = entry.get("title", "(no title)") + results.append( + NormalizedPost( + url=link, + title=title, + content=entry.get("summary") or None, + summary=None, + author=None, + published_at=None, + raw_data={ + "subreddit": subreddit_name, + "feed_url": url, + "auth_mode": "rss", + }, + ) ) - else: - raw_posts = await self.client.fetch_new_posts(subreddit_name, limit=limit) - return self._normalize_praw_posts(subreddit_name, raw_posts) + return results + + def _normalize_praw_posts( + self, subreddit_name: str, raw_posts: Any + ) -> list[NormalizedPost]: + results: list[NormalizedPost] = [] + + for post in raw_posts: + results.append( + NormalizedPost( + url=f"https://www.reddit.com{post.permalink}", + title=post.title, + content=post.selftext or None, + summary=None, + author=None, + published_at=datetime.fromtimestamp( + post.created_utc, tz=timezone.utc + ), + raw_data={ + "score": post.score, + "num_comments": post.num_comments, + "url": post.url, + "subreddit": subreddit_name, + "flair": post.link_flair_text, + }, + ) + ) + + return results def _normalize_praw_posts( self, subreddit_name: str, raw_posts: Any diff --git a/voucherbot/services/scheduler.py b/voucherbot/services/scheduler.py index 86f4b25..e7af967 100644 --- a/voucherbot/services/scheduler.py +++ b/voucherbot/services/scheduler.py @@ -16,9 +16,11 @@ import structlog from sqlalchemy import func, or_, select +from voucherbot.config.settings import settings from voucherbot.database.connection import session_scope from voucherbot.models.notification import NotificationOutbox, NotificationStatus from voucherbot.models.source import Source +from voucherbot.providers.Kodexis.client import KodexisRedditClient from voucherbot.providers.reddit.client import RedditClient from voucherbot.providers.reddit.collector import RedditCollector from voucherbot.providers.rss.collector import RssCollector @@ -33,8 +35,9 @@ logger = structlog.get_logger(__name__) _reddit_client = RedditClient() +_kodexis_client = KodexisRedditClient(settings.kodexis_api_key) if settings.kodexis_api_key else None _collectors = { - "reddit": RedditCollector(_reddit_client), + "reddit": RedditCollector(_reddit_client, _kodexis_client), "rss": RssCollector(), "web": WebsiteCollector(), "pearsonvue": PearsonVUECollector(), @@ -180,3 +183,5 @@ async def stop_scheduler() -> None: _loop_task.cancel() await asyncio.gather(_loop_task, return_exceptions=True) await _reddit_client.close() + if _kodexis_client: + await _kodexis_client.close() \ No newline at end of file From 429ffe659706d1a215b02094c0e7ac68152067cb Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Tue, 22 Sep 2026 20:02:37 +0530 Subject: [PATCH 2/6] base url and response format change --- voucherbot/providers/Kodexis/client.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/voucherbot/providers/Kodexis/client.py b/voucherbot/providers/Kodexis/client.py index 11560d9..488ab52 100644 --- a/voucherbot/providers/Kodexis/client.py +++ b/voucherbot/providers/Kodexis/client.py @@ -5,8 +5,7 @@ logger = structlog.get_logger(__name__) -KODEXIS_API_BASE = "https://api.kodexis.com/v1" -KODEXIS_REDDIT_ENDPOINT = "/reddit/posts" +KODEXIS_API_BASE = "https://app.kodexisapi.com" class KodexisRedditClient: @@ -31,16 +30,20 @@ async def fetch_latest_posts( """Fetch latest posts from a subreddit using Kodexis API.""" try: response = await self.http_client.get( - KODEXIS_REDDIT_ENDPOINT, + f"/marketplace/reddit/api/reddit/{subreddit}", params={ - "subreddit": subreddit, "sort": "new", - "limit": limit, + "limit": min(limit, 10), # API max is 10 }, ) response.raise_for_status() data = response.json() - return data.get("posts", []) + # Parse Reddit's Listing format + posts = [] + for child in data.get("data", {}).get("children", []): + if child.get("kind") == "t3": # t3 = link/post + posts.append(child.get("data", {})) + return posts except httpx.HTTPStatusError as e: logger.error( "Kodexis: HTTP error fetching posts from {subreddit}", From 47d54f5f81d87aeccc85b47c651f0de97a381cc8 Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Tue, 22 Sep 2026 20:47:23 +0530 Subject: [PATCH 3/6] filter modificatin --- voucherbot/providers/reddit/collector.py | 43 ++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/voucherbot/providers/reddit/collector.py b/voucherbot/providers/reddit/collector.py index 0bfa442..66d90ff 100644 --- a/voucherbot/providers/reddit/collector.py +++ b/voucherbot/providers/reddit/collector.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from typing import Any from urllib.parse import quote_plus +import re import asyncio @@ -77,10 +78,48 @@ async def _collect_via_kodexis( ) return [] - return self._normalize_kodexis_posts(subreddit_name, raw_posts) + filtered_posts = self._filter_exam_pass_posts(raw_posts) + return self._normalize_kodexis_posts(subreddit_name, filtered_posts, limit) + + def _filter_exam_pass_posts( + self, raw_posts: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Filter out posts mentioning 'exam' or 'certification' + 'pass'/'passed'. + + Only filters when 'exam' or 'certification' appear as standalone terms, + not as part of 'free exam', 'exam voucher', 'certification voucher', etc. + """ + filtered = [] + pass_regex = re.compile(r"\b(pass|passed|passing)\b") + exam_regex = re.compile(r"\bexam\b") + cert_regex = re.compile(r"\bcertification\b") + + exclude_exam = re.compile(r"\b(free exam|exam voucher|exam credit|beta exam|retake)\b") + exclude_cert = re.compile(r"\b(free certification|certification voucher)\b") + + for post in raw_posts: + title = (post.get("title", "") or "").lower() + content = (post.get("selftext", "") or "").lower() + text = f"{title} {content}" + + has_exam = bool(exam_regex.search(text)) and not exclude_exam.search(text) + has_cert = bool(cert_regex.search(text)) and not exclude_cert.search(text) + has_pass = bool(pass_regex.search(text)) + + if (has_exam or has_cert) and has_pass: + logger.info( + "Kodexis: filtered exam/cert + pass post", + title=post.get("title", "")[:80], + subreddit=post.get("subreddit", ""), + ) + continue + + filtered.append(post) + + return filtered def _normalize_kodexis_posts( - self, subreddit_name: str, raw_posts: list[dict[str, Any]] + self, subreddit_name: str, raw_posts: list[dict[str, Any]], limit: int ) -> list[NormalizedPost]: results: list[NormalizedPost] = [] From 92b9c0723fff6381b7abcd945d6041406f525b31 Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Wed, 23 Sep 2026 13:27:59 +0530 Subject: [PATCH 4/6] mcsa removal from sub list --- Sources/Subreddit.txt | 1 - voucherbot/database/bootstrap.py | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/Subreddit.txt b/Sources/Subreddit.txt index 455466a..9fa3ce6 100644 --- a/Sources/Subreddit.txt +++ b/Sources/Subreddit.txt @@ -10,7 +10,6 @@ FreeUdemyCoupons # Tier B — every 60 min O365Certification -mcsa CompTIA ccna ccnp diff --git a/voucherbot/database/bootstrap.py b/voucherbot/database/bootstrap.py index cc21b7c..ffb9ee2 100644 --- a/voucherbot/database/bootstrap.py +++ b/voucherbot/database/bootstrap.py @@ -117,7 +117,6 @@ def _warn_on_invalid_selectors(config: dict[str, Any], source_name: str) -> None "AWSCertifications", "AzureCertification", "O365Certification", - "mcsa", "CompTIA", "ccna", "ccnp", From f87e3a2c9a05f5c1ad32f2b3ed93d2407cb04e80 Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Wed, 23 Sep 2026 13:34:06 +0530 Subject: [PATCH 5/6] Formatting fix --- voucherbot/providers/Kodexis/__init__.py | 3 ++- voucherbot/providers/Kodexis/client.py | 4 +-- voucherbot/providers/reddit/collector.py | 32 ++++++++++++++++-------- voucherbot/services/scheduler.py | 6 +++-- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/voucherbot/providers/Kodexis/__init__.py b/voucherbot/providers/Kodexis/__init__.py index b127bcb..a232b47 100644 --- a/voucherbot/providers/Kodexis/__init__.py +++ b/voucherbot/providers/Kodexis/__init__.py @@ -1,5 +1,6 @@ """Kodexis Reddit provider for fetching posts via Kodexis API.""" + from .client import KodexisRedditClient -__all__ = ["KodexisRedditClient"] \ No newline at end of file +__all__ = ["KodexisRedditClient"] diff --git a/voucherbot/providers/Kodexis/client.py b/voucherbot/providers/Kodexis/client.py index 488ab52..08d6f43 100644 --- a/voucherbot/providers/Kodexis/client.py +++ b/voucherbot/providers/Kodexis/client.py @@ -1,5 +1,5 @@ import httpx -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import structlog logger = structlog.get_logger(__name__) @@ -60,4 +60,4 @@ async def fetch_latest_posts( raise async def close(self) -> None: - await self.http_client.aclose() \ No newline at end of file + await self.http_client.aclose() diff --git a/voucherbot/providers/reddit/collector.py b/voucherbot/providers/reddit/collector.py index 66d90ff..aac1037 100644 --- a/voucherbot/providers/reddit/collector.py +++ b/voucherbot/providers/reddit/collector.py @@ -24,7 +24,11 @@ class RedditCollector(BaseCollector): """Collects posts from a subreddit via asyncpraw or Kodexis fallback.""" - def __init__(self, reddit_client: RedditClient, kodexis_client: KodexisRedditClient | None = None) -> None: + def __init__( + self, + reddit_client: RedditClient, + kodexis_client: KodexisRedditClient | None = None, + ) -> None: self.client = reddit_client self.kodexis_client = kodexis_client @@ -50,7 +54,9 @@ async def collect( limit=limit, ) else: - raw_posts = await self.client.fetch_new_posts(subreddit_name, limit=limit) + raw_posts = await self.client.fetch_new_posts( + subreddit_name, limit=limit + ) return self._normalize_praw_posts(subreddit_name, raw_posts) @@ -74,7 +80,9 @@ async def _collect_via_kodexis( ) except Exception as e: logger.error( - "Kodexis fallback failed for {subreddit}", subreddit=subreddit_name, error=str(e) + "Kodexis fallback failed for {subreddit}", + subreddit=subreddit_name, + error=str(e), ) return [] @@ -85,7 +93,7 @@ def _filter_exam_pass_posts( self, raw_posts: list[dict[str, Any]] ) -> list[dict[str, Any]]: """Filter out posts mentioning 'exam' or 'certification' + 'pass'/'passed'. - + Only filters when 'exam' or 'certification' appear as standalone terms, not as part of 'free exam', 'exam voucher', 'certification voucher', etc. """ @@ -93,19 +101,21 @@ def _filter_exam_pass_posts( pass_regex = re.compile(r"\b(pass|passed|passing)\b") exam_regex = re.compile(r"\bexam\b") cert_regex = re.compile(r"\bcertification\b") - - exclude_exam = re.compile(r"\b(free exam|exam voucher|exam credit|beta exam|retake)\b") + + exclude_exam = re.compile( + r"\b(free exam|exam voucher|exam credit|beta exam|retake)\b" + ) exclude_cert = re.compile(r"\b(free certification|certification voucher)\b") - + for post in raw_posts: title = (post.get("title", "") or "").lower() content = (post.get("selftext", "") or "").lower() text = f"{title} {content}" - + has_exam = bool(exam_regex.search(text)) and not exclude_exam.search(text) has_cert = bool(cert_regex.search(text)) and not exclude_cert.search(text) has_pass = bool(pass_regex.search(text)) - + if (has_exam or has_cert) and has_pass: logger.info( "Kodexis: filtered exam/cert + pass post", @@ -113,9 +123,9 @@ def _filter_exam_pass_posts( subreddit=post.get("subreddit", ""), ) continue - + filtered.append(post) - + return filtered def _normalize_kodexis_posts( diff --git a/voucherbot/services/scheduler.py b/voucherbot/services/scheduler.py index e7af967..0da6dd3 100644 --- a/voucherbot/services/scheduler.py +++ b/voucherbot/services/scheduler.py @@ -35,7 +35,9 @@ logger = structlog.get_logger(__name__) _reddit_client = RedditClient() -_kodexis_client = KodexisRedditClient(settings.kodexis_api_key) if settings.kodexis_api_key else None +_kodexis_client = ( + KodexisRedditClient(settings.kodexis_api_key) if settings.kodexis_api_key else None +) _collectors = { "reddit": RedditCollector(_reddit_client, _kodexis_client), "rss": RssCollector(), @@ -184,4 +186,4 @@ async def stop_scheduler() -> None: await asyncio.gather(_loop_task, return_exceptions=True) await _reddit_client.close() if _kodexis_client: - await _kodexis_client.close() \ No newline at end of file + await _kodexis_client.close() From 96a010e410cabd7b29b1823e9dd2a4e13bba835e Mon Sep 17 00:00:00 2001 From: Devathmaj Date: Wed, 23 Sep 2026 14:03:20 +0530 Subject: [PATCH 6/6] mypy fixes --- voucherbot/providers/Kodexis/client.py | 4 +- voucherbot/providers/reddit/collector.py | 96 ++---------------------- 2 files changed, 7 insertions(+), 93 deletions(-) diff --git a/voucherbot/providers/Kodexis/client.py b/voucherbot/providers/Kodexis/client.py index 08d6f43..4196d67 100644 --- a/voucherbot/providers/Kodexis/client.py +++ b/voucherbot/providers/Kodexis/client.py @@ -18,10 +18,10 @@ def __init__(self, api_key: str) -> None: timeout=30.0, ) - async def __aenter__(self): + async def __aenter__(self) -> "KodexisRedditClient": return self - async def __aexit__(self, *args): + async def __aexit__(self, *args: Any) -> None: await self.http_client.aclose() async def fetch_latest_posts( diff --git a/voucherbot/providers/reddit/collector.py b/voucherbot/providers/reddit/collector.py index aac1037..792f3b2 100644 --- a/voucherbot/providers/reddit/collector.py +++ b/voucherbot/providers/reddit/collector.py @@ -74,8 +74,12 @@ async def _collect_via_kodexis( if not subreddit_name: return [] + # self.kodexis_client is guaranteed non-None by the caller check + client = self.kodexis_client + assert client is not None + try: - raw_posts = await self.kodexis_client.fetch_latest_posts( + raw_posts = await client.fetch_latest_posts( subreddit=subreddit_name, limit=limit ) except Exception as e: @@ -245,93 +249,3 @@ def _normalize_praw_posts( ) return results - - def _normalize_praw_posts( - self, subreddit_name: str, raw_posts: Any - ) -> list[NormalizedPost]: - results: list[NormalizedPost] = [] - - for post in raw_posts: - results.append( - NormalizedPost( - url=f"https://www.reddit.com{post.permalink}", - title=post.title, - content=post.selftext or None, - summary=None, - author=None, - published_at=datetime.fromtimestamp( - post.created_utc, tz=timezone.utc - ), - raw_data={ - "score": post.score, - "num_comments": post.num_comments, - "url": post.url, - "subreddit": subreddit_name, - "flair": post.link_flair_text, - }, - ) - ) - - return results - - async def _collect_via_rss( - self, - source_config: dict[str, Any], - limit: int, - ) -> list[NormalizedPost]: - subreddit_name = source_config["subreddit"] - query_terms = source_config.get("query_terms") or [] - if query_terms: - query = quote_plus(" OR ".join(query_terms)) - url = f"https://www.reddit.com/r/{subreddit_name}/search.rss?q={query}&restrict_sr=on&sort=new" - else: - url = f"https://www.reddit.com/r/{subreddit_name}/new.rss" - - headers = { - "User-Agent": scraper_user_agent(), - } - logger.info("RedditCollector: fetching RSS fallback", subreddit=subreddit_name) - - try: - response = await polite_get( - url, - accept="application/rss+xml, application/atom+xml, application/xml, text/xml, */*", - timeout=15, - extra_headers=headers, - ) - except RobotsDisallowedError: - logger.info( - "RedditCollector: RSS fallback blocked by robots.txt", - subreddit=subreddit_name, - ) - return [] - except Exception as exc: - logger.error( - "RedditCollector: RSS fallback failed", - subreddit=subreddit_name, - error=str(exc), - ) - return [] - - feed = await asyncio.to_thread(feedparser.parse, response.content) - results: list[NormalizedPost] = [] - for entry in feed.entries[:limit]: - link = entry.get("link", "") - title = entry.get("title", "(no title)") - results.append( - NormalizedPost( - url=link, - title=title, - content=entry.get("summary") or None, - summary=None, - author=None, - published_at=None, - raw_data={ - "subreddit": subreddit_name, - "feed_url": url, - "auth_mode": "rss", - }, - ) - ) - - return results