File size: 5,214 Bytes
f07812e
f6a6455
f07812e
 
 
 
f6a6455
 
 
f07812e
 
 
 
f6a6455
 
 
f07812e
 
f6a6455
 
 
 
 
f07812e
 
 
 
f6a6455
 
 
 
 
 
 
 
 
f07812e
f6a6455
 
 
 
 
 
 
 
 
 
 
 
 
 
f07812e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6a6455
 
 
f07812e
 
f6a6455
 
 
 
 
 
 
 
f07812e
f6a6455
f07812e
f6a6455
 
 
 
 
 
 
 
f07812e
 
 
f6a6455
 
 
f07812e
f6a6455
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""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."""


@lru_cache(maxsize=1)
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