TutorialMaker / pipeline /search.py
vivekchakraverty's picture
Video search via Piped API with normalized engagement ranking + instance failover; Data API/yt-dlp fallback
e698ddd verified
Raw
History Blame
9.21 kB
"""Stage 1: find the top videos for a topic and pick candidates by an engagement signal.
Primary source is the **Piped API** (a privacy frontend for YouTube). Piped's
``/streams/{id}`` exposes likes, dislikes and the uploader's subscriber count, and
``/comments/{id}`` exposes the comment count — everything needed for a per-video
engagement score. Public Piped instances are ephemeral, so we discover a live instance
list and **fail over across instances** on any error.
Engagement (each metric min-max normalized across the candidate pool, then weighted):
score = w_like*likes + w_comment*comments + w_sub*subscribers - w_dislike*dislikes
Engagement picks the top ``max_results`` candidates; the downstream sentiment stage then
decides the final winner. If Piped is entirely unreachable we fall back to the YouTube
Data API (``search.list``) or yt-dlp ``ytsearch`` — without engagement metadata, letting
sentiment alone rank.
"""
from __future__ import annotations
import html
import json
import os
import urllib.error
import urllib.parse
import urllib.request
# Live instance list (best-effort) + a seed list to fall back on.
PIPED_INSTANCE_LIST = "https://piped-instances.kavin.rocks/"
SEED_INSTANCES = [
"https://api.piped.private.coffee",
"https://pipedapi.kavin.rocks",
"https://pipedapi.adminforge.de",
"https://pipedapi.drgns.space",
"https://pipedapi.ducks.party",
"https://pipedapi.reallyaweso.me",
"https://piped-api.lunar.icu",
"https://pipedapi.r4fo.com",
"https://pipedapi.phoenixthrush.com",
"https://api.piped.yt",
]
# Engagement weights (subscribers down-weighted: channel-level, not video-level).
W_LIKE, W_COMMENT, W_SUB, W_DISLIKE = 1.0, 1.0, 0.5, 1.0
SEARCH_API = "https://www.googleapis.com/youtube/v3/search"
_UA = {"User-Agent": "TutorialMaker/1.0"}
def _get_json(url: str, timeout: int = 15):
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp)
def _instances() -> list[str]:
"""Live Piped instances (dynamic list first, then seeds), deduped in order."""
insts: list[str] = []
try:
data = _get_json(PIPED_INSTANCE_LIST, timeout=10)
for entry in data if isinstance(data, list) else []:
api = (entry or {}).get("api_url")
if api:
insts.append(api.rstrip("/"))
except Exception:
pass
for s in SEED_INSTANCES:
s = s.rstrip("/")
if s not in insts:
insts.append(s)
return insts
class _Piped:
"""Fetches Piped API paths, sticking to a working instance and rotating on failure."""
def __init__(self):
self.instances = _instances()
self.current = None
def get(self, path: str, timeout: int = 15):
order = ([self.current] if self.current else [])
order += [i for i in self.instances if i != self.current]
last = None
for inst in order:
try:
data = _get_json(inst + path, timeout=timeout)
self.current = inst # remember the one that worked
return data
except Exception as exc:
last = exc
continue
raise RuntimeError(f"all Piped instances failed for {path}: {last}")
def _vid_from_watch(url: str) -> str | None:
if not url:
return None
q = urllib.parse.urlparse(url).query
return urllib.parse.parse_qs(q).get("v", [None])[0]
def _nn(x) -> int:
"""Non-negative int (Piped returns -1 when a metric is unavailable)."""
try:
v = int(x)
except (TypeError, ValueError):
return 0
return v if v > 0 else 0
def _search_piped(topic: str, pool: int) -> list[dict]:
"""Piped search + per-candidate engagement metrics. Returns unscored candidates."""
p = _Piped()
data = p.get("/search?" + urllib.parse.urlencode({"q": topic, "filter": "videos"}))
items = [it for it in (data.get("items") or [])
if str(it.get("url", "")).startswith("/watch")][:pool]
cands: list[dict] = []
for it in items:
vid = _vid_from_watch(it.get("url", ""))
if not vid:
continue
try:
st = p.get(f"/streams/{vid}")
except Exception:
continue # can't score this one; skip
try:
comments = _nn(p.get(f"/comments/{vid}").get("commentCount"))
except Exception:
comments = 0 # best-effort; don't drop the candidate
cands.append({
"video_id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": html.unescape(it.get("title") or st.get("title") or vid),
"views": _nn(st.get("views")),
"likes": _nn(st.get("likes")),
"dislikes": _nn(st.get("dislikes")),
"subscribers": _nn(st.get("uploaderSubscriberCount")),
"comments": comments,
})
return cands
def _rank_by_engagement(cands: list[dict]) -> list[dict]:
"""Attach a normalized weighted ``engagement`` score and sort desc.
Each metric is min-max normalized across the pool so wildly different scales
(subscribers in millions vs comments in thousands) contribute comparably.
"""
if not cands:
return cands
def norm(key: str) -> list[float]:
vals = [c.get(key, 0) or 0 for c in cands]
lo, hi = min(vals), max(vals)
if hi == lo:
return [0.5] * len(vals) # neutral when all equal
return [(v - lo) / (hi - lo) for v in vals]
nl, nc, ns, nd = (norm("likes"), norm("comments"),
norm("subscribers"), norm("dislikes"))
for i, c in enumerate(cands):
c["engagement"] = round(
W_LIKE * nl[i] + W_COMMENT * nc[i] + W_SUB * ns[i] - W_DISLIKE * nd[i], 4)
return sorted(cands, key=lambda c: -c["engagement"])
# ---------------------------------------------------------------- fallbacks (no engagement)
def _search_data_api(topic: str, api_key: str, max_results: int) -> list[dict]:
params = {"part": "snippet", "q": topic, "type": "video",
"maxResults": str(max(1, min(max_results, 50))),
"order": "relevance", "key": api_key}
try:
data = _get_json(SEARCH_API + "?" + urllib.parse.urlencode(params), timeout=30)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "ignore")
raise RuntimeError(f"Data API search HTTP {exc.code}: {body[:160]}") from exc
out = []
for item in data.get("items", []):
vid = item.get("id", {}).get("videoId")
if vid:
out.append({"video_id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": html.unescape((item.get("snippet") or {}).get("title", "") or vid)})
return out
def _search_ytdlp(topic: str, max_results: int, proxy: str | None) -> list[dict]:
from yt_dlp import YoutubeDL
opts = {"quiet": True, "no_warnings": True, "skip_download": True, "extract_flat": True}
if proxy:
opts["proxy"] = proxy
if os.environ.get("SSL_CERT_FILE"):
opts["compat_opts"] = ["no-certifi"]
with YoutubeDL(opts) as ydl:
info = ydl.extract_info(f"ytsearch{max_results}:{topic}", download=False)
out = []
for e in (info.get("entries") or [])[:max_results]:
if e.get("id"):
out.append({"video_id": e["id"],
"url": e.get("url") or f"https://www.youtube.com/watch?v={e['id']}",
"title": e.get("title") or e["id"]})
return out
def search_top5(topic: str, api_key: str | None = None, proxy: str | None = None,
max_results: int = 5, pool: int = 8) -> list[dict]:
"""Return up to ``max_results`` videos for ``topic``, chosen by engagement.
Piped supplies candidates + engagement metrics; the top ``max_results`` by the
normalized weighted score are returned (each item carries ``views/likes/dislikes/
subscribers/comments/engagement`` for transparency). On total Piped failure, falls
back to the Data API or yt-dlp (candidates only, no engagement).
"""
topic = (topic or "").strip()
if not topic:
raise ValueError("Please enter a topic to search for.")
errors: list[str] = []
try:
cands = _search_piped(topic, max(pool, max_results))
if cands:
return _rank_by_engagement(cands)[:max_results]
errors.append("Piped: no scorable candidates")
except Exception as exc:
errors.append(f"Piped: {exc}")
# Fallbacks — search only, sentiment stage will do the ranking.
if api_key:
try:
res = _search_data_api(topic, api_key, max_results)
if res:
return res[:max_results]
except Exception as exc:
errors.append(f"Data API: {exc}")
try:
res = _search_ytdlp(topic, max_results, proxy)
if res:
return res[:max_results]
except Exception as exc:
errors.append(f"yt-dlp: {exc}")
raise RuntimeError("Video search failed. " + " | ".join(errors)[:400])