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
@@ -6,6 +6,7 @@ transcript extraction. No API keys needed — just have yt-dlp installed.
6
6
  Inspired by Peter Steinberger's toolchain approach (yt-dlp + summarize CLI).
7
7
  """
8
8
 
9
+ import copy
9
10
  import json
10
11
  import math
11
12
  import os
@@ -14,6 +15,7 @@ import shlex
14
15
  import shutil
15
16
  import sys
16
17
  import tempfile
18
+ import threading
17
19
  import time
18
20
  import urllib.error
19
21
  import urllib.request
@@ -56,7 +58,7 @@ def reset_transcript_fetch_stats() -> None:
56
58
  # Max words to keep from each transcript
57
59
  TRANSCRIPT_MAX_WORDS = 5000
58
60
 
59
- from . import dates, http, log, subproc
61
+ from . import dates, health, http, log, subproc
60
62
  from .query import infer_query_intent
61
63
 
62
64
  from .relevance import token_overlap_relevance as _compute_relevance
@@ -71,7 +73,24 @@ _TRANSCRIPT_MAX_RETRIES = 2
71
73
  _TRANSCRIPT_BACKOFF_BASE = 2.0 # seconds; multiplied by (attempt + 1)
72
74
  _TRANSCRIPT_TIMEOUT = 30 # seconds per yt-dlp attempt (keyless: no fallback to fail over to)
73
75
  _TRANSCRIPT_FAST_TIMEOUT = 12 # seconds per attempt when a ScrapeCreators fallback exists
76
+ _SEARCH_TIMEOUT = 120 # seconds per ytsearch metadata extraction
77
+ # Comparison-mode fan-out (and nested transcript/comment pools) can stampede the
78
+ # same throttled YouTube IP. Cap concurrent yt-dlp processes process-wide.
79
+ _YTDLP_MAX_CONCURRENT = 2
80
+ _ytdlp_slots = threading.Semaphore(_YTDLP_MAX_CONCURRENT)
81
+ # In-run search cache: comparison mode re-issues identical ytsearch queries from
82
+ # every entity sub-run; cache hits avoid the redundant expensive --dump-json work.
83
+ # Inflight coalescing prevents N concurrent identical searches from all missing
84
+ # the cache and stampeding YouTube together.
85
+ _search_cache: Dict[Tuple[str, int, str], Dict[str, Any]] = {}
86
+ _search_inflight: Dict[Tuple[str, int, str], tuple[threading.Event, list]] = {}
87
+ _search_cache_lock = threading.Lock()
88
+ # Comments are enrichment, not core evidence: keep the budget tight so a slow
89
+ # comment API can never dominate a run's wall clock (bounded to 3 videos).
90
+ _COMMENT_TIMEOUT = 20
74
91
  _SC_LOW_CREDIT_THRESHOLD = 50 # warn once ScrapeCreators credits drop below this
92
+ _SC_TRANSCRIPT_MAX_LANGUAGES = 3
93
+ _SC_TRANSCRIPT_TIMEOUT = 30
75
94
  # Transient = worth retrying (and definitely not "no captions").
76
95
  _TRANSIENT_RE = re.compile(
77
96
  r"429|too many requests|sign in to confirm|not a bot|rate.?limit"
@@ -143,6 +162,128 @@ def _log(msg: str):
143
162
  log.source_log("YouTube", msg, tty_only=False)
144
163
 
145
164
 
165
+ def reset_search_cache() -> None:
166
+ """Clear the in-run ytsearch cache.
167
+
168
+ Call at the start of each top-level research run so a long-lived process
169
+ (agent host, REPL, test suite) does not reuse results across runs. Within
170
+ one comparison fan-out the cache stays hot so identical queries coalesce.
171
+ """
172
+ with _search_cache_lock:
173
+ _search_cache.clear()
174
+ _search_inflight.clear()
175
+
176
+
177
+ def _env_positive_float(name: str, default: float) -> float:
178
+ """Read a positive finite float from the environment, else ``default``."""
179
+ raw = os.environ.get(name, "").strip()
180
+ try:
181
+ value = float(raw) if raw else float(default)
182
+ except ValueError:
183
+ return float(default)
184
+ if not math.isfinite(value) or value <= 0:
185
+ return float(default)
186
+ return value
187
+
188
+
189
+ def _search_timeout() -> float:
190
+ """Return the ytsearch timeout, preserving the 120s default."""
191
+ return _env_positive_float("LAST30DAYS_YT_SEARCH_TIMEOUT", float(_SEARCH_TIMEOUT))
192
+
193
+
194
+ def _run_ytdlp(cmd: List[str], *, timeout: float) -> subproc.SubprocResult:
195
+ """Run a yt-dlp (or SSH-wrapped) command under the process-wide concurrency gate."""
196
+ with _ytdlp_slots:
197
+ return subproc.run_with_timeout(cmd, timeout=timeout)
198
+
199
+
200
+ def _claim_search_slot(
201
+ cache_key: Tuple[str, int, str],
202
+ ) -> tuple[Optional[Dict[str, Any]], Optional[threading.Event], Optional[list], bool]:
203
+ """Return ``(cached, event, slot, is_leader)`` for search coalesce.
204
+
205
+ - Cache hit: ``(payload, None, None, False)`` — caller returns ``payload``.
206
+ - Waiter: ``(None, event, slot, False)`` — caller awaits ``slot`` via ``event``.
207
+ - Leader: ``(None, event, slot, True)`` — caller runs yt-dlp and finishes the slot.
208
+ """
209
+ with _search_cache_lock:
210
+ cached = _search_cache.get(cache_key)
211
+ if cached is not None:
212
+ return copy.deepcopy(cached), None, None, False
213
+ existing = _search_inflight.get(cache_key)
214
+ if existing is not None:
215
+ return None, existing[0], existing[1], False
216
+ event = threading.Event()
217
+ slot: list = [None]
218
+ _search_inflight[cache_key] = (event, slot)
219
+ return None, event, slot, True
220
+
221
+
222
+ def _finish_search_slot(
223
+ cache_key: Tuple[str, int, str],
224
+ payload: Dict[str, Any],
225
+ *,
226
+ event: threading.Event,
227
+ slot: list,
228
+ ) -> Dict[str, Any]:
229
+ """Publish a search result to waiters; cache only clean (non-error) payloads.
230
+
231
+ Ownership is by slot identity: after ``reset_search_cache()`` clears the
232
+ registry, a stale leader must still wake its own waiters but must not pop
233
+ or overwrite a newer run's registration for the same key.
234
+ """
235
+ shared = copy.deepcopy(payload)
236
+ with _search_cache_lock:
237
+ if slot[0] is not None:
238
+ # Idempotent re-finish of this slot (e.g. finally after return).
239
+ event.set()
240
+ return payload
241
+ slot[0] = shared
242
+ current = _search_inflight.get(cache_key)
243
+ if current is not None and current[1] is slot:
244
+ if not payload.get("error"):
245
+ _search_cache[cache_key] = shared
246
+ _search_inflight.pop(cache_key, None)
247
+ # else: stale leader after a reset — wake local waiters only.
248
+ event.set()
249
+ return payload
250
+
251
+
252
+ def _await_search_slot(
253
+ event: threading.Event,
254
+ slot: list,
255
+ ) -> Dict[str, Any]:
256
+ """Wait for a leader search to publish.
257
+
258
+ Waiters block until the leader finishes (success or failure). The leader
259
+ path always publishes via ``_finish_search_slot``, including on unexpected
260
+ exceptions, so a timed wait would only invent a false timeout while the
261
+ leader was still queued behind other yt-dlp work.
262
+ """
263
+ event.wait()
264
+ shared = slot[0]
265
+ if isinstance(shared, dict):
266
+ return copy.deepcopy(shared)
267
+ return {"items": [], "error": "YouTube search failed"}
268
+
269
+
270
+ def classify_run_failure(detail: str) -> str:
271
+ """Map yt-dlp's text-only throttling and bot-gate errors."""
272
+ text = detail.lower()
273
+ if any(marker in text for marker in ("yt-dlp not installed", "yt-dlp not found")):
274
+ return health.SKIPPED_UNCONFIGURED
275
+ if any(marker in text for marker in ("timed out", "timeout")):
276
+ return health.TIMEOUT
277
+ if any(
278
+ marker in text
279
+ for marker in ("http error 429", "confirm you're not a bot", "confirm you’re not a bot", "bot-gate")
280
+ ):
281
+ return health.RATE_LIMITED
282
+ if any(marker in text for marker in ("sign in", "login required", "cookies are no longer valid")):
283
+ return health.AUTH_FAILED
284
+ return http.classify_failure(message=detail)
285
+
286
+
146
287
  def is_ytdlp_installed() -> bool:
