| 1 | """Reddit search via ScrapeCreators API for the v3 pipeline. |
| 2 | |
| 3 | Uses ScrapeCreators REST API to search Reddit globally, discover relevant |
| 4 | subreddits, run targeted subreddit searches, and fetch comment trees. |
| 5 | |
| 6 | Requires SCRAPECREATORS_API_KEY in config (same key as TikTok + Instagram). |
| 7 | API docs: https://scrapecreators.com/docs |
| 8 | """ |
| 9 | |
| 10 | import math |
| 11 | import re |
| 12 | import sys |
| 13 | import time |
| 14 | from collections import Counter |
| 15 | from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait |
| 16 | from datetime import date, datetime, timezone |
| 17 | from typing import Any, Dict, List, Optional, Set |
| 18 | |
| 19 | def _first_of(*values, default=None): |
| 20 | """Return first value that is not None.""" |
| 21 | for v in values: |
| 22 | if v is not None: |
| 23 | return v |
| 24 | return default |
| 25 | |
| 26 | from . import dates, health, http, log |
| 27 | |
| 28 | SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit" |
| 29 | |
| 30 | # Reddit's highest-upvote content (relationship drama, AITA, viral news) often |
| 31 | # has near-zero topic overlap. Engagement-only ranking floats it above on-topic |
| 32 | # posts, especially on bare global searches where no subreddits were resolved |
| 33 | # upstream. A relevance floor + relevance-first ranking (see _relevance_rank_key |
| 34 | # below and RELEVANCE_FLOOR / MIN_ON_TOPIC in relevance.py) keeps an off-topic |
| 35 | # viral post from ever outranking an on-topic one. |
| 36 | |
| 37 | # Depth configurations: how many API calls per phase |
| 38 | DEPTH_CONFIG = { |
| 39 | "quick": { |
| 40 | "global_searches": 1, |
| 41 | "subreddit_searches": 2, |
| 42 | "comment_enrichments": 3, |
| 43 | "timeframe": "week", |
| 44 | }, |
| 45 | "default": { |
| 46 | "global_searches": 2, |
| 47 | "subreddit_searches": 3, |
| 48 | "comment_enrichments": 5, |
| 49 | "timeframe": "month", |
| 50 | }, |
| 51 | "deep": { |
| 52 | "global_searches": 3, |
| 53 | "subreddit_searches": 5, |
| 54 | "comment_enrichments": 8, |
| 55 | "timeframe": "month", |
| 56 | }, |
| 57 | } |
| 58 | |
| 59 | from .query import extract_core_subject as _query_extract, infer_query_intent |
| 60 | from .relevance import token_overlap_relevance, RELEVANCE_FLOOR, MIN_ON_TOPIC |
| 61 | |
| 62 | |
| 63 | # Reddit-specific noise words (preserves original smaller set) |
| 64 | NOISE_WORDS = frozenset({ |
| 65 | 'best', 'top', 'good', 'great', 'awesome', 'killer', |
| 66 | 'latest', 'new', 'news', 'update', 'updates', |
| 67 | 'trending', 'hottest', 'popular', |
| 68 | 'practices', 'features', 'tips', |
| 69 | 'recommendations', 'advice', |
| 70 | 'prompt', 'prompts', 'prompting', |
| 71 | 'methods', 'strategies', 'approaches', |
| 72 | 'how', 'to', 'the', 'a', 'an', 'for', 'with', |
| 73 | 'of', 'in', 'on', 'is', 'are', 'what', 'which', |
| 74 | 'guide', 'tutorial', 'using', |
| 75 | }) |
| 76 | |
| 77 | |
| 78 | def _log(msg: str): |
| 79 | log.source_log("Reddit", msg, tty_only=False) |
| 80 | |
| 81 | |
| 82 | def classify_run_failure(detail: str) -> str: |
| 83 | """Map Reddit auth and anti-bot responses that do not carry HTTP status.""" |
| 84 | text = detail.lower() |
| 85 | if any(marker in text for marker in ("interstitial", "blocked by reddit", "too many requests")): |
| 86 | return health.RATE_LIMITED |
| 87 | if any(marker in text for marker in ("login required", "invalid token", "expired token")): |
| 88 | return health.AUTH_FAILED |
| 89 | return http.classify_failure(message=detail) |
| 90 | |
| 91 | |
| 92 | def _extract_core_subject(topic: str) -> str: |
| 93 | """Extract core subject from verbose query. |
| 94 | |
| 95 | Strips meta/research words to keep only the core product/concept name. |
| 96 | """ |
| 97 | return _query_extract(topic, noise=NOISE_WORDS) |
| 98 | |
| 99 | |
| 100 | def expand_reddit_queries(topic: str, depth: str) -> List[str]: |
| 101 | """Generate multiple Reddit search queries from a topic. |
| 102 | |
| 103 | Uses local logic (no LLM call needed): |
| 104 | 1. Extract core subject (strip noise words) |
| 105 | 2. Include original topic if different from core |
| 106 | 3. For default/deep: add casual/review variant |
| 107 | 4. For deep: add problem/issues variant |
| 108 | |
| 109 | Returns 1-4 query strings depending on depth. |
| 110 | """ |
| 111 | core = _extract_core_subject(topic) |
| 112 | queries = [core] |
| 113 | |
| 114 | # Broader variant: include more context from original topic |
| 115 | original_clean = topic.strip().rstrip('?!.') |
| 116 | if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8: |
| 117 | queries.append(original_clean) |
| 118 | |
| 119 | qtype = infer_query_intent(topic) |
| 120 | |
| 121 | # Product queries: always include review-oriented variant to bias toward |
| 122 | # review communities instead of keyword-matching unrelated subreddits. |
| 123 | if qtype == "product": |
| 124 | queries.append(f"{core} review OR recommendation OR best") |
| 125 | |
| 126 | # Comparison queries: include head-to-head discussion variant. |
| 127 | if qtype == "comparison": |
| 128 | queries.append(f"{core} worth it OR vs OR compared") |
| 129 | |
| 130 | # Opinion/review variants for default/deep depth. |
| 131 | if depth in ("default", "deep") and qtype in ("product", "opinion"): |
| 132 | queries.append(f"{core} worth it OR thoughts OR review") |
| 133 | |
| 134 | # Problem/bug variants are useful for tool workflows, not generic news. |
| 135 | if depth == "deep" and qtype in ("product", "opinion", "how_to"): |
| 136 | queries.append(f"{core} issues OR problems OR bug OR broken") |
| 137 | |
| 138 | return queries |
| 139 | |
| 140 | |
| 141 | # Known utility/meta subreddits that match queries but aren't discussion subs. |
| 142 | # These get a 0.3x penalty (not banned) in subreddit discovery scoring. |
| 143 | UTILITY_SUBS = frozenset({ |
| 144 | 'namethatsong', 'findthatsong', 'tipofmytongue', |
| 145 | 'whatisthissong', 'helpmefind', 'whatisthisthing', |
| 146 | 'whatsthissong', 'findareddit', 'subredditdrama', |
| 147 | }) |
| 148 | |
| 149 | |
| 150 | def discover_subreddits( |
| 151 | results: List[Dict[str, Any]], |
| 152 | topic: str = "", |
| 153 | max_subs: int = 5, |
| 154 | ) -> List[str]: |
| 155 | """Extract top subreddits from global search results with relevance weighting. |
| 156 | |
| 157 | Uses frequency + topic-word matching + utility-sub penalties + engagement |
| 158 | bonus to find discussion subs rather than utility/meta subs. |
| 159 | |
| 160 | Args: |
| 161 | results: List of post dicts from global search |
| 162 | topic: Original search topic (for relevance matching) |
| 163 | max_subs: Maximum subreddits to return |
| 164 | |
| 165 | Returns: |
| 166 | Top subreddit names sorted by weighted score |
| 167 | """ |
| 168 | core = _extract_core_subject(topic) if topic else "" |
| 169 | core_words = set(core.lower().split()) if core else set() |
| 170 | |
| 171 | scores = Counter() |
| 172 | for post in results: |
| 173 | sub = _extract_subreddit_name(post.get("subreddit", "")) |
| 174 | if not sub: |
| 175 | continue |
| 176 | |
| 177 | # Base: frequency count |
| 178 | base = 1.0 |
| 179 | |
| 180 | # Bonus: subreddit name contains a core topic word |
| 181 | sub_lower = sub.lower() |
| 182 | if core_words and any(w in sub_lower for w in core_words if len(w) > 2): |
| 183 | base += 2.0 |
| 184 | |
| 185 | # Penalty: known utility/meta subreddits |
| 186 | if sub_lower in UTILITY_SUBS: |
| 187 | base *= 0.3 |
| 188 | |
| 189 | # Bonus: post engagement (high-engagement posts = better sub) |
| 190 | ups = _first_of(post.get("ups"), post.get("score"), post.get("votes"), default=0) |
| 191 | if ups and ups > 100: |
| 192 | base += 0.5 |
| 193 | |
| 194 | scores[sub] += base |
| 195 | |
| 196 | return [sub for sub, _ in scores.most_common(max_subs)] |
| 197 | |
| 198 | |
| 199 | def _parse_date(value) -> Optional[str]: |
| 200 | """Convert Unix timestamp or ISO-8601 string to YYYY-MM-DD. |
| 201 | |
| 202 | Global search returns ``created_at`` as an ISO string |
| 203 | (e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns |
| 204 | ``created_utc`` as a Unix timestamp. dates.parse_date() handles both, |
| 205 | plus edge cases like Z suffix and +0000 (no colon) offset. |
| 206 | |
| 207 | Falsy inputs (None, "", 0) return None, matching the original behavior |
| 208 | where a Unix timestamp of 0 meant "no date" rather than epoch 0. |
| 209 | """ |
| 210 | if not value: |
| 211 | return None |
| 212 | dt = dates.parse_date(str(value)) |
| 213 | return dt.strftime("%Y-%m-%d") if dt else None |
| 214 | |
| 215 | |
| 216 | def _extract_subreddit_name(value: Any) -> str: |
| 217 | """Extract subreddit name from string or API object dict.""" |
| 218 | if isinstance(value, dict): |
| 219 | return str(value.get("name") or value.get("display_name") or "").strip() |
| 220 | return str(value).strip() |
| 221 | |
| 222 | |
| 223 | def _extract_score(post: Dict[str, Any]) -> int: |
| 224 | """Extract post score from either API schema. |
| 225 | |
| 226 | Global search uses ``votes``; subreddit search uses ``ups``/``score``. |
| 227 | """ |
| 228 | return _first_of(post.get("ups"), post.get("score"), post.get("votes"), default=0) |
| 229 | |
| 230 | |
| 231 | def _extract_date(post: Dict[str, Any]) -> Optional[str]: |
| 232 | """Extract date from either API schema. |
| 233 | |
| 234 | Global search uses ``created_at`` (ISO); subreddit search uses ``created_utc`` (Unix). |
| 235 | """ |
| 236 | return _parse_date( |
| 237 | post.get("created_utc") or post.get("created_at") or post.get("created_at_iso") |
| 238 | ) |
| 239 | |
| 240 | |
| 241 | def _normalize_reddit_id(raw_id: str) -> str: |
| 242 | """Strip Reddit fullname prefix (t3_) for consistent dedup.""" |
| 243 | s = str(raw_id or "") |
| 244 | return s[3:] if s.startswith("t3_") else s |
| 245 | |
| 246 | |
| 247 | def _total_engagement(item: Dict[str, Any]) -> int: |
| 248 | """Combined engagement score: upvotes + comment count. |
| 249 | |
| 250 | Used for selecting which threads to enrich with comments. |
| 251 | Threads with lots of comments are high-value even if upvote score is low. |
| 252 | """ |
| 253 | eng = item.get("engagement", {}) |
| 254 | score = eng.get("score", 0) or 0 |
| 255 | num_comments = eng.get("num_comments", 0) or 0 |
| 256 | return score + num_comments |
| 257 | |
| 258 | |
| 259 | def _relevance_rank_key(item: Dict[str, Any]) -> float: |
| 260 | """Rank by relevance first, with a bounded engagement bonus as tiebreaker. |
| 261 | |
| 262 | The log-scaled bonus is capped at 0.25 so it orders similarly-relevant posts |
| 263 | by discussion volume but is too small to lift an off-topic post (relevance |
| 264 | ~0) above an on-topic one (relevance >= RELEVANCE_FLOOR). |
| 265 | """ |
| 266 | rel = item.get("relevance") or 0.0 |
| 267 | eng_bonus = min(0.25, math.log10(_total_engagement(item) + 1) / 20.0) |
| 268 | return rel + eng_bonus |
| 269 | |
| 270 | |
| 271 | def _normalize_post(post: Dict[str, Any], idx: int, source_label: str = "global", query: str = "") -> Dict[str, Any]: |
| 272 | """Normalize a ScrapeCreators Reddit post to our internal format. |
| 273 | |
| 274 | Handles both the global-search schema (``votes``, ``created_at``, |
| 275 | ``subreddit`` as dict) and the subreddit-search schema (``ups``/``score``, |
| 276 | ``created_utc``, ``subreddit`` as string). |
| 277 | """ |
| 278 | permalink = post.get("permalink", "") |
| 279 | url = f"https://www.reddit.com{permalink}" if permalink else post.get("url", "") |
| 280 | |
| 281 | # Ensure URL looks like a Reddit thread |
| 282 | if url and "reddit.com" not in url: |
| 283 | url = "" |
| 284 | |
| 285 | title = str(post.get("title", "")).strip() |
| 286 | selftext = str(post.get("selftext", "")) |
| 287 | |
| 288 | # Score the title first, then let the body provide limited support. |
| 289 | # This keeps long selftexts from overpowering the visible topic signal. |
| 290 | relevance = _compute_post_relevance(query, title, selftext) if query else 0.7 |
| 291 | |
| 292 | return { |
| 293 | "id": f"R{idx}", |
| 294 | "reddit_id": _normalize_reddit_id(post.get("id", "")), |
| 295 | "title": title, |
| 296 | "url": url, |
| 297 | "subreddit": _extract_subreddit_name(post.get("subreddit", "")), |
| 298 | "date": _extract_date(post), |
| 299 | "engagement": { |
| 300 | "score": _extract_score(post), |
| 301 | "num_comments": post.get("num_comments", 0), |
| 302 | "upvote_ratio": post.get("upvote_ratio"), |
| 303 | }, |
| 304 | "relevance": relevance, |
| 305 | "why_relevant": f"Reddit {source_label} search", |
| 306 | "selftext": str(post.get("selftext", ""))[:500], |
| 307 | } |
| 308 | |
| 309 | |
| 310 | def _compute_post_relevance(query: str, title: str, selftext: str) -> float: |
| 311 | """Compute Reddit relevance with title-first weighting. |
| 312 | |
| 313 | Title should carry most of the weight because it is the visible summary the |
| 314 | user sees. Selftext can lift a marginal match, but it should not rescue a |
| 315 | weak or ambiguous title into the top ranks. |
| 316 | """ |
| 317 | title_score = token_overlap_relevance(query, title) |
| 318 | if not selftext.strip(): |
| 319 | return title_score |
| 320 | |
| 321 | body_score = token_overlap_relevance(query, selftext) |
| 322 | support_score = max(title_score, body_score) |
| 323 | return round(0.75 * title_score + 0.25 * support_score, 2) |
| 324 | |
| 325 | |
| 326 | def _global_search( |
| 327 | query: str, |
| 328 | token: str, |
| 329 | sort: str = "relevance", |
| 330 | timeframe: str = "month", |
| 331 | ) -> List[Dict[str, Any]]: |
| 332 | """Search across all of Reddit via ScrapeCreators global search. |
| 333 | |
| 334 | Args: |
| 335 | query: Search query |
| 336 | token: ScrapeCreators API key |
| 337 | sort: Sort order (relevance, hot, top, new) |
| 338 | timeframe: Time filter (hour, day, week, month, year, all) |
| 339 | |
| 340 | Returns: |
| 341 | List of post dicts |
| 342 | """ |
| 343 | try: |
| 344 | data = http.get( |
| 345 | f"{SCRAPECREATORS_BASE}/search", |
| 346 | headers=http.scrapecreators_headers(token), |
| 347 | params={"query": query, "sort": sort, "timeframe": timeframe}, |
| 348 | timeout=30, |
| 349 | retries=2, |
| 350 | ) |
| 351 | return data.get("posts", data.get("data", [])) |
| 352 | except http.HTTPError as e: |
| 353 | if e.status_code in (401, 402, 403): |
| 354 | raise |
| 355 | _log(f"Global search error: {e}") |
| 356 | return [] |
| 357 | except Exception as e: |
| 358 | _log(f"Global search error: {e}") |
| 359 | return [] |
| 360 | |
| 361 | |
| 362 | def _subreddit_search( |
| 363 | subreddit: str, |
| 364 | query: str, |
| 365 | token: str, |
| 366 | sort: str = "relevance", |
| 367 | timeframe: str = "month", |
| 368 | ) -> List[Dict[str, Any]]: |
| 369 | """Search within a specific subreddit via ScrapeCreators. |
| 370 | |
| 371 | Args: |
| 372 | subreddit: Subreddit name (without r/) |
| 373 | query: Search query |
| 374 | token: ScrapeCreators API key |
| 375 | sort: Sort order |
| 376 | timeframe: Time filter |
| 377 | |
| 378 | Returns: |
| 379 | List of post dicts |
| 380 | """ |
| 381 | try: |
| 382 | data = http.get( |
| 383 | f"{SCRAPECREATORS_BASE}/subreddit/search", |
| 384 | headers=http.scrapecreators_headers(token), |
| 385 | params={ |
| 386 | "subreddit": subreddit, |
| 387 | "query": query, |
| 388 | "sort": sort, |
| 389 | "timeframe": timeframe, |
| 390 | }, |
| 391 | timeout=30, |
| 392 | retries=2, |
| 393 | ) |
| 394 | return data.get("posts", data.get("data", [])) |
| 395 | except http.HTTPError as e: |
| 396 | if e.status_code in (401, 402, 403): |
| 397 | raise |
| 398 | _log(f"Subreddit search error for r/{subreddit}: {e}") |
| 399 | return [] |
| 400 | except Exception as e: |
| 401 | _log(f"Subreddit search error for r/{subreddit}: {e}") |
| 402 | return [] |
| 403 | |
| 404 | |
| 405 | def fetch_post_comments( |
| 406 | url: str, |
| 407 | token: str, |
| 408 | ) -> List[Dict[str, Any]]: |
| 409 | """Fetch comments for a Reddit post via ScrapeCreators. |
| 410 | |
| 411 | Args: |
| 412 | url: Reddit post URL or permalink |
| 413 | token: ScrapeCreators API key |
| 414 | |
| 415 | Returns: |
| 416 | List of comment dicts with score, author, body, etc. |
| 417 | """ |
| 418 | try: |
| 419 | data = http.get( |
| 420 | f"{SCRAPECREATORS_BASE}/post/comments", |
| 421 | headers=http.scrapecreators_headers(token), |
| 422 | params={"url": url}, |
| 423 | timeout=30, |
| 424 | retries=2, |
| 425 | ) |
| 426 | return data.get("comments", data.get("data", [])) |
| 427 | except http.HTTPError as e: |
| 428 | if e.status_code in (401, 402, 403): |
| 429 | raise |
| 430 | _log(f"Comment fetch error: {e}") |
| 431 | return [] |
| 432 | except Exception as e: |
| 433 | _log(f"Comment fetch error: {e}") |
| 434 | return [] |
| 435 | |
| 436 | |
| 437 | def _dedupe_posts(posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| 438 | """Deduplicate posts by reddit_id, keeping first occurrence.""" |
| 439 | seen_ids = set() |
| 440 | seen_urls = set() |
| 441 | unique = [] |
| 442 | for post in posts: |
| 443 | rid = post.get("reddit_id", "") |
| 444 | url = post.get("url", "") |
| 445 | if rid and rid in seen_ids: |
| 446 | continue |
| 447 | if url and url in seen_urls: |
| 448 | continue |
| 449 | if rid: |
| 450 | seen_ids.add(rid) |
| 451 | if url: |
| 452 | seen_urls.add(url) |
| 453 | unique.append(post) |
| 454 | return unique |
| 455 | |
| 456 | |
| 457 | _TIMEFRAME_ORDER = {"hour": 0, "day": 1, "week": 2, "month": 3, "year": 4, "all": 5} |
| 458 | |
| 459 | |
| 460 | def _days_to_reddit_bucket(days: float) -> str: |
| 461 | """Map a day count onto the smallest Reddit rolling bucket that covers it. |
| 462 | |
| 463 | Adds one day of slack so calendar windows that cross a day boundary still |
| 464 | fit inside Reddit's rolling ``t=`` buckets (a yesterday→today request needs |
| 465 | ``week``, not ``day``). |
| 466 | """ |
| 467 | covered = days + 1 |
| 468 | if covered <= 1: |
| 469 | return "day" |
| 470 | if covered <= 7: |
| 471 | return "week" |
| 472 | if covered <= 31: |
| 473 | return "month" |
| 474 | if covered <= 366: |
| 475 | return "year" |
| 476 | return "all" |
| 477 | |
| 478 | |
| 479 | def _window_to_time_filter(from_date: str, to_date: str) -> str: |
| 480 | """Map a requested YYYY-MM-DD window onto Reddit's coarse `t` param. |
| 481 | |
| 482 | Reddit's ``t=day|week|month`` buckets are rolling windows ending *now*, not |
| 483 | calendar spans and not anchored to ``to_date``. Coverage therefore needs: |
| 484 | |
| 485 | 1. Span — a yesterday→today request needs more than rolling ``t=day``. |
| 486 | 2. Historical reach — a one-day request ending two weeks ago still needs a |
| 487 | bucket that reaches ``from_date``; span-alone would pick ``week`` and |
| 488 | the API would omit the entire requested range. |
| 489 | |
| 490 | Take the wider of the two; the caller then mins with the depth default. |
| 491 | Phase 5 still trims to ``from_date``/``to_date``. Falls back to ``month`` |
| 492 | if the dates don't parse. |
| 493 | """ |
| 494 | try: |
| 495 | start = date.fromisoformat(from_date) |
| 496 | end = date.fromisoformat(to_date) |
| 497 | except (ValueError, TypeError): |
| 498 | return "month" |
| 499 | span_days = max(0, (end - start).days) |
| 500 | # Age of from_date relative to "today" — Reddit always anchors to now. |
| 501 | age_days = max(0, (datetime.now(timezone.utc).date() - start).days) |
| 502 | return _days_to_reddit_bucket(max(span_days, age_days)) |
| 503 | |
| 504 | |
| 505 | def search_reddit( |
| 506 | topic: str, |
| 507 | from_date: str, |
| 508 | to_date: str, |
| 509 | depth: str = "default", |
| 510 | token: str = None, |
| 511 | subreddits: List[str] | None = None, |
| 512 | ) -> Dict[str, Any]: |
| 513 | """Full Reddit search: multi-query global discovery + subreddit drill-down. |
| 514 | |
| 515 | This is the main v3 Reddit entry point. |
| 516 | |
| 517 | Args: |
| 518 | topic: Search topic |
| 519 | from_date: Start date (YYYY-MM-DD) |
| 520 | to_date: End date (YYYY-MM-DD) |
| 521 | depth: 'quick', 'default', or 'deep' |
| 522 | token: ScrapeCreators API key |
| 523 | subreddits: Optional list of subreddit names to search first (pre-resolved) |
| 524 | |
| 525 | Returns: |
| 526 | Dict with 'items' list and optional 'error'. |
| 527 | """ |
| 528 | if not token: |
| 529 | return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"} |
| 530 | |
| 531 | config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) |
| 532 | # Fetch window must track the requested date range, not just the depth |
| 533 | # default. Otherwise a --days 1 request fetches a month of relevance- |
| 534 | # sorted posts and Phase 5 discards everything outside 24h (0 on quiet |
| 535 | # days). Use the tighter of {window-derived, depth default}. |
| 536 | _depth_tf = config["timeframe"] |
| 537 | _window_tf = _window_to_time_filter(from_date, to_date) |
| 538 | timeframe = _window_tf if _TIMEFRAME_ORDER.get(_window_tf, 3) <= _TIMEFRAME_ORDER.get(_depth_tf, 3) else _depth_tf |
| 539 | intent = infer_query_intent(topic) |
| 540 | |
| 541 | # === Phase 1: Query Expansion === |
| 542 | queries = expand_reddit_queries(topic, depth) |
| 543 | _log(f"Expanded '{topic}' into {len(queries)} queries: {queries}") |
| 544 | |
| 545 | core = _extract_core_subject(topic) |
| 546 | |
| 547 | # === Phase 1.5: Pre-resolved subreddit search (high-signal) === |
| 548 | all_raw_posts = [] |
| 549 | all_items: List[Dict[str, Any]] = [] |
| 550 | if subreddits: |
| 551 | _log(f"Searching pre-resolved subreddits: {subreddits}") |
| 552 | with ThreadPoolExecutor(max_workers=min(5, len(subreddits))) as executor: |
| 553 | futures = {} |
| 554 | for sub in subreddits: |
| 555 | futures[http.submit_with_context( |
| 556 | executor, _subreddit_search, sub, core, token, "relevance", timeframe, |
| 557 | )] = sub |
| 558 | for future in as_completed(futures): |
| 559 | sub = futures[future] |
| 560 | sub_posts = future.result() |
| 561 | _log(f" -> {len(sub_posts)} results from pre-resolved r/{sub}") |
| 562 | for j, post in enumerate(sub_posts): |
| 563 | item = _normalize_post(post, len(all_items) + j + 1, f"r/{sub}", query=core) |
| 564 | all_items.append(item) |
| 565 | |
| 566 | # === Phase 2: Global Discovery === |
| 567 | max_global = config["global_searches"] |
| 568 | |
| 569 | with ThreadPoolExecutor(max_workers=max_global or 1) as executor: |
| 570 | futures = {} |
| 571 | for i, query in enumerate(queries[:max_global]): |
| 572 | # Product/comparison queries: sort=top surfaces high-engagement posts |
| 573 | # from relevant communities instead of keyword-matched noise. |
| 574 | sort = "top" if intent in ("product", "comparison") else ("relevance" if i == 0 else "top") |
| 575 | _log(f"Global search {i+1}/{max_global}: '{query}' (sort={sort})") |
| 576 | futures[http.submit_with_context( |
| 577 | executor, _global_search, query, token, sort, timeframe, |
| 578 | )] = query |
| 579 | for future in as_completed(futures): |
| 580 | query = futures[future] |
| 581 | posts = future.result() |
| 582 | _log(f" -> {len(posts)} results for '{query}'") |
| 583 | all_raw_posts.extend(posts) |
| 584 | |
| 585 | # Normalize all posts (with query for relevance scoring) |
| 586 | for i, post in enumerate(all_raw_posts): |
| 587 | item = _normalize_post(post, i + 1, "global", query=core) |
| 588 | all_items.append(item) |
| 589 | |
| 590 | # === Phase 3: Subreddit Discovery + Targeted Search === |
| 591 | subreddit_budget = 0 if intent == "how_to" else config["subreddit_searches"] |
| 592 | discovered_subs = discover_subreddits(all_raw_posts, topic=topic, max_subs=subreddit_budget) |
| 593 | _log(f"Discovered subreddits: {discovered_subs}") |
| 594 | |
| 595 | subreddit_limit = subreddit_budget |
| 596 | if subreddit_limit > 0: |
| 597 | with ThreadPoolExecutor(max_workers=subreddit_limit) as executor: |
| 598 | futures = {} |
| 599 | for sub in discovered_subs[:subreddit_limit]: |
| 600 | _log(f"Subreddit search: r/{sub} for '{core}'") |
| 601 | futures[http.submit_with_context( |
| 602 | executor, _subreddit_search, sub, core, token, "relevance", timeframe, |
| 603 | )] = sub |
| 604 | for future in as_completed(futures): |
| 605 | sub = futures[future] |
| 606 | sub_posts = future.result() |
| 607 | _log(f" -> {len(sub_posts)} results from r/{sub}") |
| 608 | for j, post in enumerate(sub_posts): |
| 609 | item = _normalize_post(post, len(all_items) + j + 1, f"r/{sub}", query=core) |
| 610 | all_items.append(item) |
| 611 | |
| 612 | # === Phase 4: Deduplicate === |
| 613 | all_items = _dedupe_posts(all_items) |
| 614 | _log(f"After dedup: {len(all_items)} unique posts") |
| 615 | |
| 616 | # === Phase 5: Date filter === |
| 617 | in_range = [] |
| 618 | out_of_range = 0 |
| 619 | for item in all_items: |
| 620 | if item["date"] and from_date <= item["date"] <= to_date: |
| 621 | in_range.append(item) |
| 622 | elif item["date"] is None: |
| 623 | in_range.append(item) # Keep unknown dates |
| 624 | else: |
| 625 | out_of_range += 1 |
| 626 | |
| 627 | if in_range: |
| 628 | all_items = in_range |
| 629 | if out_of_range: |
| 630 | _log(f"Filtered {out_of_range} posts outside date range") |
| 631 | else: |
| 632 | _log(f"No posts within date range, keeping all {len(all_items)}") |
| 633 | |
| 634 | # === Phase 6: Relevance floor + relevance-weighted ranking === |
| 635 | # Drop the off-topic tail when enough on-topic posts remain (guard mirrors |
| 636 | # the date filter: keep all if too few clear the floor). When too few clear |
| 637 | # the soft floor, still strip zero-overlap posts (relevance exactly 0 = no |
| 638 | # title/body token match at all, never on-topic) whenever anything relevant |
| 639 | # remains, so viral high-upvote junk can't fill the section. Then rank by |
| 640 | # relevance with a bounded engagement bonus (see RELEVANCE_FLOOR note above). |
| 641 | before = len(all_items) |
| 642 | on_topic = [it for it in all_items if (it.get("relevance") or 0) >= RELEVANCE_FLOOR] |
| 643 | if len(on_topic) >= MIN_ON_TOPIC: |
| 644 | all_items = on_topic |
| 645 | else: |
| 646 | nonzero = [it for it in all_items if (it.get("relevance") or 0) > 0] |
| 647 | if nonzero: |
| 648 | all_items = nonzero |
| 649 | if len(all_items) < before: |
| 650 | _log(f"Relevance floor dropped {before - len(all_items)} off-topic posts") |
| 651 | all_items.sort(key=_relevance_rank_key, reverse=True) |
| 652 | |
| 653 | # Re-index IDs |
| 654 | for i, item in enumerate(all_items): |
| 655 | item["id"] = f"R{i+1}" |
| 656 | |
| 657 | _log(f"Final: {len(all_items)} Reddit posts") |
| 658 | return {"items": all_items} |
| 659 | |
| 660 | |
| 661 | def enrich_with_comments( |
| 662 | items: List[Dict[str, Any]], |
| 663 | token: str, |
| 664 | depth: str = "default", |
| 665 | budget_seconds: int = 60, |
| 666 | ) -> List[Dict[str, Any]]: |
| 667 | """Enrich top items with comment data from ScrapeCreators. |
| 668 | |
| 669 | Args: |
| 670 | items: Reddit items from search_reddit() |
| 671 | token: ScrapeCreators API key |
| 672 | depth: Depth for comment limit |
| 673 | budget_seconds: Maximum total time for enrichment. If exceeded, |
| 674 | returns items with whatever enrichment completed. Never discards items. |
| 675 | |
| 676 | Returns: |
| 677 | Items with top_comments and comment_insights added. |
| 678 | """ |
| 679 | config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) |
| 680 | max_comments = config["comment_enrichments"] |
| 681 | |
| 682 | if not items or not token or max_comments <= 0: |
| 683 | return items |
| 684 | |
| 685 | # Select the top threads by total engagement (upvotes + comment count), |
| 686 | # not by list position. This ensures high-comment threads like [FRESH ALBUM] |
| 687 | # always get enriched even if their upvote score is low. |
| 688 | ranked = sorted(items, key=_total_engagement, reverse=True) |
| 689 | top_items = ranked[:max_comments] |
| 690 | _log(f"Enriching comments for {len(top_items)} posts (by total engagement)") |
| 691 | |
| 692 | start = time.monotonic() |
| 693 | |
| 694 | with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor: |
| 695 | futures = { |
| 696 | http.submit_with_context( |
| 697 | executor, fetch_post_comments, item.get("url", ""), token, |
| 698 | ): item |
| 699 | for item in top_items |
| 700 | if item.get("url") |
| 701 | } |
| 702 | |
| 703 | # Wait with budget instead of unbounded as_completed |
| 704 | remaining = max(0, budget_seconds - (time.monotonic() - start)) |
| 705 | done, not_done = futures_wait(futures, timeout=remaining) |
| 706 | |
| 707 | enriched_count = 0 |
| 708 | for future in done: |
| 709 | item = futures[future] |
| 710 | try: |
| 711 | raw_comments = future.result(timeout=0) |
| 712 | except Exception: |
| 713 | continue |
| 714 | if not raw_comments: |
| 715 | continue |
| 716 | |
| 717 | top_comments = [] |
| 718 | insights = [] |
| 719 | |
| 720 | for ci, c in enumerate(raw_comments[:10]): |
| 721 | body = c.get("body", "") |
| 722 | if not body or body in ("[deleted]", "[removed]"): |
| 723 | continue |
| 724 | |
| 725 | score = c.get("ups") or c.get("score", 0) |
| 726 | author = c.get("author", "[deleted]") |
| 727 | permalink = c.get("permalink", "") |
| 728 | comment_url = f"https://reddit.com{permalink}" if permalink else "" |
| 729 | |
| 730 | max_excerpt = 400 if ci == 0 else 300 |
| 731 | top_comments.append({ |
| 732 | "score": score, |
| 733 | "date": _parse_date(c.get("created_utc")), |
| 734 | "author": author, |
| 735 | "excerpt": body[:max_excerpt], |
| 736 | "url": comment_url, |
| 737 | }) |
| 738 | |
| 739 | if len(body) >= 30 and author not in ("[deleted]", "[removed]", "AutoModerator"): |
| 740 | insight = body[:150] |
| 741 | if len(body) > 150: |
| 742 | for i, char in enumerate(insight): |
| 743 | if char in '.!?' and i > 50: |
| 744 | insight = insight[:i+1] |
| 745 | break |
| 746 | else: |
| 747 | insight = insight.rstrip() + "..." |
| 748 | insights.append(insight) |
| 749 | |
| 750 | top_comments.sort(key=lambda c: c.get("score", 0), reverse=True) |
| 751 | item["top_comments"] = top_comments[:10] |
| 752 | item["comment_insights"] = insights[:10] |
| 753 | enriched_count += 1 |
| 754 | |
| 755 | if not_done: |
| 756 | _log(f"Enrichment budget hit ({budget_seconds}s): {enriched_count}/{len(futures)} posts enriched, {len(not_done)} skipped") |
| 757 | for future in not_done: |
| 758 | future.cancel() |
| 759 | else: |
| 760 | elapsed = time.monotonic() - start |
| 761 | _log(f"Enriched {enriched_count}/{len(futures)} posts in {elapsed:.1f}s") |
| 762 | |
| 763 | return items |
| 764 | |
| 765 | |
| 766 | def search_and_enrich( |
| 767 | topic: str, |
| 768 | from_date: str, |
| 769 | to_date: str, |
| 770 | depth: str = "default", |
| 771 | token: str = None, |
| 772 | subreddits: List[str] | None = None, |
| 773 | ) -> Dict[str, Any]: |
| 774 | """Full Reddit pipeline: search + comment enrichment. |
| 775 | |
| 776 | This is the convenience function that does everything. |
| 777 | |
| 778 | Args: |
| 779 | topic: Search topic |
| 780 | from_date: Start date (YYYY-MM-DD) |
| 781 | to_date: End date (YYYY-MM-DD) |
| 782 | depth: 'quick', 'default', or 'deep' |
| 783 | token: ScrapeCreators API key |
| 784 | subreddits: Optional list of subreddit names to search first (pre-resolved) |
| 785 | |
| 786 | Returns: |
| 787 | Dict with 'items' list. Items include top_comments and comment_insights. |
| 788 | """ |
| 789 | result = search_reddit(topic, from_date, to_date, depth, token, subreddits=subreddits) |
| 790 | items = result.get("items", []) |
| 791 | |
| 792 | if items and token: |
| 793 | items = enrich_with_comments(items, token, depth) |
| 794 | result["items"] = items |
| 795 | |
| 796 | return result |
| 797 | |
| 798 | |
| 799 | def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]: |
| 800 | """Parse ScrapeCreators response to item list. |
| 801 | |
| 802 | Parse raw Reddit search output into the generic item shape. |
| 803 | """ |
| 804 | return response.get("items", []) |
| 805 |