| 1 | """Instagram Reels search via ScrapeCreators API for /last30days. |
| 2 | |
| 3 | Uses ScrapeCreators REST API to search Instagram Reels by keyword, extract |
| 4 | engagement metrics (views, likes, comments), and fetch video transcripts. |
| 5 | |
| 6 | Requires SCRAPECREATORS_API_KEY in config. 100 free API calls, then PAYG. |
| 7 | API docs: https://scrapecreators.com/docs |
| 8 | """ |
| 9 | |
| 10 | import os |
| 11 | import re |
| 12 | import sys |
| 13 | from datetime import datetime |
| 14 | from typing import Any, Dict, List, Optional, Set |
| 15 | |
| 16 | from . import dates, http, log |
| 17 | from .query import infer_query_intent |
| 18 | from .relevance import token_overlap_relevance as _compute_relevance |
| 19 | |
| 20 | SCRAPECREATORS_BASE = "https://api.scrapecreators.com" |
| 21 | |
| 22 | # Depth configurations: how many results to fetch / captions to extract |
| 23 | DEPTH_CONFIG = { |
| 24 | "quick": {"results_per_page": 10, "max_captions": 3}, |
| 25 | "default": {"results_per_page": 20, "max_captions": 5}, |
| 26 | "deep": {"results_per_page": 40, "max_captions": 8}, |
| 27 | } |
| 28 | |
| 29 | # Max words to keep from each caption |
| 30 | CAPTION_MAX_WORDS = 500 |
| 31 | |
| 32 | # Default transcript fetch timeout (seconds). SC's |
| 33 | # /v2/instagram/media/transcript regularly takes >15s on real workloads, |
| 34 | # so the default is generous; override via LAST30DAYS_TRANSCRIPT_TIMEOUT. |
| 35 | DEFAULT_TRANSCRIPT_TIMEOUT = 30 |
| 36 | |
| 37 | |
| 38 | def _resolve_transcript_timeout( |
| 39 | timeout: Optional[float] = None, |
| 40 | config: Optional[Dict[str, Any]] = None, |
| 41 | ) -> float: |
| 42 | """Resolve the IG transcript-fetch timeout. |
| 43 | |
| 44 | Priority (highest wins): |
| 45 | 1. Explicit ``timeout`` kwarg |
| 46 | 2. ``LAST30DAYS_TRANSCRIPT_TIMEOUT`` in os.environ |
| 47 | 3. ``LAST30DAYS_TRANSCRIPT_TIMEOUT`` in caller-supplied config dict |
| 48 | 4. ``DEFAULT_TRANSCRIPT_TIMEOUT`` (30s) |
| 49 | |
| 50 | Mirrors the ``os.environ.get(X) or config.get(X)`` pattern used for |
| 51 | LAST30DAYS_STORE in last30days.py so the env var works whether it's |
| 52 | shell-exported or set in ~/.config/last30days/.env. |
| 53 | """ |
| 54 | if timeout is not None: |
| 55 | try: |
| 56 | return float(timeout) |
| 57 | except (TypeError, ValueError): |
| 58 | pass |
| 59 | raw = os.environ.get("LAST30DAYS_TRANSCRIPT_TIMEOUT") |
| 60 | if not raw and config: |
| 61 | raw = config.get("LAST30DAYS_TRANSCRIPT_TIMEOUT") |
| 62 | if raw: |
| 63 | try: |
| 64 | return float(raw) |
| 65 | except (TypeError, ValueError): |
| 66 | pass |
| 67 | return float(DEFAULT_TRANSCRIPT_TIMEOUT) |
| 68 | |
| 69 | |
| 70 | def _extract_core_subject(topic: str) -> str: |
| 71 | """Extract core subject from verbose query for Instagram search.""" |
| 72 | from .query import VIRAL_NOISE, extract_core_subject |
| 73 | return extract_core_subject(topic, noise=VIRAL_NOISE) |
| 74 | |
| 75 | |
| 76 | def _to_hashtag_form(query: str) -> str: |
| 77 | """Collapse a multi-word query to hashtag form (no spaces, lowercase). |
| 78 | |
| 79 | SC's /v2/instagram/reels/search wraps Google Search and is documented |
| 80 | to be flaky on multi-token queries. Single-token queries map to a |
| 81 | hashtag page lookup which is the stable path. Used as a 500-retry |
| 82 | fallback before the request bubbles up as a silent failure. |
| 83 | """ |
| 84 | return ''.join(query.split()).lower() |
| 85 | |
| 86 | |
| 87 | def expand_instagram_queries(topic: str, depth: str) -> List[str]: |
| 88 | """Generate multiple Instagram search queries from a topic. |
| 89 | |
| 90 | Mirrors reddit.py's expand_reddit_queries() pattern: |
| 91 | 1. Extract core subject (strip noise words) |
| 92 | 2. Include original topic if different from core |
| 93 | 3. Add intent-specific OR-joined content-type variants |
| 94 | 4. Cap by depth: 1 for quick, 2 for default, 3 for deep |
| 95 | |
| 96 | Returns 1-3 query strings depending on depth. |
| 97 | """ |
| 98 | core = _extract_core_subject(topic) |
| 99 | queries = [core] |
| 100 | |
| 101 | # Include cleaned original topic as variant if different from core |
| 102 | original_clean = topic.strip().rstrip('?!.') |
| 103 | if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8: |
| 104 | queries.append(original_clean) |
| 105 | |
| 106 | qtype = infer_query_intent(topic) |
| 107 | |
| 108 | # Intent-specific Instagram content-type variants |
| 109 | if qtype == "breaking_news": |
| 110 | queries.append(f"{core} reaction OR edit") |
| 111 | elif qtype == "opinion": |
| 112 | queries.append(f"{core} reaction OR edit") |
| 113 | elif qtype == "product": |
| 114 | queries.append(f"{core} review OR haul") |
| 115 | elif qtype == "comparison": |
| 116 | queries.append(f"{core} vs OR compared") |
| 117 | elif qtype == "how_to": |
| 118 | queries.append(f"{core} tutorial OR hack") |
| 119 | else: |
| 120 | queries.append(f"{core} reaction OR edit") |
| 121 | |
| 122 | # Deep depth: add viral content variant |
| 123 | if depth == "deep": |
| 124 | queries.append(f"{core} viral OR trending OR reel") |
| 125 | |
| 126 | # Cap by depth budget |
| 127 | caps = {"quick": 1, "default": 2, "deep": 3} |
| 128 | cap = caps.get(depth, 2) |
| 129 | return queries[:cap] |
| 130 | |
| 131 | |
| 132 | def _log(msg: str): |
| 133 | log.source_log("Instagram", msg, tty_only=False) |
| 134 | |
| 135 | |
| 136 | def _parse_date(item: Dict[str, Any]) -> Optional[str]: |
| 137 | """Parse date from ScrapeCreators Instagram item to YYYY-MM-DD. |
| 138 | |
| 139 | Handles taken_at as ISO string (e.g. "2026-02-26T16:00:00.000Z") |
| 140 | or unix timestamp. |
| 141 | """ |
| 142 | ts = item.get("taken_at") |
| 143 | if not ts: |
| 144 | return None |
| 145 | |
| 146 | # Try ISO string first (ScrapeCreators reels/search returns this) |
| 147 | if isinstance(ts, str): |
| 148 | try: |
| 149 | # Handle "2026-02-26T16:00:00.000Z" format |
| 150 | dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) |
| 151 | return dt.strftime("%Y-%m-%d") |
| 152 | except (ValueError, TypeError): |
| 153 | pass |
| 154 | # Try just the date portion |
| 155 | if len(ts) >= 10: |
| 156 | return ts[:10] |
| 157 | |
| 158 | # Fall back to unix timestamp |
| 159 | try: |
| 160 | return dates.timestamp_to_date(int(ts)) |
| 161 | except (ValueError, TypeError): |
| 162 | pass |
| 163 | |
| 164 | return None |
| 165 | |
| 166 | |
| 167 | def _extract_hashtags(caption_text: str) -> List[str]: |
| 168 | """Extract hashtags from Instagram caption text.""" |
| 169 | if not caption_text: |
| 170 | return [] |
| 171 | return re.findall(r'#(\w+)', caption_text) |
| 172 | |
| 173 | |
| 174 | def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]: |
| 175 | """Parse raw Instagram items into normalized dicts.""" |
| 176 | items = [] |
| 177 | for raw in raw_items: |
| 178 | if not isinstance(raw, dict): |
| 179 | continue |
| 180 | |
| 181 | # Extract reel ID and shortcode |
| 182 | reel_pk = str(raw.get("id", raw.get("pk", ""))) |
| 183 | shortcode = raw.get("shortcode", raw.get("code", "")) |
| 184 | |
| 185 | # Caption text -- can be a string or dict depending on endpoint |
| 186 | caption_obj = raw.get("caption", "") |
| 187 | if isinstance(caption_obj, dict): |
| 188 | text = caption_obj.get("text", "") |
| 189 | elif isinstance(caption_obj, str): |
| 190 | text = caption_obj |
| 191 | else: |
| 192 | text = raw.get("desc", raw.get("text", "")) |
| 193 | |
| 194 | # Engagement metrics |
| 195 | play_count = raw.get("video_play_count") or raw.get("video_view_count") or raw.get("play_count") or 0 |
| 196 | like_count = raw.get("like_count") or 0 |
| 197 | comment_count = raw.get("comment_count") or 0 |
| 198 | |
| 199 | # Author info -- 'owner' in reels/search, 'user' in user/reels |
| 200 | owner_raw = raw.get("owner") or raw.get("user") |
| 201 | if isinstance(owner_raw, dict): |
| 202 | author_name = owner_raw.get("username", "") |
| 203 | elif isinstance(owner_raw, str): |
| 204 | author_name = owner_raw |
| 205 | else: |
| 206 | author_name = "" |
| 207 | |
| 208 | # Duration |
| 209 | duration = raw.get("video_duration") |
| 210 | |
| 211 | # Date |
| 212 | date_str = _parse_date(raw) |
| 213 | |
| 214 | # Hashtags from caption text |
| 215 | hashtags = _extract_hashtags(text) |
| 216 | |
| 217 | # Compute relevance with hashtag boost |
| 218 | relevance = _compute_relevance(core_topic, text, hashtags) |
| 219 | |
| 220 | # Build URL -- prefer API-provided url, fallback to shortcode |
| 221 | url = raw.get("url", "") |
| 222 | if not url and shortcode: |
| 223 | url = f"https://www.instagram.com/reel/{shortcode}" |
| 224 | |
| 225 | items.append({ |
| 226 | "video_id": reel_pk, |
| 227 | "text": text, |
| 228 | "url": url, |
| 229 | "author_name": author_name, |
| 230 | "date": date_str, |
| 231 | "engagement": { |
| 232 | "views": play_count, |
| 233 | "likes": like_count, |
| 234 | "comments": comment_count, |
| 235 | }, |
| 236 | "hashtags": hashtags, |
| 237 | "duration": duration, |
| 238 | "relevance": relevance, |
| 239 | "why_relevant": f"Instagram: {text[:60]}" if text else f"Instagram: {core_topic}", |
| 240 | "caption_snippet": "", # populated by fetch_captions |
| 241 | }) |
| 242 | return items |
| 243 | |
| 244 | |
| 245 | def _user_reels( |
| 246 | handle: str, |
| 247 | token: str, |
| 248 | ) -> List[Dict[str, Any]]: |
| 249 | """Fetch an Instagram user's recent reels via ScrapeCreators. |
| 250 | |
| 251 | Args: |
| 252 | handle: Instagram username (without @) |
| 253 | token: ScrapeCreators API key |
| 254 | |
| 255 | Returns: |
| 256 | List of raw Instagram reel dicts. |
| 257 | """ |
| 258 | _log(f"User reels: @{handle}") |
| 259 | reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels" |
| 260 | try: |
| 261 | data = http.get( |
| 262 | reels_url, |
| 263 | params={"handle": handle}, |
| 264 | headers=http.scrapecreators_headers(token), |
| 265 | timeout=30, |
| 266 | retries=2, |
| 267 | ) |
| 268 | except Exception as e: |
| 269 | _log(f"User reels error for @{handle}: {e}") |
| 270 | return [] |
| 271 | |
| 272 | raw_items = data.get("items") or data.get("reels") or data.get("data") or [] |
| 273 | _log(f" -> {len(raw_items)} reels from @{handle}") |
| 274 | return raw_items |
| 275 | |
| 276 | |
| 277 | def search_instagram( |
| 278 | topic: str, |
| 279 | from_date: str, |
| 280 | to_date: str, |
| 281 | depth: str = "default", |
| 282 | token: str = None, |
| 283 | ) -> Dict[str, Any]: |
| 284 | """Search Instagram Reels via ScrapeCreators API. |
| 285 | |
| 286 | Args: |
| 287 | topic: Search topic |
| 288 | from_date: Start date (YYYY-MM-DD) |
| 289 | to_date: End date (YYYY-MM-DD) |
| 290 | depth: 'quick', 'default', or 'deep' |
| 291 | token: ScrapeCreators API key |
| 292 | |
| 293 | Returns: |
| 294 | Dict with 'items' list and optional 'error'. |
| 295 | """ |
| 296 | if not token: |
| 297 | return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"} |
| 298 | |
| 299 | config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) |
| 300 | core_topic = _extract_core_subject(topic) |
| 301 | |
| 302 | _log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})") |
| 303 | |
| 304 | try: |
| 305 | data = http.get( |
| 306 | f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search", |
| 307 | params={"query": core_topic}, |
| 308 | headers=http.scrapecreators_headers(token), |
| 309 | timeout=30, |
| 310 | retries=2, |
| 311 | ) |
| 312 | except http.HTTPError as e: |
| 313 | # SC's v2 reels search wraps Google Search and 500s frequently on |
| 314 | # multi-token queries. Single tokens hit the stable hashtag-page |
| 315 | # path. Retry once with hashtag form before bubbling up. |
| 316 | if getattr(e, "status_code", None) == 500 and ' ' in core_topic: |
| 317 | _log(f"IG search 500 on '{core_topic}', retrying with hashtag form") |
| 318 | try: |
| 319 | data = http.get( |
| 320 | f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search", |
| 321 | params={"query": _to_hashtag_form(core_topic)}, |
| 322 | headers=http.scrapecreators_headers(token), |
| 323 | timeout=30, |
| 324 | retries=2, |
| 325 | ) |
| 326 | except Exception as retry_e: |
| 327 | _log(f"IG search retry failed: {retry_e}") |
| 328 | return {"items": [], "error": f"{type(retry_e).__name__}: {retry_e}"} |
| 329 | else: |
| 330 | _log(f"ScrapeCreators error: {e}") |
| 331 | return {"items": [], "error": f"{type(e).__name__}: {e}"} |
| 332 | except Exception as e: |
| 333 | _log(f"ScrapeCreators error: {e}") |
| 334 | return {"items": [], "error": f"{type(e).__name__}: {e}"} |
| 335 | |
| 336 | # Items are in the 'reels' array (ScrapeCreators v2 response) |
| 337 | raw_items = data.get("reels") or data.get("items") or data.get("data") or [] |
| 338 | |
| 339 | # Limit to configured count |
| 340 | raw_items = raw_items[:config["results_per_page"]] |
| 341 | |
| 342 | # Parse items |
| 343 | items = _parse_items(raw_items, core_topic) |
| 344 | |
| 345 | # Hard date filter |
| 346 | in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date] |
| 347 | out_of_range = len(items) - len(in_range) |
| 348 | if in_range: |
| 349 | items = in_range |
| 350 | if out_of_range: |
| 351 | _log(f"Filtered {out_of_range} reels outside date range") |
| 352 | else: |
| 353 | _log(f"No reels within date range, keeping all {len(items)}") |
| 354 | |
| 355 | # Sort by views descending |
| 356 | items.sort(key=lambda x: x["engagement"]["views"], reverse=True) |
| 357 | |
| 358 | _log(f"Found {len(items)} Instagram reels") |
| 359 | return {"items": items} |
| 360 | |
| 361 | |
| 362 | def fetch_captions( |
| 363 | video_items: List[Dict[str, Any]], |
| 364 | token: str, |
| 365 | depth: str = "default", |
| 366 | timeout: Optional[float] = None, |
| 367 | config: Optional[Dict[str, Any]] = None, |
| 368 | ) -> Dict[str, str]: |
| 369 | """Fetch transcripts for top N Instagram reels via ScrapeCreators. |
| 370 | |
| 371 | Strategy: |
| 372 | 1. Use the 'text' field (caption) as baseline |
| 373 | 2. For top N, call /v2/instagram/media/transcript for spoken-word captions |
| 374 | |
| 375 | Args: |
| 376 | video_items: Items from search_instagram() |
| 377 | token: ScrapeCreators API key |
| 378 | depth: Depth level for caption limit |
| 379 | timeout: Optional per-request transcript timeout in seconds. When |
| 380 | None, resolves from LAST30DAYS_TRANSCRIPT_TIMEOUT (env or |
| 381 | config), defaulting to DEFAULT_TRANSCRIPT_TIMEOUT (30s). |
| 382 | config: Optional config dict (from env.get_config()) used as a |
| 383 | fallback source for LAST30DAYS_TRANSCRIPT_TIMEOUT when the |
| 384 | value is not exported in os.environ. |
| 385 | |
| 386 | Returns: |
| 387 | Dict mapping video_id -> caption text (truncated to 500 words) |
| 388 | """ |
| 389 | depth_cfg = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) |
| 390 | max_captions = depth_cfg["max_captions"] |
| 391 | transcript_timeout = _resolve_transcript_timeout(timeout, config) |
| 392 | |
| 393 | if not video_items or not token: |
| 394 | return {} |
| 395 | |
| 396 | top_items = video_items[:max_captions] |
| 397 | _log(f"Enriching captions for {len(top_items)} reels") |
| 398 | |
| 399 | captions = {} |
| 400 | |
| 401 | # First pass: use text field as caption (always available, free) |
| 402 | for item in top_items: |
| 403 | vid = item["video_id"] |
| 404 | text = item.get("text", "") |
| 405 | if text: |
| 406 | words = text.split() |
| 407 | if len(words) > CAPTION_MAX_WORDS: |
| 408 | text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...' |
| 409 | captions[vid] = text |
| 410 | |
| 411 | # Second pass: try to get spoken-word transcripts (1 credit each) |
| 412 | for item in top_items: |
| 413 | vid = item["video_id"] |
| 414 | url = item.get("url", "") |
| 415 | if not url: |
| 416 | continue |
| 417 | try: |
| 418 | # Isolate transcript fetch errors from the pipeline-level |
| 419 | # capture_failures() context so an individual reel's 400 doesn't |
| 420 | # poison the entire source outcome (#829). |
| 421 | with http.capture_failures() as _tf: |
| 422 | data = http.get( |
| 423 | f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript", |
| 424 | params={"url": url}, |
| 425 | headers=http.scrapecreators_headers(token), |
| 426 | timeout=transcript_timeout, |
| 427 | retries=1, |
| 428 | ) |
| 429 | transcripts = data.get("transcripts") or [] |
| 430 | if transcripts and isinstance(transcripts, list): |
| 431 | transcript_text = " ".join( |
| 432 | t.get("text", "") for t in transcripts |
| 433 | if isinstance(t, dict) and t.get("text") |
| 434 | ) |
| 435 | if transcript_text: |
| 436 | words = transcript_text.split() |
| 437 | if len(words) > CAPTION_MAX_WORDS: |
| 438 | transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...' |
| 439 | captions[vid] = transcript_text |
| 440 | except Exception as e: |
| 441 | _log(f"Transcript fetch failed for {vid}: {e}") |
| 442 | |
| 443 | got = sum(1 for v in captions.values() if v) |
| 444 | _log(f"Got captions for {got}/{len(top_items)} reels") |
| 445 | return captions |
| 446 | |
| 447 | |
| 448 | def search_and_enrich( |
| 449 | topic: str, |
| 450 | from_date: str, |
| 451 | to_date: str, |
| 452 | depth: str = "default", |
| 453 | token: str = None, |
| 454 | ig_creators: List[str] | None = None, |
| 455 | ) -> Dict[str, Any]: |
| 456 | """Full Instagram search: find reels, then fetch captions for top results. |
| 457 | |
| 458 | Uses expand_instagram_queries() to generate multiple search queries, |
| 459 | runs ScrapeCreators for each, and merges/deduplicates results by video ID. |
| 460 | |
| 461 | Args: |
| 462 | topic: Search topic (raw topic, not planner's narrowed query) |
| 463 | from_date: Start date (YYYY-MM-DD) |
| 464 | to_date: End date (YYYY-MM-DD) |
| 465 | depth: 'quick', 'default', or 'deep' |
| 466 | token: ScrapeCreators API key |
| 467 | ig_creators: Optional list of Instagram creator handles to fetch reels from |
| 468 | |
| 469 | Returns: |
| 470 | Dict with 'items' list. Each item has a 'caption_snippet' field. |
| 471 | """ |
| 472 | core_topic = _extract_core_subject(topic) |
| 473 | seen_ids: Set[str] = set() |
| 474 | items: List[Dict[str, Any]] = [] |
| 475 | last_error = None |
| 476 | |
| 477 | # Step 0: Creator reels (high-signal, runs first) |
| 478 | if ig_creators and token: |
| 479 | for creator in ig_creators: |
| 480 | raw_items = _user_reels(creator, token) |
| 481 | parsed = _parse_items(raw_items, core_topic) |
| 482 | for item in parsed: |
| 483 | vid = item.get("video_id", "") |
| 484 | if vid and vid not in seen_ids: |
| 485 | seen_ids.add(vid) |
| 486 | items.append(item) |
| 487 | |
| 488 | # Step 1: Multi-query keyword search — run ScrapeCreators for each expanded query |
| 489 | queries = expand_instagram_queries(topic, depth) |
| 490 | for q in queries: |
| 491 | search_result = search_instagram(q, from_date, to_date, depth, token) |
| 492 | if search_result.get("error"): |
| 493 | last_error = search_result["error"] |
| 494 | for item in search_result.get("items", []): |
| 495 | vid = item.get("video_id", "") |
| 496 | if vid and vid not in seen_ids: |
| 497 | seen_ids.add(vid) |
| 498 | items.append(item) |
| 499 | |
| 500 | # Sort merged results by views descending |
| 501 | items.sort(key=lambda x: x.get("engagement", {}).get("views") or 0, reverse=True) |
| 502 | |
| 503 | if not items: |
| 504 | return {"items": [], "error": last_error} |
| 505 | |
| 506 | # Step 2: Fetch captions for top N |
| 507 | captions = fetch_captions(items, token, depth) |
| 508 | |
| 509 | # Step 3: Attach captions to items |
| 510 | for item in items: |
| 511 | vid = item["video_id"] |
| 512 | caption = captions.get(vid) |
| 513 | if caption: |
| 514 | item["caption_snippet"] = caption |
| 515 | |
| 516 | return {"items": items, "error": last_error} |
| 517 | |
| 518 | |
| 519 | def parse_instagram_response(response: Dict[str, Any]) -> List[Dict[str, Any]]: |
| 520 | """Parse Instagram search response to normalized format. |
| 521 | |
| 522 | Returns: |
| 523 | List of item dicts ready for normalization. |
| 524 | """ |
| 525 | return response.get("items", []) |
| 526 | |
| 527 | |
| 528 | # --------------------------------------------------------------------------- |
| 529 | # Comments (ScrapeCreators, opt-in via INCLUDE_SOURCES=instagram_comments) |
| 530 | # --------------------------------------------------------------------------- |
| 531 | |
| 532 | |
| 533 | def _ig_total_engagement(item: Dict[str, Any]) -> int: |
| 534 | """Sum an Instagram item's engagement for picking which posts to enrich.""" |
| 535 | eng = item.get("engagement", {}) or {} |
| 536 | return (eng.get("views") or 0) + (eng.get("likes") or 0) + (eng.get("comments") or 0) |
| 537 | |
| 538 | |
| 539 | def enrich_with_comments( |
| 540 | items: List[Dict[str, Any]], |
| 541 | token: str, |
| 542 | max_posts: int = 3, |
| 543 | max_comments: int = 5, |
| 544 | ) -> List[Dict[str, Any]]: |
| 545 | """Enrich top Instagram posts with comment data from ScrapeCreators. |
| 546 | |
| 547 | Mirrors ``tiktok.enrich_with_comments`` / ``youtube_yt.enrich_with_comments``: |
| 548 | for the top N posts by engagement, fetch comments and attach them as a |
| 549 | ``top_comments`` field (highest-liked first). Failures never crash the run. |
| 550 | """ |
| 551 | if not items or not token or max_posts <= 0: |
| 552 | return items |
| 553 | |
| 554 | ranked = sorted(items, key=_ig_total_engagement, reverse=True) |
| 555 | top_items = ranked[:max_posts] |
| 556 | _log(f"Enriching comments for {len(top_items)} Instagram posts") |
| 557 | |
| 558 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 559 | |
| 560 | def _enrich_one(item: dict) -> bool: |
| 561 | post_url = item.get("url", "") |
| 562 | if not post_url: |
| 563 | return False |
| 564 | try: |
| 565 | comments = _fetch_post_comments(post_url, token, max_comments) |
| 566 | if comments: |
| 567 | item["top_comments"] = comments |
| 568 | return True |
| 569 | except Exception as exc: |
| 570 | _log(f"Comment enrichment failed for {post_url}: {exc}") |
| 571 | return False |
| 572 | |
| 573 | enriched_count = 0 |
| 574 | with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor: |
| 575 | futures = {http.submit_with_context(executor, _enrich_one, item): item for item in top_items} |
| 576 | for future in as_completed(futures): |
| 577 | if future.result(): |
| 578 | enriched_count += 1 |
| 579 | |
| 580 | _log(f"Enriched {enriched_count}/{len(top_items)} posts with comments") |
| 581 | return items |
| 582 | |
| 583 | |
| 584 | def _fetch_post_comments( |
| 585 | post_url: str, |
| 586 | token: str, |
| 587 | max_comments: int = 5, |
| 588 | ) -> List[Dict[str, Any]]: |
| 589 | """Fetch comments for a single Instagram post/reel via ScrapeCreators. |
| 590 | |
| 591 | SC endpoint: GET /v2/instagram/post/comments?url=<post_or_reel_url> |
| 592 | Response shape: { comments: [{text, comment_like_count, child_comment_count, |
| 593 | created_at, user{username, ...}}], cursor } |
| 594 | |
| 595 | Returns: |
| 596 | List of comment dicts with author, text, comment_like_count (likes), date, |
| 597 | highest-liked first. Empty list on any error — never crashes the pipeline. |
| 598 | """ |
| 599 | try: |
| 600 | data = http.get( |
| 601 | f"{SCRAPECREATORS_BASE}/v2/instagram/post/comments", |
| 602 | params={"url": post_url}, |
| 603 | headers=http.scrapecreators_headers(token), |
| 604 | timeout=30, |
| 605 | retries=2, |
| 606 | ) |
| 607 | except Exception as exc: |
| 608 | _log(f"Comment fetch error for {post_url}: {exc}") |
| 609 | return [] |
| 610 | |
| 611 | raw_comments = data.get("comments") or data.get("data") or [] |
| 612 | # Sort by like count desc so normalize sees the highest-signal first. |
| 613 | raw_comments = sorted( |
| 614 | raw_comments, |
| 615 | key=lambda c: c.get("comment_like_count", 0) or 0, |
| 616 | reverse=True, |
| 617 | ) |
| 618 | out: List[Dict[str, Any]] = [] |
| 619 | for c in raw_comments[:max_comments]: |
| 620 | if not isinstance(c, dict): |
| 621 | continue |
| 622 | text = c.get("text") or "" |
| 623 | if not text: |
| 624 | continue |
| 625 | user = c.get("user") if isinstance(c.get("user"), dict) else {} |
| 626 | author = user.get("username") or "" |
| 627 | created_at = c.get("created_at") or "" |
| 628 | # created_at is ISO 8601 (e.g. "2026-07-04T14:27:58.000Z"); take the date. |
| 629 | date_str = created_at[:10] if isinstance(created_at, str) and len(created_at) >= 10 else "" |
| 630 | out.append({ |
| 631 | "author": author, |
| 632 | "text": text[:400], |
| 633 | "comment_like_count": c.get("comment_like_count", 0) or 0, |
| 634 | "date": date_str, |
| 635 | }) |
| 636 | return out |
| 637 |