luckiest-co 1.0.0 → 1.0.3

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 (618) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/LICENSE +24 -0
  4. package/bin/install.js +7 -5
  5. package/commands/{luckiest/finish.md → finish.md} +2 -0
  6. package/commands/go.md +53 -0
  7. package/commands/plan.md +66 -0
  8. package/package.json +16 -5
  9. package/skills/brainstorm/ATTRIBUTION.md +27 -0
  10. package/skills/brainstorm/SKILL.md +139 -0
  11. package/skills/luckiest-ab-testing/ATTRIBUTION.md +35 -0
  12. package/skills/luckiest-ab-testing/CHANGELOG.md +27 -0
  13. package/skills/luckiest-ab-testing/SKILL.md +379 -0
  14. package/skills/luckiest-ab-testing/evals/evals.json +118 -0
  15. package/skills/luckiest-ab-testing/references/sample-size-guide.md +290 -0
  16. package/skills/luckiest-ab-testing/references/test-templates.md +284 -0
  17. package/skills/luckiest-ad-creative/ATTRIBUTION.md +35 -0
  18. package/skills/luckiest-ad-creative/CHANGELOG.md +23 -0
  19. package/skills/luckiest-ad-creative/SKILL.md +393 -0
  20. package/skills/luckiest-ad-creative/evals/evals.json +90 -0
  21. package/skills/luckiest-ad-creative/references/generative-tools.md +637 -0
  22. package/skills/luckiest-ad-creative/references/platform-specs.md +213 -0
  23. package/skills/luckiest-ads/ATTRIBUTION.md +36 -0
  24. package/skills/luckiest-ads/CHANGELOG.md +24 -0
  25. package/skills/luckiest-ads/LICENSE +21 -0
  26. package/skills/luckiest-ads/SKILL.md +434 -0
  27. package/skills/luckiest-ads/evals/evals.json +90 -0
  28. package/skills/luckiest-ads/references/ad-copy-templates.md +207 -0
  29. package/skills/luckiest-ads/references/audience-targeting.md +243 -0
  30. package/skills/luckiest-ads/references/conversion-tracking.md +361 -0
  31. package/skills/luckiest-ads/references/platform-setup-checklists.md +277 -0
  32. package/skills/luckiest-advisors/ATTRIBUTION.md +16 -0
  33. package/skills/luckiest-advisors/CHANGELOG.md +99 -0
  34. package/skills/luckiest-advisors/SKILL.md +151 -0
  35. package/skills/luckiest-aeo-grader/ATTRIBUTION.md +19 -0
  36. package/skills/luckiest-aeo-grader/CHANGELOG.md +52 -0
  37. package/skills/luckiest-aeo-grader/SKILL.md +200 -0
  38. package/skills/luckiest-aeo-grader/evals/evals.json +19 -0
  39. package/skills/luckiest-aeo-grader/references/rubric.md +72 -0
  40. package/skills/luckiest-aeo-grader/scripts/scan.py +265 -0
  41. package/skills/luckiest-ai-seo/ATTRIBUTION.md +38 -0
  42. package/skills/luckiest-ai-seo/CHANGELOG.md +23 -0
  43. package/skills/luckiest-ai-seo/SKILL.md +510 -0
  44. package/skills/luckiest-ai-seo/evals/evals.json +90 -0
  45. package/skills/luckiest-ai-seo/references/content-patterns.md +285 -0
  46. package/skills/luckiest-ai-seo/references/content-types.md +71 -0
  47. package/skills/luckiest-ai-seo/references/okf.md +104 -0
  48. package/skills/luckiest-ai-seo/references/platform-ranking-factors.md +154 -0
  49. package/skills/luckiest-analytics/ATTRIBUTION.md +42 -0
  50. package/skills/luckiest-analytics/CHANGELOG.md +36 -0
  51. package/skills/luckiest-analytics/SKILL.md +380 -0
  52. package/skills/luckiest-analytics/evals/evals.json +90 -0
  53. package/skills/luckiest-analytics/references/event-library.md +260 -0
  54. package/skills/luckiest-analytics/references/ga4-implementation.md +300 -0
  55. package/skills/luckiest-analytics/references/gtm-implementation.md +390 -0
  56. package/skills/luckiest-aso/ATTRIBUTION.md +40 -0
  57. package/skills/luckiest-aso/CHANGELOG.md +33 -0
  58. package/skills/luckiest-aso/SKILL.md +327 -0
  59. package/skills/luckiest-aso/evals/evals.json +91 -0
  60. package/skills/luckiest-aso/references/apple-specs.md +107 -0
  61. package/skills/luckiest-aso/references/benchmarks.md +129 -0
  62. package/skills/luckiest-aso/references/google-play-specs.md +131 -0
  63. package/skills/luckiest-aso/references/report-template.md +213 -0
  64. package/skills/luckiest-aso/references/scoring-criteria.md +213 -0
  65. package/skills/luckiest-churn-prevention/ATTRIBUTION.md +41 -0
  66. package/skills/luckiest-churn-prevention/CHANGELOG.md +24 -0
  67. package/skills/luckiest-churn-prevention/SKILL.md +445 -0
  68. package/skills/luckiest-churn-prevention/evals/evals.json +93 -0
  69. package/skills/luckiest-churn-prevention/references/cancel-flow-patterns.md +316 -0
  70. package/skills/luckiest-churn-prevention/references/dunning-playbook.md +408 -0
  71. package/skills/luckiest-co-marketing/ATTRIBUTION.md +38 -0
  72. package/skills/luckiest-co-marketing/CHANGELOG.md +25 -0
  73. package/skills/luckiest-co-marketing/SKILL.md +331 -0
  74. package/skills/luckiest-co-marketing/evals/evals.json +84 -0
  75. package/skills/luckiest-coder/ATTRIBUTION.md +26 -0
  76. package/skills/luckiest-coder/CHANGELOG.md +30 -0
  77. package/skills/luckiest-coder/SKILL.md +77 -0
  78. package/skills/luckiest-coder/references/antigravity-tools.md +23 -0
  79. package/skills/luckiest-coder/references/codex-tools.md +39 -0
  80. package/skills/luckiest-coder/references/pi-tools.md +16 -0
  81. package/skills/luckiest-coder-brainstorming/ATTRIBUTION.md +26 -0
  82. package/skills/luckiest-coder-brainstorming/CHANGELOG.md +31 -0
  83. package/skills/luckiest-coder-brainstorming/SKILL.md +173 -0
  84. package/skills/luckiest-coder-brainstorming/scripts/frame-template.html +213 -0
  85. package/skills/luckiest-coder-brainstorming/scripts/helper.js +168 -0
  86. package/skills/luckiest-coder-brainstorming/scripts/server.cjs +721 -0
  87. package/skills/luckiest-coder-brainstorming/scripts/start-server.sh +209 -0
  88. package/skills/luckiest-coder-brainstorming/scripts/stop-server.sh +120 -0
  89. package/skills/luckiest-coder-brainstorming/spec-document-reviewer-prompt.md +49 -0
  90. package/skills/luckiest-coder-brainstorming/visual-companion.md +291 -0
  91. package/skills/luckiest-coder-consistency/ATTRIBUTION.md +30 -0
  92. package/skills/luckiest-coder-consistency/CHANGELOG.md +26 -0
  93. package/skills/luckiest-coder-consistency/SKILL.md +98 -0
  94. package/skills/luckiest-coder-constitution/ATTRIBUTION.md +28 -0
  95. package/skills/luckiest-coder-constitution/CHANGELOG.md +25 -0
  96. package/skills/luckiest-coder-constitution/SKILL.md +115 -0
  97. package/skills/luckiest-coder-debugging/ATTRIBUTION.md +26 -0
  98. package/skills/luckiest-coder-debugging/CHANGELOG.md +22 -0
  99. package/skills/luckiest-coder-debugging/CREATION-LOG.md +119 -0
  100. package/skills/luckiest-coder-debugging/SKILL.md +309 -0
  101. package/skills/luckiest-coder-debugging/condition-based-waiting-example.ts +158 -0
  102. package/skills/luckiest-coder-debugging/condition-based-waiting.md +115 -0
  103. package/skills/luckiest-coder-debugging/defense-in-depth.md +122 -0
  104. package/skills/luckiest-coder-debugging/find-polluter.sh +63 -0
  105. package/skills/luckiest-coder-debugging/root-cause-tracing.md +169 -0
  106. package/skills/luckiest-coder-debugging/test-academic.md +14 -0
  107. package/skills/luckiest-coder-debugging/test-pressure-1.md +58 -0
  108. package/skills/luckiest-coder-debugging/test-pressure-2.md +68 -0
  109. package/skills/luckiest-coder-debugging/test-pressure-3.md +69 -0
  110. package/skills/luckiest-coder-dispatching-parallel-agents/ATTRIBUTION.md +26 -0
  111. package/skills/luckiest-coder-dispatching-parallel-agents/CHANGELOG.md +23 -0
  112. package/skills/luckiest-coder-dispatching-parallel-agents/SKILL.md +198 -0
  113. package/skills/luckiest-coder-executing-plans/ATTRIBUTION.md +26 -0
  114. package/skills/luckiest-coder-executing-plans/CHANGELOG.md +22 -0
  115. package/skills/luckiest-coder-executing-plans/SKILL.md +83 -0
  116. package/skills/luckiest-coder-finishing-a-branch/ATTRIBUTION.md +26 -0
  117. package/skills/luckiest-coder-finishing-a-branch/CHANGELOG.md +22 -0
  118. package/skills/luckiest-coder-finishing-a-branch/SKILL.md +258 -0
  119. package/skills/luckiest-coder-git-worktrees/ATTRIBUTION.md +26 -0
  120. package/skills/luckiest-coder-git-worktrees/CHANGELOG.md +22 -0
  121. package/skills/luckiest-coder-git-worktrees/SKILL.md +215 -0
  122. package/skills/luckiest-coder-guard/ATTRIBUTION.md +29 -0
  123. package/skills/luckiest-coder-guard/CHANGELOG.md +48 -0
  124. package/skills/luckiest-coder-guard/LICENSE +21 -0
  125. package/skills/luckiest-coder-guard/SKILL.md +126 -0
  126. package/skills/luckiest-coder-guard/bin/check-careful.sh +65 -0
  127. package/skills/luckiest-coder-guard/bin/check-freeze.sh +48 -0
  128. package/skills/luckiest-coder-qa/ATTRIBUTION.md +28 -0
  129. package/skills/luckiest-coder-qa/CHANGELOG.md +49 -0
  130. package/skills/luckiest-coder-qa/LICENSE +21 -0
  131. package/skills/luckiest-coder-qa/SKILL.md +160 -0
  132. package/skills/luckiest-coder-qa/references/issue-taxonomy.md +85 -0
  133. package/skills/luckiest-coder-qa/references/qa-report-template.md +126 -0
  134. package/skills/luckiest-coder-receiving-code-review/ATTRIBUTION.md +26 -0
  135. package/skills/luckiest-coder-receiving-code-review/CHANGELOG.md +28 -0
  136. package/skills/luckiest-coder-receiving-code-review/SKILL.md +226 -0
  137. package/skills/luckiest-coder-requesting-code-review/ATTRIBUTION.md +26 -0
  138. package/skills/luckiest-coder-requesting-code-review/CHANGELOG.md +31 -0
  139. package/skills/luckiest-coder-requesting-code-review/SKILL.md +118 -0
  140. package/skills/luckiest-coder-requesting-code-review/code-reviewer.md +172 -0
  141. package/skills/luckiest-coder-shipping/ATTRIBUTION.md +26 -0
  142. package/skills/luckiest-coder-shipping/CHANGELOG.md +48 -0
  143. package/skills/luckiest-coder-shipping/LICENSE +21 -0
  144. package/skills/luckiest-coder-shipping/SKILL.md +148 -0
  145. package/skills/luckiest-coder-shipping/references/canary.md +44 -0
  146. package/skills/luckiest-coder-shipping/references/test-and-coverage.md +91 -0
  147. package/skills/luckiest-coder-subagent-driven-development/ATTRIBUTION.md +26 -0
  148. package/skills/luckiest-coder-subagent-driven-development/CHANGELOG.md +28 -0
  149. package/skills/luckiest-coder-subagent-driven-development/SKILL.md +439 -0
  150. package/skills/luckiest-coder-subagent-driven-development/implementer-prompt.md +139 -0
  151. package/skills/luckiest-coder-subagent-driven-development/scripts/review-package +44 -0
  152. package/skills/luckiest-coder-subagent-driven-development/scripts/sdd-workspace +22 -0
  153. package/skills/luckiest-coder-subagent-driven-development/scripts/task-brief +40 -0
  154. package/skills/luckiest-coder-subagent-driven-development/task-reviewer-prompt.md +188 -0
  155. package/skills/luckiest-coder-tdd/ATTRIBUTION.md +26 -0
  156. package/skills/luckiest-coder-tdd/CHANGELOG.md +26 -0
  157. package/skills/luckiest-coder-tdd/SKILL.md +384 -0
  158. package/skills/luckiest-coder-tdd/testing-anti-patterns.md +299 -0
  159. package/skills/luckiest-coder-verification/ATTRIBUTION.md +26 -0
  160. package/skills/luckiest-coder-verification/CHANGELOG.md +22 -0
  161. package/skills/luckiest-coder-verification/SKILL.md +152 -0
  162. package/skills/luckiest-coder-writing-plans/ATTRIBUTION.md +26 -0
  163. package/skills/luckiest-coder-writing-plans/CHANGELOG.md +22 -0
  164. package/skills/luckiest-coder-writing-plans/SKILL.md +191 -0
  165. package/skills/luckiest-coder-writing-plans/plan-document-reviewer-prompt.md +49 -0
  166. package/skills/luckiest-coder-writing-skills/ATTRIBUTION.md +26 -0
  167. package/skills/luckiest-coder-writing-skills/CHANGELOG.md +31 -0
  168. package/skills/luckiest-coder-writing-skills/SKILL.md +702 -0
  169. package/skills/luckiest-coder-writing-skills/anthropic-best-practices.md +1150 -0
  170. package/skills/luckiest-coder-writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  171. package/skills/luckiest-coder-writing-skills/graphviz-conventions.dot +172 -0
  172. package/skills/luckiest-coder-writing-skills/persuasion-principles.md +187 -0
  173. package/skills/luckiest-coder-writing-skills/render-graphs.js +168 -0
  174. package/skills/luckiest-coder-writing-skills/testing-skills-with-subagents.md +384 -0
  175. package/skills/luckiest-cold-email/ATTRIBUTION.md +36 -0
  176. package/skills/luckiest-cold-email/CHANGELOG.md +24 -0
  177. package/skills/luckiest-cold-email/SKILL.md +213 -0
  178. package/skills/luckiest-cold-email/evals/evals.json +94 -0
  179. package/skills/luckiest-cold-email/references/benchmarks.md +83 -0
  180. package/skills/luckiest-cold-email/references/follow-up-sequences.md +81 -0
  181. package/skills/luckiest-cold-email/references/frameworks.md +90 -0
  182. package/skills/luckiest-cold-email/references/personalization.md +79 -0
  183. package/skills/luckiest-cold-email/references/subject-lines.md +53 -0
  184. package/skills/luckiest-community-marketing/ATTRIBUTION.md +41 -0
  185. package/skills/luckiest-community-marketing/CHANGELOG.md +35 -0
  186. package/skills/luckiest-community-marketing/SKILL.md +200 -0
  187. package/skills/luckiest-community-marketing/evals/evals.json +89 -0
  188. package/skills/luckiest-competitor-profiling/ATTRIBUTION.md +36 -0
  189. package/skills/luckiest-competitor-profiling/CHANGELOG.md +24 -0
  190. package/skills/luckiest-competitor-profiling/SKILL.md +443 -0
  191. package/skills/luckiest-competitor-profiling/evals/evals.json +85 -0
  192. package/skills/luckiest-competitor-profiling/references/templates.md +167 -0
  193. package/skills/luckiest-competitor-profiling/references/tool-reference.md +179 -0
  194. package/skills/luckiest-competitors/ATTRIBUTION.md +36 -0
  195. package/skills/luckiest-competitors/CHANGELOG.md +25 -0
  196. package/skills/luckiest-competitors/SKILL.md +171 -0
  197. package/skills/luckiest-competitors/evals/evals.json +93 -0
  198. package/skills/luckiest-competitors/references/content-architecture.md +271 -0
  199. package/skills/luckiest-competitors/references/templates.md +223 -0
  200. package/skills/luckiest-content-strategy/ATTRIBUTION.md +39 -0
  201. package/skills/luckiest-content-strategy/CHANGELOG.md +23 -0
  202. package/skills/luckiest-content-strategy/SKILL.md +395 -0
  203. package/skills/luckiest-content-strategy/evals/evals.json +90 -0
  204. package/skills/luckiest-content-strategy/references/headless-cms.md +194 -0
  205. package/skills/luckiest-copy-editing/ATTRIBUTION.md +41 -0
  206. package/skills/luckiest-copy-editing/CHANGELOG.md +31 -0
  207. package/skills/luckiest-copy-editing/SKILL.md +491 -0
  208. package/skills/luckiest-copy-editing/evals/evals.json +89 -0
  209. package/skills/luckiest-copy-editing/references/checklist.md +66 -0
  210. package/skills/luckiest-copy-editing/references/content-refresh.md +38 -0
  211. package/skills/luckiest-copy-editing/references/plain-english-alternatives.md +394 -0
  212. package/skills/luckiest-copywriting/ATTRIBUTION.md +35 -0
  213. package/skills/luckiest-copywriting/CHANGELOG.md +23 -0
  214. package/skills/luckiest-copywriting/SKILL.md +296 -0
  215. package/skills/luckiest-copywriting/evals/evals.json +111 -0
  216. package/skills/luckiest-copywriting/references/copy-frameworks.md +344 -0
  217. package/skills/luckiest-copywriting/references/natural-transitions.md +272 -0
  218. package/skills/luckiest-cro/ATTRIBUTION.md +34 -0
  219. package/skills/luckiest-cro/CHANGELOG.md +23 -0
  220. package/skills/luckiest-cro/SKILL.md +215 -0
  221. package/skills/luckiest-cro/evals/evals.json +111 -0
  222. package/skills/luckiest-cro/references/experiments.md +248 -0
  223. package/skills/luckiest-cro/references/form.md +422 -0
  224. package/skills/luckiest-customer-research/ATTRIBUTION.md +40 -0
  225. package/skills/luckiest-customer-research/CHANGELOG.md +26 -0
  226. package/skills/luckiest-customer-research/SKILL.md +293 -0
  227. package/skills/luckiest-customer-research/evals/evals.json +162 -0
  228. package/skills/luckiest-customer-research/references/source-guides.md +401 -0
  229. package/skills/luckiest-directory-submissions/ATTRIBUTION.md +40 -0
  230. package/skills/luckiest-directory-submissions/CHANGELOG.md +33 -0
  231. package/skills/luckiest-directory-submissions/SKILL.md +419 -0
  232. package/skills/luckiest-directory-submissions/evals/evals.json +94 -0
  233. package/skills/luckiest-directory-submissions/references/directory-list.md +477 -0
  234. package/skills/luckiest-directory-submissions/references/positioning-variations.md +232 -0
  235. package/skills/luckiest-directory-submissions/references/submission-tracker-template.csv +266 -0
  236. package/skills/luckiest-emails/ATTRIBUTION.md +42 -0
  237. package/skills/luckiest-emails/CHANGELOG.md +33 -0
  238. package/skills/luckiest-emails/SKILL.md +220 -0
  239. package/skills/luckiest-emails/evals/evals.json +93 -0
  240. package/skills/luckiest-emails/references/copy-guidelines.md +120 -0
  241. package/skills/luckiest-emails/references/email-types.md +515 -0
  242. package/skills/luckiest-emails/references/sequence-templates.md +168 -0
  243. package/skills/luckiest-extract-design-system/ATTRIBUTION.md +8 -0
  244. package/skills/luckiest-extract-design-system/CHANGELOG.md +37 -0
  245. package/skills/luckiest-extract-design-system/SKILL.md +83 -0
  246. package/skills/luckiest-free-tools/ATTRIBUTION.md +43 -0
  247. package/skills/luckiest-free-tools/CHANGELOG.md +25 -0
  248. package/skills/luckiest-free-tools/SKILL.md +220 -0
  249. package/skills/luckiest-free-tools/evals/evals.json +90 -0
  250. package/skills/luckiest-free-tools/references/tool-types.md +217 -0
  251. package/skills/luckiest-image/ATTRIBUTION.md +43 -0
  252. package/skills/luckiest-image/CHANGELOG.md +23 -0
  253. package/skills/luckiest-image/SKILL.md +376 -0
  254. package/skills/luckiest-image/evals/evals.json +89 -0
  255. package/skills/luckiest-image/references/ai-image-prompting.md +229 -0
  256. package/skills/luckiest-launch/ATTRIBUTION.md +40 -0
  257. package/skills/luckiest-launch/CHANGELOG.md +43 -0
  258. package/skills/luckiest-launch/SKILL.md +385 -0
  259. package/skills/luckiest-launch/evals/evals.json +91 -0
  260. package/skills/luckiest-lead-magnets/ATTRIBUTION.md +35 -0
  261. package/skills/luckiest-lead-magnets/CHANGELOG.md +23 -0
  262. package/skills/luckiest-lead-magnets/SKILL.md +331 -0
  263. package/skills/luckiest-lead-magnets/evals/evals.json +87 -0
  264. package/skills/luckiest-lead-magnets/references/benchmarks.md +129 -0
  265. package/skills/luckiest-lead-magnets/references/format-guide.md +196 -0
  266. package/skills/luckiest-marketing-ideas/ATTRIBUTION.md +39 -0
  267. package/skills/luckiest-marketing-ideas/CHANGELOG.md +33 -0
  268. package/skills/luckiest-marketing-ideas/SKILL.md +191 -0
  269. package/skills/luckiest-marketing-ideas/evals/evals.json +90 -0
  270. package/skills/luckiest-marketing-ideas/references/ideas-by-category.md +366 -0
  271. package/skills/luckiest-marketing-plan/ATTRIBUTION.md +40 -0
  272. package/skills/luckiest-marketing-plan/CHANGELOG.md +23 -0
  273. package/skills/luckiest-marketing-plan/SKILL.md +285 -0
  274. package/skills/luckiest-marketing-plan/evals/evals.json +96 -0
  275. package/skills/luckiest-marketing-plan/references/aarrr-framework.md +180 -0
  276. package/skills/luckiest-marketing-plan/references/budget-planning.md +168 -0
  277. package/skills/luckiest-marketing-plan/references/client-types.md +373 -0
  278. package/skills/luckiest-marketing-plan/references/current-state-rubric.md +255 -0
  279. package/skills/luckiest-marketing-plan/references/example-quietude.md +972 -0
  280. package/skills/luckiest-marketing-plan/references/funding-stage-unlocks.md +230 -0
  281. package/skills/luckiest-marketing-plan/references/growth-patterns.md +148 -0
  282. package/skills/luckiest-marketing-plan/references/idea-cross-reference.md +265 -0
  283. package/skills/luckiest-marketing-plan/references/measurement-framework.md +213 -0
  284. package/skills/luckiest-marketing-plan/references/methodology.md +363 -0
  285. package/skills/luckiest-marketing-plan/references/ops-stack-mapping.md +197 -0
  286. package/skills/luckiest-marketing-plan/references/plan-template.md +494 -0
  287. package/skills/luckiest-marketing-plan/references/team-and-agency-model.md +278 -0
  288. package/skills/luckiest-marketing-psychology/ATTRIBUTION.md +31 -0
  289. package/skills/luckiest-marketing-psychology/CHANGELOG.md +32 -0
  290. package/skills/luckiest-marketing-psychology/SKILL.md +202 -0
  291. package/skills/luckiest-marketing-psychology/evals.json +88 -0
  292. package/skills/luckiest-model-router/ATTRIBUTION.md +11 -0
  293. package/skills/luckiest-model-router/CHANGELOG.md +45 -0
  294. package/skills/luckiest-model-router/SKILL.md +74 -0
  295. package/skills/luckiest-model-router/scripts/route.py +145 -0
  296. package/skills/luckiest-offers/ATTRIBUTION.md +39 -0
  297. package/skills/luckiest-offers/CHANGELOG.md +23 -0
  298. package/skills/luckiest-offers/SKILL.md +167 -0
  299. package/skills/luckiest-offers/references/bonus-stacking.md +150 -0
  300. package/skills/luckiest-offers/references/examples.md +215 -0
  301. package/skills/luckiest-offers/references/guarantee-design.md +172 -0
  302. package/skills/luckiest-offers/references/offer-anatomy.md +203 -0
  303. package/skills/luckiest-offers/references/offer-formats.md +240 -0
  304. package/skills/luckiest-offers/references/scarcity-urgency.md +175 -0
  305. package/skills/luckiest-offers/references/value-equation.md +134 -0
  306. package/skills/luckiest-onboarding/ATTRIBUTION.md +43 -0
  307. package/skills/luckiest-onboarding/CHANGELOG.md +31 -0
  308. package/skills/luckiest-onboarding/SKILL.md +243 -0
  309. package/skills/luckiest-onboarding/references/experiments.md +258 -0
  310. package/skills/luckiest-paywalls/ATTRIBUTION.md +39 -0
  311. package/skills/luckiest-paywalls/CHANGELOG.md +25 -0
  312. package/skills/luckiest-paywalls/SKILL.md +256 -0
  313. package/skills/luckiest-paywalls/evals/evals.json +93 -0
  314. package/skills/luckiest-paywalls/references/experiments.md +164 -0
  315. package/skills/luckiest-popups/ATTRIBUTION.md +35 -0
  316. package/skills/luckiest-popups/CHANGELOG.md +27 -0
  317. package/skills/luckiest-popups/SKILL.md +477 -0
  318. package/skills/luckiest-popups/evals/evals.json +107 -0
  319. package/skills/luckiest-pricing/ATTRIBUTION.md +35 -0
  320. package/skills/luckiest-pricing/CHANGELOG.md +31 -0
  321. package/skills/luckiest-pricing/SKILL.md +283 -0
  322. package/skills/luckiest-pricing/evals/evals.json +90 -0
  323. package/skills/luckiest-pricing/references/research-methods.md +152 -0
  324. package/skills/luckiest-pricing/references/tier-structure.md +232 -0
  325. package/skills/luckiest-product-marketing/ATTRIBUTION.md +38 -0
  326. package/skills/luckiest-product-marketing/CHANGELOG.md +23 -0
  327. package/skills/luckiest-product-marketing/SKILL.md +256 -0
  328. package/skills/luckiest-product-marketing/evals/evals.json +85 -0
  329. package/skills/luckiest-programmatic-seo/ATTRIBUTION.md +35 -0
  330. package/skills/luckiest-programmatic-seo/CHANGELOG.md +24 -0
  331. package/skills/luckiest-programmatic-seo/SKILL.md +286 -0
  332. package/skills/luckiest-programmatic-seo/evals/evals.json +95 -0
  333. package/skills/luckiest-programmatic-seo/references/playbooks.md +308 -0
  334. package/skills/luckiest-prompt-rewrite/ATTRIBUTION.md +39 -0
  335. package/skills/luckiest-prompt-rewrite/CHANGELOG.md +70 -0
  336. package/skills/luckiest-prompt-rewrite/LICENSE +21 -0
  337. package/skills/luckiest-prompt-rewrite/SKILL.md +494 -0
  338. package/skills/luckiest-prompt-rewrite/references/patterns.md +82 -0
  339. package/skills/luckiest-prompt-rewrite/references/templates.md +454 -0
  340. package/skills/luckiest-prospecting/ATTRIBUTION.md +29 -0
  341. package/skills/luckiest-prospecting/CHANGELOG.md +24 -0
  342. package/skills/luckiest-prospecting/SKILL.md +275 -0
  343. package/skills/luckiest-prospecting/evals/evals.json +107 -0
  344. package/skills/luckiest-prospecting/references/b2b-prospecting.md +106 -0
  345. package/skills/luckiest-prospecting/references/compliance.md +123 -0
  346. package/skills/luckiest-prospecting/references/data-sources.md +287 -0
  347. package/skills/luckiest-prospecting/references/local-prospecting.md +165 -0
  348. package/skills/luckiest-prospecting/references/saas-prospecting.md +123 -0
  349. package/skills/luckiest-public-relations/ATTRIBUTION.md +34 -0
  350. package/skills/luckiest-public-relations/CHANGELOG.md +23 -0
  351. package/skills/luckiest-public-relations/SKILL.md +154 -0
  352. package/skills/luckiest-public-relations/references/journalist-pitching.md +368 -0
  353. package/skills/luckiest-public-relations/references/media-outlets.md +207 -0
  354. package/skills/luckiest-public-relations/references/newsjacking.md +218 -0
  355. package/skills/luckiest-public-relations/references/press-platforms.md +188 -0
  356. package/skills/luckiest-referrals/ATTRIBUTION.md +41 -0
  357. package/skills/luckiest-referrals/CHANGELOG.md +23 -0
  358. package/skills/luckiest-referrals/SKILL.md +292 -0
  359. package/skills/luckiest-referrals/evals/evals.json +90 -0
  360. package/skills/luckiest-referrals/references/affiliate-programs.md +167 -0
  361. package/skills/luckiest-referrals/references/program-examples.md +143 -0
  362. package/skills/luckiest-revops/ATTRIBUTION.md +40 -0
  363. package/skills/luckiest-revops/CHANGELOG.md +24 -0
  364. package/skills/luckiest-revops/SKILL.md +362 -0
  365. package/skills/luckiest-revops/evals/evals.json +91 -0
  366. package/skills/luckiest-revops/references/automation-playbooks.md +290 -0
  367. package/skills/luckiest-revops/references/lifecycle-definitions.md +278 -0
  368. package/skills/luckiest-revops/references/routing-rules.md +203 -0
  369. package/skills/luckiest-revops/references/scoring-models.md +247 -0
  370. package/skills/luckiest-sales-enablement/ATTRIBUTION.md +39 -0
  371. package/skills/luckiest-sales-enablement/CHANGELOG.md +23 -0
  372. package/skills/luckiest-sales-enablement/SKILL.md +379 -0
  373. package/skills/luckiest-sales-enablement/evals/evals.json +91 -0
  374. package/skills/luckiest-sales-enablement/references/deck-frameworks.md +263 -0
  375. package/skills/luckiest-sales-enablement/references/demo-scripts.md +355 -0
  376. package/skills/luckiest-sales-enablement/references/objection-library.md +270 -0
  377. package/skills/luckiest-sales-enablement/references/one-pager-templates.md +208 -0
  378. package/skills/luckiest-schema/ATTRIBUTION.md +39 -0
  379. package/skills/luckiest-schema/CHANGELOG.md +25 -0
  380. package/skills/luckiest-schema/SKILL.md +209 -0
  381. package/skills/luckiest-schema/evals/evals.json +88 -0
  382. package/skills/luckiest-schema/references/schema-examples.md +398 -0
  383. package/skills/luckiest-seo-audit/ATTRIBUTION.md +39 -0
  384. package/skills/luckiest-seo-audit/CHANGELOG.md +23 -0
  385. package/skills/luckiest-seo-audit/SKILL.md +529 -0
  386. package/skills/luckiest-seo-audit/evals/evals.json +136 -0
  387. package/skills/luckiest-seo-audit/references/ai-writing-detection.md +200 -0
  388. package/skills/luckiest-seo-audit/references/international-seo.md +230 -0
  389. package/skills/luckiest-session-handoff/ATTRIBUTION.md +14 -0
  390. package/skills/luckiest-session-handoff/CHANGELOG.md +30 -0
  391. package/skills/luckiest-session-handoff/SKILL.md +96 -0
  392. package/skills/luckiest-signup/ATTRIBUTION.md +48 -0
  393. package/skills/luckiest-signup/CHANGELOG.md +24 -0
  394. package/skills/luckiest-signup/SKILL.md +382 -0
  395. package/skills/luckiest-signup/evals/evals.json +88 -0
  396. package/skills/luckiest-site-architecture/ATTRIBUTION.md +38 -0
  397. package/skills/luckiest-site-architecture/CHANGELOG.md +23 -0
  398. package/skills/luckiest-site-architecture/SKILL.md +383 -0
  399. package/skills/luckiest-site-architecture/evals/evals.json +88 -0
  400. package/skills/luckiest-site-architecture/references/mermaid-templates.md +216 -0
  401. package/skills/luckiest-site-architecture/references/navigation-patterns.md +305 -0
  402. package/skills/luckiest-site-architecture/references/site-type-templates.md +293 -0
  403. package/skills/luckiest-sms/ATTRIBUTION.md +27 -0
  404. package/skills/luckiest-sms/CHANGELOG.md +24 -0
  405. package/skills/luckiest-sms/SKILL.md +400 -0
  406. package/skills/luckiest-sms/evals/evals.json +100 -0
  407. package/skills/luckiest-sms/references/compliance.md +202 -0
  408. package/skills/luckiest-sms/references/platforms.md +318 -0
  409. package/skills/luckiest-sms/references/sequence-templates.md +282 -0
  410. package/skills/luckiest-social/ATTRIBUTION.md +27 -0
  411. package/skills/luckiest-social/CHANGELOG.md +24 -0
  412. package/skills/luckiest-social/SKILL.md +430 -0
  413. package/skills/luckiest-social/evals/evals.json +92 -0
  414. package/skills/luckiest-social/references/listening-sources-template.md +123 -0
  415. package/skills/luckiest-social/references/listening.md +283 -0
  416. package/skills/luckiest-social/references/platform-limits.md +110 -0
  417. package/skills/luckiest-social/references/platforms.md +170 -0
  418. package/skills/luckiest-social/references/post-templates.md +177 -0
  419. package/skills/luckiest-social/references/reverse-engineering.md +195 -0
  420. package/skills/luckiest-social/references/short-form-video.md +237 -0
  421. package/skills/luckiest-thinker/ATTRIBUTION.md +16 -0
  422. package/skills/luckiest-thinker/CHANGELOG.md +20 -0
  423. package/skills/luckiest-thinker/LICENSE +21 -0
  424. package/skills/luckiest-thinker/SKILL.md +92 -0
  425. package/skills/luckiest-thinker/references/distribution-calibration.md +106 -0
  426. package/skills/luckiest-thinker/references/research-foundations.md +214 -0
  427. package/skills/luckiest-thinker/references/thinking-patterns.md +138 -0
  428. package/skills/luckiest-trends/.skillignore +10 -0
  429. package/skills/luckiest-trends/ATTRIBUTION.md +43 -0
  430. package/skills/luckiest-trends/CHANGELOG.md +86 -0
  431. package/skills/luckiest-trends/LICENSE +21 -0
  432. package/skills/luckiest-trends/SKILL.md +194 -0
  433. package/skills/luckiest-trends/agents/openai.yaml +8 -0
  434. package/skills/luckiest-trends/references/engine-contract.md +2041 -0
  435. package/skills/luckiest-trends/references/save-html-brief.md +206 -0
  436. package/skills/luckiest-trends/scripts/briefing.py +272 -0
  437. package/skills/luckiest-trends/scripts/build-skill.sh +39 -0
  438. package/skills/luckiest-trends/scripts/compare.sh +61 -0
  439. package/skills/luckiest-trends/scripts/evaluate_search_quality.py +573 -0
  440. package/skills/luckiest-trends/scripts/last30days.py +1467 -0
  441. package/skills/luckiest-trends/scripts/lib/__init__.py +1 -0
  442. package/skills/luckiest-trends/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  443. package/skills/luckiest-trends/scripts/lib/__pycache__/arxiv.cpython-313.pyc +0 -0
  444. package/skills/luckiest-trends/scripts/lib/__pycache__/bird_x.cpython-313.pyc +0 -0
  445. package/skills/luckiest-trends/scripts/lib/__pycache__/bluesky.cpython-313.pyc +0 -0
  446. package/skills/luckiest-trends/scripts/lib/__pycache__/categories.cpython-313.pyc +0 -0
  447. package/skills/luckiest-trends/scripts/lib/__pycache__/cjk.cpython-313.pyc +0 -0
  448. package/skills/luckiest-trends/scripts/lib/__pycache__/cluster.cpython-313.pyc +0 -0
  449. package/skills/luckiest-trends/scripts/lib/__pycache__/dates.cpython-313.pyc +0 -0
  450. package/skills/luckiest-trends/scripts/lib/__pycache__/dedupe.cpython-313.pyc +0 -0
  451. package/skills/luckiest-trends/scripts/lib/__pycache__/digg.cpython-313.pyc +0 -0
  452. package/skills/luckiest-trends/scripts/lib/__pycache__/entity_extract.cpython-313.pyc +0 -0
  453. package/skills/luckiest-trends/scripts/lib/__pycache__/env.cpython-313.pyc +0 -0
  454. package/skills/luckiest-trends/scripts/lib/__pycache__/fusion.cpython-313.pyc +0 -0
  455. package/skills/luckiest-trends/scripts/lib/__pycache__/github.cpython-313.pyc +0 -0
  456. package/skills/luckiest-trends/scripts/lib/__pycache__/grounding.cpython-313.pyc +0 -0
  457. package/skills/luckiest-trends/scripts/lib/__pycache__/hackernews.cpython-313.pyc +0 -0
  458. package/skills/luckiest-trends/scripts/lib/__pycache__/hiring_signals.cpython-313.pyc +0 -0
  459. package/skills/luckiest-trends/scripts/lib/__pycache__/html_render.cpython-313.pyc +0 -0
  460. package/skills/luckiest-trends/scripts/lib/__pycache__/http.cpython-313.pyc +0 -0
  461. package/skills/luckiest-trends/scripts/lib/__pycache__/instagram.cpython-313.pyc +0 -0
  462. package/skills/luckiest-trends/scripts/lib/__pycache__/jobs.cpython-313.pyc +0 -0
  463. package/skills/luckiest-trends/scripts/lib/__pycache__/linkedin.cpython-313.pyc +0 -0
  464. package/skills/luckiest-trends/scripts/lib/__pycache__/log.cpython-313.pyc +0 -0
  465. package/skills/luckiest-trends/scripts/lib/__pycache__/normalize.cpython-313.pyc +0 -0
  466. package/skills/luckiest-trends/scripts/lib/__pycache__/permission_preflight.cpython-313.pyc +0 -0
  467. package/skills/luckiest-trends/scripts/lib/__pycache__/perplexity.cpython-313.pyc +0 -0
  468. package/skills/luckiest-trends/scripts/lib/__pycache__/pinterest.cpython-313.pyc +0 -0
  469. package/skills/luckiest-trends/scripts/lib/__pycache__/pipeline.cpython-313.pyc +0 -0
  470. package/skills/luckiest-trends/scripts/lib/__pycache__/planner.cpython-313.pyc +0 -0
  471. package/skills/luckiest-trends/scripts/lib/__pycache__/polymarket.cpython-313.pyc +0 -0
  472. package/skills/luckiest-trends/scripts/lib/__pycache__/preflight.cpython-313.pyc +0 -0
  473. package/skills/luckiest-trends/scripts/lib/__pycache__/providers.cpython-313.pyc +0 -0
  474. package/skills/luckiest-trends/scripts/lib/__pycache__/quality_nudge.cpython-313.pyc +0 -0
  475. package/skills/luckiest-trends/scripts/lib/__pycache__/query.cpython-313.pyc +0 -0
  476. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit.cpython-313.pyc +0 -0
  477. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_arctic.cpython-313.pyc +0 -0
  478. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_enrich.cpython-313.pyc +0 -0
  479. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_keyless.cpython-313.pyc +0 -0
  480. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_listing.cpython-313.pyc +0 -0
  481. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_public.cpython-313.pyc +0 -0
  482. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_rss.cpython-313.pyc +0 -0
  483. package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_shreddit.cpython-313.pyc +0 -0
  484. package/skills/luckiest-trends/scripts/lib/__pycache__/relevance.cpython-313.pyc +0 -0
  485. package/skills/luckiest-trends/scripts/lib/__pycache__/render.cpython-313.pyc +0 -0
  486. package/skills/luckiest-trends/scripts/lib/__pycache__/rerank.cpython-313.pyc +0 -0
  487. package/skills/luckiest-trends/scripts/lib/__pycache__/resolve.cpython-313.pyc +0 -0
  488. package/skills/luckiest-trends/scripts/lib/__pycache__/schema.cpython-313.pyc +0 -0
  489. package/skills/luckiest-trends/scripts/lib/__pycache__/signals.cpython-313.pyc +0 -0
  490. package/skills/luckiest-trends/scripts/lib/__pycache__/skill_meta.cpython-313.pyc +0 -0
  491. package/skills/luckiest-trends/scripts/lib/__pycache__/snippet.cpython-313.pyc +0 -0
  492. package/skills/luckiest-trends/scripts/lib/__pycache__/subproc.cpython-313.pyc +0 -0
  493. package/skills/luckiest-trends/scripts/lib/__pycache__/techmeme.cpython-313.pyc +0 -0
  494. package/skills/luckiest-trends/scripts/lib/__pycache__/threads.cpython-313.pyc +0 -0
  495. package/skills/luckiest-trends/scripts/lib/__pycache__/tiktok.cpython-313.pyc +0 -0
  496. package/skills/luckiest-trends/scripts/lib/__pycache__/trustpilot.cpython-313.pyc +0 -0
  497. package/skills/luckiest-trends/scripts/lib/__pycache__/truthsocial.cpython-313.pyc +0 -0
  498. package/skills/luckiest-trends/scripts/lib/__pycache__/ui.cpython-313.pyc +0 -0
  499. package/skills/luckiest-trends/scripts/lib/__pycache__/web_search_keyless.cpython-313.pyc +0 -0
  500. package/skills/luckiest-trends/scripts/lib/__pycache__/xai_x.cpython-313.pyc +0 -0
  501. package/skills/luckiest-trends/scripts/lib/__pycache__/xiaohongshu_api.cpython-313.pyc +0 -0
  502. package/skills/luckiest-trends/scripts/lib/__pycache__/xquik.cpython-313.pyc +0 -0
  503. package/skills/luckiest-trends/scripts/lib/__pycache__/xurl_x.cpython-313.pyc +0 -0
  504. package/skills/luckiest-trends/scripts/lib/__pycache__/youtube_yt.cpython-313.pyc +0 -0
  505. package/skills/luckiest-trends/scripts/lib/arxiv.py +290 -0
  506. package/skills/luckiest-trends/scripts/lib/bird_x.py +639 -0
  507. package/skills/luckiest-trends/scripts/lib/bluesky.py +323 -0
  508. package/skills/luckiest-trends/scripts/lib/categories.py +283 -0
  509. package/skills/luckiest-trends/scripts/lib/chrome_cookies.py +414 -0
  510. package/skills/luckiest-trends/scripts/lib/cjk.py +107 -0
  511. package/skills/luckiest-trends/scripts/lib/cluster.py +271 -0
  512. package/skills/luckiest-trends/scripts/lib/competitors.py +198 -0
  513. package/skills/luckiest-trends/scripts/lib/cookie_extract.py +508 -0
  514. package/skills/luckiest-trends/scripts/lib/dates.py +169 -0
  515. package/skills/luckiest-trends/scripts/lib/dedupe.py +145 -0
  516. package/skills/luckiest-trends/scripts/lib/digg.py +440 -0
  517. package/skills/luckiest-trends/scripts/lib/entity_extract.py +127 -0
  518. package/skills/luckiest-trends/scripts/lib/env.py +1073 -0
  519. package/skills/luckiest-trends/scripts/lib/fanout.py +84 -0
  520. package/skills/luckiest-trends/scripts/lib/fusion.py +207 -0
  521. package/skills/luckiest-trends/scripts/lib/github.py +996 -0
  522. package/skills/luckiest-trends/scripts/lib/grounding.py +331 -0
  523. package/skills/luckiest-trends/scripts/lib/hackernews.py +357 -0
  524. package/skills/luckiest-trends/scripts/lib/health.py +92 -0
  525. package/skills/luckiest-trends/scripts/lib/hiring_signals.py +326 -0
  526. package/skills/luckiest-trends/scripts/lib/html_publish.py +73 -0
  527. package/skills/luckiest-trends/scripts/lib/html_render.py +738 -0
  528. package/skills/luckiest-trends/scripts/lib/http.py +372 -0
  529. package/skills/luckiest-trends/scripts/lib/instagram.py +521 -0
  530. package/skills/luckiest-trends/scripts/lib/jobs.py +708 -0
  531. package/skills/luckiest-trends/scripts/lib/linkedin.py +363 -0
  532. package/skills/luckiest-trends/scripts/lib/log.py +35 -0
  533. package/skills/luckiest-trends/scripts/lib/normalize.py +759 -0
  534. package/skills/luckiest-trends/scripts/lib/permission_preflight.py +242 -0
  535. package/skills/luckiest-trends/scripts/lib/perplexity.py +745 -0
  536. package/skills/luckiest-trends/scripts/lib/pinterest.py +154 -0
  537. package/skills/luckiest-trends/scripts/lib/pipeline.py +1720 -0
  538. package/skills/luckiest-trends/scripts/lib/planner.py +763 -0
  539. package/skills/luckiest-trends/scripts/lib/polymarket.py +794 -0
  540. package/skills/luckiest-trends/scripts/lib/preflight.py +119 -0
  541. package/skills/luckiest-trends/scripts/lib/providers.py +380 -0
  542. package/skills/luckiest-trends/scripts/lib/quality_nudge.py +383 -0
  543. package/skills/luckiest-trends/scripts/lib/query.py +202 -0
  544. package/skills/luckiest-trends/scripts/lib/reddit.py +731 -0
  545. package/skills/luckiest-trends/scripts/lib/reddit_arctic.py +92 -0
  546. package/skills/luckiest-trends/scripts/lib/reddit_enrich.py +322 -0
  547. package/skills/luckiest-trends/scripts/lib/reddit_keyless.py +329 -0
  548. package/skills/luckiest-trends/scripts/lib/reddit_listing.py +188 -0
  549. package/skills/luckiest-trends/scripts/lib/reddit_public.py +275 -0
  550. package/skills/luckiest-trends/scripts/lib/reddit_rss.py +224 -0
  551. package/skills/luckiest-trends/scripts/lib/reddit_shreddit.py +184 -0
  552. package/skills/luckiest-trends/scripts/lib/relevance.py +200 -0
  553. package/skills/luckiest-trends/scripts/lib/render.py +2130 -0
  554. package/skills/luckiest-trends/scripts/lib/rerank.py +708 -0
  555. package/skills/luckiest-trends/scripts/lib/resolve.py +345 -0
  556. package/skills/luckiest-trends/scripts/lib/safari_cookies.py +193 -0
  557. package/skills/luckiest-trends/scripts/lib/schema.py +319 -0
  558. package/skills/luckiest-trends/scripts/lib/setup_wizard.py +843 -0
  559. package/skills/luckiest-trends/scripts/lib/signals.py +334 -0
  560. package/skills/luckiest-trends/scripts/lib/skill_meta.py +33 -0
  561. package/skills/luckiest-trends/scripts/lib/snippet.py +51 -0
  562. package/skills/luckiest-trends/scripts/lib/subproc.py +116 -0
  563. package/skills/luckiest-trends/scripts/lib/techmeme.py +239 -0
  564. package/skills/luckiest-trends/scripts/lib/threads.py +207 -0
  565. package/skills/luckiest-trends/scripts/lib/tiktok.py +596 -0
  566. package/skills/luckiest-trends/scripts/lib/transcribe.py +235 -0
  567. package/skills/luckiest-trends/scripts/lib/trustpilot.py +277 -0
  568. package/skills/luckiest-trends/scripts/lib/truthsocial.py +162 -0
  569. package/skills/luckiest-trends/scripts/lib/ui.py +626 -0
  570. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/LICENSE +21 -0
  571. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/bird-search.mjs +147 -0
  572. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/cookies.js +220 -0
  573. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/features.json +17 -0
  574. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/paginate-cursor.js +37 -0
  575. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/query-ids.json +20 -0
  576. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-features.js +151 -0
  577. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-query-ids.js +264 -0
  578. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-base.js +129 -0
  579. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-constants.js +50 -0
  580. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-features.js +347 -0
  581. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-search.js +157 -0
  582. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-types.js +2 -0
  583. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-utils.js +511 -0
  584. package/skills/luckiest-trends/scripts/lib/vendor/bird-search/package.json +13 -0
  585. package/skills/luckiest-trends/scripts/lib/web_fetch_keyless.py +105 -0
  586. package/skills/luckiest-trends/scripts/lib/web_search_keyless.py +158 -0
  587. package/skills/luckiest-trends/scripts/lib/xai_x.py +236 -0
  588. package/skills/luckiest-trends/scripts/lib/xiaohongshu_api.py +162 -0
  589. package/skills/luckiest-trends/scripts/lib/xquik.py +404 -0
  590. package/skills/luckiest-trends/scripts/lib/xurl_x.py +171 -0
  591. package/skills/luckiest-trends/scripts/lib/youtube_yt.py +1365 -0
  592. package/skills/luckiest-trends/scripts/setup-keychain.sh +123 -0
  593. package/skills/luckiest-trends/scripts/setup-pass.sh +132 -0
  594. package/skills/luckiest-trends/scripts/store.py +1020 -0
  595. package/skills/luckiest-trends/scripts/test-v1-vs-v2.sh +220 -0
  596. package/skills/luckiest-trends/scripts/test_device_auth.py +128 -0
  597. package/skills/luckiest-trends/scripts/verify_v3.py +195 -0
  598. package/skills/luckiest-trends/scripts/watchlist.py +318 -0
  599. package/skills/luckiest-video/ATTRIBUTION.md +27 -0
  600. package/skills/luckiest-video/CHANGELOG.md +23 -0
  601. package/skills/luckiest-video/SKILL.md +371 -0
  602. package/skills/luckiest-video/evals/evals.json +88 -0
  603. package/skills/luckiest-video/references/ai-video-prompting.md +175 -0
  604. package/skills/remix/SKILL.md +32 -0
  605. package/commands/luckiest/go.md +0 -45
  606. package/commands/luckiest/plan.md +0 -55
  607. /package/commands/{luckiest/charms.md → charms.md} +0 -0
  608. /package/commands/{luckiest/helpers.md → helpers.md} +0 -0
  609. /package/commands/{luckiest/home.md → home.md} +0 -0
  610. /package/commands/{luckiest/leaderboard.md → leaderboard.md} +0 -0
  611. /package/commands/{luckiest/merge.md → merge.md} +0 -0
  612. /package/commands/{luckiest/overlaps.md → overlaps.md} +0 -0
  613. /package/commands/{luckiest/skills.md → skills.md} +0 -0
  614. /package/commands/{luckiest/start.md → start.md} +0 -0
  615. /package/commands/{luckiest/status.md → status.md} +0 -0
  616. /package/commands/{luckiest/updates.md → updates.md} +0 -0
  617. /package/commands/{luckiest/vouch.md → vouch.md} +0 -0
  618. /package/commands/{luckiest/wishes.md → wishes.md} +0 -0
