vivekchakraverty Claude Opus 5 commited on
Commit
a88e258
·
1 Parent(s): 822d3cc

Search: direct scrape as primary tier, Piped as fallback

Browse files

Discovery and engagement scoring are now separate concerns, because they fail
independently.

Discovery is tiered, first tier with results wins:
direct scrape -> Piped -> Data API search.list -> yt-dlp ytsearch.

The scrape runs in-process, so it depends on no third party, costs no API quota,
honours YT_PROXY (residential egress), and yields view count + duration straight
from YouTube. Piped keeps its instance failover and remains the fallback.

Engagement metrics (likes/dislikes/subscribers/comments) are attached from Piped
to whichever tier produced the candidates, so scrape-first does not lose the
normalized weighted ranking. When Piped is unreachable, search still succeeds and
ranking falls through to the sentiment stage as before.

Enrichment is deliberately impatient: 3 instances max on a 6s timeout, abandoned
on the first unreachable candidate, plus a 15-minute TTL cache on the instance
list. Without those bounds a dead Piped cost 52.8s per search; now 4.1s cold,
2.6s warm.

Also drops sub-minute Shorts before a (quota-capped) download is spent on one,
with a starve guard and treating unknown duration as keep. No caption filter:
transcripts come from faster-whisper ASR on the downloaded video, so videos
without a caption track are perfectly usable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. README.md +8 -1
  2. app.py +7 -3
  3. pipeline/search.py +325 -41
README.md CHANGED
@@ -20,7 +20,14 @@ single best YouTube video on that topic. **No video download** — acquisition i
20
 
21
  ## Pipeline
22
 
23
- 1. **Search** — top 5 videos via the [`adarshajay/youtube-search`](https://huggingface.co/spaces/adarshajay/youtube-search) Space.
 
 
 
 
 
 
 
24
  2. **Sentiment rank** — fetch each video's comments via the **YouTube Data API v3** and
25
  score them with the BERT classifier
26
  [`OmarMedhat7/youtube-sentiment-analysis-model`](https://huggingface.co/OmarMedhat7/youtube-sentiment-analysis-model);
 
20
 
21
  ## Pipeline
22
 
23
+ 1. **Search** — tiered discovery, first tier with results wins:
24
+ **direct scrape** of YouTube's results page (no third party, no API quota, honours
25
+ `YT_PROXY`) → **Piped API** (with instance failover) → **Data API `search.list`** →
26
+ **yt-dlp `ytsearch`**. Sub-minute Shorts are dropped so a download request is never
27
+ wasted on one. Engagement metrics (likes / dislikes / subscribers / comment count) are
28
+ then attached from Piped when the winning tier didn't already supply them, and
29
+ candidates are ranked by a normalized weighted score. If Piped is unreachable the
30
+ search still succeeds — ranking simply falls to the sentiment stage.
31
  2. **Sentiment rank** — fetch each video's comments via the **YouTube Data API v3** and
32
  score them with the BERT classifier
33
  [`OmarMedhat7/youtube-sentiment-analysis-model`](https://huggingface.co/OmarMedhat7/youtube-sentiment-analysis-model);
app.py CHANGED
@@ -134,7 +134,8 @@ def check_access():
134
  f"- **Comment sentiment** (`YOUTUBE_API_KEY`): "
135
  f"{'set ✅' if os.environ.get('YOUTUBE_API_KEY') else 'not set ⚪ (sentiment skipped)'}",
136
  "- **Transcript**: faster-whisper, local (no key/proxy needed) ✅",
137
- "- **Search**: Piped API (no key needed) ✅",
 
138
  ]
139
 
140
  api_ok = None
@@ -230,11 +231,14 @@ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
230
  # 1. Search ------------------------------------------------------------------
231
  progress(0.03, desc="Searching")
232
  yield status(f"🔍 Searching top videos for **{topic}** (engagement-ranked)…"), gr.update(), gr.update(), gr.update()
233
- videos = search_mod.search_top5(topic, api_key=api_key)
234
  eng_note = ""
235
  if videos and "engagement" in videos[0]:
236
  eng_note = " • ranked by likes+comments+subscribers−dislikes (normalized)"
237
- yield status(f"Found {len(videos)} candidate videos{eng_note}."), gr.update(), gr.update(), gr.update()
 
 
 
238
 
239
  # 2. Sentiment ranking (YouTube Data API comments) ---------------------------
240
  if api_key:
 
134
  f"- **Comment sentiment** (`YOUTUBE_API_KEY`): "
135
  f"{'set ✅' if os.environ.get('YOUTUBE_API_KEY') else 'not set ⚪ (sentiment skipped)'}",
136
  "- **Transcript**: faster-whisper, local (no key/proxy needed) ✅",
137
+ f"- **Search**: direct scrape → Piped → Data API → yt-dlp (no key needed) ✅"
138
+ f"{' · scrape routed via `YT_PROXY`' if _resolve_proxy() else ''}",
139
  ]