147
288
  """Check if yt-dlp is available locally, or if SSH routing is configured.
148
289
 
@@ -157,7 +298,7 @@ def is_ytdlp_installed() -> bool:
157
298
  # Host aliases must be plain hostnames / SSH config aliases — no flags, no
158
299
  # shell metacharacters. Rejects any value that could be reinterpreted by ssh
159
300
  # (or the surrounding shell) as something other than a destination.
160
- _SSH_HOST_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
301
+ _SSH_HOST_ALIAS_RE = re.compile(r"^(?!-)[a-zA-Z0-9._-]+$")
161
302
 
162
303
 
163
304
  def _ytdlp_ssh_host() -> Optional[str]:
@@ -199,6 +340,67 @@ def _ytdlp_ssh_host() -> Optional[str]:
199
340
  return host
200
341
 
201
342
 
343
+ _PLAYER_CLIENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
344
+
345
+
346
+ def _ytdlp_player_client() -> Optional[str]:
347
+ """Return the yt-dlp YouTube player_client, or None to leave the default.
348
+
349
+ Default is ``android``, which bypasses the web bot-gate without cookies.
350
+ Set ``LAST30DAYS_YT_PLAYER_CLIENT`` empty to disable; any other value is
351
+ passed through when it is a safe extractor token.
352
+ """
353
+ if "LAST30DAYS_YT_PLAYER_CLIENT" in os.environ:
354
+ raw = os.environ.get("LAST30DAYS_YT_PLAYER_CLIENT", "").strip()
355
+ if not raw:
356
+ return None
357
+ else:
358
+ raw = "android"
359
+ if not _PLAYER_CLIENT_RE.match(raw):
360
+ sys.stderr.write(
361
+ f"[youtube_yt] WARNING: LAST30DAYS_YT_PLAYER_CLIENT={raw!r} "
362
+ "is not a plain player-client token; ignoring.\n"
363
+ )
364
+ return None
365
+ return raw
366
+
367
+
368
+ def _ytdlp_cmd_needs_player_client(cmd: List[str]) -> bool:
369
+ blob = " ".join(cmd)
370
+ return any(
371
+ marker in blob
372
+ for marker in (
373
+ "ytsearch",
374
+ "youtube.com",
375
+ "--write-comments",
376
+ "--write-auto-subs",
377
+ )
378
+ )
379
+
380
+
381
+ def _inject_youtube_player_client(cmd: List[str]) -> List[str]:
382
+ """Merge player_client into a single youtube --extractor-args (#1052).
383
+
384
+ yt-dlp does not merge two ``--extractor-args`` for the same extractor;
385
+ the last one wins. Always fold into an existing ``youtube:`` spec.
386
+ """
387
+ client = _ytdlp_player_client()
388
+ if not client or not _ytdlp_cmd_needs_player_client(cmd):
389
+ return list(cmd)
390
+ out = list(cmd)
391
+ needle = f"player_client={client}"
392
+ for i, arg in enumerate(out):
393
+ if arg == "--extractor-args" and i + 1 < len(out):
394
+ spec = out[i + 1]
395
+ if spec.startswith("youtube:"):
396
+ if "player_client=" in spec:
397
+ return out
398
+ out[i + 1] = f"{spec};{needle}"
399
+ return out
400
+ out.extend(["--extractor-args", f"youtube:{needle}"])
401
+ return out
402
+
403
+
202
404
  def _wrap_ytdlp_cmd(cmd: List[str]) -> List[str]:
203
405
  """Wrap a yt-dlp command list with `ssh <host>` when SSH routing is set.
204
406
 
@@ -207,6 +409,7 @@ def _wrap_ytdlp_cmd(cmd: List[str]) -> List[str]:
207
409
  The `--` option terminator prevents an SSH option-injection if
208
410
  LAST30DAYS_YOUTUBE_SSH_HOST were ever set to a value starting with `-`.
209
411
  """
412
+ cmd = _inject_youtube_player_client(cmd)
210
413
  host = _ytdlp_ssh_host()
211
414
  if not host:
212
415
  return cmd
@@ -277,11 +480,43 @@ def expand_youtube_queries(topic: str, depth: str) -> List[str]:
277
480
  return queries[:cap]
278
481
 
279
482
 
483
+ # Freshness modes where an out-of-window video is noise, not a fallback: a
484
+ # recent topic with no in-range videos returns an honest empty result rather
485
+ # than years-old, often off-topic matches.
486
+ _RECENT_FRESHNESS_MODES = frozenset({"strict_recent", "balanced_recent"})
487
+
488
+
489
+ def _apply_date_window(
490
+ items: List[Dict[str, Any]],
491
+ from_date: str,
492
+ freshness_mode: Optional[str],
493
+ ) -> List[Dict[str, Any]]:
494
+ """Prefer in-window videos; scope the keep-all fallback by freshness mode.
495
+
496
+ 3+ in-window videos: keep only those (every mode). Fewer: recent modes
497
+ keep only the in-window ones; evergreen_ok and unknown/None keep all so
498
+ evergreen topics can still fall back to older videos.
499
+ """
500
+ recent = [i for i in items if i["date"] and i["date"] >= from_date]
501
+ if len(recent) >= 3:
502
+ _log(f"Found {len(recent)} videos within date range")
503
+ return recent
504
+ if freshness_mode in _RECENT_FRESHNESS_MODES:
505
+ _log(
506
+ f"Found {len(items)} videos ({len(recent)} within date range, "
507
+ f"keeping only in-range for {freshness_mode})"
508
+ )
509
+ return recent
510
+ _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
511
+ return items
512
+
513
+
280
514
  def search_youtube(
281
515
  topic: str,
282
516
  from_date: str,
283
517
  to_date: str,
284
518
  depth: str = "default",
519
+ freshness_mode: Optional[str] = None,
285
520
  ) -> Dict[str, Any]:
286
521
  """Search YouTube via yt-dlp. No API key needed.
287
522
 
@@ -290,6 +525,8 @@ def search_youtube(
290
525
  from_date: Start date (YYYY-MM-DD)
291
526
  to_date: End date (YYYY-MM-DD)
292
527
  depth: 'quick', 'default', or 'deep'
528
+ freshness_mode: Plan freshness mode; scopes the out-of-window
529
+ fallback (see _apply_date_window). None keeps the soft fallback.
293
530
 
294
531
  Returns:
295
532
  Dict with 'items' list of video metadata dicts.
@@ -299,6 +536,20 @@ def search_youtube(
299
536
 
300
537
  count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
301
538
  core_topic = _extract_core_subject(topic)
539
+ cache_key = (core_topic, count, from_date, freshness_mode)
540
+ timeout = _search_timeout()
541
+
542
+ cached, event, slot, is_leader = _claim_search_slot(cache_key)
543
+ if cached is not None:
544
+ _log(f"YouTube search cache hit for '{core_topic}' (count={count})")
545
+ return cached
546
+ assert event is not None and slot is not None
547
+ if not is_leader:
548
+ _log(f"YouTube search awaiting in-flight query for '{core_topic}'")
549
+ return _await_search_slot(event, slot)
550
+
551
+ def _publish(payload: Dict[str, Any]) -> Dict[str, Any]:
552
+ return _finish_search_slot(cache_key, payload, event=event, slot=slot)
302
553
 
303
554
  _log(f"Searching YouTube for '{core_topic}' (since {from_date}, count={count})")
304
555
 
@@ -318,82 +569,96 @@ def search_youtube(
318
569
  cmd = _wrap_ytdlp_cmd(cmd)
319
570
  ssh_host = _ytdlp_ssh_host()
320
571
 
572
+ published: Dict[str, Any] | None = None
321
573
  try:
322
- result = subproc.run_with_timeout(cmd, timeout=120)
323
- except subproc.SubprocTimeout:
324
- _log("YouTube search timed out (120s)")
325
- return {"items": [], "error": "Search timed out"}
326
- except FileNotFoundError:
327
- return {"items": [], "error": "yt-dlp not found"}
328
-
329
- stdout = result.stdout
330
- if ssh_host and result.returncode != 0 and not stdout.strip():
331
- stderr_first = (result.stderr or "").strip().splitlines()
332
- first_line = stderr_first[0] if stderr_first else "(no stderr)"
333
- _log(
334
- f"YouTube search via SSH host {ssh_host!r} failed "
335
- f"(rc={result.returncode}): {first_line}"
336
- )
337
- return {
338
- "items": [],
339
- "error": f"SSH routing to {ssh_host!r} failed: {first_line}",
340
- }
341
- if not stdout.strip():
342
- _log("YouTube search returned 0 results")
343
- return {"items": []}
344
-
345
- # Parse JSON-per-line output
346
- items = []
347
- for line in stdout.strip().split("\n"):
348
- line = line.strip()
349
- if not line:
350
- continue
351
574
  try:
352
- video = json.loads(line)
353
- except json.JSONDecodeError:
354
- continue
355
-
356
- video_id = video.get("id", "")
357
- view_count = video.get("view_count") if video.get("view_count") is not None else 0
358
- like_count = video.get("like_count") if video.get("like_count") is not None else 0
359
- comment_count = video.get("comment_count") if video.get("comment_count") is not None else 0
360
- upload_date = video.get("upload_date", "") # YYYYMMDD
361
-
362
- # Convert YYYYMMDD to YYYY-MM-DD
363
- date_str = None
364
- if upload_date and len(upload_date) == 8:
365
- date_str = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:8]}"
366
-
367
- description = str(video.get("description", ""))[:500]
368
- items.append({
369
- "video_id": video_id,
370
- "title": video.get("title", ""),
371
- "url": f"https://www.youtube.com/watch?v={video_id}",
372
- "channel_name": video.get("channel", video.get("uploader", "")),
373
- "date": date_str,
374
- "engagement": {
375
- "views": view_count,
376
- "likes": like_count,
377
- "comments": comment_count,
378
- },
379
- "duration": video.get("duration"),
380
- "relevance": _compute_relevance(core_topic, f"{video.get('title', '')} {description}"),
381
- "why_relevant": f"YouTube: {video.get('title', core_topic)[:60]}",
382
- "description": description,
383
- })
384
-
385
- # Soft date filter: prefer recent items but fall back to all if too few
386
- recent = [i for i in items if i["date"] and i["date"] >= from_date]
387
- if len(recent) >= 3:
388
- items = recent
389
- _log(f"Found {len(items)} videos within date range")
390
- else:
391
- _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
392
-
393
- # Sort by views descending
394
- items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
575
+ result = _run_ytdlp(cmd, timeout=timeout)
576
+ except subproc.SubprocTimeout:
577
+ _log(f"YouTube search timed out ({timeout:g}s)")
578
+ published = _publish(
579
+ {"items": [], "error": f"Search timed out after {timeout:g}s"}
580
+ )
581
+ return published
582
+ except FileNotFoundError:
583
+ published = _publish({"items": [], "error": "yt-dlp not found"})
584
+ return published
585
+
586
+ stdout = result.stdout
587
+ if result.returncode != 0 and not stdout.strip():
588
+ stderr_first = (result.stderr or "").strip().splitlines()
589
+ first_line = stderr_first[0] if stderr_first else "(no stderr)"
590
+ if ssh_host:
591
+ _log(
592
+ f"YouTube search via SSH host {ssh_host!r} failed "
593
+ f"(rc={result.returncode}): {first_line}"
594
+ )
595
+ error = f"SSH routing to {ssh_host!r} failed: {first_line}"
596
+ else:
597
+ _log(f"YouTube search failed (rc={result.returncode}): {first_line}")
598
+ error = f"yt-dlp search failed: {first_line}"
599
+ published = _publish({"items": [], "error": error})
600
+ return published
601
+ if not stdout.strip():
602
+ _log("YouTube search returned 0 results")
603
+ published = _publish({"items": []})
604
+ return published
605
+
606
+ # Parse JSON-per-line output
607
+ items = []
608
+ for line in stdout.strip().split("\n"):
609
+ line = line.strip()
610
+ if not line:
611
+ continue
612
+ try:
613
+ video = json.loads(line)
614
+ except json.JSONDecodeError:
615
+ continue
395
616
 
396
- return {"items": items}
617
+ video_id = video.get("id", "")
618
+ view_count = video.get("view_count") if video.get("view_count") is not None else 0
619
+ like_count = video.get("like_count") if video.get("like_count") is not None else 0
620
+ comment_count = video.get("comment_count") if video.get("comment_count") is not None else 0
621
+ upload_date = video.get("upload_date", "") # YYYYMMDD
622
+
623
+ # Convert YYYYMMDD to YYYY-MM-DD
624
+ date_str = None
625
+ if upload_date and len(upload_date) == 8:
626
+ date_str = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:8]}"
627
+
628
+ description = str(video.get("description", ""))[:500]
629
+ items.append({
630
+ "video_id": video_id,
631
+ "title": video.get("title", ""),
632
+ "url": f"https://www.youtube.com/watch?v={video_id}",
633
+ "channel_name": video.get("channel", video.get("uploader", "")),
634
+ "date": date_str,
635
+ "engagement": {
636
+ "views": view_count,
637
+ "likes": like_count,
638
+ "comments": comment_count,
639
+ },
640
+ "duration": video.get("duration"),
641
+ "relevance": _compute_relevance(core_topic, f"{video.get('title', '')} {description}"),
642
+ "why_relevant": f"YouTube: {video.get('title', core_topic)[:60]}",
643
+ "description": description,
644
+ })
645
+
646
+ # Date filter: prefer recent items; the keep-all fallback is scoped
647
+ # by freshness mode (evergreen only), see _apply_date_window.
648
+ items = _apply_date_window(items, from_date, freshness_mode)
649
+
650
+ # Sort by views descending
651
+ items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
652
+ published = _publish({"items": items})
653
+ return published
654
+ except Exception as exc:
655
+ # Post-subprocess failures (parse/relevance/sort) must still unblock
656
+ # coalesced waiters — otherwise the inflight key orphans forever.
657
+ published = _publish({"items": [], "error": str(exc)})
658
+ return published
659
+ finally:
660
+ if published is None:
661
+ _publish({"items": [], "error": "YouTube search failed"})
397
662
 