@@ -0,0 +1,1365 @@
1
+ """YouTube search and transcript extraction via yt-dlp for the v3.0.0 pipeline.
2
+
3
+ Uses yt-dlp (https://github.com/yt-dlp/yt-dlp) for both YouTube search and
4
+ transcript extraction. No API keys needed — just have yt-dlp installed.
5
+
6
+ Inspired by Peter Steinberger's toolchain approach (yt-dlp + summarize CLI).
7
+ """
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Set, Tuple
23
+
24
+ # Depth configurations: how many videos to search / transcribe
25
+ DEPTH_CONFIG = {
26
+ "quick": 6,
27
+ "default": 8,
28
+ "deep": 40,
29
+ }
30
+
31
+ TRANSCRIPT_LIMITS = {
32
+ "quick": 0,
33
+ "default": 2,
34
+ "deep": 8,
35
+ }
36
+
37
+ # Cumulative yt-dlp transcript-fetch stats for the current process. The final
38
+ # report only sees post-pruning items, so it can't distinguish "fetches failed
39
+ # (stale binary)" from "fetches succeeded but the videos were pruned later".
40
+ # quality_nudge reads these via last30days.py to suppress the stale-yt-dlp
41
+ # nudge when every attempted fetch actually succeeded. yt-dlp path only: the
42
+ # nudge diagnoses the local binary, not the ScrapeCreators API.
43
+ _TRANSCRIPT_FETCH_STATS = {"attempts": 0, "failures": 0}
44
+
45
+
46
+ def get_transcript_fetch_stats() -> Dict[str, int]:
47
+ """Return cumulative transcript-fetch stats for this process."""
48
+ return dict(_TRANSCRIPT_FETCH_STATS)
49
+
50
+
51
+ def reset_transcript_fetch_stats() -> None:
52
+ """Reset cumulative transcript-fetch stats (used by tests)."""
53
+ _TRANSCRIPT_FETCH_STATS["attempts"] = 0
54
+ _TRANSCRIPT_FETCH_STATS["failures"] = 0
55
+
56
+ # Max words to keep from each transcript
57
+ TRANSCRIPT_MAX_WORDS = 5000
58
+
59
+ from . import dates, http, log, subproc
60
+ from .query import infer_query_intent
61
+
62
+ from .relevance import token_overlap_relevance as _compute_relevance
63
+
64
+ # yt-dlp transcript-fetch resilience. A non-zero yt-dlp exit means a real fetch
65
+ # error (rate-limit / bot-check / network), NOT "no captions" — yt-dlp exits 0
66
+ # with no file for a video that genuinely lacks the requested captions. So we
67
+ # capture the returncode, log a classified reason instead of failing silently,
68
+ # and retry transient errors a couple of times with a small per-video staggered
69
+ # backoff.
70
+ _TRANSCRIPT_MAX_RETRIES = 2
71
+ _TRANSCRIPT_BACKOFF_BASE = 2.0 # seconds; multiplied by (attempt + 1)
72
+ _TRANSCRIPT_TIMEOUT = 30 # seconds per yt-dlp attempt (keyless: no fallback to fail over to)
73
+ _TRANSCRIPT_FAST_TIMEOUT = 12 # seconds per attempt when a ScrapeCreators fallback exists
74
+ _SC_LOW_CREDIT_THRESHOLD = 50 # warn once ScrapeCreators credits drop below this
75
+ # Transient = worth retrying (and definitely not "no captions").
76
+ _TRANSIENT_RE = re.compile(
77
+ r"429|too many requests|sign in to confirm|not a bot|rate.?limit"
78
+ r"|temporarily|try again|timed out|timeout|connection|unable to (extract|download)"
79
+ r"|failed to (extract|download)|got error|read error",
80
+ re.IGNORECASE,
81
+ )
82
+ # A genuine no-captions signal — treat as no captions, never retry/surface.
83
+ _NO_CAPTION_RE = re.compile(
84
+ r"no subtitles|requested (format|language)|there'?s no .*subtitles",
85
+ re.IGNORECASE,
86
+ )
87
+
88
+
89
+ def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -> list[str]:
90
+ """Extract quotable highlights from a YouTube transcript.
91
+
92
+ Filters filler (subscribe, welcome back, etc.), scores sentences by
93
+ specificity (numbers, proper nouns, topic relevance), and returns
94
+ the top highlights.
95
+ """
96
+ if not transcript:
97
+ return []
98
+
99
+ sentences = re.split(r'(?<=[.!?])\s+', transcript)
100
+
101
+ # Fallback for punctuation-free transcripts (common with auto-captions):
102
+ # chunk into ~20-word segments so they pass the 8-50 word filter.
103
+ if len(sentences) <= 1 and len(transcript.split()) > 50:
104
+ words = transcript.split()
105
+ sentences = [' '.join(words[i:i+20]) for i in range(0, len(words), 20)]
106
+
107
+ filler = [
108
+ r"^(hey |hi |what's up|welcome back|in today's video|don't forget to)",
109
+ r"(subscribe|like and comment|hit the bell|check out the link|down below)",
110
+ r"^(so |and |but |okay |alright |um |uh )",
111
+ r"(thanks for watching|see you (next|in the)|bye)",
112
+ ]
113
+
114
+ topic_words = [w.lower() for w in topic.lower().split() if len(w) > 2]
115
+
116
+ candidates = []
117
+ for sent in sentences:
118
+ sent = sent.strip()
119
+ words = sent.split()
120
+ if len(words) < 8 or len(words) > 50:
121
+ continue
122
+ if any(re.search(p, sent, re.IGNORECASE) for p in filler):
123
+ continue
124
+
125
+ score = 0
126
+ if re.search(r'\d', sent):
127
+ score += 2
128
+ if re.search(r'[A-Z][a-z]+', sent):
129
+ score += 1
130
+ if '?' in sent:
131
+ score += 1
132
+ sent_lower = sent.lower()
133
+ if any(w in sent_lower for w in topic_words):
134
+ score += 2
135
+
136
+ candidates.append((score, sent))
137
+
138
+ candidates.sort(key=lambda x: -x[0])
139
+ return [sent for _, sent in candidates[:limit]]
140
+
141
+
142
+ def _log(msg: str):
143
+ log.source_log("YouTube", msg, tty_only=False)
144
+
145
+
146
+ def is_ytdlp_installed() -> bool:
147
+ """Check if yt-dlp is available locally, or if SSH routing is configured.
148
+
149
+ When LAST30DAYS_YOUTUBE_SSH_HOST is set, returns True without a local check —
150
+ yt-dlp lives on the remote host. Failures surface naturally on first use.
151
+ """
152
+ if _ytdlp_ssh_host():
153
+ return True
154
+ return shutil.which("yt-dlp") is not None
155
+
156
+
157
+ # Host aliases must be plain hostnames / SSH config aliases — no flags, no
158
+ # shell metacharacters. Rejects any value that could be reinterpreted by ssh
159
+ # (or the surrounding shell) as something other than a destination.
160
+ _SSH_HOST_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
161
+
162
+
163
+ def _ytdlp_ssh_host() -> Optional[str]:
164
+ """Return SSH host alias if yt-dlp should be routed via SSH, else None.
165
+
166
+ Set LAST30DAYS_YOUTUBE_SSH_HOST=<ssh-alias> (e.g. 'macmini') in the environment
167
+ to route yt-dlp through SSH for residential IP egress. This bypasses
168
+ YouTube's bot-wall on datacenter IPs (Hetzner, DigitalOcean, AWS, etc.)
169
+ where ytsearch returns 0 results regardless of cookies.
170
+
171
+ The remote host must have yt-dlp installed and reachable via the named
172
+ SSH alias (configured in ~/.ssh/config). On macOS hosts with Homebrew,
173
+ add brew shellenv to ~/.zshenv (not just ~/.zprofile) so non-login SSH
174
+ shells find yt-dlp on PATH.
175
+
176
+ Validation: host value must match ``[A-Za-z0-9._-]+``. Anything starting
177
+ with ``-`` or containing shell/SSH metacharacters is rejected with a
178
+ stderr warning and treated as unset, so a misconfigured or attacker-
179
+ controlled value can't slip through as an SSH option flag or proxy command.
180
+ The ``--`` option terminator in ``_wrap_ytdlp_cmd`` is a second line of
181
+ defense; this regex closes the door on the env var ever reaching ssh
182
+ in the first place.
183
+
184
+ To use a value from ~/.config/last30days/.env, export it into the
185
+ environment before invoking the engine, e.g. in a wrapper:
186
+ set -a; source ~/.config/last30days/.env; set +a
187
+ python3 last30days.py "..."
188
+ """
189
+ host = os.environ.get("LAST30DAYS_YOUTUBE_SSH_HOST", "").strip()
190
+ if not host:
191
+ return None
192
+ if not _SSH_HOST_ALIAS_RE.match(host):
193
+ sys.stderr.write(
194
+ f"[youtube_yt] WARNING: LAST30DAYS_YOUTUBE_SSH_HOST={host!r} "
195
+ "does not look like a plain hostname/alias; ignoring. "
196
+ "Expected pattern: letters, digits, dot, underscore, hyphen.\n"
197
+ )
198
+ return None
199
+ return host
200
+
201
+
202
+ def _wrap_ytdlp_cmd(cmd: List[str]) -> List[str]:
203
+ """Wrap a yt-dlp command list with `ssh <host>` when SSH routing is set.
204
+
205
+ Args are shell-quoted to survive the remote shell. Uses BatchMode=yes so
206
+ a misconfigured key fails fast instead of hanging on a password prompt.
207
+ The `--` option terminator prevents an SSH option-injection if
208
+ LAST30DAYS_YOUTUBE_SSH_HOST were ever set to a value starting with `-`.
209
+ """
210
+ host = _ytdlp_ssh_host()
211
+ if not host:
212
+ return cmd
213
+ remote_cmd = " ".join(shlex.quote(a) for a in cmd)
214
+ return ["ssh", "-o", "BatchMode=yes", "--", host, remote_cmd]
215
+
216
+
217
+ def _extract_core_subject(topic: str) -> str:
218
+ """Extract core subject from verbose query for YouTube search.
219
+
220
+ NOTE: 'tips', 'tricks', 'tutorial', 'guide', 'review', 'reviews'
221
+ are intentionally KEPT — they're YouTube content types that improve search.
222
+ """
223
+ from .query import VIRAL_NOISE, extract_core_subject
224
+ # YouTube extends VIRAL_NOISE with temporal/meta words the planner emits
225
+ # that don't appear in YouTube titles (months, recent year tokens, etc.).
226
+ _YT_EXTRA = frozenset({
227
+ 'last', 'days', 'recent', 'recently', 'month', 'week',
228
+ 'january', 'february', 'march', 'april', 'may', 'june',
229
+ 'july', 'august', 'september', 'october', 'november', 'december',
230
+ '2025', '2026', '2027',
231
+ 'music', 'public', 'appearances', 'developments', 'discussions', 'coverage',
232
+ })
233
+ return extract_core_subject(topic, noise=VIRAL_NOISE | _YT_EXTRA)
234
+
235
+
236
+ def expand_youtube_queries(topic: str, depth: str) -> List[str]:
237
+ """Generate multiple YouTube search queries from a topic.
238
+
239
+ Mirrors reddit.py's expand_reddit_queries() pattern:
240
+ 1. Extract core subject (strip noise words)
241
+ 2. Include original topic if different from core
242
+ 3. Add intent-specific OR-joined content-type variants
243
+ 4. Cap by depth: 1 for quick, 2 for default, 3 for deep
244
+
245
+ Returns 1-3 query strings depending on depth.
246
+ """
247
+ core = _extract_core_subject(topic)
248
+ queries = [core]
249
+
250
+ # Include cleaned original topic as variant if different from core
251
+ original_clean = topic.strip().rstrip('?!.')
252
+ if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
253
+ queries.append(original_clean)
254
+
255
+ qtype = infer_query_intent(topic)
256
+
257
+ # Intent-specific YouTube content-type variants
258
+ if qtype == "opinion":
259
+ queries.append(f"{core} review OR reaction OR breakdown")
260
+ elif qtype == "product":
261
+ queries.append(f"{core} review OR comparison OR unboxing")
262
+ elif qtype == "comparison":
263
+ queries.append(f"{core} vs OR compared OR head to head")
264
+ elif qtype == "how_to":
265
+ queries.append(f"{core} tutorial OR guide OR explained")
266
+ else:
267
+ # breaking_news / general — YouTube content types
268
+ queries.append(f"{core} review OR reaction OR breakdown")
269
+
270
+ # Deep depth: add full-length content variant
271
+ if depth == "deep":
272
+ queries.append(f"{core} full OR complete OR official")
273
+
274
+ # Cap by depth budget
275
+ caps = {"quick": 1, "default": 2, "deep": 3}
276
+ cap = caps.get(depth, 2)
277
+ return queries[:cap]
278
+
279
+
280
+ def search_youtube(
281
+ topic: str,
282
+ from_date: str,
283
+ to_date: str,
284
+ depth: str = "default",
285
+ ) -> Dict[str, Any]:
286
+ """Search YouTube via yt-dlp. No API key needed.
287
+
288
+ Args:
289
+ topic: Search topic
290
+ from_date: Start date (YYYY-MM-DD)
291
+ to_date: End date (YYYY-MM-DD)
292
+ depth: 'quick', 'default', or 'deep'
293
+
294
+ Returns:
295
+ Dict with 'items' list of video metadata dicts.
296
+ """
297
+ if not is_ytdlp_installed():
298
+ return {"items": [], "error": "yt-dlp not installed"}
299
+
300
+ count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
301
+ core_topic = _extract_core_subject(topic)
302
+
303
+ _log(f"Searching YouTube for '{core_topic}' (since {from_date}, count={count})")
304
+
305
+ # yt-dlp search with full metadata (no --flat-playlist so dates are real).
306
+ # NOTE: --dateafter intentionally omitted — YouTube search returns
307
+ # relevance-sorted results and strict date filtering returns 0 for
308
+ # evergreen topics. Python soft filter (below) handles date filtering.
309
+ cmd = [
310
+ "yt-dlp",
311
+ "--ignore-config",
312
+ "--no-cookies-from-browser",
313
+ f"ytsearch{count}:{core_topic}",
314
+ "--dump-json",
315
+ "--no-warnings",
316
+ "--no-download",
317
+ ]
318
+ cmd = _wrap_ytdlp_cmd(cmd)
319
+ ssh_host = _ytdlp_ssh_host()
320
+
321
+ 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
+ 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)
395
+
396
+ return {"items": items}
397
+
398
+
399
+ def _clean_vtt(vtt_text: str) -> str:
400
+ """Convert VTT subtitle format to clean plaintext."""
401
+ # Strip VTT header
402
+ text = re.sub(r'^WEBVTT.*?\n\n', '', vtt_text, flags=re.DOTALL)
403
+ # Strip timestamps
404
+ text = re.sub(r'\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}.*\n', '', text)
405
+ # Strip position/alignment tags
406
+ text = re.sub(r'<[^>]+>', '', text)
407
+ # Strip cue numbers
408
+ text = re.sub(r'^\d+\s*$', '', text, flags=re.MULTILINE)
409
+ # Deduplicate overlapping lines
410
+ lines = text.strip().split('\n')
411
+ seen = set()
412
+ unique = []
413
+ for line in lines:
414
+ stripped = line.strip()
415
+ if stripped and stripped not in seen:
416
+ seen.add(stripped)
417
+ unique.append(stripped)
418
+ return re.sub(r'\s+', ' ', ' '.join(unique)).strip()
419
+
420
+
421
+ _YT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
422
+
423
+
424
+ def _fetch_transcript_direct(
425
+ video_id: str,
426
+ timeout: int = 30,
427
+ status: Optional[Dict[str, Any]] = None,
428
+ ) -> Optional[str]:
429
+ """Fetch YouTube transcript via direct HTTP without yt-dlp.
430
+
431
+ Scrapes the watch page HTML for the captions track URL in
432
+ ytInitialPlayerResponse, then fetches the VTT subtitle file.
433
+
434
+ Args:
435
+ video_id: YouTube video ID
436
+ timeout: HTTP request timeout in seconds
437
+ status: Optional dict mutated to record per-video signals. Sets
438
+ ``status["no_caption_tracks"] = True`` when the player response
439
+ confirms the uploader has no caption tracks (vs. fetch failure).
440
+
441
+ Returns:
442
+ Raw VTT text, or None if captions are unavailable.
443
+ """
444
+ watch_url = f"https://www.youtube.com/watch?v={video_id}"
445
+ headers = {
446
+ "User-Agent": _YT_USER_AGENT,
447
+ "Accept-Language": "en-US,en;q=0.9",
448
+ }
449
+
450
+ # Step 1: Fetch the watch page HTML
451
+ req = urllib.request.Request(watch_url, headers=headers)
452
+ try:
453
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
454
+ html = resp.read().decode("utf-8", errors="replace")
455
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
456
+ _log(f"Direct transcript: failed to fetch watch page for {video_id}: {exc}")
457
+ return None
458
+
459
+ # Step 2: Extract captions URL from ytInitialPlayerResponse
460
+ # YouTube embeds this as a JS variable in the page HTML
461
+ match = re.search(
462
+ r'ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;(?:\s*var\s|\s*<\/script>)',
463
+ html,
464
+ )
465
+ if not match:
466
+ # Fallback: try the JSON embedded in the script tag
467
+ match = re.search(
468
+ r'var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;',
469
+ html,
470
+ )
471
+ if not match:
472
+ _log(f"Direct transcript: no ytInitialPlayerResponse found for {video_id}")
473
+ return None
474
+
475
+ try:
476
+ player_response = json.loads(match.group(1))
477
+ except json.JSONDecodeError:
478
+ _log(f"Direct transcript: failed to parse ytInitialPlayerResponse for {video_id}")
479
+ return None
480
+
481
+ # Navigate to caption tracks
482
+ captions = player_response.get("captions", {})
483
+ renderer = captions.get("playerCaptionsTracklistRenderer", {})
484
+ caption_tracks = renderer.get("captionTracks", [])
485
+
486
+ if not caption_tracks:
487
+ _log(f"Direct transcript: no caption tracks for {video_id}")
488
+ if status is not None:
489
+ status["no_caption_tracks"] = True
490
+ return None
491
+
492
+ # Find English track (prefer exact 'en', then any en variant, then first track)
493
+ base_url = None
494
+ for track in caption_tracks:
495
+ lang = track.get("languageCode", "")
496
+ if lang == "en":
497
+ base_url = track.get("baseUrl")
498
+ break
499
+ if not base_url:
500
+ for track in caption_tracks:
501
+ lang = track.get("languageCode", "")
502
+ if lang.startswith("en"):
503
+ base_url = track.get("baseUrl")
504
+ break
505
+ if not base_url:
506
+ # Fall back to first available track
507
+ base_url = caption_tracks[0].get("baseUrl")
508
+ if not base_url:
509
+ _log(f"Direct transcript: no baseUrl in caption tracks for {video_id}")
510
+ return None
511
+
512
+ # Step 3: Fetch the VTT subtitle file
513
+ sep = "&" if "?" in base_url else "?"
514
+ vtt_url = f"{base_url}{sep}fmt=vtt"
515
+ vtt_req = urllib.request.Request(vtt_url, headers=headers)
516
+ try:
517
+ with urllib.request.urlopen(vtt_req, timeout=timeout) as resp:
518
+ vtt_text = resp.read().decode("utf-8", errors="replace")
519
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
520
+ _log(f"Direct transcript: failed to fetch VTT for {video_id}: {exc}")
521
+ return None
522
+
523
+ if not vtt_text or not vtt_text.strip():
524
+ return None
525
+
526
+ return vtt_text
527
+
528
+
529
+ def _fetch_transcript_ytdlp_via_ssh(video_id: str, ssh_host: str) -> Optional[str]:
530
+ """Fetch transcript via yt-dlp on a remote SSH host (mktemp + cat pipeline)."""
531
+ if not _SSH_HOST_ALIAS_RE.match(ssh_host):
532
+ return None
533
+ url = f"https://www.youtube.com/watch?v={video_id}"
534
+ quoted_url = shlex.quote(url)
535
+ sub_langs = shlex.quote(_ytdlp_sub_langs())
536
+ remote_script = (
537
+ "set -e; "
538
+ "TMPD=$(mktemp -d); "
539
+ "yt-dlp --ignore-config --no-cookies-from-browser "
540
+ f"--write-auto-subs --sub-lang {sub_langs} --sub-format vtt "
541
+ "--skip-download --no-warnings "
542
+ f'-o "$TMPD/%(id)s" {quoted_url} >/dev/null 2>&1 || true; '
543
+ 'VTT=$(find "$TMPD" -maxdepth 1 -name "*.vtt" 2>/dev/null | head -1); '
544
+ '[ -n "$VTT" ] && cat "$VTT"; '
545
+ 'rm -rf "$TMPD"'
546
+ )
547
+ cmd = ["ssh", "-o", "BatchMode=yes", "--", ssh_host, remote_script]
548
+ try:
549
+ result = subproc.run_with_timeout(cmd, timeout=45)
550
+ except subproc.SubprocTimeout:
551
+ _log(f"SSH yt-dlp transcript timed out for {video_id} via {ssh_host!r}")
552
+ return None
553
+ except FileNotFoundError:
554
+ _log("ssh executable not found; cannot route transcript fetch")
555
+ return None
556
+ out = result.stdout or ""
557
+ if not out.strip().startswith("WEBVTT"):
558
+ if result.returncode != 0 and result.stderr:
559
+ first_line = result.stderr.strip().splitlines()[0]
560
+ _log(
561
+ f"SSH yt-dlp transcript via {ssh_host!r} failed for "
562
+ f"{video_id} (rc={result.returncode}): {first_line}"
563
+ )
564
+ return None
565
+ return out
566
+
567
+
568
+ def _ytdlp_sub_langs() -> str:
569
+ """Caption languages to try, from LAST30DAYS_YT_SUB_LANGS (default en,es,pt)."""
570
+ raw = os.environ.get("LAST30DAYS_YT_SUB_LANGS", "").strip()
571
+ if not raw:
572
+ return "en,es,pt"
573
+ return ",".join(code.strip().lower() for code in raw.split(",") if code.strip()) or "en,es,pt"
574
+
575
+
576
+ def _pick_ytdlp_vtt(video_id: str, temp_dir: str, priority: List[str]) -> Optional[Path]:
577
+ """Return the best on-disk VTT match for video_id, preferring priority order."""
578
+ matches = list(Path(temp_dir).glob(f"{video_id}*.vtt"))
579
+ if not matches:
580
+ return None
581
+ priority_index = {code: i for i, code in enumerate(priority)}
582
+
583
+ def rank(p: Path) -> int:
584
+ stem = p.stem
585
+ suffix = stem[len(video_id) + 1:] if stem.startswith(video_id + ".") else ""
586
+ code = suffix.split("-")[0].split(".")[0]
587
+ return priority_index.get(code, len(priority_index))
588
+
589
+ return sorted(matches, key=rank)[0]
590
+
591
+
592
+ def _transcript_backoff(video_id: str, attempt: int) -> float:
593
+ """Backoff seconds before a transcript retry.
594
+
595
+ Staggered per-video (a sub-second offset derived from the id) so parallel
596
+ workers don't retry in lockstep and re-trip YouTube's limiter.
597
+ """
598
+ offset = (sum(ord(c) for c in video_id) % 1000) / 1000.0 # 0.0–1.0s
599
+ return _TRANSCRIPT_BACKOFF_BASE * (attempt + 1) + offset
600
+
601
+
602
+ def _read_vtt(video_id: str, temp_dir: str) -> Optional[str]:
603
+ """Return the VTT text yt-dlp wrote for ``video_id``, or None if absent."""
604
+ vtt_path = _pick_ytdlp_vtt(video_id, temp_dir, _ytdlp_sub_langs().split(","))
605
+ if vtt_path is None:
606
+ return None
607
+
608
+ try:
609
+ return vtt_path.read_text(encoding="utf-8", errors="replace")
610
+ except OSError:
611
+ return None
612
+
613
+
614
+ def _fetch_transcript_ytdlp(
615
+ video_id: str,
616
+ temp_dir: str,
617
+ status: Optional[Dict[str, Any]] = None,
618
+ fast_fail: bool = False,
619
+ ) -> Optional[str]:
620
+ """Fetch transcript using yt-dlp (original implementation).
621
+
622
+ Args:
623
+ video_id: YouTube video ID
624
+ temp_dir: Temporary directory for subtitle files
625
+ status: Optional dict mutated to record a yt-dlp failure reason
626
+ (``status["ytdlp_error"]``) so the caller can tell a real fetch
627
+ error (rate-limit / bot-check / network / timeout) apart from a
628
+ video that genuinely has no captions, and skip the misleading
629
+ "no captions found" log + the YouTube-blocked HTTP fallback.
630
+ fast_fail: When True, a ScrapeCreators fallback is available, so a
631
+ transient failure (429 / bot-gate) should fail over fast rather
632
+ than retry into the same rate limit. Collapses to a single attempt
633
+ with a shorter per-attempt timeout. The slow thing in a run is
634
+ yt-dlp retrying, not the fast SC fetch, so this is what keeps
635
+ "yt-dlp first" from reintroducing the multi-minute hang.
636
+
637
+ Returns:
638
+ Raw VTT text, or None if no captions are available or the fetch failed.
639
+ On a hard (non-no-caption) failure, sets ``status["ytdlp_error"]``.
640
+ """
641
+ cmd = [
642
+ "yt-dlp",
643
+ "--ignore-config",
644
+ "--no-cookies-from-browser",
645
+ "--write-auto-subs",
646
+ "--sub-lang", _ytdlp_sub_langs(),
647
+ "--sub-format", "vtt",
648
+ "--skip-download",
649
+ "--no-warnings",
650
+ "-o", f"{temp_dir}/%(id)s",
651
+ f"https://www.youtube.com/watch?v={video_id}",
652
+ ]
653
+
654
+ timeout = _TRANSCRIPT_FAST_TIMEOUT if fast_fail else _TRANSCRIPT_TIMEOUT
655
+ attempts = 1 if fast_fail else _TRANSCRIPT_MAX_RETRIES + 1
656
+ last_reason: Optional[str] = None
657
+ for attempt in range(attempts):
658
+ try:
659
+ result = subproc.run_with_timeout(cmd, timeout=timeout)
660
+ except subproc.SubprocTimeout:
661
+ last_reason = f"timed out after {timeout}s"
662
+ _log(f"yt-dlp transcript timed out after {timeout}s for {video_id} "
663
+ f"(attempt {attempt + 1}/{attempts})")
664
+ if attempt < attempts - 1:
665
+ time.sleep(_transcript_backoff(video_id, attempt))
666
+ continue
667
+ break
668
+ except FileNotFoundError:
669
+ # yt-dlp binary missing — not transient, not retryable.
670
+ if status is not None:
671
+ status["ytdlp_error"] = "yt-dlp not found"
672
+ return None
673
+
674
+ if result.returncode == 0:
675
+ vtt = _read_vtt(video_id, temp_dir)
676
+ if vtt is not None:
677
+ return vtt
678
+ # Exit 0 with no file == the uploader has no matching captions.
679
+ # Genuine no-captions: return quietly (caller may still try direct).
680
+ return None
681
+
682
+ # Non-zero exit, but yt-dlp may have written a usable VTT before the
683
+ # failing language errored. With the default `--sub-lang en,es,pt`, an
684
+ # English video fetches `en` fine, then `es`/`pt` hit a 429 and yt-dlp
685
+ # exits non-zero — yet the `en` track is already on disk. A partial
686
+ # success is still a real transcript, so salvage any VTT before
687
+ # classifying this as an error (and, worse, retrying straight back into
688
+ # the same rate limit). This is the root cause of the 0/N transcript
689
+ # runs reported when every video had captions.
690
+ partial_vtt = _read_vtt(video_id, temp_dir)
691
+ if partial_vtt is not None:
692
+ return partial_vtt
693
+
694
+ # Non-zero exit == a real error worth classifying & surfacing.
695
+ stderr = (result.stderr or "").strip()
696
+ snippet = (stderr.splitlines()[-1][:200] if stderr
697
+ else f"exit {result.returncode}")
698
+ if _NO_CAPTION_RE.search(stderr):
699
+ # yt-dlp can exit non-zero when the requested language is absent.
700
+ # Treat as genuine no-captions, not an error worth retrying.
701
+ return None
702
+ last_reason = snippet
703
+ if _TRANSIENT_RE.search(stderr) and attempt < attempts - 1:
704
+ _log(f"yt-dlp transcript transient failure for {video_id} "
705
+ f"(attempt {attempt + 1}/{attempts}): {snippet}")
706
+ time.sleep(_transcript_backoff(video_id, attempt))
707
+ continue
708
+ # Non-transient, or retries exhausted — surface the real reason.
709
+ _log(f"yt-dlp transcript failed for {video_id} "
710
+ f"(exit {result.returncode}): {snippet}")
711
+ break
712
+
713
+ if status is not None and last_reason is not None:
714
+ status["ytdlp_error"] = last_reason
715
+ return None
716
+
717
+
718
+ def _should_try_sc_transcript(status: Optional[Dict[str, Any]]) -> bool:
719
+ """Whether to spend a ScrapeCreators credit after the keyless cascade failed.
720
+
721
+ Skip when the keyless path *proved* the uploader has no caption track
722
+ (``no_caption_tracks``): SC would also return nothing, so a credit would be
723
+ wasted. A transient hard failure (``ytdlp_error``: 429 / bot-gate / timeout)
724
+ is a false negative, so SC is worth trying.
725
+ """
726
+ st = status or {}
727
+ return not st.get("no_caption_tracks")
728
+
729
+
730
+ def fetch_transcript(
731
+ video_id: str,
732
+ temp_dir: str,
733
+ status: Optional[Dict[str, Any]] = None,
734
+ token: Optional[str] = None,
735
+ ) -> Optional[str]:
736
+ """Fetch auto-generated transcript for a YouTube video.
737
+
738
+ Uses yt-dlp when available (preferred, more robust). Falls back to
739
+ direct HTTP transcript fetching when yt-dlp is not installed, and finally
740
+ to the ScrapeCreators transcript endpoint when a key is present and the
741
+ keyless cascade comes back empty.
742
+
743
+ Args:
744
+ video_id: YouTube video ID
745
+ temp_dir: Temporary directory for subtitle files
746
+ status: Optional dict mutated by the direct-HTTP path to record
747
+ per-video signals like ``no_caption_tracks``. Used to surface a
748
+ captions-disabled count so the quality nudge avoids false-positive
749
+ "stale yt-dlp" flags.
750
+ token: Optional ScrapeCreators API key. When present, yt-dlp fails over
751
+ fast (see ``_fetch_transcript_ytdlp`` ``fast_fail``) and a true hard
752
+ failure falls back to the SC transcript endpoint. A credit is only
753
+ spent on a genuine yt-dlp failure, never on success and never on a
754
+ video proven to have no captions. None preserves keyless behavior.
755
+
756
+ Returns:
757
+ Plaintext transcript string, or None if no captions available.
758
+ """
759
+ raw_vtt = None
760
+ ssh_host = _ytdlp_ssh_host()
761
+ if ssh_host and is_ytdlp_installed():
762
+ raw_vtt = _fetch_transcript_ytdlp_via_ssh(video_id, ssh_host)
763
+ if not raw_vtt:
764
+ _log(f"SSH yt-dlp transcript failed for {video_id}, trying direct HTTP fallback")
765
+ raw_vtt = _fetch_transcript_direct(video_id, status=status)
766
+ elif is_ytdlp_installed():
767
+ raw_vtt = _fetch_transcript_ytdlp(
768
+ video_id, temp_dir, status=status, fast_fail=bool(token),
769
+ )
770
+ if not raw_vtt:
771
+ ytdlp_error = (status or {}).get("ytdlp_error")
772
+ if ytdlp_error:
773
+ # Hard failure (429 / bot-gate / timeout). The direct-HTTP
774
+ # fallback is also YouTube-blocked, so skip it and let the
775
+ # ScrapeCreators fallback below handle it when a key is present.
776
+ _log(f"Transcript fetch failed for {video_id}: {ytdlp_error}")
777
+ else:
778
+ _log(f"yt-dlp found no captions for {video_id}, trying direct HTTP fallback")
779
+ raw_vtt = _fetch_transcript_direct(video_id, status=status)
780
+ else:
781
+ _log("yt-dlp not installed, using direct HTTP transcript fetch")
782
+ raw_vtt = _fetch_transcript_direct(video_id, status=status)
783
+
784
+ if raw_vtt:
785
+ transcript = _clean_vtt(raw_vtt)
786
+ # Truncate to max words
787
+ words = transcript.split()
788
+ if len(words) > TRANSCRIPT_MAX_WORDS:
789
+ transcript = ' '.join(words[:TRANSCRIPT_MAX_WORDS]) + '...'
790
+ return transcript if transcript else None
791
+
792
+ # Keyless cascade produced nothing. When a ScrapeCreators key is present and
793
+ # the video was not proven caption-less, fall back to the SC transcript
794
+ # endpoint (fetched server-side: no 429, cookies, or PO tokens). Returns
795
+ # already-cleaned, word-capped plaintext.
796
+ if token and _should_try_sc_transcript(status):
797
+ sc_transcript = _sc_fetch_transcript(video_id, token)
798
+ if sc_transcript:
799
+ return sc_transcript
800
+
801
+ _log(f"No transcript available for {video_id}")
802
+ return None
803
+
804
+
805
+ def fetch_transcripts_parallel(
806
+ video_ids: List[str],
807
+ max_workers: int = 5,
808
+ out_captions_disabled: Optional[Set[str]] = None,
809
+ token: Optional[str] = None,
810
+ ) -> Dict[str, Optional[str]]:
811
+ """Fetch transcripts for multiple videos in parallel.
812
+
813
+ Args:
814
+ video_ids: List of YouTube video IDs
815
+ max_workers: Max parallel fetches
816
+ out_captions_disabled: Optional set mutated to record video_ids whose
817
+ uploader confirmed no caption tracks (vs. transient fetch failures).
818
+ Backward-compatible: callers that don't care can omit.
819
+ token: Optional ScrapeCreators API key, threaded to each
820
+ ``fetch_transcript`` so the per-video SC fallback activates on
821
+ yt-dlp failure. None preserves keyless behavior.
822
+
823
+ Returns:
824
+ Dict mapping video_id to transcript text (or None).
825
+ """
826
+ if not video_ids:
827
+ return {}
828
+
829
+ _log(f"Fetching transcripts for {len(video_ids)} videos")
830
+
831
+ results = {}
832
+ statuses: Dict[str, Dict[str, Any]] = {vid: {} for vid in video_ids}
833
+ with tempfile.TemporaryDirectory() as temp_dir:
834
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
835
+ futures = {
836
+ executor.submit(
837
+ fetch_transcript, vid, temp_dir, statuses[vid], token,
838
+ ): vid
839
+ for vid in video_ids
840
+ }
841
+ for future in as_completed(futures):
842
+ vid = futures[future]
843
+ try:
844
+ results[vid] = future.result()
845
+ except OSError as exc:
846
+ _log(f"Transcript fetch error for {vid}: {exc}")
847
+ results[vid] = None
848
+ except Exception as exc:
849
+ _log(f"Unexpected transcript error for {vid}: {type(exc).__name__}: {exc}")
850
+ results[vid] = None
851
+
852
+ if out_captions_disabled is not None:
853
+ for vid, st in statuses.items():
854
+ if st.get("no_caption_tracks"):
855
+ out_captions_disabled.add(vid)
856
+
857
+ got = sum(1 for v in results.values() if v)
858
+ 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)")
860
+ return results
861
+
862
+
863
+ def backfill_transcripts(
864
+ items: List[Any], topic: str = "", depth: str = "default",
865
+ token: Optional[str] = None,
866
+ ) -> None:
867
+ """Second-pass transcript fetch for finalized items that lack one (#542).
868
+
869
+ ``token`` is the optional ScrapeCreators key, threaded to
870
+ ``fetch_transcripts_parallel`` so the SC fallback covers backfill survivors
871
+ that yt-dlp can't fetch. None preserves keyless behavior.
872
+ """
873
+ limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
874
+ if limit <= 0 or not items or not is_ytdlp_installed():
875
+ return
876
+ have = sum(
877
+ 1 for it in items
878
+ if it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet")
879
+ )
880
+ need = limit - have
881
+ if need <= 0:
882
+ return
883
+ missing = [
884
+ it for it in items
885
+ if it.item_id
886
+ and not it.metadata.get("transcript_highlights")
887
+ and not it.metadata.get("transcript_snippet")
888
+ and not it.metadata.get("captions_disabled")
889
+ ]
890
+ attempts = missing[: need * 3]
891
+ if not attempts:
892
+ return
893
+ _log(f"Backfilling transcripts for {len(attempts)} finalized videos (target: {need})")
894
+ captions_disabled: Set[str] = set()
895
+ transcripts = fetch_transcripts_parallel(
896
+ [it.item_id for it in attempts],
897
+ out_captions_disabled=captions_disabled,
898
+ token=token,
899
+ )
900
+ for it in attempts:
901
+ if it.item_id in captions_disabled:
902
+ it.metadata["captions_disabled"] = True
903
+ continue
904
+ transcript = transcripts.get(it.item_id)
905
+ if not transcript:
906
+ continue
907
+ it.metadata["transcript_snippet"] = transcript
908
+ highlights = extract_transcript_highlights(transcript, topic)
909
+ if highlights:
910
+ it.metadata["transcript_highlights"] = highlights
911
+ if not it.snippet:
912
+ it.snippet = " ".join(transcript.split()[:80])
913
+
914
+
915
+ def _transcript_candidate_sort_key(item: dict) -> tuple:
916
+ """Sort key for transcript candidate selection.
917
+
918
+ Combines views with recency so that recent videos (which survive
919
+ strict_recent freshness pruning) are prioritised over old high-view
920
+ videos whose transcripts would be discarded downstream.
921
+ """
922
+ views = item.get("engagement", {}).get("views", 0) or 0
923
+ recency = dates.recency_score(item.get("date", ""))
924
+ return (views, recency)
925
+
926
+
927
+ def search_and_transcribe(
928
+ topic: str,
929
+ from_date: str,
930
+ to_date: str,
931
+ depth: str = "default",
932
+ token: Optional[str] = None,
933
+ ) -> Dict[str, Any]:
934
+ """Full YouTube search: find videos, then fetch transcripts for top results.
935
+
936
+ Uses expand_youtube_queries() to generate multiple search queries,
937
+ runs yt-dlp for each, and merges/deduplicates results by video ID.
938
+
939
+ Args:
940
+ topic: Search topic
941
+ from_date: Start date (YYYY-MM-DD)
942
+ to_date: End date (YYYY-MM-DD)
943
+ depth: 'quick', 'default', or 'deep'
944
+ token: Optional ScrapeCreators key for the per-video transcript
945
+ fallback (threaded to fetch_transcripts_parallel).
946
+
947
+ Returns:
948
+ Dict with 'items' list. Each item has a 'transcript_snippet' field.
949
+ """
950
+ # Step 1: Multi-query search — run yt-dlp for each expanded query
951
+ queries = expand_youtube_queries(topic, depth)
952
+ seen_ids: Set[str] = set()
953
+ items: List[Dict[str, Any]] = []
954
+ for q in queries:
955
+ search_result = search_youtube(q, from_date, to_date, depth)
956
+ for item in search_result.get("items", []):
957
+ vid = item.get("video_id", "")
958
+ if vid and vid not in seen_ids:
959
+ seen_ids.add(vid)
960
+ items.append(item)
961
+
962
+ # Sort merged results by views descending
963
+ items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
964
+
965
+ if not items:
966
+ return search_result
967
+
968
+ # Step 2: Fetch transcripts for top videos.
969
+ # Sort candidates by a combination of views and recency so that recent
970
+ # videos (which survive strict_recent pruning) are not starved of
971
+ # transcript budget by older high-view-count outliers.
972
+ # Try more candidates than the limit because some videos (music videos,
973
+ # short clips) lack captions. Attempt up to 3x the limit so we have a
974
+ # good chance of reaching the target number of successful transcripts.
975
+ transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
976
+ transcripts: Dict[str, Optional[str]] = {}
977
+ captions_disabled_ids: Set[str] = set()
978
+ if transcript_limit > 0:
979
+ attempt_count = min(len(items), transcript_limit * 3)
980
+ transcript_candidates = sorted(
981
+ items, key=_transcript_candidate_sort_key, reverse=True,
982
+ )
983
+ candidate_ids = [item["video_id"] for item in transcript_candidates[:attempt_count]]
984
+ _log(f"Fetching transcripts for up to {attempt_count} videos (target: {transcript_limit}): {candidate_ids}")
985
+ transcripts = fetch_transcripts_parallel(
986
+ candidate_ids, out_captions_disabled=captions_disabled_ids,
987
+ token=token,
988
+ )
989
+ # Record fetch outcomes (captions-disabled videos can never succeed,
990
+ # so they don't count as failures) for the stale-yt-dlp nudge.
991
+ _TRANSCRIPT_FETCH_STATS["attempts"] += len(candidate_ids)
992
+ _TRANSCRIPT_FETCH_STATS["failures"] += sum(
993
+ 1 for vid in candidate_ids
994
+ if not transcripts.get(vid) and vid not in captions_disabled_ids
995
+ )
996
+ else:
997
+ _log(f"Transcript limit is 0 for depth={depth}, skipping transcript fetch")
998
+
999
+ # Step 3: Attach transcripts and extract highlights. Mark captions_disabled
1000
+ # so quality_nudge can subtract those videos from the degraded-ratio
1001
+ # denominator (uploader-disabled captions can never produce a transcript;
1002
+ # counting them was producing false-positive stale-yt-dlp nudges).
1003
+ core_topic = _extract_core_subject(topic)
1004
+ for item in items:
1005
+ vid = item["video_id"]
1006
+ transcript = transcripts.get(vid)
1007
+ item["transcript_snippet"] = transcript or ""
1008
+ item["transcript_highlights"] = extract_transcript_highlights(
1009
+ transcript or "", core_topic,
1010
+ )
1011
+ item["captions_disabled"] = vid in captions_disabled_ids
1012
+
1013
+ return {"items": items}
1014
+
1015
+
1016
+ def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
1017
+ """Parse YouTube search response to normalized format.
1018
+
1019
+ Returns:
1020
+ List of item dicts ready for normalization.
1021
+ """
1022
+ return response.get("items", [])
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # ScrapeCreators YouTube API support
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+ SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
1030
+
1031
+
1032
+ def _total_engagement(item: Dict[str, Any]) -> int:
1033
+ """Combined engagement score for ranking which videos to enrich."""
1034
+ eng = item.get("engagement", {})
1035
+ views = eng.get("views", 0) or 0
1036
+ likes = eng.get("likes", 0) or 0
1037
+ comments = eng.get("comments", 0) or 0
1038
+ return views + likes + comments
1039
+
1040
+
1041
+ def enrich_with_comments(
1042
+ items: List[Dict[str, Any]],
1043
+ token: str,
1044
+ max_videos: int = 3,
1045
+ max_comments: int = 5,
1046
+ ) -> List[Dict[str, Any]]:
1047
+ """Enrich top YouTube videos with comment data from ScrapeCreators.
1048
+
1049
+ For the top N videos by engagement, fetches comments via the SC API
1050
+ and attaches them as a ``top_comments`` field on each item.
1051
+
1052
+ Args:
1053
+ items: YouTube items from search_and_transcribe() or search_youtube_sc()
1054
+ token: ScrapeCreators API key
1055
+ max_videos: How many videos to enrich with comments
1056
+ max_comments: Max comments to keep per video
1057
+
1058
+ Returns:
1059
+ Items list (mutated in place) with top_comments added to enriched items.
1060
+ """
1061
+ if not items or not token or max_videos <= 0:
1062
+ return items
1063
+
1064
+ ranked = sorted(items, key=_total_engagement, reverse=True)
1065
+ top_items = ranked[:max_videos]
1066
+ _log(f"Enriching comments for {len(top_items)} YouTube videos")
1067
+
1068
+ from concurrent.futures import ThreadPoolExecutor, as_completed
1069
+
1070
+ def _enrich_one(item: dict) -> bool:
1071
+ video_id = item.get("video_id", "")
1072
+ if not video_id:
1073
+ return False
1074
+ try:
1075
+ comments = _fetch_video_comments(video_id, token, max_comments)
1076
+ if comments:
1077
+ item["top_comments"] = comments
1078
+ return True
1079
+ except Exception as exc:
1080
+ _log(f"Comment enrichment failed for {video_id}: {exc}")
1081
+ return False
1082
+
1083
+ enriched_count = 0
1084
+ with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
1085
+ futures = {executor.submit(_enrich_one, item): item for item in top_items}
1086
+ for future in as_completed(futures):
1087
+ if future.result():
1088
+ enriched_count += 1
1089
+
1090
+ _log(f"Enriched {enriched_count}/{len(top_items)} videos with comments")
1091
+ return items
1092
+
1093
+
1094
+ def _fetch_video_comments(
1095
+ video_id: str,
1096
+ token: str,
1097
+ max_comments: int = 5,
1098
+ ) -> List[Dict[str, Any]]:
1099
+ """Fetch comments for a single YouTube video via ScrapeCreators.
1100
+
1101
+ Args:
1102
+ video_id: YouTube video ID
1103
+ token: ScrapeCreators API key
1104
+ max_comments: Maximum comments to return
1105
+
1106
+ Returns:
1107
+ List of comment dicts with author, text, likes, date.
1108
+ """
1109
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
1110
+ try:
1111
+ data = http.get(
1112
+ f"{SCRAPECREATORS_YT_BASE}/video/comments",
1113
+ params={"url": video_url},
1114
+ headers=http.scrapecreators_headers(token),
1115
+ timeout=30,
1116
+ retries=2,
1117
+ )
1118
+ except Exception as exc:
1119
+ _log(f"Comment fetch error for {video_id}: {exc}")
1120
+ return []
1121
+
1122
+ raw_comments = data.get("comments", data.get("data", []))
1123
+ comments = []
1124
+ for c in raw_comments[:max_comments]:
1125
+ text = c.get("text") or c.get("body") or c.get("content", "")
1126
+ if not text:
1127
+ continue
1128
+
1129
+ # SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
1130
+ author = c.get("author") or c.get("author_name", "")
1131
+ if isinstance(author, dict):
1132
+ author = author.get("name") or author.get("handle") or ""
1133
+
1134
+ # SC nests likes under engagement.likes; legacy shapes used top-level keys.
1135
+ engagement = c.get("engagement") or {}
1136
+ likes = c.get("likes")
1137
+ if likes is None:
1138
+ likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
1139
+ if not likes:
1140
+ likes = c.get("vote_count", 0)
1141
+
1142
+ date = (
1143
+ c.get("date")
1144
+ or c.get("published_at")
1145
+ or c.get("publishedTime")
1146
+ or c.get("publishedTimeText", "")
1147
+ )
1148
+
1149
+ comments.append({
1150
+ "author": author,
1151
+ "text": text[:400],
1152
+ "likes": likes,
1153
+ "date": date,
1154
+ })
1155
+
1156
+ return comments
1157
+
1158
+
1159
+ def search_youtube_sc(
1160
+ topic: str,
1161
+ from_date: str,
1162
+ to_date: str,
1163
+ depth: str = "default",
1164
+ token: str = None,
1165
+ ) -> Dict[str, Any]:
1166
+ """Search YouTube via ScrapeCreators API (fallback when yt-dlp is unavailable).
1167
+
1168
+ Uses SC keyword search to find videos and SC transcript endpoint to
1169
+ fetch transcripts. Called by pipeline.py when yt-dlp fails.
1170
+
1171
+ Args:
1172
+ topic: Search topic
1173
+ from_date: Start date (YYYY-MM-DD)
1174
+ to_date: End date (YYYY-MM-DD)
1175
+ depth: 'quick', 'default', or 'deep'
1176
+ token: ScrapeCreators API key
1177
+
1178
+ Returns:
1179
+ Dict with 'items' list of video metadata dicts.
1180
+ """
1181
+ if not token:
1182
+ return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
1183
+
1184
+ count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
1185
+ core_topic = _extract_core_subject(topic)
1186
+ _log(f"Searching YouTube via ScrapeCreators for '{core_topic}' (depth={depth})")
1187
+
1188
+ # Step 1: Search
1189
+ raw_items = _sc_youtube_search(core_topic, token)
1190
+ if not raw_items:
1191
+ _log("SC YouTube search returned 0 results")
1192
+ return {"items": []}
1193
+
1194
+ # Parse into normalized items
1195
+ items = []
1196
+ for i, raw in enumerate(raw_items[:count]):
1197
+ video_id = (
1198
+ raw.get("id") or raw.get("video_id") or raw.get("videoId") or ""
1199
+ )
1200
+ title = raw.get("title", "")
1201
+ channel = raw.get("channel") or raw.get("channel_name") or raw.get("uploader", "")
1202
+ description = str(raw.get("description", ""))[:500]
1203
+ view_count = raw.get("view_count") or raw.get("views", 0)
1204
+ like_count = raw.get("like_count") or raw.get("likes", 0)
1205
+ comment_count = raw.get("comment_count") or raw.get("comments", 0)
1206
+
1207
+ # Date: try multiple field names
1208
+ date_str = raw.get("upload_date") or raw.get("date") or raw.get("published_at", "")
1209
+ if date_str and len(date_str) == 8 and date_str.isdigit():
1210
+ date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
1211
+ elif date_str and "T" in date_str:
1212
+ date_str = date_str[:10]
1213
+
1214
+ url = raw.get("url", "")
1215
+ if not url and video_id:
1216
+ url = f"https://www.youtube.com/watch?v={video_id}"
1217
+
1218
+ items.append({
1219
+ "video_id": video_id,
1220
+ "title": title,
1221
+ "url": url,
1222
+ "channel_name": channel,
1223
+ "date": date_str if date_str else None,
1224
+ "engagement": {
1225
+ "views": view_count or 0,
1226
+ "likes": like_count or 0,
1227
+ "comments": comment_count or 0,
1228
+ },
1229
+ "duration": raw.get("duration"),
1230
+ "relevance": _compute_relevance(core_topic, f"{title} {description}"),
1231
+ "why_relevant": f"YouTube: {title[:60]}" if title else f"YouTube: {core_topic}",
1232
+ "description": description,
1233
+ })
1234
+
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)")
1242
+
1243
+ # Sort by views
1244
+ items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
1245
+
1246
+ # Step 2: Fetch transcripts for top videos
1247
+ transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
1248
+ if transcript_limit > 0 and items:
1249
+ attempt_count = min(len(items), transcript_limit * 3)
1250
+ # Same in-window-first ordering as search_and_transcribe(): don't let
1251
+ # an out-of-window back-catalog (kept by the soft date filter above)
1252
+ # consume the transcript budget of videos the freshness scorer keeps.
1253
+ in_window = [i for i in items if i.get("date") and i["date"] >= from_date]
1254
+ out_of_window = [i for i in items if not (i.get("date") and i["date"] >= from_date)]
1255
+ _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]:
1257
+ vid = item["video_id"]
1258
+ if not vid:
1259
+ continue
1260
+ transcript = _sc_fetch_transcript(vid, token)
1261
+ item["transcript_snippet"] = transcript or ""
1262
+ item["transcript_highlights"] = extract_transcript_highlights(
1263
+ transcript or "", core_topic,
1264
+ )
1265
+ else:
1266
+ for item in items:
1267
+ item["transcript_snippet"] = ""
1268
+ item["transcript_highlights"] = []
1269
+
1270
+ _log(f"SC YouTube: {len(items)} videos returned")
1271
+ return {"items": items}
1272
+
1273
+
1274
+ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
1275
+ """Call ScrapeCreators YouTube search endpoint.
1276
+
1277
+ Args:
1278
+ keyword: Search keyword
1279
+ token: ScrapeCreators API key
1280
+
1281
+ Returns:
1282
+ List of raw video dicts from the API.
1283
+ """
1284
+ try:
1285
+ # SC's /v1/youtube/search rejects ?keyword= with HTTP 400; the canonical
1286
+ # parameter for that endpoint is `query`. Other SC endpoints use their
1287
+ # own per-endpoint param names so this was the lone outlier.
1288
+ data = http.get(
1289
+ f"{SCRAPECREATORS_YT_BASE}/search",
1290
+ params={"query": keyword},
1291
+ headers=http.scrapecreators_headers(token),
1292
+ timeout=30,
1293
+ retries=2,
1294
+ )
1295
+ return data.get("videos", data.get("data", data.get("items", [])))
1296
+ except Exception as exc:
1297
+ _log(f"SC YouTube search error: {exc}")
1298
+ return []
1299
+
1300
+
1301
+ def _sc_segment_text(seg: Any) -> str:
1302
+ """Extract caption text from a ScrapeCreators transcript segment.
1303
+
1304
+ The transcript endpoint returns a list of segment dicts
1305
+ (``{text, startMs, endMs}``); older/simpler shapes return plain strings.
1306
+ Pull the ``text`` field for dicts so segment metadata is not stringified
1307
+ into the output (``{'text': ...}`` garbage).
1308
+ """
1309
+ if isinstance(seg, dict):
1310
+ # `or ""` (not a get default): a present-but-null `text` returns None,
1311
+ # which would stringify to the literal "None" for silent/music segments.
1312
+ return str(seg.get("text") or "")
1313
+ return str(seg)
1314
+
1315
+
1316
+ def _warn_low_sc_credits(data: Dict[str, Any]) -> None:
1317
+ """Surface a low-credit warning from a ScrapeCreators response, if present."""
1318
+ credits = data.get("credits_remaining")
1319
+ if isinstance(credits, (int, float)) and not isinstance(credits, bool):
1320
+ if credits < _SC_LOW_CREDIT_THRESHOLD:
1321
+ _log(f"ScrapeCreators credits low: {int(credits)} remaining "
1322
+ f"(below {_SC_LOW_CREDIT_THRESHOLD})")
1323
+
1324
+
1325
+ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
1326
+ """Fetch transcript for a YouTube video via ScrapeCreators.
1327
+
1328
+ Args:
1329
+ video_id: YouTube video ID
1330
+ token: ScrapeCreators API key
1331
+
1332
+ Returns:
1333
+ Plaintext transcript string, or None if unavailable.
1334
+ """
1335
+ 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
1347
+
1348
+ _warn_low_sc_credits(data)
1349
+
1350
+ transcript = data.get("transcript")
1351
+ if not transcript:
1352
+ return None
1353
+
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
+ # Truncate to max words
1361
+ words = transcript.split()
1362
+ if len(words) > TRANSCRIPT_MAX_WORDS:
1363
+ transcript = " ".join(words[:TRANSCRIPT_MAX_WORDS]) + "..."
1364
+
1365
+ return transcript if transcript else None