140
 
141
  api_ok = None
 
231
  # 1. Search ------------------------------------------------------------------
232
  progress(0.03, desc="Searching")
233
  yield status(f"🔍 Searching top videos for **{topic}** (engagement-ranked)…"), gr.update(), gr.update(), gr.update()
234
+ videos = search_mod.search_top5(topic, api_key=api_key, proxy=_resolve_proxy())
235
  eng_note = ""
236
  if videos and "engagement" in videos[0]:
237
  eng_note = " • ranked by likes+comments+subscribers−dislikes (normalized)"
238
+ found = f"Found {len(videos)} candidate videos via {videos[0].get('tier', 'search')}{eng_note}."
239
+ if videos and videos[0].get("filter_note"):
240
+ found += f" ({videos[0]['filter_note']})"
241
+ yield status(found), gr.update(), gr.update(), gr.update()
242
 
243
  # 2. Sentiment ranking (YouTube Data API comments) ---------------------------
244
  if api_key:
pipeline/search.py CHANGED
@@ -1,25 +1,41 @@
1
  """Stage 1: find the top videos for a topic and pick candidates by an engagement signal.
2
 
3
- Primary source is the **Piped API** (a privacy frontend for YouTube). Piped's
4
- ``/streams/{id}`` exposes likes, dislikes and the uploader's subscriber count, and
5
- ``/comments/{id}`` exposes the comment count — everything needed for a per-video
6
- engagement score. Public Piped instances are ephemeral, so we discover a live instance
7
- list and **fail over across instances** on any error.
8
 
9
- Engagement (each metric min-max normalized across the candidate pool, then weighted):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  score = w_like*likes + w_comment*comments + w_sub*subscribers - w_dislike*dislikes
12
 
13
- Engagement picks the top ``max_results`` candidates; the downstream sentiment stage then
14
- decides the final winner. If Piped is entirely unreachable we fall back to the YouTube
15
- Data API (``search.list``) or yt-dlp ``ytsearch``without engagement metadata, letting
16
- sentiment alone rank.
 
 
 
17
  """
18
  from __future__ import annotations
19
 
20
  import html
21
  import json
22
  import os
 
 
23
  import urllib.error
24
  import urllib.parse
25
  import urllib.request
@@ -45,6 +61,37 @@ W_LIKE, W_COMMENT, W_SUB, W_DISLIKE = 1.0, 1.0, 0.5, 1.0
45
  SEARCH_API = "https://www.googleapis.com/youtube/v3/search"
46
  _UA = {"User-Agent": "TutorialMaker/1.0"}
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  def _get_json(url: str, timeout: int = 15):
50
  req = urllib.request.Request(url, headers=_UA)
@@ -53,7 +100,17 @@ def _get_json(url: str, timeout: int = 15):
53
 
54
 
55
  def _instances() -> list[str]:
