返回 last30days-skill
reddit.py
根目录 / skills / last30days / scripts / lib / reddit.py
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
805 lines PYTHON