vivekchakraverty commited on
Commit
e698ddd
·
verified ·
1 Parent(s): 3836283

Video search via Piped API with normalized engagement ranking + instance failover; Data API/yt-dlp fallback

Browse files
Files changed (1) hide show
  1. pipeline/search.py +222 -57
pipeline/search.py CHANGED
@@ -1,77 +1,242 @@
1
- """Stage 1: search the top videos for a topic via the adarshajay/youtube-search Space.
2
 
3
- The upstream Space exposes ``api_name="/youtube_search"``. It takes a single query
4
- string and returns a single plain-text blob listing the top 5 results (title, URL and
5
- video id only). We parse the ids/titles out of that text.
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
- import re
 
 
 
 
 
10
 
11
- from gradio_client import Client
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- SEARCH_SPACE = "adarshajay/youtube-search"
14
- SEARCH_API = "/youtube_search"
15
 
16
- # 11-char YouTube ids as they appear in watch?v=... or youtu.be/... URLs.
17
- _ID_RE = re.compile(r"(?:v=|youtu\.be/|/watch/|/embed/)([A-Za-z0-9_-]{11})")
18
 
19
 
20
- def _parse_titles(text: str, ids: list[str]) -> dict[str, str]:
21
- """Best-effort: map each id to a nearby title line in the result blob.
 
 
22
 
23
- The upstream format is not guaranteed, so this is intentionally forgiving: for each
24
- id we take the longest non-URL line that appears on or just before the line holding
25
- the id. Anything we can't resolve falls back to the id itself in ``search_top5``.
26
- """
27
- lines = text.splitlines()
28
- titles: dict[str, str] = {}
29
- for vid in ids:
30
- idx = next((i for i, ln in enumerate(lines) if vid in ln), None)
31
- if idx is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  continue
33
- window = lines[max(0, idx - 1): idx + 1]
34
- cands = [ln.strip(" -*\t") for ln in window if "http" not in ln and vid not in ln]
35
- cands = [c for c in cands if c]
36
- if cands:
37
- titles[vid] = max(cands, key=len)
38
- return titles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
- def search_top5(topic: str, max_results: int = 5) -> list[dict]:
42
- """Return up to ``max_results`` unique videos for ``topic``.
 
43
 
44
- Each item: ``{"video_id", "url", "title"}``. Raises RuntimeError if the upstream
45
- Space is unreachable or returns no parseable video ids.
 
 
46
  """
47
  topic = (topic or "").strip()
48
  if not topic:
49
  raise ValueError("Please enter a topic to search for.")
50
 
 
51
  try:
52
- client = Client(SEARCH_SPACE)
53
- raw = client.predict(topic, api_name=SEARCH_API)
54
- except Exception as exc: # network / Space down / API renamed
55
- raise RuntimeError(
56
- f"Could not reach the search Space '{SEARCH_SPACE}'. It may be sleeping or "
57
- f"its API changed. Details: {exc}"
58
- ) from exc
59
-
60
- text = raw if isinstance(raw, str) else str(raw)
61
- ids = list(dict.fromkeys(_ID_RE.findall(text))) # preserve order, dedupe
62
- if not ids:
63
- raise RuntimeError(
64
- "The search Space returned no recognizable YouTube video ids. Raw output:\n"
65
- + text[:500]
66
- )
67
-
68
- ids = ids[:max_results]
69
- titles = _parse_titles(text, ids)
70
- return [
71
- {
72
- "video_id": vid,
73
- "url": f"https://www.youtube.com/watch?v={vid}",
74
- "title": titles.get(vid, vid),
75
- }
76
- for vid in ids
77
- ]
 
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
26
 
27
+ # Live instance list (best-effort) + a seed list to fall back on.
28
+ PIPED_INSTANCE_LIST = "https://piped-instances.kavin.rocks/"
29
+ SEED_INSTANCES = [
30
+ "https://api.piped.private.coffee",
31
+ "https://pipedapi.kavin.rocks",
32
+ "https://pipedapi.adminforge.de",
33
+ "https://pipedapi.drgns.space",
34
+ "https://pipedapi.ducks.party",
35
+ "https://pipedapi.reallyaweso.me",
36
+ "https://piped-api.lunar.icu",
37
+ "https://pipedapi.r4fo.com",
38
+ "https://pipedapi.phoenixthrush.com",
39
+ "https://api.piped.yt",
40
+ ]
41
 
42
+ # Engagement weights (subscribers down-weighted: channel-level, not video-level).
43
+ W_LIKE, W_COMMENT, W_SUB, W_DISLIKE = 1.0, 1.0, 0.5, 1.0
44
 
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)
51
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
52
+ return json.load(resp)
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)
60
+ for entry in data if isinstance(data, list) else []:
61
+ api = (entry or {}).get("api_url")
62
+ if api:
63
+ insts.append(api.rstrip("/"))
64
+ except Exception:
65
+ pass
66
+ for s in SEED_INSTANCES:
67
+ s = s.rstrip("/")
68
+ if s not in insts:
69
+ insts.append(s)
70
+ return insts
71
+
72
+
73
+ class _Piped:
74
+ """Fetches Piped API paths, sticking to a working instance and rotating on failure."""
75
+
76
+ def __init__(self):
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:
86
+ data = _get_json(inst + path, timeout=timeout)
87
+ self.current = inst # remember the one that worked
88
+ return data
89
+ except Exception as exc:
90
+ last = exc
91
+ continue
92
+ raise RuntimeError(f"all Piped instances failed for {path}: {last}")
93
+
94
+
95
+ def _vid_from_watch(url: str) -> str | None:
96
+ if not url:
97
+ return None
98
+ q = urllib.parse.urlparse(url).query
99
+ return urllib.parse.parse_qs(q).get("v", [None])[0]
100
+
101
+
102
+ def _nn(x) -> int:
103
+ """Non-negative int (Piped returns -1 when a metric is unavailable)."""
104
+ try:
105
+ v = int(x)
106
+ except (TypeError, ValueError):
107
+ return 0
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()
114
+ data = p.get("/search?" + urllib.parse.urlencode({"q": topic, "filter": "videos"}))
115
+ items = [it for it in (data.get("items") or [])
116
+ if str(it.get("url", "")).startswith("/watch")][:pool]
117
+ cands: list[dict] = []
118
+ for it in items:
119
+ vid = _vid_from_watch(it.get("url", ""))
120
+ if not vid:
121
  continue
122
+ try:
123
+ st = p.get(f"/streams/{vid}")
124
+ except Exception:
125
+ continue # can't score this one; skip
126
+ try:
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")),
137
+ "subscribers": _nn(st.get("uploaderSubscriberCount")),
138
+ "comments": comments,
139
+ })
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
+
146
+ Each metric is min-max normalized across the pool so wildly different scales
147
+ (subscribers in millions vs comments in thousands) contribute comparably.
148
+ """
149
+ if not cands:
150
+ return cands
151
+
152
+ def norm(key: str) -> list[float]:
153
+ vals = [c.get(key, 0) or 0 for c in cands]
154
+ lo, hi = min(vals), max(vals)
155
+ if hi == lo:
156
+ return [0.5] * len(vals) # neutral when all equal
157
+ return [(v - lo) / (hi - lo) for v in vals]
158
+
159
+ nl, nc, ns, nd = (norm("likes"), norm("comments"),
160
+ norm("subscribers"), norm("dislikes"))
161
+ for i, c in enumerate(cands):
162
+ c["engagement"] = round(
163
+ W_LIKE * nl[i] + W_COMMENT * nc[i] + W_SUB * ns[i] - W_DISLIKE * nd[i], 4)
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))),
171
+ "order": "relevance", "key": api_key}
172
+ try:
173
+ data = _get_json(SEARCH_API + "?" + urllib.parse.urlencode(params), timeout=30)
174
+ except urllib.error.HTTPError as exc:
175
+ body = exc.read().decode("utf-8", "ignore")
176
+ raise RuntimeError(f"Data API search HTTP {exc.code}: {body[:160]}") from exc
177
+ out = []
178
+ for item in data.get("items", []):
179
+ vid = item.get("id", {}).get("videoId")
180
+ if vid:
181
+ out.append({"video_id": vid,
182
+ "url": f"https://www.youtube.com/watch?v={vid}",
183
+ "title": html.unescape((item.get("snippet") or {}).get("title", "") or vid)})
184
+ return out
185
+
186
+
187
+ def _search_ytdlp(topic: str, max_results: int, proxy: str | None) -> list[dict]:
188
+ from yt_dlp import YoutubeDL
189
+ opts = {"quiet": True, "no_warnings": True, "skip_download": True, "extract_flat": True}
190
+ if proxy:
191
+ opts["proxy"] = proxy
192
+ if os.environ.get("SSL_CERT_FILE"):
193
+ opts["compat_opts"] = ["no-certifi"]
194
+ with YoutubeDL(opts) as ydl:
195
+ info = ydl.extract_info(f"ytsearch{max_results}:{topic}", download=False)
196
+ out = []
197
+ for e in (info.get("entries") or [])[:max_results]:
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])