56
- """Live Piped instances (dynamic list first, then seeds), deduped in order."""
 
 
 
 
 
 
 
 
 
 
57
  insts: list[str] = []
58
  try:
59
  data = _get_json(PIPED_INSTANCE_LIST, timeout=10)
@@ -67,6 +124,7 @@ def _instances() -> list[str]:
67
  s = s.rstrip("/")
68
  if s not in insts:
69
  insts.append(s)
 
70
  return insts
71
 
72
 
@@ -77,9 +135,11 @@ class _Piped:
77
  self.instances = _instances()
78
  self.current = None
79
 
80
- def get(self, path: str, timeout: int = 15):
81
  order = ([self.current] if self.current else [])
82
  order += [i for i in self.instances if i != self.current]
 
 
83
  last = None
84
  for inst in order:
85
  try:
@@ -108,6 +168,153 @@ def _nn(x) -> int:
108
  return v if v > 0 else 0
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def _search_piped(topic: str, pool: int) -> list[dict]:
112
  """Piped search + per-candidate engagement metrics. Returns unscored candidates."""
113
  p = _Piped()
@@ -127,10 +334,13 @@ def _search_piped(topic: str, pool: int) -> list[dict]:
127
  comments = _nn(p.get(f"/comments/{vid}").get("commentCount"))
128
  except Exception:
129
  comments = 0 # best-effort; don't drop the candidate
 
