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.
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +24 -0
- package/bin/install.js +7 -5
- package/commands/{luckiest/finish.md → finish.md} +2 -0
- package/commands/go.md +53 -0
- package/commands/plan.md +66 -0
- package/package.json +16 -5
- package/skills/brainstorm/ATTRIBUTION.md +27 -0
- package/skills/brainstorm/SKILL.md +139 -0
- package/skills/luckiest-ab-testing/ATTRIBUTION.md +35 -0
- package/skills/luckiest-ab-testing/CHANGELOG.md +27 -0
- package/skills/luckiest-ab-testing/SKILL.md +379 -0
- package/skills/luckiest-ab-testing/evals/evals.json +118 -0
- package/skills/luckiest-ab-testing/references/sample-size-guide.md +290 -0
- package/skills/luckiest-ab-testing/references/test-templates.md +284 -0
- package/skills/luckiest-ad-creative/ATTRIBUTION.md +35 -0
- package/skills/luckiest-ad-creative/CHANGELOG.md +23 -0
- package/skills/luckiest-ad-creative/SKILL.md +393 -0
- package/skills/luckiest-ad-creative/evals/evals.json +90 -0
- package/skills/luckiest-ad-creative/references/generative-tools.md +637 -0
- package/skills/luckiest-ad-creative/references/platform-specs.md +213 -0
- package/skills/luckiest-ads/ATTRIBUTION.md +36 -0
- package/skills/luckiest-ads/CHANGELOG.md +24 -0
- package/skills/luckiest-ads/LICENSE +21 -0
- package/skills/luckiest-ads/SKILL.md +434 -0
- package/skills/luckiest-ads/evals/evals.json +90 -0
- package/skills/luckiest-ads/references/ad-copy-templates.md +207 -0
- package/skills/luckiest-ads/references/audience-targeting.md +243 -0
- package/skills/luckiest-ads/references/conversion-tracking.md +361 -0
- package/skills/luckiest-ads/references/platform-setup-checklists.md +277 -0
- package/skills/luckiest-advisors/ATTRIBUTION.md +16 -0
- package/skills/luckiest-advisors/CHANGELOG.md +99 -0
- package/skills/luckiest-advisors/SKILL.md +151 -0
- package/skills/luckiest-aeo-grader/ATTRIBUTION.md +19 -0
- package/skills/luckiest-aeo-grader/CHANGELOG.md +52 -0
- package/skills/luckiest-aeo-grader/SKILL.md +200 -0
- package/skills/luckiest-aeo-grader/evals/evals.json +19 -0
- package/skills/luckiest-aeo-grader/references/rubric.md +72 -0
- package/skills/luckiest-aeo-grader/scripts/scan.py +265 -0
- package/skills/luckiest-ai-seo/ATTRIBUTION.md +38 -0
- package/skills/luckiest-ai-seo/CHANGELOG.md +23 -0
- package/skills/luckiest-ai-seo/SKILL.md +510 -0
- package/skills/luckiest-ai-seo/evals/evals.json +90 -0
- package/skills/luckiest-ai-seo/references/content-patterns.md +285 -0
- package/skills/luckiest-ai-seo/references/content-types.md +71 -0
- package/skills/luckiest-ai-seo/references/okf.md +104 -0
- package/skills/luckiest-ai-seo/references/platform-ranking-factors.md +154 -0
- package/skills/luckiest-analytics/ATTRIBUTION.md +42 -0
- package/skills/luckiest-analytics/CHANGELOG.md +36 -0
- package/skills/luckiest-analytics/SKILL.md +380 -0
- package/skills/luckiest-analytics/evals/evals.json +90 -0
- package/skills/luckiest-analytics/references/event-library.md +260 -0
- package/skills/luckiest-analytics/references/ga4-implementation.md +300 -0
- package/skills/luckiest-analytics/references/gtm-implementation.md +390 -0
- package/skills/luckiest-aso/ATTRIBUTION.md +40 -0
- package/skills/luckiest-aso/CHANGELOG.md +33 -0
- package/skills/luckiest-aso/SKILL.md +327 -0
- package/skills/luckiest-aso/evals/evals.json +91 -0
- package/skills/luckiest-aso/references/apple-specs.md +107 -0
- package/skills/luckiest-aso/references/benchmarks.md +129 -0
- package/skills/luckiest-aso/references/google-play-specs.md +131 -0
- package/skills/luckiest-aso/references/report-template.md +213 -0
- package/skills/luckiest-aso/references/scoring-criteria.md +213 -0
- package/skills/luckiest-churn-prevention/ATTRIBUTION.md +41 -0
- package/skills/luckiest-churn-prevention/CHANGELOG.md +24 -0
- package/skills/luckiest-churn-prevention/SKILL.md +445 -0
- package/skills/luckiest-churn-prevention/evals/evals.json +93 -0
- package/skills/luckiest-churn-prevention/references/cancel-flow-patterns.md +316 -0
- package/skills/luckiest-churn-prevention/references/dunning-playbook.md +408 -0
- package/skills/luckiest-co-marketing/ATTRIBUTION.md +38 -0
- package/skills/luckiest-co-marketing/CHANGELOG.md +25 -0
- package/skills/luckiest-co-marketing/SKILL.md +331 -0
- package/skills/luckiest-co-marketing/evals/evals.json +84 -0
- package/skills/luckiest-coder/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder/CHANGELOG.md +30 -0
- package/skills/luckiest-coder/SKILL.md +77 -0
- package/skills/luckiest-coder/references/antigravity-tools.md +23 -0
- package/skills/luckiest-coder/references/codex-tools.md +39 -0
- package/skills/luckiest-coder/references/pi-tools.md +16 -0
- package/skills/luckiest-coder-brainstorming/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-brainstorming/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-brainstorming/SKILL.md +173 -0
- package/skills/luckiest-coder-brainstorming/scripts/frame-template.html +213 -0
- package/skills/luckiest-coder-brainstorming/scripts/helper.js +168 -0
- package/skills/luckiest-coder-brainstorming/scripts/server.cjs +721 -0
- package/skills/luckiest-coder-brainstorming/scripts/start-server.sh +209 -0
- package/skills/luckiest-coder-brainstorming/scripts/stop-server.sh +120 -0
- package/skills/luckiest-coder-brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/skills/luckiest-coder-brainstorming/visual-companion.md +291 -0
- package/skills/luckiest-coder-consistency/ATTRIBUTION.md +30 -0
- package/skills/luckiest-coder-consistency/CHANGELOG.md +26 -0
- package/skills/luckiest-coder-consistency/SKILL.md +98 -0
- package/skills/luckiest-coder-constitution/ATTRIBUTION.md +28 -0
- package/skills/luckiest-coder-constitution/CHANGELOG.md +25 -0
- package/skills/luckiest-coder-constitution/SKILL.md +115 -0
- package/skills/luckiest-coder-debugging/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-debugging/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-debugging/CREATION-LOG.md +119 -0
- package/skills/luckiest-coder-debugging/SKILL.md +309 -0
- package/skills/luckiest-coder-debugging/condition-based-waiting-example.ts +158 -0
- package/skills/luckiest-coder-debugging/condition-based-waiting.md +115 -0
- package/skills/luckiest-coder-debugging/defense-in-depth.md +122 -0
- package/skills/luckiest-coder-debugging/find-polluter.sh +63 -0
- package/skills/luckiest-coder-debugging/root-cause-tracing.md +169 -0
- package/skills/luckiest-coder-debugging/test-academic.md +14 -0
- package/skills/luckiest-coder-debugging/test-pressure-1.md +58 -0
- package/skills/luckiest-coder-debugging/test-pressure-2.md +68 -0
- package/skills/luckiest-coder-debugging/test-pressure-3.md +69 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/CHANGELOG.md +23 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/SKILL.md +198 -0
- package/skills/luckiest-coder-executing-plans/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-executing-plans/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-executing-plans/SKILL.md +83 -0
- package/skills/luckiest-coder-finishing-a-branch/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-finishing-a-branch/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-finishing-a-branch/SKILL.md +258 -0
- package/skills/luckiest-coder-git-worktrees/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-git-worktrees/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-git-worktrees/SKILL.md +215 -0
- package/skills/luckiest-coder-guard/ATTRIBUTION.md +29 -0
- package/skills/luckiest-coder-guard/CHANGELOG.md +48 -0
- package/skills/luckiest-coder-guard/LICENSE +21 -0
- package/skills/luckiest-coder-guard/SKILL.md +126 -0
- package/skills/luckiest-coder-guard/bin/check-careful.sh +65 -0
- package/skills/luckiest-coder-guard/bin/check-freeze.sh +48 -0
- package/skills/luckiest-coder-qa/ATTRIBUTION.md +28 -0
- package/skills/luckiest-coder-qa/CHANGELOG.md +49 -0
- package/skills/luckiest-coder-qa/LICENSE +21 -0
- package/skills/luckiest-coder-qa/SKILL.md +160 -0
- package/skills/luckiest-coder-qa/references/issue-taxonomy.md +85 -0
- package/skills/luckiest-coder-qa/references/qa-report-template.md +126 -0
- package/skills/luckiest-coder-receiving-code-review/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-receiving-code-review/CHANGELOG.md +28 -0
- package/skills/luckiest-coder-receiving-code-review/SKILL.md +226 -0
- package/skills/luckiest-coder-requesting-code-review/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-requesting-code-review/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-requesting-code-review/SKILL.md +118 -0
- package/skills/luckiest-coder-requesting-code-review/code-reviewer.md +172 -0
- package/skills/luckiest-coder-shipping/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-shipping/CHANGELOG.md +48 -0
- package/skills/luckiest-coder-shipping/LICENSE +21 -0
- package/skills/luckiest-coder-shipping/SKILL.md +148 -0
- package/skills/luckiest-coder-shipping/references/canary.md +44 -0
- package/skills/luckiest-coder-shipping/references/test-and-coverage.md +91 -0
- package/skills/luckiest-coder-subagent-driven-development/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-subagent-driven-development/CHANGELOG.md +28 -0
- package/skills/luckiest-coder-subagent-driven-development/SKILL.md +439 -0
- package/skills/luckiest-coder-subagent-driven-development/implementer-prompt.md +139 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/review-package +44 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/sdd-workspace +22 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/task-brief +40 -0
- package/skills/luckiest-coder-subagent-driven-development/task-reviewer-prompt.md +188 -0
- package/skills/luckiest-coder-tdd/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-tdd/CHANGELOG.md +26 -0
- package/skills/luckiest-coder-tdd/SKILL.md +384 -0
- package/skills/luckiest-coder-tdd/testing-anti-patterns.md +299 -0
- package/skills/luckiest-coder-verification/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-verification/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-verification/SKILL.md +152 -0
- package/skills/luckiest-coder-writing-plans/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-writing-plans/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-writing-plans/SKILL.md +191 -0
- package/skills/luckiest-coder-writing-plans/plan-document-reviewer-prompt.md +49 -0
- package/skills/luckiest-coder-writing-skills/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-writing-skills/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-writing-skills/SKILL.md +702 -0
- package/skills/luckiest-coder-writing-skills/anthropic-best-practices.md +1150 -0
- package/skills/luckiest-coder-writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
- package/skills/luckiest-coder-writing-skills/graphviz-conventions.dot +172 -0
- package/skills/luckiest-coder-writing-skills/persuasion-principles.md +187 -0
- package/skills/luckiest-coder-writing-skills/render-graphs.js +168 -0
- package/skills/luckiest-coder-writing-skills/testing-skills-with-subagents.md +384 -0
- package/skills/luckiest-cold-email/ATTRIBUTION.md +36 -0
- package/skills/luckiest-cold-email/CHANGELOG.md +24 -0
- package/skills/luckiest-cold-email/SKILL.md +213 -0
- package/skills/luckiest-cold-email/evals/evals.json +94 -0
- package/skills/luckiest-cold-email/references/benchmarks.md +83 -0
- package/skills/luckiest-cold-email/references/follow-up-sequences.md +81 -0
- package/skills/luckiest-cold-email/references/frameworks.md +90 -0
- package/skills/luckiest-cold-email/references/personalization.md +79 -0
- package/skills/luckiest-cold-email/references/subject-lines.md +53 -0
- package/skills/luckiest-community-marketing/ATTRIBUTION.md +41 -0
- package/skills/luckiest-community-marketing/CHANGELOG.md +35 -0
- package/skills/luckiest-community-marketing/SKILL.md +200 -0
- package/skills/luckiest-community-marketing/evals/evals.json +89 -0
- package/skills/luckiest-competitor-profiling/ATTRIBUTION.md +36 -0
- package/skills/luckiest-competitor-profiling/CHANGELOG.md +24 -0
- package/skills/luckiest-competitor-profiling/SKILL.md +443 -0
- package/skills/luckiest-competitor-profiling/evals/evals.json +85 -0
- package/skills/luckiest-competitor-profiling/references/templates.md +167 -0
- package/skills/luckiest-competitor-profiling/references/tool-reference.md +179 -0
- package/skills/luckiest-competitors/ATTRIBUTION.md +36 -0
- package/skills/luckiest-competitors/CHANGELOG.md +25 -0
- package/skills/luckiest-competitors/SKILL.md +171 -0
- package/skills/luckiest-competitors/evals/evals.json +93 -0
- package/skills/luckiest-competitors/references/content-architecture.md +271 -0
- package/skills/luckiest-competitors/references/templates.md +223 -0
- package/skills/luckiest-content-strategy/ATTRIBUTION.md +39 -0
- package/skills/luckiest-content-strategy/CHANGELOG.md +23 -0
- package/skills/luckiest-content-strategy/SKILL.md +395 -0
- package/skills/luckiest-content-strategy/evals/evals.json +90 -0
- package/skills/luckiest-content-strategy/references/headless-cms.md +194 -0
- package/skills/luckiest-copy-editing/ATTRIBUTION.md +41 -0
- package/skills/luckiest-copy-editing/CHANGELOG.md +31 -0
- package/skills/luckiest-copy-editing/SKILL.md +491 -0
- package/skills/luckiest-copy-editing/evals/evals.json +89 -0
- package/skills/luckiest-copy-editing/references/checklist.md +66 -0
- package/skills/luckiest-copy-editing/references/content-refresh.md +38 -0
- package/skills/luckiest-copy-editing/references/plain-english-alternatives.md +394 -0
- package/skills/luckiest-copywriting/ATTRIBUTION.md +35 -0
- package/skills/luckiest-copywriting/CHANGELOG.md +23 -0
- package/skills/luckiest-copywriting/SKILL.md +296 -0
- package/skills/luckiest-copywriting/evals/evals.json +111 -0
- package/skills/luckiest-copywriting/references/copy-frameworks.md +344 -0
- package/skills/luckiest-copywriting/references/natural-transitions.md +272 -0
- package/skills/luckiest-cro/ATTRIBUTION.md +34 -0
- package/skills/luckiest-cro/CHANGELOG.md +23 -0
- package/skills/luckiest-cro/SKILL.md +215 -0
- package/skills/luckiest-cro/evals/evals.json +111 -0
- package/skills/luckiest-cro/references/experiments.md +248 -0
- package/skills/luckiest-cro/references/form.md +422 -0
- package/skills/luckiest-customer-research/ATTRIBUTION.md +40 -0
- package/skills/luckiest-customer-research/CHANGELOG.md +26 -0
- package/skills/luckiest-customer-research/SKILL.md +293 -0
- package/skills/luckiest-customer-research/evals/evals.json +162 -0
- package/skills/luckiest-customer-research/references/source-guides.md +401 -0
- package/skills/luckiest-directory-submissions/ATTRIBUTION.md +40 -0
- package/skills/luckiest-directory-submissions/CHANGELOG.md +33 -0
- package/skills/luckiest-directory-submissions/SKILL.md +419 -0
- package/skills/luckiest-directory-submissions/evals/evals.json +94 -0
- package/skills/luckiest-directory-submissions/references/directory-list.md +477 -0
- package/skills/luckiest-directory-submissions/references/positioning-variations.md +232 -0
- package/skills/luckiest-directory-submissions/references/submission-tracker-template.csv +266 -0
- package/skills/luckiest-emails/ATTRIBUTION.md +42 -0
- package/skills/luckiest-emails/CHANGELOG.md +33 -0
- package/skills/luckiest-emails/SKILL.md +220 -0
- package/skills/luckiest-emails/evals/evals.json +93 -0
- package/skills/luckiest-emails/references/copy-guidelines.md +120 -0
- package/skills/luckiest-emails/references/email-types.md +515 -0
- package/skills/luckiest-emails/references/sequence-templates.md +168 -0
- package/skills/luckiest-extract-design-system/ATTRIBUTION.md +8 -0
- package/skills/luckiest-extract-design-system/CHANGELOG.md +37 -0
- package/skills/luckiest-extract-design-system/SKILL.md +83 -0
- package/skills/luckiest-free-tools/ATTRIBUTION.md +43 -0
- package/skills/luckiest-free-tools/CHANGELOG.md +25 -0
- package/skills/luckiest-free-tools/SKILL.md +220 -0
- package/skills/luckiest-free-tools/evals/evals.json +90 -0
- package/skills/luckiest-free-tools/references/tool-types.md +217 -0
- package/skills/luckiest-image/ATTRIBUTION.md +43 -0
- package/skills/luckiest-image/CHANGELOG.md +23 -0
- package/skills/luckiest-image/SKILL.md +376 -0
- package/skills/luckiest-image/evals/evals.json +89 -0
- package/skills/luckiest-image/references/ai-image-prompting.md +229 -0
- package/skills/luckiest-launch/ATTRIBUTION.md +40 -0
- package/skills/luckiest-launch/CHANGELOG.md +43 -0
- package/skills/luckiest-launch/SKILL.md +385 -0
- package/skills/luckiest-launch/evals/evals.json +91 -0
- package/skills/luckiest-lead-magnets/ATTRIBUTION.md +35 -0
- package/skills/luckiest-lead-magnets/CHANGELOG.md +23 -0
- package/skills/luckiest-lead-magnets/SKILL.md +331 -0
- package/skills/luckiest-lead-magnets/evals/evals.json +87 -0
- package/skills/luckiest-lead-magnets/references/benchmarks.md +129 -0
- package/skills/luckiest-lead-magnets/references/format-guide.md +196 -0
- package/skills/luckiest-marketing-ideas/ATTRIBUTION.md +39 -0
- package/skills/luckiest-marketing-ideas/CHANGELOG.md +33 -0
- package/skills/luckiest-marketing-ideas/SKILL.md +191 -0
- package/skills/luckiest-marketing-ideas/evals/evals.json +90 -0
- package/skills/luckiest-marketing-ideas/references/ideas-by-category.md +366 -0
- package/skills/luckiest-marketing-plan/ATTRIBUTION.md +40 -0
- package/skills/luckiest-marketing-plan/CHANGELOG.md +23 -0
- package/skills/luckiest-marketing-plan/SKILL.md +285 -0
- package/skills/luckiest-marketing-plan/evals/evals.json +96 -0
- package/skills/luckiest-marketing-plan/references/aarrr-framework.md +180 -0
- package/skills/luckiest-marketing-plan/references/budget-planning.md +168 -0
- package/skills/luckiest-marketing-plan/references/client-types.md +373 -0
- package/skills/luckiest-marketing-plan/references/current-state-rubric.md +255 -0
- package/skills/luckiest-marketing-plan/references/example-quietude.md +972 -0
- package/skills/luckiest-marketing-plan/references/funding-stage-unlocks.md +230 -0
- package/skills/luckiest-marketing-plan/references/growth-patterns.md +148 -0
- package/skills/luckiest-marketing-plan/references/idea-cross-reference.md +265 -0
- package/skills/luckiest-marketing-plan/references/measurement-framework.md +213 -0
- package/skills/luckiest-marketing-plan/references/methodology.md +363 -0
- package/skills/luckiest-marketing-plan/references/ops-stack-mapping.md +197 -0
- package/skills/luckiest-marketing-plan/references/plan-template.md +494 -0
- package/skills/luckiest-marketing-plan/references/team-and-agency-model.md +278 -0
- package/skills/luckiest-marketing-psychology/ATTRIBUTION.md +31 -0
- package/skills/luckiest-marketing-psychology/CHANGELOG.md +32 -0
- package/skills/luckiest-marketing-psychology/SKILL.md +202 -0
- package/skills/luckiest-marketing-psychology/evals.json +88 -0
- package/skills/luckiest-model-router/ATTRIBUTION.md +11 -0
- package/skills/luckiest-model-router/CHANGELOG.md +45 -0
- package/skills/luckiest-model-router/SKILL.md +74 -0
- package/skills/luckiest-model-router/scripts/route.py +145 -0
- package/skills/luckiest-offers/ATTRIBUTION.md +39 -0
- package/skills/luckiest-offers/CHANGELOG.md +23 -0
- package/skills/luckiest-offers/SKILL.md +167 -0
- package/skills/luckiest-offers/references/bonus-stacking.md +150 -0
- package/skills/luckiest-offers/references/examples.md +215 -0
- package/skills/luckiest-offers/references/guarantee-design.md +172 -0
- package/skills/luckiest-offers/references/offer-anatomy.md +203 -0
- package/skills/luckiest-offers/references/offer-formats.md +240 -0
- package/skills/luckiest-offers/references/scarcity-urgency.md +175 -0
- package/skills/luckiest-offers/references/value-equation.md +134 -0
- package/skills/luckiest-onboarding/ATTRIBUTION.md +43 -0
- package/skills/luckiest-onboarding/CHANGELOG.md +31 -0
- package/skills/luckiest-onboarding/SKILL.md +243 -0
- package/skills/luckiest-onboarding/references/experiments.md +258 -0
- package/skills/luckiest-paywalls/ATTRIBUTION.md +39 -0
- package/skills/luckiest-paywalls/CHANGELOG.md +25 -0
- package/skills/luckiest-paywalls/SKILL.md +256 -0
- package/skills/luckiest-paywalls/evals/evals.json +93 -0
- package/skills/luckiest-paywalls/references/experiments.md +164 -0
- package/skills/luckiest-popups/ATTRIBUTION.md +35 -0
- package/skills/luckiest-popups/CHANGELOG.md +27 -0
- package/skills/luckiest-popups/SKILL.md +477 -0
- package/skills/luckiest-popups/evals/evals.json +107 -0
- package/skills/luckiest-pricing/ATTRIBUTION.md +35 -0
- package/skills/luckiest-pricing/CHANGELOG.md +31 -0
- package/skills/luckiest-pricing/SKILL.md +283 -0
- package/skills/luckiest-pricing/evals/evals.json +90 -0
- package/skills/luckiest-pricing/references/research-methods.md +152 -0
- package/skills/luckiest-pricing/references/tier-structure.md +232 -0
- package/skills/luckiest-product-marketing/ATTRIBUTION.md +38 -0
- package/skills/luckiest-product-marketing/CHANGELOG.md +23 -0
- package/skills/luckiest-product-marketing/SKILL.md +256 -0
- package/skills/luckiest-product-marketing/evals/evals.json +85 -0
- package/skills/luckiest-programmatic-seo/ATTRIBUTION.md +35 -0
- package/skills/luckiest-programmatic-seo/CHANGELOG.md +24 -0
- package/skills/luckiest-programmatic-seo/SKILL.md +286 -0
- package/skills/luckiest-programmatic-seo/evals/evals.json +95 -0
- package/skills/luckiest-programmatic-seo/references/playbooks.md +308 -0
- package/skills/luckiest-prompt-rewrite/ATTRIBUTION.md +39 -0
- package/skills/luckiest-prompt-rewrite/CHANGELOG.md +70 -0
- package/skills/luckiest-prompt-rewrite/LICENSE +21 -0
- package/skills/luckiest-prompt-rewrite/SKILL.md +494 -0
- package/skills/luckiest-prompt-rewrite/references/patterns.md +82 -0
- package/skills/luckiest-prompt-rewrite/references/templates.md +454 -0
- package/skills/luckiest-prospecting/ATTRIBUTION.md +29 -0
- package/skills/luckiest-prospecting/CHANGELOG.md +24 -0
- package/skills/luckiest-prospecting/SKILL.md +275 -0
- package/skills/luckiest-prospecting/evals/evals.json +107 -0
- package/skills/luckiest-prospecting/references/b2b-prospecting.md +106 -0
- package/skills/luckiest-prospecting/references/compliance.md +123 -0
- package/skills/luckiest-prospecting/references/data-sources.md +287 -0
- package/skills/luckiest-prospecting/references/local-prospecting.md +165 -0
- package/skills/luckiest-prospecting/references/saas-prospecting.md +123 -0
- package/skills/luckiest-public-relations/ATTRIBUTION.md +34 -0
- package/skills/luckiest-public-relations/CHANGELOG.md +23 -0
- package/skills/luckiest-public-relations/SKILL.md +154 -0
- package/skills/luckiest-public-relations/references/journalist-pitching.md +368 -0
- package/skills/luckiest-public-relations/references/media-outlets.md +207 -0
- package/skills/luckiest-public-relations/references/newsjacking.md +218 -0
- package/skills/luckiest-public-relations/references/press-platforms.md +188 -0
- package/skills/luckiest-referrals/ATTRIBUTION.md +41 -0
- package/skills/luckiest-referrals/CHANGELOG.md +23 -0
- package/skills/luckiest-referrals/SKILL.md +292 -0
- package/skills/luckiest-referrals/evals/evals.json +90 -0
- package/skills/luckiest-referrals/references/affiliate-programs.md +167 -0
- package/skills/luckiest-referrals/references/program-examples.md +143 -0
- package/skills/luckiest-revops/ATTRIBUTION.md +40 -0
- package/skills/luckiest-revops/CHANGELOG.md +24 -0
- package/skills/luckiest-revops/SKILL.md +362 -0
- package/skills/luckiest-revops/evals/evals.json +91 -0
- package/skills/luckiest-revops/references/automation-playbooks.md +290 -0
- package/skills/luckiest-revops/references/lifecycle-definitions.md +278 -0
- package/skills/luckiest-revops/references/routing-rules.md +203 -0
- package/skills/luckiest-revops/references/scoring-models.md +247 -0
- package/skills/luckiest-sales-enablement/ATTRIBUTION.md +39 -0
- package/skills/luckiest-sales-enablement/CHANGELOG.md +23 -0
- package/skills/luckiest-sales-enablement/SKILL.md +379 -0
- package/skills/luckiest-sales-enablement/evals/evals.json +91 -0
- package/skills/luckiest-sales-enablement/references/deck-frameworks.md +263 -0
- package/skills/luckiest-sales-enablement/references/demo-scripts.md +355 -0
- package/skills/luckiest-sales-enablement/references/objection-library.md +270 -0
- package/skills/luckiest-sales-enablement/references/one-pager-templates.md +208 -0
- package/skills/luckiest-schema/ATTRIBUTION.md +39 -0
- package/skills/luckiest-schema/CHANGELOG.md +25 -0
- package/skills/luckiest-schema/SKILL.md +209 -0
- package/skills/luckiest-schema/evals/evals.json +88 -0
- package/skills/luckiest-schema/references/schema-examples.md +398 -0
- package/skills/luckiest-seo-audit/ATTRIBUTION.md +39 -0
- package/skills/luckiest-seo-audit/CHANGELOG.md +23 -0
- package/skills/luckiest-seo-audit/SKILL.md +529 -0
- package/skills/luckiest-seo-audit/evals/evals.json +136 -0
- package/skills/luckiest-seo-audit/references/ai-writing-detection.md +200 -0
- package/skills/luckiest-seo-audit/references/international-seo.md +230 -0
- package/skills/luckiest-session-handoff/ATTRIBUTION.md +14 -0
- package/skills/luckiest-session-handoff/CHANGELOG.md +30 -0
- package/skills/luckiest-session-handoff/SKILL.md +96 -0
- package/skills/luckiest-signup/ATTRIBUTION.md +48 -0
- package/skills/luckiest-signup/CHANGELOG.md +24 -0
- package/skills/luckiest-signup/SKILL.md +382 -0
- package/skills/luckiest-signup/evals/evals.json +88 -0
- package/skills/luckiest-site-architecture/ATTRIBUTION.md +38 -0
- package/skills/luckiest-site-architecture/CHANGELOG.md +23 -0
- package/skills/luckiest-site-architecture/SKILL.md +383 -0
- package/skills/luckiest-site-architecture/evals/evals.json +88 -0
- package/skills/luckiest-site-architecture/references/mermaid-templates.md +216 -0
- package/skills/luckiest-site-architecture/references/navigation-patterns.md +305 -0
- package/skills/luckiest-site-architecture/references/site-type-templates.md +293 -0
- package/skills/luckiest-sms/ATTRIBUTION.md +27 -0
- package/skills/luckiest-sms/CHANGELOG.md +24 -0
- package/skills/luckiest-sms/SKILL.md +400 -0
- package/skills/luckiest-sms/evals/evals.json +100 -0
- package/skills/luckiest-sms/references/compliance.md +202 -0
- package/skills/luckiest-sms/references/platforms.md +318 -0
- package/skills/luckiest-sms/references/sequence-templates.md +282 -0
- package/skills/luckiest-social/ATTRIBUTION.md +27 -0
- package/skills/luckiest-social/CHANGELOG.md +24 -0
- package/skills/luckiest-social/SKILL.md +430 -0
- package/skills/luckiest-social/evals/evals.json +92 -0
- package/skills/luckiest-social/references/listening-sources-template.md +123 -0
- package/skills/luckiest-social/references/listening.md +283 -0
- package/skills/luckiest-social/references/platform-limits.md +110 -0
- package/skills/luckiest-social/references/platforms.md +170 -0
- package/skills/luckiest-social/references/post-templates.md +177 -0
- package/skills/luckiest-social/references/reverse-engineering.md +195 -0
- package/skills/luckiest-social/references/short-form-video.md +237 -0
- package/skills/luckiest-thinker/ATTRIBUTION.md +16 -0
- package/skills/luckiest-thinker/CHANGELOG.md +20 -0
- package/skills/luckiest-thinker/LICENSE +21 -0
- package/skills/luckiest-thinker/SKILL.md +92 -0
- package/skills/luckiest-thinker/references/distribution-calibration.md +106 -0
- package/skills/luckiest-thinker/references/research-foundations.md +214 -0
- package/skills/luckiest-thinker/references/thinking-patterns.md +138 -0
- package/skills/luckiest-trends/.skillignore +10 -0
- package/skills/luckiest-trends/ATTRIBUTION.md +43 -0
- package/skills/luckiest-trends/CHANGELOG.md +86 -0
- package/skills/luckiest-trends/LICENSE +21 -0
- package/skills/luckiest-trends/SKILL.md +194 -0
- package/skills/luckiest-trends/agents/openai.yaml +8 -0
- package/skills/luckiest-trends/references/engine-contract.md +2041 -0
- package/skills/luckiest-trends/references/save-html-brief.md +206 -0
- package/skills/luckiest-trends/scripts/briefing.py +272 -0
- package/skills/luckiest-trends/scripts/build-skill.sh +39 -0
- package/skills/luckiest-trends/scripts/compare.sh +61 -0
- package/skills/luckiest-trends/scripts/evaluate_search_quality.py +573 -0
- package/skills/luckiest-trends/scripts/last30days.py +1467 -0
- package/skills/luckiest-trends/scripts/lib/__init__.py +1 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/arxiv.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/bird_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/bluesky.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/categories.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/cjk.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/cluster.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/dates.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/dedupe.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/digg.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/entity_extract.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/env.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/fusion.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/github.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/grounding.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/hackernews.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/hiring_signals.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/html_render.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/http.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/instagram.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/jobs.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/log.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/normalize.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/permission_preflight.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/perplexity.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/pinterest.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/pipeline.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/planner.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/polymarket.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/preflight.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/providers.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/quality_nudge.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/query.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_arctic.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_enrich.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_keyless.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_listing.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_public.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_rss.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_shreddit.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/relevance.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/render.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/rerank.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/resolve.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/schema.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/signals.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/skill_meta.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/snippet.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/subproc.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/techmeme.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/threads.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/tiktok.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/trustpilot.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/truthsocial.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/ui.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/web_search_keyless.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xai_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xiaohongshu_api.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xquik.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xurl_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/youtube_yt.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/arxiv.py +290 -0
- package/skills/luckiest-trends/scripts/lib/bird_x.py +639 -0
- package/skills/luckiest-trends/scripts/lib/bluesky.py +323 -0
- package/skills/luckiest-trends/scripts/lib/categories.py +283 -0
- package/skills/luckiest-trends/scripts/lib/chrome_cookies.py +414 -0
- package/skills/luckiest-trends/scripts/lib/cjk.py +107 -0
- package/skills/luckiest-trends/scripts/lib/cluster.py +271 -0
- package/skills/luckiest-trends/scripts/lib/competitors.py +198 -0
- package/skills/luckiest-trends/scripts/lib/cookie_extract.py +508 -0
- package/skills/luckiest-trends/scripts/lib/dates.py +169 -0
- package/skills/luckiest-trends/scripts/lib/dedupe.py +145 -0
- package/skills/luckiest-trends/scripts/lib/digg.py +440 -0
- package/skills/luckiest-trends/scripts/lib/entity_extract.py +127 -0
- package/skills/luckiest-trends/scripts/lib/env.py +1073 -0
- package/skills/luckiest-trends/scripts/lib/fanout.py +84 -0
- package/skills/luckiest-trends/scripts/lib/fusion.py +207 -0
- package/skills/luckiest-trends/scripts/lib/github.py +996 -0
- package/skills/luckiest-trends/scripts/lib/grounding.py +331 -0
- package/skills/luckiest-trends/scripts/lib/hackernews.py +357 -0
- package/skills/luckiest-trends/scripts/lib/health.py +92 -0
- package/skills/luckiest-trends/scripts/lib/hiring_signals.py +326 -0
- package/skills/luckiest-trends/scripts/lib/html_publish.py +73 -0
- package/skills/luckiest-trends/scripts/lib/html_render.py +738 -0
- package/skills/luckiest-trends/scripts/lib/http.py +372 -0
- package/skills/luckiest-trends/scripts/lib/instagram.py +521 -0
- package/skills/luckiest-trends/scripts/lib/jobs.py +708 -0
- package/skills/luckiest-trends/scripts/lib/linkedin.py +363 -0
- package/skills/luckiest-trends/scripts/lib/log.py +35 -0
- package/skills/luckiest-trends/scripts/lib/normalize.py +759 -0
- package/skills/luckiest-trends/scripts/lib/permission_preflight.py +242 -0
- package/skills/luckiest-trends/scripts/lib/perplexity.py +745 -0
- package/skills/luckiest-trends/scripts/lib/pinterest.py +154 -0
- package/skills/luckiest-trends/scripts/lib/pipeline.py +1720 -0
- package/skills/luckiest-trends/scripts/lib/planner.py +763 -0
- package/skills/luckiest-trends/scripts/lib/polymarket.py +794 -0
- package/skills/luckiest-trends/scripts/lib/preflight.py +119 -0
- package/skills/luckiest-trends/scripts/lib/providers.py +380 -0
- package/skills/luckiest-trends/scripts/lib/quality_nudge.py +383 -0
- package/skills/luckiest-trends/scripts/lib/query.py +202 -0
- package/skills/luckiest-trends/scripts/lib/reddit.py +731 -0
- package/skills/luckiest-trends/scripts/lib/reddit_arctic.py +92 -0
- package/skills/luckiest-trends/scripts/lib/reddit_enrich.py +322 -0
- package/skills/luckiest-trends/scripts/lib/reddit_keyless.py +329 -0
- package/skills/luckiest-trends/scripts/lib/reddit_listing.py +188 -0
- package/skills/luckiest-trends/scripts/lib/reddit_public.py +275 -0
- package/skills/luckiest-trends/scripts/lib/reddit_rss.py +224 -0
- package/skills/luckiest-trends/scripts/lib/reddit_shreddit.py +184 -0
- package/skills/luckiest-trends/scripts/lib/relevance.py +200 -0
- package/skills/luckiest-trends/scripts/lib/render.py +2130 -0
- package/skills/luckiest-trends/scripts/lib/rerank.py +708 -0
- package/skills/luckiest-trends/scripts/lib/resolve.py +345 -0
- package/skills/luckiest-trends/scripts/lib/safari_cookies.py +193 -0
- package/skills/luckiest-trends/scripts/lib/schema.py +319 -0
- package/skills/luckiest-trends/scripts/lib/setup_wizard.py +843 -0
- package/skills/luckiest-trends/scripts/lib/signals.py +334 -0
- package/skills/luckiest-trends/scripts/lib/skill_meta.py +33 -0
- package/skills/luckiest-trends/scripts/lib/snippet.py +51 -0
- package/skills/luckiest-trends/scripts/lib/subproc.py +116 -0
- package/skills/luckiest-trends/scripts/lib/techmeme.py +239 -0
- package/skills/luckiest-trends/scripts/lib/threads.py +207 -0
- package/skills/luckiest-trends/scripts/lib/tiktok.py +596 -0
- package/skills/luckiest-trends/scripts/lib/transcribe.py +235 -0
- package/skills/luckiest-trends/scripts/lib/trustpilot.py +277 -0
- package/skills/luckiest-trends/scripts/lib/truthsocial.py +162 -0
- package/skills/luckiest-trends/scripts/lib/ui.py +626 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/LICENSE +21 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/bird-search.mjs +147 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/cookies.js +220 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/features.json +17 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/paginate-cursor.js +37 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/query-ids.json +20 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-features.js +151 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-query-ids.js +264 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-base.js +129 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-constants.js +50 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-features.js +347 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-search.js +157 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-types.js +2 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-utils.js +511 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/package.json +13 -0
- package/skills/luckiest-trends/scripts/lib/web_fetch_keyless.py +105 -0
- package/skills/luckiest-trends/scripts/lib/web_search_keyless.py +158 -0
- package/skills/luckiest-trends/scripts/lib/xai_x.py +236 -0
- package/skills/luckiest-trends/scripts/lib/xiaohongshu_api.py +162 -0
- package/skills/luckiest-trends/scripts/lib/xquik.py +404 -0
- package/skills/luckiest-trends/scripts/lib/xurl_x.py +171 -0
- package/skills/luckiest-trends/scripts/lib/youtube_yt.py +1365 -0
- package/skills/luckiest-trends/scripts/setup-keychain.sh +123 -0
- package/skills/luckiest-trends/scripts/setup-pass.sh +132 -0
- package/skills/luckiest-trends/scripts/store.py +1020 -0
- package/skills/luckiest-trends/scripts/test-v1-vs-v2.sh +220 -0
- package/skills/luckiest-trends/scripts/test_device_auth.py +128 -0
- package/skills/luckiest-trends/scripts/verify_v3.py +195 -0
- package/skills/luckiest-trends/scripts/watchlist.py +318 -0
- package/skills/luckiest-video/ATTRIBUTION.md +27 -0
- package/skills/luckiest-video/CHANGELOG.md +23 -0
- package/skills/luckiest-video/SKILL.md +371 -0
- package/skills/luckiest-video/evals/evals.json +88 -0
- package/skills/luckiest-video/references/ai-video-prompting.md +175 -0
- package/skills/remix/SKILL.md +32 -0
- package/commands/luckiest/go.md +0 -45
- package/commands/luckiest/plan.md +0 -55
- /package/commands/{luckiest/charms.md → charms.md} +0 -0
- /package/commands/{luckiest/helpers.md → helpers.md} +0 -0
- /package/commands/{luckiest/home.md → home.md} +0 -0
- /package/commands/{luckiest/leaderboard.md → leaderboard.md} +0 -0
- /package/commands/{luckiest/merge.md → merge.md} +0 -0
- /package/commands/{luckiest/overlaps.md → overlaps.md} +0 -0
- /package/commands/{luckiest/skills.md → skills.md} +0 -0
- /package/commands/{luckiest/start.md → start.md} +0 -0
- /package/commands/{luckiest/status.md → status.md} +0 -0
- /package/commands/{luckiest/updates.md → updates.md} +0 -0
- /package/commands/{luckiest/vouch.md → vouch.md} +0 -0
- /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
|