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