130
  cands.append({
131
  "video_id": vid,
132
  "url": f"https://www.youtube.com/watch?v={vid}",
133
  "title": html.unescape(it.get("title") or st.get("title") or vid),
 
 
134
  "views": _nn(st.get("views")),
135
  "likes": _nn(st.get("likes")),
136
  "dislikes": _nn(st.get("dislikes")),
@@ -140,6 +350,47 @@ def _search_piped(topic: str, pool: int) -> list[dict]:
140
  return cands
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def _rank_by_engagement(cands: list[dict]) -> list[dict]:
144
  """Attach a normalized weighted ``engagement`` score and sort desc.
145
 
@@ -164,7 +415,7 @@ def _rank_by_engagement(cands: list[dict]) -> list[dict]:
164
  return sorted(cands, key=lambda c: -c["engagement"])
165
 
166
 
167
- # ---------------------------------------------------------------- fallbacks (no engagement)
168
  def _search_data_api(topic: str, api_key: str, max_results: int) -> list[dict]:
169
  params = {"part": "snippet", "q": topic, "type": "video",
170
  "maxResults": str(max(1, min(max_results, 50))),
@@ -198,45 +449,78 @@ def _search_ytdlp(topic: str, max_results: int, proxy: str | None) -> list[dict]
198
  if e.get("id"):
199
  out.append({"video_id": e["id"],
200
  "url": e.get("url") or f"https://www.youtube.com/watch?v={e['id']}",
201
- "title": e.get("title") or e["id"]})
 
202
  return out
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  def search_top5(topic: str, api_key: str | None = None, proxy: str | None = None,
206
  max_results: int = 5, pool: int = 8) -> list[dict]:
207
- """Return up to ``max_results`` videos for ``topic``, chosen by engagement.
208
 
209
- Piped supplies candidates + engagement metrics; the top ``max_results`` by the
210
- normalized weighted score are returned (each item carries ``views/likes/dislikes/
211
- subscribers/comments/engagement`` for transparency). On total Piped failure, falls
212
- back to the Data API or yt-dlp (candidates only, no engagement).
 
 
 
 
213
  """
214
  topic = (topic or "").strip()
215
  if not topic:
216
  raise ValueError("Please enter a topic to search for.")
217
 
218
- errors: list[str] = []
219
- try:
220
- cands = _search_piped(topic, max(pool, max_results))
221
- if cands:
222
- return _rank_by_engagement(cands)[:max_results]
223
- errors.append("Piped: no scorable candidates")
224
- except Exception as exc:
225
- errors.append(f"Piped: {exc}")
226
-
227
- # Fallbacks — search only, sentiment stage will do the ranking.
228
  if api_key:
 
 
 
 
 
 
 
229
  try:
230
- res = _search_data_api(topic, api_key, max_results)
231
- if res:
232
- return res[:max_results]
233
- except Exception as exc:
234
- errors.append(f"Data API: {exc}")
235
- try:
236
- res = _search_ytdlp(topic, max_results, proxy)
237
- if res:
238
- return res[:max_results]
239
- except Exception as exc:
240
- errors.append(f"yt-dlp: {exc}")
241
 
242
- raise RuntimeError("Video search failed. " + " | ".join(errors)[:400])
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Stage 1: find the top videos for a topic and pick candidates by an engagement signal.
2
 
3
+ Discovery and engagement scoring are separate concerns here, because they fail
4
+ independently.
 
 
 
5
 
6
+ **Discovery** is tiered the first tier that returns candidates wins:
7
+
8
+ 1. **Direct scrape** of ``youtube.com/results``. Runs in this process, so it depends on
9
+ no third party, costs no API quota, and honours ``YT_PROXY`` — meaning it can exit
10
+ from a residential IP rather than the Space's datacenter one. It also yields view
11
+ count and duration straight from YouTube.
12
+ 2. **Piped API** (a privacy frontend for YouTube). Public instances are ephemeral, so we
13
+ discover a live instance list and **fail over across instances** on any error.
14
+ 3. **YouTube Data API** ``search.list`` — deterministic, but 100 quota units a call.
15
+ 4. **yt-dlp** ``ytsearch`` — last resort.
16
+
17
+ **Engagement** is a best-effort layer applied to whichever tier produced the candidates.
18
+ Piped's ``/streams/{id}`` exposes likes, dislikes and the uploader's subscriber count, and
19
+ ``/comments/{id}`` the comment count. Each metric is min-max normalized across the
20
+ candidate pool, then weighted:
21
 
22
  score = w_like*likes + w_comment*comments + w_sub*subscribers - w_dislike*dislikes
23
 
24
+ Tier 2 gets these for free while searching; the other tiers have them fetched separately.
25
+ If Piped is unreachable entirely, candidates are returned in discovery order and the
26
+ downstream sentiment stage alone decides the winner exactly as before.
27
+
28
+ Shorts are dropped before the caller spends a (quota-capped) video download on them.
29
+ Note there is deliberately **no caption filter**: transcripts come from faster-whisper ASR
30
+ on the downloaded video, so a video without a caption track works just as well.
31
  """
32
  from __future__ import annotations
33
 
34
  import html
35
  import json
36
  import os
37
+ import re
38
+ import time
39
  import urllib.error
40
  import urllib.parse
41
  import urllib.request
 
61
  SEARCH_API = "https://www.googleapis.com/youtube/v3/search"
62
  _UA = {"User-Agent": "TutorialMaker/1.0"}
63
 
64
+ # Piped instance list is cached process-wide; public instances churn, so not for long.
65
+ INSTANCE_TTL = 900
66
+ _INSTANCE_CACHE: tuple[float, list[str]] | None = None
67
+
68
+ # Engagement enrichment is optional, so it must fail *fast* — when Piped is down we'd
69
+ # otherwise pay a full rotation across every instance before giving up on a search that
70
+ # already has its candidates. Discovery (tier 2) keeps the patient full-rotation budget.
71
+ ENRICH_MAX_INSTANCES = 3
72
+ ENRICH_TIMEOUT = 6
73
+
74
+ # --- direct scrape ---------------------------------------------------------------
75
+ RESULTS_URL = "https://www.youtube.com/results"
76
+ # YouTube's "Type: Video" result filter — keeps channels and playlists out.
77
+ _SP_VIDEOS_ONLY = "EgIQAQ%3D%3D"
78
+ _BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
79
+ "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
80
+
81
+ # Anything this short is a Short (or a trailer) — never a tutorial worth downloading.
82
+ SHORTS_MAX_SECONDS = 60
83
+
84
+ # Raw ids in the results page JSON, used only when the ytInitialData walk comes up empty.
85
+ _BARE_ID_RE = re.compile(r'"videoId"\s*:\s*"([A-Za-z0-9_-]{11})"')
86
+ # Strip credentials from any proxy URL an exception might echo, so a configured
87
+ # http://user:pass@host proxy never leaks into the UI or logs.
88
+ _CRED_RE = re.compile(r"(https?://)[^/@\s]+@")
89
+ _VIEWS_RE = re.compile(r"([\d.,]+)\s*([KMB])?", re.I)
90
+
91
+
92
+ def _redact(text) -> str:
93
+ return _CRED_RE.sub(r"\1", str(text))
94
+
95
 
96
  def _get_json(url: str, timeout: int = 15):
97
  req = urllib.request.Request(url, headers=_UA)
 
100
 
101
 
102
  def _instances() -> list[str]:
103
+ """Live Piped instances (dynamic list first, then seeds), deduped in order.
104
+
105
+ Cached for ``INSTANCE_TTL`` so a Space serving many searches doesn't re-pay the
106
+ instance-list fetch every time — but still short enough to pick up churn, since
107
+ public instances come and go.
108
+ """
109
+ global _INSTANCE_CACHE
110
+ now = time.time()
111
+ if _INSTANCE_CACHE and now - _INSTANCE_CACHE[0] < INSTANCE_TTL:
112
+ return _INSTANCE_CACHE[1]
113
+
114
  insts: list[str] = []
115
  try:
116
  data = _get_json(PIPED_INSTANCE_LIST, timeout=10)
 
124
  s = s.rstrip("/")
125
  if s not in insts:
126
  insts.append(s)
127
+ _INSTANCE_CACHE = (now, insts)
128
  return insts
129
 
130
 
 
135
  self.instances = _instances()
136
  self.current = None
137
 
138
+ def get(self, path: str, timeout: int = 15, max_instances: int | None = None):
139
  order = ([self.current] if self.current else [])
140
  order += [i for i in self.instances if i != self.current]
141
+ if max_instances:
142
+ order = order[:max_instances]
143
  last = None
144
  for inst in order:
145
  try:
 
168
  return v if v > 0 else 0
169
 
170
 
171
+ # ---------------------------------------------------------------- tier 1: direct scrape
172
+ def _initial_data(page: str) -> dict | None:
173
+ """Pull the ``ytInitialData`` JSON blob out of a results page.
174
+
175
+ Brace-matched rather than regex'd: the blob contains plenty of nested braces and
176
+ escaped quotes inside string literals.
177
+ """
178
+ for marker in ('var ytInitialData = ', 'window["ytInitialData"] = ', 'ytInitialData = '):
179
+ i = page.find(marker)
180
+ if i == -1:
181
+ continue
182
+ start = page.find("{", i)
183
+ if start == -1:
184
+ continue
185
+ depth, in_str, esc = 0, False, False
186
+ for j in range(start, len(page)):
187
+ ch = page[j]
188
+ if in_str:
189
+ if esc:
190
+ esc = False
191
+ elif ch == "\\":
192
+ esc = True
193
+ elif ch == '"':
194
+ in_str = False
195
+ continue
196
+ if ch == '"':
197
+ in_str = True
198
+ elif ch == "{":
199
+ depth += 1
200
+ elif ch == "}":
201
+ depth -= 1
202
+ if depth == 0:
203
+ try:
204
+ return json.loads(page[start:j + 1])
205
+ except json.JSONDecodeError:
206
+ break # try the next marker
207
+ return None
208
+
209
+
210
+ def _walk_renderers(node, out: list) -> None:
211
+ """Collect every ``videoRenderer`` dict anywhere in the response tree."""
212
+ if isinstance(node, dict):
213
+ vr = node.get("videoRenderer")
214
+ if isinstance(vr, dict) and vr.get("videoId"):
215
+ out.append(vr)
216
+ for value in node.values():
217
+ _walk_renderers(value, out)
218
+ elif isinstance(node, list):
219
+ for value in node:
220
+ _walk_renderers(value, out)
221
+
222
+
223
+ def _renderer_text(field) -> str:
224
+ """YouTube renders text as either ``simpleText`` or a list of ``runs``."""
225
+ if not isinstance(field, dict):
226
+ return ""
227
+ if field.get("simpleText"):
228
+ return str(field["simpleText"]).strip()
229
+ return "".join(r.get("text", "") for r in field.get("runs") or []).strip()
230
+
231
+
232
+ def _hms_to_seconds(text: str | None) -> int | None:
233
+ """Parse a ``lengthText`` like ``12:34`` or ``1:02:03`` into seconds."""
234
+ text = (text or "").strip()
235
+ if not text:
236
+ return None
237
+ try:
238
+ nums = [int(p) for p in text.split(":")]
239
+ except ValueError:
240
+ return None # "LIVE", "SHORTS", etc.
241
+ total = 0
242
+ for n in nums:
243
+ total = total * 60 + n
244
+ return total
245
+
246
+
247
+ def _parse_views(text: str | None) -> int:
248
+ """Parse a ``viewCountText`` like ``1,234 views`` or ``1.2M views`` into an int."""
249
+ text = (text or "").strip()
250
+ if not text:
251
+ return 0
252
+ m = _VIEWS_RE.match(text)
253
+ if not m:
254
+ return 0
255
+ try:
256
+ value = float(m.group(1).replace(",", ""))
257
+ except ValueError:
258
+ return 0
259
+ return int(value * {"k": 1e3, "m": 1e6, "b": 1e9}.get((m.group(2) or "").lower(), 1))
260
+
261
+
262
+ def _parse_results_page(page: str) -> list[dict]:
263
+ data = _initial_data(page)
264
+ videos: list[dict] = []
265
+ seen: set[str] = set()
266
+
267
+ if data:
268
+ renderers: list[dict] = []
269
+ _walk_renderers(data, renderers)
270
+ for vr in renderers:
271
+ vid = vr.get("videoId")
272
+ if not vid or vid in seen:
273
+ continue
274
+ seen.add(vid)
275
+ videos.append({
276
+ "video_id": vid,
277
+ "url": f"https://www.youtube.com/watch?v={vid}",
278
+ "title": html.unescape(_renderer_text(vr.get("title")) or vid),
279
+ "channel": _renderer_text(vr.get("ownerText")),
280
+ "duration_s": _hms_to_seconds(_renderer_text(vr.get("lengthText"))),
281
+ "views": _parse_views(_renderer_text(vr.get("viewCountText"))),
282
+ })
283
+
284
+ if not videos:
285
+ # Parser drift (YouTube reshuffles this tree periodically): fall back to raw ids
286
+ # in page order. Less precise — may catch a shelf or promo — but still usable.
287
+ for vid in dict.fromkeys(_BARE_ID_RE.findall(page)):
288
+ videos.append({
289
+ "video_id": vid,
290
+ "url": f"https://www.youtube.com/watch?v={vid}",
291
+ "title": vid,
292
+ "channel": "",
293
+ "duration_s": None,
294
+ "views": 0,
295
+ })
296
+ return videos
297
+
298
+
299
+ def _search_scrape(topic: str, pool: int, proxy: str | None, timeout: int = 30) -> list[dict]:
300
+ """Fetch and parse YouTube's results page ourselves, through ``proxy`` when set."""
301
+ import requests
302
+
303
+ url = (f"{RESULTS_URL}?{urllib.parse.urlencode({'search_query': topic})}"
304
+ f"&sp={_SP_VIDEOS_ONLY}")
305
+ headers = {
306
+ "User-Agent": _BROWSER_UA,
307
+ "Accept-Language": "en-US,en;q=0.9",
308
+ # Skip the EU consent interstitial, which otherwise replaces the results page.
309
+ "Cookie": "CONSENT=YES+1; SOCS=CAI",
310
+ }
311
+ proxies = {"http": proxy, "https": proxy} if proxy else None
312
+ resp = requests.get(url, headers=headers, proxies=proxies, timeout=timeout)
313
+ resp.raise_for_status()
314
+ return _parse_results_page(resp.text)[:pool]
315
+
316
+
317
+ # ---------------------------------------------------------------- tier 2: Piped
318
  def _search_piped(topic: str, pool: int) -> list[dict]:
319
  """Piped search + per-candidate engagement metrics. Returns unscored candidates."""
320
  p = _Piped()
 
334
  comments = _nn(p.get(f"/comments/{vid}").get("commentCount"))
335
  except Exception:
336
  comments = 0 # best-effort; don't drop the candidate
337
+ duration = _nn(it.get("duration")) or None
338
  cands.append({
339
  "video_id": vid,
340
  "url": f"https://www.youtube.com/watch?v={vid}",
341
  "title": html.unescape(it.get("title") or st.get("title") or vid),
342
+ "channel": (it.get("uploaderName") or "").strip(),
343
+ "duration_s": duration,
344
  "views": _nn(st.get("views")),
345
  "likes": _nn(st.get("likes")),
346
  "dislikes": _nn(st.get("dislikes")),
 
350
  return cands
351
 
352
 
353
+ def _enrich_engagement(cands: list[dict]) -> bool:
354
+ """Best-effort: attach Piped engagement metrics to candidates that lack them.
355
+
356
+ Lets tiers 1/3/4 be ranked by the same signal tier 2 gets for free. Returns whether
357
+ any candidate was enriched.
358
+
359
+ Deliberately impatient: each call tries at most ``ENRICH_MAX_INSTANCES`` instances on
360
+ a short timeout, and the whole pass is abandoned the first time a candidate can't be
361
+ reached. Ranking is a nice-to-have — a dead Piped must cost seconds, not a minute,
362
+ since the caller already has its candidates and degrades to sentiment-only ranking.
363
+ """
364
+ missing = [c for c in cands if "likes" not in c]
365
+ if not missing:
366
+ return False
367
+
368
+ p = _Piped()
369
+ enriched = 0
370
+ for c in missing:
371
+ vid = c["video_id"]
372
+ try:
373
+ st = p.get(f"/streams/{vid}", timeout=ENRICH_TIMEOUT,
374
+ max_instances=ENRICH_MAX_INSTANCES)
375
+ except Exception:
376
+ break # Piped unreachable — stop trying
377
+ try:
378
+ c["comments"] = _nn(p.get(f"/comments/{vid}", timeout=ENRICH_TIMEOUT,
379
+ max_instances=ENRICH_MAX_INSTANCES)
380
+ .get("commentCount"))
381
+ except Exception:
382
+ c["comments"] = 0
383
+ c["likes"] = _nn(st.get("likes"))
384
+ c["dislikes"] = _nn(st.get("dislikes"))
385
+ c["subscribers"] = _nn(st.get("uploaderSubscriberCount"))
386
+ if not c.get("views"):
387
+ c["views"] = _nn(st.get("views"))
388
+ if not c.get("duration_s"):
389
+ c["duration_s"] = _nn(st.get("duration")) or None
390
+ enriched += 1
391
+ return enriched > 0
392
+
393
+
394
  def _rank_by_engagement(cands: list[dict]) -> list[dict]:
395
  """Attach a normalized weighted ``engagement`` score and sort desc.
396
 
 
415
  return sorted(cands, key=lambda c: -c["engagement"])
416
 
417
 
418
+ # ---------------------------------------------------------------- tiers 3 & 4
419
  def _search_data_api(topic: str, api_key: str, max_results: int) -> list[dict]:
420
  params = {"part": "snippet", "q": topic, "type": "video",
421
  "maxResults": str(max(1, min(max_results, 50))),
 
449
  if e.get("id"):
450
  out.append({"video_id": e["id"],
451
  "url": e.get("url") or f"https://www.youtube.com/watch?v={e['id']}",
452
+ "title": e.get("title") or e["id"],
453
+ "duration_s": _nn(e.get("duration")) or None})
454
  return out
455
 
456
 
457
+ # ---------------------------------------------------------------- public
458
+ def _drop_shorts(cands: list[dict]) -> tuple[list[dict], str | None]:
459
+ """Remove sub-minute videos, which make poor tutorials and waste a download request.
460
+
461
+ Never starves the pipeline: if every candidate looks like a Short (usually a bad
462
+ duration read rather than a page of Shorts), keep them all and say so.
463
+ """
464
+ kept = [c for c in cands
465
+ if c.get("duration_s") is None or c["duration_s"] >= SHORTS_MAX_SECONDS]
466
+ if len(kept) == len(cands):
467
+ return cands, None
468
+ if not kept:
469
+ return cands, "every candidate looked like a Short — kept them all"
470
+ return kept, f"dropped {len(cands) - len(kept)} Short(s) under {SHORTS_MAX_SECONDS}s"
471
+
472
+
473
  def search_top5(topic: str, api_key: str | None = None, proxy: str | None = None,
474
  max_results: int = 5, pool: int = 8) -> list[dict]:
475
+ """Return up to ``max_results`` videos for ``topic``, best-effort engagement-ranked.
476
 
477
+ Discovery falls through direct scrape -> Piped -> Data API -> yt-dlp; the first tier
478
+ with results wins. Engagement metrics are then attached from Piped when the winning
479
+ tier didn't already supply them, and candidates carrying metrics are sorted by the
480
+ normalized weighted score. Without metrics they stay in discovery order and the
481
+ sentiment stage ranks them.
482
+
483
+ Each item carries at least ``video_id/url/title``, plus ``tier`` and whichever of
484
+ ``views/likes/dislikes/subscribers/comments/duration_s/engagement`` were available.
485
  """
486
  topic = (topic or "").strip()
487
  if not topic:
488
  raise ValueError("Please enter a topic to search for.")
489
 
490
+ want = max(pool, max_results)
491
+ tiers = [
492
+ ("direct scrape" + (" via proxy" if proxy else ""),
493
+ lambda: _search_scrape(topic, want, proxy)),
494
+ ("Piped", lambda: _search_piped(topic, want)),
495
+ ]
 
 
 
 
496
  if api_key:
497
+ tiers.append(("Data API", lambda: _search_data_api(topic, api_key, max_results)))
498
+ tiers.append(("yt-dlp", lambda: _search_ytdlp(topic, max_results, proxy)))
499
+
500
+ cands: list[dict] = []
501
+ tier_label = ""
502
+ errors: list[str] = []
503
+ for label, fetch in tiers:
504
  try:
505
+ found = fetch()
506
+ except Exception as exc: # noqa: BLE001 - any tier may fail; try the next
507
+ errors.append(f"{label}: {_redact(exc)[:160]}")
508
+ continue
509
+ if found:
510
+ cands, tier_label = found, label
511
+ break
512
+ errors.append(f"{label}: no candidates")
 
 
 
513
 
514
+ if not cands:
515
+ raise RuntimeError("Video search failed. " + " | ".join(errors)[:400])
516
+
517
+ cands, shorts_note = _drop_shorts(cands)
518
+ _enrich_engagement(cands)
519
+ if any("likes" in c for c in cands):
520
+ cands = _rank_by_engagement(cands)
521
+
522
+ for c in cands:
523
+ c["tier"] = tier_label
524
+ if shorts_note:
525
+ c["filter_note"] = shorts_note
526
+ return cands[:max_results]