Spaces:
Sleeping
Sleeping
| """Stage 2: rank candidate videos by the sentiment of their comments. | |
| Comments are fetched via the **YouTube Data API v3** (commentThreads) using an API key, | |
| then scored with the BERT classifier ``OmarMedhat7/youtube-sentiment-analysis-model`` | |
| running inside the Space. The video with the highest positive share wins. Any video whose | |
| comments are disabled or fail to fetch simply scores 0 and never aborts the ranking. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| from functools import lru_cache | |
| SENTIMENT_MODEL = "OmarMedhat7/youtube-sentiment-analysis-model" | |
| COMMENTS_API = "https://www.googleapis.com/youtube/v3/commentThreads" | |
| MAX_COMMENTS = 200 # cap per video to bound time/quota (commentThreads = 1 unit/100) | |
| _POSITIVE = {"positive", "pos", "label_2", "label_1"} | |
| _NEGATIVE = {"negative", "neg", "label_0"} | |
| class SentimentError(RuntimeError): | |
| """Raised for API-key / quota problems that should surface to the user.""" | |
| def _classifier(): | |
| from transformers import pipeline | |
| return pipeline( | |
| "text-classification", | |
| model=SENTIMENT_MODEL, | |
| truncation=True, | |
| max_length=256, | |
| top_k=None, | |
| ) | |
| def _polarity(scores) -> float: | |
| val = 0.0 | |
| for s in scores: | |
| label = str(s["label"]).strip().lower() | |
| if label in _POSITIVE: | |
| val += s["score"] | |
| elif label in _NEGATIVE: | |
| val -= s["score"] | |
| return val | |
| def _fetch_comments(video_id: str, api_key: str, cap: int = MAX_COMMENTS) -> list[str]: | |
| """Fetch up to ``cap`` top-relevance comments via the YouTube Data API. | |
| Returns [] if comments are disabled. Raises SentimentError on auth/quota failures | |
| (which apply to every video, so the caller should stop). | |
| """ | |
| comments: list[str] = [] | |
| page = None | |
| while len(comments) < cap: | |
| params = { | |
| "part": "snippet", | |
| "videoId": video_id, | |
| "maxResults": "100", | |
| "order": "relevance", | |
| "textFormat": "plainText", | |
| "key": api_key, | |
| } | |
| if page: | |
| params["pageToken"] = page | |
| url = COMMENTS_API + "?" + urllib.parse.urlencode(params) | |
| try: | |
| with urllib.request.urlopen(url, timeout=30) as resp: | |
| data = json.load(resp) | |
| except urllib.error.HTTPError as exc: | |
| body = exc.read().decode("utf-8", "ignore") | |
| if exc.code == 403 and "commentsDisabled" in body: | |
| return [] | |
| if exc.code in (400, 403) and ("keyInvalid" in body or "quota" in body.lower() | |
| or "forbidden" in body.lower()): | |
| raise SentimentError( | |
| f"YouTube Data API rejected the key (HTTP {exc.code}). Check the key " | |
| f"and that YouTube Data API v3 is enabled / has quota. {body[:160]}") | |
| # video-specific error (e.g. not found): treat as no comments | |
| return comments | |
| except Exception: | |
| return comments | |
| for item in data.get("items", []): | |
| sn = item.get("snippet", {}).get("topLevelComment", {}).get("snippet", {}) | |
| text = sn.get("textDisplay") or sn.get("textOriginal") | |
| if text: | |
| comments.append(text.strip()) | |
| page = data.get("nextPageToken") | |
| if not page: | |
| break | |
| return comments[:cap] | |
| def rank_by_sentiment(videos: list[dict], api_key: str, | |
| progress=None) -> tuple[dict, list[dict]]: | |
| """Score each video by comment sentiment; return ``(best_video, scored)``. | |
| ``scored`` mirrors ``videos`` with ``positive_share`` (0..1), ``n_comments``, ``note`` | |
| and is sorted by positive_share desc (search order as tie-break / fallback). | |
| """ | |
| if not videos: | |
| raise ValueError("No videos to rank.") | |
| if not api_key: | |
| raise SentimentError("A YouTube Data API key is required to fetch comments.") | |
| clf = _classifier() | |
| scored: list[dict] = [] | |
| for i, v in enumerate(videos): | |
| if progress: | |
| progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}") | |
| item = dict(v) | |
| try: | |
| comments = _fetch_comments(v["video_id"], api_key) | |
| if comments: | |
| results = clf(comments) | |
| pols = [_polarity(r) for r in results] | |
| positives = sum(1 for p in pols if p > 0) | |
| item["positive_share"] = positives / len(pols) | |
| item["n_comments"] = len(comments) | |
| item["note"] = "" | |
| else: | |
| item["positive_share"] = 0.0 | |
| item["n_comments"] = 0 | |
| item["note"] = "no comments" | |
| except SentimentError: | |
| raise # key/quota problem affects all videos | |
| except Exception as exc: | |
| item["positive_share"] = 0.0 | |
| item["n_comments"] = 0 | |
| item["note"] = f"fetch failed: {type(exc).__name__}" | |
| item["search_rank"] = i | |
| scored.append(item) | |
| scored.sort(key=lambda d: (-d["positive_share"], d["search_rank"])) | |
| return scored[0], scored | |