398
663
 
399
664
  def _clean_vtt(vtt_text: str) -> str:
@@ -533,10 +798,17 @@ def _fetch_transcript_ytdlp_via_ssh(video_id: str, ssh_host: str) -> Optional[st
533
798
  url = f"https://www.youtube.com/watch?v={video_id}"
534
799
  quoted_url = shlex.quote(url)
535
800
  sub_langs = shlex.quote(_ytdlp_sub_langs())
801
+ client = _ytdlp_player_client()
802
+ extractor = (
803
+ f"--extractor-args {shlex.quote(f'youtube:player_client={client}')} "
804
+ if client
805
+ else ""
806
+ )
536
807
  remote_script = (
537
808
  "set -e; "
538
809
  "TMPD=$(mktemp -d); "
539
810
  "yt-dlp --ignore-config --no-cookies-from-browser "
811
+ f"{extractor}"
540
812
  f"--write-auto-subs --sub-lang {sub_langs} --sub-format vtt "
541
813
  "--skip-download --no-warnings "
542
814
  f'-o "$TMPD/%(id)s" {quoted_url} >/dev/null 2>&1 || true; '
@@ -546,7 +818,7 @@ def _fetch_transcript_ytdlp_via_ssh(video_id: str, ssh_host: str) -> Optional[st
546
818
  )
547
819
  cmd = ["ssh", "-o", "BatchMode=yes", "--", ssh_host, remote_script]
548
820
  try:
549
- result = subproc.run_with_timeout(cmd, timeout=45)
821
+ result = _run_ytdlp(cmd, timeout=45)
550
822
  except subproc.SubprocTimeout:
551
823
  _log(f"SSH yt-dlp transcript timed out for {video_id} via {ssh_host!r}")
552
824
  return None
@@ -573,6 +845,13 @@ def _ytdlp_sub_langs() -> str:
573
845
  return ",".join(code.strip().lower() for code in raw.split(",") if code.strip()) or "en,es,pt"
574
846
 
575
847
 
848
+ def _transcript_fast_timeout() -> float:
849
+ """Return the keyed-run yt-dlp timeout, preserving the 12s default."""
850
+ return _env_positive_float(
851
+ "LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT",
852
+ float(_TRANSCRIPT_FAST_TIMEOUT),
853
+ )
854
+
576
855
  def _pick_ytdlp_vtt(video_id: str, temp_dir: str, priority: List[str]) -> Optional[Path]:
577
856
  """Return the best on-disk VTT match for video_id, preferring priority order."""
578
857
  matches = list(Path(temp_dir).glob(f"{video_id}*.vtt"))
@@ -650,17 +929,24 @@ def _fetch_transcript_ytdlp(
650
929
  "-o", f"{temp_dir}/%(id)s",
651
930
  f"https://www.youtube.com/watch?v={video_id}",
652
931
  ]
932
+ cmd = _inject_youtube_player_client(cmd)
653
933
 
654
- timeout = _TRANSCRIPT_FAST_TIMEOUT if fast_fail else _TRANSCRIPT_TIMEOUT
934
+ timeout = _transcript_fast_timeout() if fast_fail else _TRANSCRIPT_TIMEOUT
655
935
  attempts = 1 if fast_fail else _TRANSCRIPT_MAX_RETRIES + 1
656
936
  last_reason: Optional[str] = None
657
937
  for attempt in range(attempts):
658
938
  try:
659
- result = subproc.run_with_timeout(cmd, timeout=timeout)
939
+ result = _run_ytdlp(cmd, timeout=timeout)
660
940
  except subproc.SubprocTimeout:
661
941
  last_reason = f"timed out after {timeout}s"
662
942
  _log(f"yt-dlp transcript timed out after {timeout}s for {video_id} "
663
943
  f"(attempt {attempt + 1}/{attempts})")
944
+ # yt-dlp downloads requested languages sequentially. A timeout can
945
+ # therefore leave a complete first-choice VTT on disk; keep it
946
+ # instead of spending a ScrapeCreators fallback credit.
947
+ partial_vtt = _read_vtt(video_id, temp_dir)
948
+ if partial_vtt is not None:
949
+ return partial_vtt
664
950
  if attempt < attempts - 1:
665
951
  time.sleep(_transcript_backoff(video_id, attempt))
666
952
  continue
@@ -796,6 +1082,17 @@ def fetch_transcript(
796
1082
  if token and _should_try_sc_transcript(status):
797
1083
  sc_transcript = _sc_fetch_transcript(video_id, token)
798
1084
  if sc_transcript:
1085
+ # The keyless cascade (yt-dlp / direct HTTP) already logged its
1086
+ # failure above. Without this line that failure is the last thing
1087
+ # printed for this video, and the batch summary in
1088
+ # fetch_transcripts_parallel() counts it as a plain success —
1089
+ # making a rate-limited/bot-gated run look like nothing went
1090
+ # wrong. Log the rescue and flag it in `status` so the summary
1091
+ # can report it explicitly instead of masking it (#831).
1092
+ _log(f"ScrapeCreators transcript fallback rescued {video_id} "
1093
+ f"after the keyless fetch cascade failed")
1094
+ if status is not None:
1095
+ status["sc_rescued"] = True
799
1096
  return sc_transcript
800
1097
 
801
1098
  _log(f"No transcript available for {video_id}")
@@ -833,8 +1130,8 @@ def fetch_transcripts_parallel(
833
1130
  with tempfile.TemporaryDirectory() as temp_dir:
834
1131
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
835
1132
  futures = {
836
- executor.submit(
837
- fetch_transcript, vid, temp_dir, statuses[vid], token,
1133
+ http.submit_with_context(
1134
+ executor, fetch_transcript, vid, temp_dir, statuses[vid], token,
838
1135
  ): vid
839
1136
  for vid in video_ids
840
1137
  }
@@ -856,7 +1153,20 @@ def fetch_transcripts_parallel(
856
1153
 
857
1154
  got = sum(1 for v in results.values() if v)
858
1155
  errors = sum(1 for v in results.values() if v is None)
859
- _log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)")
1156
+ # `got` includes videos that only succeeded because the ScrapeCreators
1157
+ # fallback rescued a failed keyless fetch — yt-dlp when available, or the
1158
+ # direct HTTP path alone (see fetch_transcript()). Folding
1159
+ # those into a bare "M failed" count previously made a fully rate-limited
1160
+ # yt-dlp run — every fetch failing, silently saved by the fallback — read
1161
+ # as "0 failed", with no trace of the fallback ever having fired (#831).
1162
+ # Surface the split so the summary can't misrepresent a masked failure
1163
+ # as a clean success.
1164
+ sc_rescued = sum(1 for st in statuses.values() if st.get("sc_rescued"))
1165
+ if sc_rescued:
1166
+ _log(f"Got transcripts for {got}/{len(video_ids)} videos "
1167
+ f"({errors} failed, {sc_rescued} rescued via ScrapeCreators fallback)")
1168
+ else:
1169
+ _log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)")
860
1170
  return results
861
1171
 
862
1172
 
@@ -924,12 +1234,29 @@ def _transcript_candidate_sort_key(item: dict) -> tuple:
924
1234
  return (views, recency)
925
1235
 
926
1236
 
1237
+ def _prefer_search_error(current: Optional[str], new: str) -> str:
1238
+ """Keep the most actionable search failure across multi-query merges."""
1239
+ if current is None:
1240
+ return new
1241
+ priority = ("timed out", "timeout", "429", "bot")
1242
+
1243
+ def _rank(text: str) -> int:
1244
+ lower = text.lower()
1245
+ for index, marker in enumerate(priority):
1246
+ if marker in lower:
1247
+ return index
1248
+ return len(priority)
1249
+
1250
+ return new if _rank(new) < _rank(current) else current
1251
+
1252
+
927
1253
  def search_and_transcribe(
928
1254
  topic: str,
929
1255
  from_date: str,
930
1256
  to_date: str,
931
1257
  depth: str = "default",
932
1258
  token: Optional[str] = None,
1259
+ freshness_mode: Optional[str] = None,
933
1260
  ) -> Dict[str, Any]:
934
1261
  """Full YouTube search: find videos, then fetch transcripts for top results.
935
1262
 
@@ -943,6 +1270,7 @@ def search_and_transcribe(
943
1270
  depth: 'quick', 'default', or 'deep'
944
1271
  token: Optional ScrapeCreators key for the per-video transcript
945
1272
  fallback (threaded to fetch_transcripts_parallel).
1273
+ freshness_mode: Plan freshness mode, threaded to search_youtube().
946
1274
 
947
1275
  Returns:
948
1276
  Dict with 'items' list. Each item has a 'transcript_snippet' field.
@@ -951,8 +1279,14 @@ def search_and_transcribe(
951
1279
  queries = expand_youtube_queries(topic, depth)
952
1280
  seen_ids: Set[str] = set()
953
1281
  items: List[Dict[str, Any]] = []
1282
+ search_error: Optional[str] = None
954
1283
  for q in queries:
955
- search_result = search_youtube(q, from_date, to_date, depth)
1284
+ search_result = search_youtube(
1285
+ q, from_date, to_date, depth, freshness_mode=freshness_mode,
1286
+ )
1287
+ err = search_result.get("error")
1288
+ if err:
1289
+ search_error = _prefer_search_error(search_error, str(err))
956
1290
  for item in search_result.get("items", []):
957
1291
  vid = item.get("video_id", "")
958
1292
  if vid and vid not in seen_ids:
@@ -960,10 +1294,10 @@ def search_and_transcribe(
960
1294
  items.append(item)
961
1295
 
962
1296
  # Sort merged results by views descending
963
- items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
1297
+ items.sort(key=lambda x: x.get("engagement", {}).get("views") or 0, reverse=True)
964
1298
 
965
1299
  if not items:
966
- return search_result
1300
+ return {"items": [], **({"error": search_error} if search_error else {})}
967
1301
 
968
1302
  # Step 2: Fetch transcripts for top videos.
969
1303
  # Sort candidates by a combination of views and recency so that recent
@@ -1010,7 +1344,12 @@ def search_and_transcribe(
1010
1344
  )
1011
1345
  item["captions_disabled"] = vid in captions_disabled_ids
1012
1346
 
1013
- return {"items": items}
1347
+ result: Dict[str, Any] = {"items": items}
1348
+ if search_error:
1349
+ # Partial coverage: some queries succeeded; keep the failure visible so
1350
+ # source_status becomes partial/timeout rather than a quiet OK.
1351
+ result["error"] = search_error
1352
+ return result
1014
1353
 
1015
1354
 
1016
1355
  def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
@@ -1058,7 +1397,10 @@ def enrich_with_comments(
1058
1397
  Returns:
1059
1398
  Items list (mutated in place) with top_comments added to enriched items.
1060
1399
  """
1061
- if not items or not token or max_videos <= 0:
1400
+ if not items or max_videos <= 0:
1401
+ return items
1402
+ # yt-dlp needs no key, so an empty token is only fatal when it is absent too.
1403
+ if not token and not is_ytdlp_installed():
1062
1404
  return items
1063
1405
 
1064
1406
  ranked = sorted(items, key=_total_engagement, reverse=True)
@@ -1082,7 +1424,7 @@ def enrich_with_comments(
1082
1424
 
1083
1425
  enriched_count = 0
1084
1426
  with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
1085
- futures = {executor.submit(_enrich_one, item): item for item in top_items}
1427
+ futures = {http.submit_with_context(executor, _enrich_one, item): item for item in top_items}
1086
1428
  for future in as_completed(futures):
1087
1429
  if future.result():
1088
1430
  enriched_count += 1
@@ -1091,21 +1433,108 @@ def enrich_with_comments(
1091
1433
  return items
1092
1434
 
1093
1435
 
1436
+ def _ytdlp_comments_result(
1437
+ video_id: str,
1438
+ max_comments: int = 5,
1439
+ ) -> tuple[List[Dict[str, Any]], bool]:
1440
+ """Fetch top comments via yt-dlp, returning ``(comments, ran_cleanly)``.
1441
+
1442
+ The bool distinguishes "yt-dlp succeeded, this video simply has no
1443
+ comments" (True, []) from "yt-dlp was absent or errored" (False, []), so
1444
+ the caller only spends a ScrapeCreators credit on a genuine failure — not
1445
+ on a video that legitimately has zero comments. Mirrors the transcript
1446
+ path, which is likewise careful not to bill SC for a caption-less video.
1447
+
1448
+ Comments are sorted by top so a low ``max_comments`` still returns the
1449
+ highest-voted ones rather than an arbitrary slice.
1450
+ """
1451
+ if not is_ytdlp_installed():
1452
+ return [], False
1453
+
1454
+ cmd = _wrap_ytdlp_cmd([
1455
+ "yt-dlp",
1456
+ "--write-comments",
1457
+ "--skip-download",
1458
+ "--dump-single-json",
1459
+ "--no-warnings",
1460
+ "--ignore-config",
1461
+ "--extractor-args",
1462
+ f"youtube:comment_sort=top;max_comments={max_comments},all,{max_comments}",
1463
+ f"https://www.youtube.com/watch?v={video_id}",
1464
+ ])
1465
+
1466
+ try:
1467
+ result = _run_ytdlp(cmd, timeout=_COMMENT_TIMEOUT)
1468
+ except Exception as exc:
1469
+ _log(f"yt-dlp comment fetch failed for {video_id}: {exc}")
1470
+ return [], False
1471
+
1472
+ if result.returncode != 0 or not result.stdout:
1473
+ _log(f"yt-dlp comment fetch failed for {video_id} (exit {result.returncode})")
1474
+ return [], False
1475
+
1476
+ try:
1477
+ payload = json.loads(result.stdout)
1478
+ except (ValueError, TypeError) as exc:
1479
+ _log(f"yt-dlp comment JSON parse failed for {video_id}: {exc}")
1480
+ return [], False
1481
+
1482
+ comments = []
1483
+ for c in (payload.get("comments") or [])[:max_comments]:
1484
+ text = c.get("text") or ""
1485
+ if not text:
1486
+ continue
1487
+ comments.append({
1488
+ "author": c.get("author") or "",
1489
+ "text": text[:400],
1490
+ "likes": c.get("like_count") or 0,
1491
+ "date": c.get("_time_text") or "",
1492
+ })
1493
+ return comments, True
1494
+
1495
+
1496
+ def _fetch_video_comments_ytdlp(
1497
+ video_id: str,
1498
+ max_comments: int = 5,
1499
+ ) -> List[Dict[str, Any]]:
1500
+ """Comments for a video via yt-dlp (free, keyless), or [] on any failure.
1501
+
1502
+ Thin list-returning wrapper over ``_ytdlp_comments_result`` for callers
1503
+ that don't need to tell a clean empty result from a failure.
1504
+ """
1505
+ return _ytdlp_comments_result(video_id, max_comments)[0]
1506
+
1507
+
1094
1508
  def _fetch_video_comments(
1095
1509
  video_id: str,
1096
1510
  token: str,
1097
1511
  max_comments: int = 5,
1098
1512
  ) -> List[Dict[str, Any]]:
1099
- """Fetch comments for a single YouTube video via ScrapeCreators.
1513
+ """Fetch comments for one video, preferring the free yt-dlp path.
1514
+
1515
+ yt-dlp is tried first because it is keyless and costs nothing.
1516
+ ScrapeCreators stays as the backstop for when yt-dlp is absent or gets
1517
+ throttled, and is only called when a token is actually configured.
1100
1518
 
1101
1519
  Args:
1102
1520
  video_id: YouTube video ID
1103
- token: ScrapeCreators API key
1521
+ token: ScrapeCreators API key (may be empty — yt-dlp needs none)
1104
1522
  max_comments: Maximum comments to return
1105
1523
 
1106
1524
  Returns:
1107
1525
  List of comment dicts with author, text, likes, date.
1108
1526
  """
1527
+ ytdlp_comments, ran_cleanly = _ytdlp_comments_result(video_id, max_comments)
1528
+ if ytdlp_comments:
1529
+ return ytdlp_comments
1530
+ # Clean run with no comments -> the video simply has none. Don't spend an
1531
+ # SC credit chasing comments that aren't there; only fall back on failure.
1532
+ if ran_cleanly:
1533
+ return []
1534
+
1535
+ if not token:
1536
+ return []
1537
+
1109
1538
  video_url = f"https://www.youtube.com/watch?v={video_id}"
1110
1539
  try:
1111
1540
  data = http.get(
@@ -1162,11 +1591,13 @@ def search_youtube_sc(
1162
1591
  to_date: str,
1163
1592
  depth: str = "default",
1164
1593
  token: str = None,
1594
+ skip_transcript_ids: Optional[Set[str]] = None,
1595
+ freshness_mode: Optional[str] = None,
1165
1596
  ) -> Dict[str, Any]:
1166
- """Search YouTube via ScrapeCreators API (fallback when yt-dlp is unavailable).
1597
+ """Search YouTube via ScrapeCreators when yt-dlp is absent, empty, or thin.
1167
1598
 
1168
1599
  Uses SC keyword search to find videos and SC transcript endpoint to
1169
- fetch transcripts. Called by pipeline.py when yt-dlp fails.
1600
+ fetch transcripts. The pipeline uses the configured thin-result floor.
1170
1601
 
1171
1602
  Args:
1172
1603
  topic: Search topic
@@ -1174,6 +1605,9 @@ def search_youtube_sc(
1174
1605
  to_date: End date (YYYY-MM-DD)
1175
1606
  depth: 'quick', 'default', or 'deep'
1176
1607
  token: ScrapeCreators API key
1608
+ skip_transcript_ids: Video IDs already transcribed by the free search
1609
+ freshness_mode: Plan freshness mode; scopes the out-of-window
1610
+ fallback (see _apply_date_window). None keeps the soft fallback.
1177
1611
 
1178
1612
  Returns:
1179
1613
  Dict with 'items' list of video metadata dicts.
@@ -1232,13 +1666,8 @@ def search_youtube_sc(
1232
1666
  "description": description,
1233
1667
  })
1234
1668
 
1235
- # Soft date filter
1236
- recent = [i for i in items if i["date"] and i["date"] >= from_date]
1237
- if len(recent) >= 3:
1238
- items = recent
1239
- _log(f"Found {len(items)} videos within date range")
1240
- else:
1241
- _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
1669
+ # Date filter (keep-all fallback scoped by freshness mode)
1670
+ items = _apply_date_window(items, from_date, freshness_mode)
1242
1671
 
1243
1672
  # Sort by views
1244
1673
  items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
@@ -1246,14 +1675,19 @@ def search_youtube_sc(
1246
1675
  # Step 2: Fetch transcripts for top videos
1247
1676
  transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
1248
1677
  if transcript_limit > 0 and items:
1249
- attempt_count = min(len(items), transcript_limit * 3)
1250
1678
  # Same in-window-first ordering as search_and_transcribe(): don't let
1251
1679
  # an out-of-window back-catalog (kept by the soft date filter above)
1252
1680
  # consume the transcript budget of videos the freshness scorer keeps.
1253
1681
  in_window = [i for i in items if i.get("date") and i["date"] >= from_date]
1254
1682
  out_of_window = [i for i in items if not (i.get("date") and i["date"] >= from_date)]
1683
+ excluded_ids = skip_transcript_ids or set()
1684
+ transcript_candidates = [
1685
+ item for item in in_window + out_of_window
1686
+ if item["video_id"] not in excluded_ids
1687
+ ]
1688
+ attempt_count = min(len(transcript_candidates), transcript_limit * 3)
1255
1689
  _log(f"Fetching SC transcripts for up to {attempt_count} videos (target: {transcript_limit})")
1256
- for item in (in_window + out_of_window)[:attempt_count]:
1690
+ for item in transcript_candidates[:attempt_count]:
1257
1691
  vid = item["video_id"]
1258
1692
  if not vid:
1259
1693
  continue
@@ -1333,30 +1767,47 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
1333
1767
  Plaintext transcript string, or None if unavailable.
1334
1768
  """
1335
1769
  video_url = f"https://www.youtube.com/watch?v={video_id}"
1336
- try:
1337
- data = http.get(
1338
- f"{SCRAPECREATORS_YT_BASE}/video/transcript",
1339
- params={"url": video_url},
1340
- headers=http.scrapecreators_headers(token),
1341
- timeout=30,
1342
- retries=1,
1343
- )
1344
- except Exception as exc:
1345
- _log(f"SC transcript error for {video_id}: {exc}")
1346
- return None
1770
+ transcript = None
1771
+ deadline = time.monotonic() + _SC_TRANSCRIPT_TIMEOUT
1772
+ # Without a language the endpoint may return an auto-dubbed track (#1169).
1773
+ languages = list(dict.fromkeys(_ytdlp_sub_langs().split(",")))[:_SC_TRANSCRIPT_MAX_LANGUAGES]
1774
+ for language in languages:
1775
+ if time.monotonic() >= deadline:
1776
+ break
1777
+ try:
1778
+ # Isolate SC transcript fetch errors from the pipeline-level
1779
+ # capture_failures() context.
1780
+ with http.capture_failures() as _tf:
1781
+ data = http.get(
1782
+ f"{SCRAPECREATORS_YT_BASE}/video/transcript",
1783
+ params={"url": video_url, "language": language},
1784
+ headers=http.scrapecreators_headers(token),
1785
+ timeout=_SC_TRANSCRIPT_TIMEOUT,
1786
+ retries=1,
1787
+ max_429_retries=0,
1788
+ deadline_monotonic=deadline,
1789
+ owned_get=True,
1790
+ )
1791
+ except Exception as exc:
1792
+ _log(f"SC transcript error for {video_id} ({language}): {exc}")
1793
+ if getattr(exc, "status_code", None) == 404:
1794
+ continue
1795
+ return None
1347
1796
 
1348
- _warn_low_sc_credits(data)
1797
+ _warn_low_sc_credits(data)
1349
1798
 
1350
- transcript = data.get("transcript")
1799
+ transcript = data.get("transcript")
1800
+ if isinstance(transcript, list):
1801
+ transcript = " ".join(_sc_segment_text(seg) for seg in transcript).strip()
1802
+ if not isinstance(transcript, str):
1803
+ transcript = None
1804
+ continue
1805
+ transcript = _clean_vtt(transcript)
1806
+ if transcript:
1807
+ break
1351
1808
  if not transcript:
1352
1809
  return None
1353
1810
 
1354
- if isinstance(transcript, list):
1355
- transcript = " ".join(_sc_segment_text(seg) for seg in transcript).strip()
1356
-
1357
- # Clean VTT formatting if present
1358
- transcript = _clean_vtt(transcript)
1359
-
1360
1811
  # Truncate to max words
1361
1812
  words = transcript.split()
1362
1813
  if len(words) > TRANSCRIPT_MAX_WORDS: