Spaces:
Sleeping
Sleeping
Video search via Piped API with normalized engagement ranking + instance failover; Data API/yt-dlp fallback
Browse files- pipeline/search.py +222 -57
pipeline/search.py
CHANGED
|
@@ -1,77 +1,242 @@
|
|
| 1 |
-
"""Stage 1:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
continue
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
-
def search_top5(topic: str,
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
"""
|
| 47 |
topic = (topic or "").strip()
|
| 48 |
if not topic:
|
| 49 |
raise ValueError("Please enter a topic to search for.")
|
| 50 |
|
|
|
|
| 51 |
try:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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])
|
|
|
|
|
|
|
|
|