luckiest-co 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -7,12 +7,16 @@ Requires SCRAPECREATORS_API_KEY in config (same key as TikTok + Instagram).
7
7
  API docs: https://scrapecreators.com/docs
8
8
  """
9
9
 
10
+ import copy
10
11
  import math
11
12
  import re
12
13
  import sys
14
+ import threading
13
15
  import time
16
+ from contextlib import ExitStack
14
17
  from collections import Counter
15
18
  from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
19
+ from datetime import date, datetime, timezone
16
20
  from typing import Any, Dict, List, Optional, Set
17
21
 
18
22
  def _first_of(*values, default=None):
@@ -22,7 +26,7 @@ def _first_of(*values, default=None):
22
26
  return v
23
27
  return default
24
28
 
25
- from . import dates, http, log
29
+ from . import dates, health, http, log
26
30
 
27
31
  SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
28
32
 
@@ -39,19 +43,16 @@ DEPTH_CONFIG = {
39
43
  "global_searches": 1,
40
44
  "subreddit_searches": 2,
41
45
  "comment_enrichments": 3,
42
- "timeframe": "week",
43
46
  },
44
47
  "default": {
45
48
  "global_searches": 2,
46
49
  "subreddit_searches": 3,
47
50
  "comment_enrichments": 5,
48
- "timeframe": "month",
49
51
  },
50
52
  "deep": {
51
53
  "global_searches": 3,
52
54
  "subreddit_searches": 5,
53
55
  "comment_enrichments": 8,
54
- "timeframe": "month",
55
56
  },
56
57
  }
57
58
 
@@ -78,6 +79,16 @@ def _log(msg: str):
78
79
  log.source_log("Reddit", msg, tty_only=False)
79
80
 
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
+
81
92
  def _extract_core_subject(topic: str) -> str:
82
93
  """Extract core subject from verbose query.
83
94
 
@@ -253,7 +264,7 @@ def _relevance_rank_key(item: Dict[str, Any]) -> float:
253
264
  ~0) above an on-topic one (relevance >= RELEVANCE_FLOOR).
254
265
  """
255
266
  rel = item.get("relevance") or 0.0
256
- eng_bonus = min(0.25, math.log10(_total_engagement(item) + 1) / 20.0)
267
+ eng_bonus = min(0.25, math.log10(max(0, _total_engagement(item)) + 1) / 20.0)
257
268
  return rel + eng_bonus
258
269
 
259
270
 
@@ -394,6 +405,9 @@ def _subreddit_search(
394
405
  def fetch_post_comments(
395
406
  url: str,
396
407
  token: str,
408
+ *,
409
+ deadline_monotonic: float | None = None,
410
+ cancel: threading.Event | None = None,
397
411
  ) -> List[Dict[str, Any]]:
398
412
  """Fetch comments for a Reddit post via ScrapeCreators.
399
413
 
@@ -411,6 +425,9 @@ def fetch_post_comments(
411
425
  params={"url": url},
412
426
  timeout=30,
413
427
  retries=2,
428
+ deadline_monotonic=deadline_monotonic,
429
+ cancel=cancel,
430
+ owned_get=deadline_monotonic is not None,
414
431
  )
415
432
  return data.get("comments", data.get("data", []))
416
433
  except http.HTTPError as e:
@@ -443,6 +460,51 @@ def _dedupe_posts(posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
443
460
  return unique
444
461
 
445
462
 
463
+ def _days_to_reddit_bucket(days: float) -> str:
464
+ """Map a day count onto the smallest Reddit rolling bucket that covers it.
465
+
466
+ Adds one day of slack so calendar windows that cross a day boundary still
467
+ fit inside Reddit's rolling ``t=`` buckets (a yesterday→today request needs
468
+ ``week``, not ``day``).
469
+ """
470
+ covered = days + 1
471
+ if covered <= 1:
472
+ return "day"
473
+ if covered <= 7:
474
+ return "week"
475
+ if covered <= 31:
476
+ return "month"
477
+ if covered <= 366:
478
+ return "year"
479
+ return "all"
480
+
481
+
482
+ def _window_to_time_filter(from_date: str, to_date: str) -> str:
483
+ """Map a requested YYYY-MM-DD window onto Reddit's coarse `t` param.
484
+
485
+ Reddit's ``t=day|week|month`` buckets are rolling windows ending *now*, not
486
+ calendar spans and not anchored to ``to_date``. Coverage therefore needs:
487
+
488
+ 1. Span — a yesterday→today request needs more than rolling ``t=day``.
489
+ 2. Historical reach — a one-day request ending two weeks ago still needs a
490
+ bucket that reaches ``from_date``; span-alone would pick ``week`` and
491
+ the API would omit the entire requested range.
492
+
493
+ Take the wider of the two, independently of retrieval depth.
494
+ Phase 5 still trims to ``from_date``/``to_date``. Falls back to ``month``
495
+ if the dates don't parse.
496
+ """
497
+ try:
498
+ start = date.fromisoformat(from_date)
499
+ end = date.fromisoformat(to_date)
500
+ except (ValueError, TypeError):
501
+ return "month"
502
+ span_days = max(0, (end - start).days)
503
+ # Age of from_date relative to "today" — Reddit always anchors to now.
504
+ age_days = max(0, (datetime.now(timezone.utc).date() - start).days)
505
+ return _days_to_reddit_bucket(max(span_days, age_days))
506
+
507
+
446
508
  def search_reddit(
447
509
  topic: str,
448
510
  from_date: str,
@@ -470,7 +532,7 @@ def search_reddit(
470
532
  return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
471
533
 
472
534
  config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
473
- timeframe = config["timeframe"]
535
+ timeframe = _window_to_time_filter(from_date, to_date)
474
536
  intent = infer_query_intent(topic)
475
537
 
476
538
  # === Phase 1: Query Expansion ===
@@ -487,7 +549,9 @@ def search_reddit(
487
549
  with ThreadPoolExecutor(max_workers=min(5, len(subreddits))) as executor:
488
550
  futures = {}
489
551
  for sub in subreddits:
490
- futures[executor.submit(_subreddit_search, sub, core, token, "relevance", timeframe)] = sub
552
+ futures[http.submit_with_context(
553
+ executor, _subreddit_search, sub, core, token, "relevance", timeframe,
554
+ )] = sub
491
555
  for future in as_completed(futures):
492
556
  sub = futures[future]
493
557
  sub_posts = future.result()
@@ -506,7 +570,9 @@ def search_reddit(
506
570
  # from relevant communities instead of keyword-matched noise.
507
571
  sort = "top" if intent in ("product", "comparison") else ("relevance" if i == 0 else "top")
508
572
  _log(f"Global search {i+1}/{max_global}: '{query}' (sort={sort})")
509
- futures[executor.submit(_global_search, query, token, sort, timeframe)] = query
573
+ futures[http.submit_with_context(
574
+ executor, _global_search, query, token, sort, timeframe,
575
+ )] = query
510
576
  for future in as_completed(futures):
511
577
  query = futures[future]
512
578
  posts = future.result()
@@ -529,7 +595,9 @@ def search_reddit(
529
595
  futures = {}
530
596
  for sub in discovered_subs[:subreddit_limit]:
531
597
  _log(f"Subreddit search: r/{sub} for '{core}'")
532
- futures[executor.submit(_subreddit_search, sub, core, token, "relevance", timeframe)] = sub
598
+ futures[http.submit_with_context(
599
+ executor, _subreddit_search, sub, core, token, "relevance", timeframe,
600
+ )] = sub
533
601
  for future in as_completed(futures):
534
602
  sub = futures[future]
535
603
  sub_posts = future.result()
@@ -608,7 +676,7 @@ def enrich_with_comments(
608
676
  config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
609
677
  max_comments = config["comment_enrichments"]
610
678
 
611
- if not items or not token or max_comments <= 0:
679
+ if not items or not token or max_comments <= 0 or budget_seconds <= 0:
612
680
  return items
613
681
 
614
682
  # Select the top threads by total engagement (upvotes + comment count),
@@ -619,10 +687,24 @@ def enrich_with_comments(
619
687
  _log(f"Enriching comments for {len(top_items)} posts (by total engagement)")
620
688
 
621
689
  start = time.monotonic()
690
+ deadline = start + budget_seconds
691
+ cancel = threading.Event()
692
+
693
+ def fetch(item):
694
+ if cancel.is_set() or time.monotonic() >= deadline:
695
+ return None
696
+ comments = fetch_post_comments(
697
+ item.get("url", ""), token,
698
+ deadline_monotonic=deadline, cancel=cancel,
699
+ )
700
+ return comments if time.monotonic() < deadline else None
622
701
 
623
- with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
702
+ with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor, ExitStack() as cleanup:
703
+ cleanup.callback(cancel.set)
624
704
  futures = {
625
- executor.submit(fetch_post_comments, item.get("url", ""), token): item
705
+ http.submit_with_context(
706
+ executor, fetch, item,
707
+ ): item
626
708
  for item in top_items
627
709
  if item.get("url")
628
710
  }
@@ -630,6 +712,8 @@ def enrich_with_comments(
630
712
  # Wait with budget instead of unbounded as_completed
631
713
  remaining = max(0, budget_seconds - (time.monotonic() - start))
632
714
  done, not_done = futures_wait(futures, timeout=remaining)
715
+ if not_done:
716
+ cancel.set()
633
717
 
634
718
  enriched_count = 0
635
719
  for future in done:
@@ -723,6 +807,87 @@ def search_and_enrich(
723
807
  return result
724
808
 
725
809
 
810
+ # Run-scoped memo for the paid ScrapeCreators Reddit call (R9). All subquery
811
+ # streams share the raw topic, and the thin-source retry repeats it, so without
812
+ # this one run could pay for the same query five times. Concurrent callers for
813
+ # one key wait on the first call; results AND failures are kept until the
814
+ # per-command reset, so a failed backfill is never retried within the run.
815
+ _SC_MEMO: Dict[tuple, tuple] = {}
816
+ _SC_INFLIGHT: Dict[tuple, threading.Event] = {}
817
+ _SC_MEMO_LOCK = threading.Lock()
818
+
819
+
820
+ def reset_scrapecreators_memo() -> None:
821
+ """Forget memoized ScrapeCreators Reddit calls. Called once per command, and by tests."""
822
+ with _SC_MEMO_LOCK:
823
+ _SC_MEMO.clear()
824
+ _SC_INFLIGHT.clear()
825
+
826
+
827
+ def _sc_memo_outcome(entry: tuple, replay_failures: bool = True) -> Dict[str, Any]:
828
+ ok, value, failures = entry
829
+ if replay_failures:
830
+ # search_and_enrich swallows ScrapeCreators HTTP errors into the
831
+ # caller's failure sink; a later caller must see them too, or an
832
+ # empty cached result reads as a clean no-results.
833
+ for failure in failures:
834
+ http._record_failure(failure)
835
+ if not ok:
836
+ raise value
837
+ # Each caller gets its own copy so one stream cannot mutate another's items.
838
+ return copy.deepcopy(value)
839
+
840
+
841
+ def search_and_enrich_memo(
842
+ topic: str,
843
+ from_date: str,
844
+ to_date: str,
845
+ depth: str = "default",
846
+ token: str = None,
847
+ subreddits: List[str] | None = None,
848
+ ) -> Dict[str, Any]:
849
+ """:func:`search_and_enrich`, at most once per key per command.
850
+
851
+ Keyed by query, date window, depth, and sorted subreddits.
852
+ """
853
+ key = (topic, from_date, to_date, depth, tuple(sorted(subreddits or ())))
854
+ while True:
855
+ with _SC_MEMO_LOCK:
856
+ entry = _SC_MEMO.get(key)
857
+ if entry is None:
858
+ gate = _SC_INFLIGHT.get(key)
859
+ owner = gate is None
860
+ if owner:
861
+ gate = threading.Event()
862
+ _SC_INFLIGHT[key] = gate
863
+ if entry is not None:
864
+ return _sc_memo_outcome(entry)
865
+ if owner:
866
+ break
867
+ gate.wait()
868
+ # Loop: read the owner's cached outcome, or re-elect if it was
869
+ # interrupted before caching one (e.g. KeyboardInterrupt).
870
+ try:
871
+ with http.tee_failures() as recorded:
872
+ try:
873
+ result = search_and_enrich(
874
+ topic, from_date, to_date, depth=depth, token=token,
875
+ subreddits=subreddits,
876
+ )
877
+ outcome = (True, result)
878
+ except Exception as exc:
879
+ outcome = (False, exc)
880
+ entry = (*outcome, list(recorded))
881
+ with _SC_MEMO_LOCK:
882
+ _SC_MEMO[key] = entry
883
+ finally:
884
+ with _SC_MEMO_LOCK:
885
+ _SC_INFLIGHT.pop(key, None)
886
+ gate.set()
887
+ # The owner's failures already reached its own sink through tee_failures.
888
+ return _sc_memo_outcome(entry, replay_failures=False)
889
+
890
+
726
891
  def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
727
892
  """Parse ScrapeCreators response to item list.
728
893
 
@@ -1,9 +1,9 @@
1
1
  """Arctic-shift score resolver — post upvote counts by id, keyless and free.
2
2
 
3
- ``search.json`` and ``/comments/{id}.json`` are 403 keyless, and ``search.rss``
4
- (used for discovery) carries titles but NO score; the shreddit listing partials
5
- score only posts that appear in a pulled listing. For a thread found only via
6
- global RSS search in a broad sub, the free score comes from arctic-shift
3
+ ``search.json`` and ``/comments/{id}.json`` are 403 keyless. Site search and the
4
+ shreddit listing partials usually carry a score, but some posts still arrive
5
+ unscored. For a thread that reaches the merge with score 0, the free score
6
+ comes from arctic-shift
7
7
  (https://arctic-shift.photon-reddit.com), a public Reddit archive whose
8
8
  ``/api/posts/ids`` returns the post object (score, num_comments, title) for a
9
9
  batch of base36 post ids. Scores are point-in-time snapshots — slightly stale vs
@@ -16,16 +16,32 @@ count rather than failing the Reddit source.
16
16
 
17
17
  import sys
18
18
  import time
19
- from typing import Dict, List
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Dict, List, Optional
20
21
 
21
22
  from . import http
22
23
 
23
24
  API = "https://arctic-shift.photon-reddit.com/api/posts/ids"
25
+ SEARCH_API = "https://arctic-shift.photon-reddit.com/api/posts/search"
24
26
  BATCH = 50 # ids per request
25
27
  TIMEOUT = 15
26
28
  MAX_BATCHES = 3 # cap total requests per run (bounds latency + rate-limit risk)
27
29
  PACE_SECONDS = 0.4 # gap between batches; arctic-shift answers 422 "slow down"
28
30
  CACHE_MAX = 4096 # hard size bound so the in-run memo can never grow unbounded
31
+ # Listing-lane knobs. Base limits mirror reddit_listing's DEPTH_LIMITS so callers
32
+ # get the same per-depth volume. The supplement multiplier is applied when the
33
+ # caller requested multiple sorts (top/hot/new) — arctic-shift has no sort lanes,
34
+ # so we fetch more posts to increase the chance of covering what the failed
35
+ # shreddit lanes would have returned.
36
+ #
37
+ # KNOWN LIMITATION: Arctic-shift is recency-only (sort=desc). It has no top/hot/
38
+ # new/rising lanes — failed shreddit sort lanes are supplemented with recent
39
+ # posts, not lane-specific results. This is a fundamental backend constraint.
40
+ _LISTING_DEPTH_LIMITS = {"quick": 10, "default": 25, "deep": 50}
41
+ _LISTING_SUPPLEMENT_MULTIPLIER = 2 # fetch 2x posts when supplementing multi-sort requests
42
+ # Total deadline for listing fetches to prevent unbounded stalls when many
43
+ # subreddits are requested and arctic is slow/unreachable.
44
+ _LISTING_DEADLINE_SECONDS = 45 # ~3 subs at 15s timeout each
29
45
  # In-run memo: base36 id -> {score, num_comments}. Module-level so repeated
30
46
  # fetch_scores calls within one `/last30days` run (e.g. across subqueries) reuse
31
47
  # results, but capped at CACHE_MAX entries (never reached in a normal CLI run).
@@ -90,3 +106,128 @@ def fetch_scores(post_ids: List[str]) -> Dict[str, Dict[str, int]]:
90
106
  _cache[rid] = entry
91
107
  out[rid] = entry
92
108
  return out
109
+
110
+
111
+ def _epoch_to_date(value: Any) -> Optional[str]:
112
+ """Epoch seconds -> YYYY-MM-DD (UTC), or None on garbage."""
113
+ try:
114
+ return datetime.fromtimestamp(int(value), tz=timezone.utc).date().isoformat()
115
+ except (TypeError, ValueError, OSError):
116
+ return None
117
+
118
+
119
+ def _normalize_listing_row(row: Dict[str, Any], query: str = "") -> Dict[str, Any]:
120
+ """Normalize an arctic-shift post row to reddit_listing.parse_cards shape.
121
+
122
+ Mirrors the shreddit card schema (title/url/score/num_comments/subreddit/
123
+ created_utc/author/selftext/date/engagement/relevance/metadata.post_id) so
124
+ reddit_keyless can consume either backend interchangeably.
125
+ """
126
+ from .relevance import token_overlap_relevance
127
+
128
+ pid = str(row.get("id") or "").removeprefix("t3_")
129
+ permalink = row.get("permalink") or ""
130
+ title = row.get("title") or ""
131
+ try:
132
+ score = int(row.get("score") or 0)
133
+ except (TypeError, ValueError):
134
+ score = 0
135
+ try:
136
+ num_comments = int(row.get("num_comments") or 0)
137
+ except (TypeError, ValueError):
138
+ num_comments = 0
139
+ author = row.get("author") or "[deleted]"
140
+ if author in ("[deleted]", "[removed]"):
141
+ author = "[deleted]"
142
+ url = f"https://www.reddit.com{permalink}" if permalink.startswith("/") else (permalink or "")
143
+ return {
144
+ "id": "",
145
+ "title": title,
146
+ "url": url,
147
+ "score": score,
148
+ "num_comments": num_comments,
149
+ "subreddit": row.get("subreddit") or "",
150
+ "created_utc": row.get("created_utc"),
151
+ "author": author,
152
+ "selftext": row.get("selftext") or "",
153
+ "date": _epoch_to_date(row.get("created_utc")),
154
+ "engagement": {"score": score, "num_comments": num_comments, "upvote_ratio": None},
155
+ "relevance": round(token_overlap_relevance(query, title), 3) if query else 0.0,
156
+ "why_relevant": "Reddit listing (arctic-shift)",
157
+ "metadata": {"post_id": pid},
158
+ }
159
+
160
+
161
+ def fetch_listings(
162
+ subreddits: List[str],
163
+ depth: str = "default",
164
+ query: str = "",
165
+ sorts: Optional[List[str]] = None,
166
+ timeframe: str = "month",
167
+ limit: Optional[int] = None,
168
+ ) -> List[Dict[str, Any]]:
169
+ """Scored subreddit listings from the arctic-shift archive, keyless.
170
+
171
+ Drop-in fallback/supplement for ``reddit_listing.fetch_listings`` (shreddit
172
+ partials), which datacenter IPs get HTTP 403 on. Arctic-shift serves recent
173
+ posts with real score/num_comments from any IP.
174
+
175
+ Arctic-shift has no top/hot/new lanes, only recency. When ``sorts`` contains
176
+ multiple entries (e.g., dedicated lanes requesting top+hot+new), we fetch
177
+ more posts per subreddit to partially compensate for the missing lane
178
+ coverage — the caller's engagement ranking does the final sorting.
179
+
180
+ Best-effort, never raises: returns ``[]`` on any failure.
181
+ """
182
+ if not subreddits:
183
+ return []
184
+ base = limit or _LISTING_DEPTH_LIMITS.get(depth, _LISTING_DEPTH_LIMITS["default"])
185
+ # When multiple sorts were requested, fetch more posts to compensate for
186
+ # arctic-shift's lack of sort lanes.
187
+ n = base * _LISTING_SUPPLEMENT_MULTIPLIER if sorts and len(sorts) > 1 else base
188
+ out: List[Dict[str, Any]] = []
189
+ # Process all requested subreddits with pacing and a total deadline to
190
+ # prevent unbounded stalls when arctic is slow or unreachable.
191
+ deadline = time.time() + _LISTING_DEADLINE_SECONDS
192
+ fetched_count = 0
193
+ for sub in subreddits:
194
+ if time.time() >= deadline:
195
+ _log(f"listing deadline reached after {fetched_count} subs; skipping remaining")
196
+ break
197
+ sub = sub.removeprefix("r/").strip()
198
+ if not sub or sub.lower() == "all":
199
+ continue
200
+ if fetched_count:
201
+ time.sleep(PACE_SECONDS)
202
+ fetched_count += 1
203
+ try:
204
+ # Use retries=1 (single attempt) so retries don't exceed our deadline.
205
+ # The deadline handles overall timing; per-request retries would
206
+ # multiply the delay unpredictably.
207
+ data = http.get(
208
+ f"{SEARCH_API}?subreddit={sub}&limit={n}&sort=desc",
209
+ headers={"User-Agent": http.BROWSER_USER_AGENT},
210
+ timeout=TIMEOUT,
211
+ retries=1,
212
+ )
213
+ except Exception as e: # network error / non-200 — degrade, never raise
214
+ _log(f"listing search failed r/{sub}: {e}")
215
+ continue
216
+ rows = (data or {}).get("data")
217
+ if not isinstance(rows, list):
218
+ _log(f"unexpected listing response for r/{sub}: {str(data)[:80]}")
219
+ continue
220
+ for row in rows:
221
+ if not isinstance(row, dict):
222
+ continue
223
+ post = _normalize_listing_row(row, query)
224
+ if post["url"]:
225
+ out.append(post)
226
+
227
+ seen: set = set()
228
+ unique: List[Dict[str, Any]] = []
229
+ for p in out:
230
+ if p["url"] not in seen:
231
+ seen.add(p["url"])
232
+ unique.append(p)
233
+ return unique