File size: 7,270 Bytes
a105f7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133bd66
a105f7e
 
 
 
 
 
 
 
 
 
 
 
 
 
0815197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a105f7e
 
 
 
 
 
 
 
133bd66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a105f7e
 
133bd66
a105f7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133bd66
 
 
 
 
 
 
 
 
 
 
 
 
a105f7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""Baselines, saturation score, and verdict. Pure functions — no I/O, no API calls."""

import re
from collections import defaultdict
from datetime import datetime, timedelta
from statistics import median

import config

_DURATION_RE = re.compile(
    r"^P(?:(?P<days>\d+)D)?(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$"
)


def duration_seconds(iso_duration):
    """Parse an ISO 8601 duration (e.g. PT4M13S) into seconds. Unparseable → 0."""
    if not iso_duration:
        return 0
    match = _DURATION_RE.match(iso_duration)
    if not match:
        return 0
    days, hours, minutes, seconds = (int(g) if g else 0 for g in match.groups())
    return days * 86400 + hours * 3600 + minutes * 60 + seconds


def parse_timestamp(value):
    """Parse an RFC3339 timestamp from the API into an aware datetime."""
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def is_short(seconds):
    return seconds <= config.SHORTS_MAX_SECONDS


def lifetime_average(view_count, video_count):
    if not video_count:
        return 0.0
    return view_count / video_count


def recent_median_baseline(uploads, now):
    """Median views of uploads older than BASELINE_MIN_AGE_DAYS and longer than the
    Shorts cutoff. Returns None when fewer than BASELINE_MIN_VIDEOS qualify (caller
    falls back to the channel's lifetime average)."""
    cutoff = now - timedelta(days=config.BASELINE_MIN_AGE_DAYS)
    qualifying = [
        v["views"]
        for v in uploads
        if v["views"] is not None
        and v["published_at"] is not None
        and v["published_at"] < cutoff
        and not is_short(v["seconds"])
        and not v.get("stream")  # cumulative stream views would poison the median
    ]
    if len(qualifying) < config.BASELINE_MIN_VIDEOS:
        return None
    return float(median(qualifying))


def outlier_multiple(views, baseline):
    """views / baseline, or None when the baseline is below BASELINE_FLOOR
    (avoids absurd multiples on dead channels)."""
    if baseline < config.BASELINE_FLOOR:
        return None
    return views / baseline


def views_per_day(views, published_at, now):
    age_days = max((now - published_at).total_seconds() / 86400, 1.0)
    return views / age_days


def classify_video(multiple):
    """Deterministic performance label from the outlier multiple."""
    if multiple is None:
        return "INSUFFICIENT_BASELINE"
    if multiple >= config.VIDEO_MEGA_OUTLIER_MULTIPLE:
        return "MEGA_OUTLIER"
    if multiple >= config.SCAN_OUTLIER_MULTIPLE:
        return "OUTLIER"
    if multiple >= config.VIDEO_ABOVE_BASELINE_MULTIPLE:
        return "ABOVE_BASELINE"
    if multiple >= config.VIDEO_TYPICAL_MULTIPLE:
        return "TYPICAL"
    return "UNDERPERFORMER"


def assess_niche(videos, outlier_records, now):
    """Compute signals, saturation score, verdict, and reasons over the analyzed set.

    videos: normalized video dicts (post Shorts/hidden-count filtering).
    outlier_records: dicts carrying at least 'subs' (None when hidden) per outlier.
    Returns (saturation, verdict, reasons, signals).
    """
    n = len(videos)
    if n == 0:
        # no qualifying videos: an honest no-data state, never a scored verdict
        return (
            None,
            "NO_DATA",
            [
                "no qualifying long-form videos were found for this query in the "
                "recency window, so no saturation score or entry verdict can be "
                "computed; try a broader query or a longer recency window"
            ],
            {
                "channel_diversity": None,
                "top3_view_concentration": None,
                "fresh_share_90d": None,
                "small_channel_outliers": 0,
            },
        )

    unique_channels = {v["channel_id"] for v in videos}
    u = len(unique_channels)
    diversity = u / n

    views_by_channel = defaultdict(int)
    for v in videos:
        views_by_channel[v["channel_id"]] += v["views"]
    total_views = sum(views_by_channel.values())
    top3_views = sum(sorted(views_by_channel.values(), reverse=True)[:3])
    c3 = top3_views / total_views if total_views else 0.0

    fresh_cutoff = now - timedelta(days=config.FRESH_WINDOW_DAYS)
    fresh_count = sum(1 for v in videos if v["published_at"] >= fresh_cutoff)
    f90 = fresh_count / n if n else 0.0

    sw = sum(
        1
        for r in outlier_records
        if r["subs"] is not None and r["subs"] < config.SMALL_CHANNEL_SUBS
    )

    openness = (
        config.OPENNESS_W_DIVERSITY * diversity
        + config.OPENNESS_W_CONCENTRATION * (1 - c3)
        + config.OPENNESS_W_FRESHNESS * f90
        + config.OPENNESS_W_SMALL_OUTLIERS
        * min(sw, config.SMALL_OUTLIERS_CAP)
        / config.SMALL_OUTLIERS_CAP
    )
    saturation = round(100 - openness)

    if (
        saturation <= config.VERDICT_ENTER_MAX_SATURATION
        and sw >= config.VERDICT_ENTER_MIN_SMALL_OUTLIERS
    ):
        verdict = "ENTER"
        lead = (
            f"saturation {saturation}/100 is at or below "
            f"{config.VERDICT_ENTER_MAX_SATURATION} with {sw} small-channel "
            f"breakout(s); there is room for a new entrant"
        )
    elif (
        saturation > config.VERDICT_AVOID_MIN_SATURATION
        or f90 < config.VERDICT_AVOID_MAX_FRESH_SHARE
    ):
        verdict = "AVOID"
        parts = []
        if saturation > config.VERDICT_AVOID_MIN_SATURATION:
            parts.append(
                f"saturation {saturation}/100 exceeds {config.VERDICT_AVOID_MIN_SATURATION}"
            )
        if f90 < config.VERDICT_AVOID_MAX_FRESH_SHARE:
            parts.append(
                f"only {round(f90 * 100)}% of analyzed videos are from the last "
                f"{config.FRESH_WINDOW_DAYS} days; the niche looks stale"
            )
        lead = " and ".join(parts)
    else:
        verdict = "CROWDED"
        if saturation <= config.VERDICT_ENTER_MAX_SATURATION:
            lead = (
                f"saturation {saturation}/100 is moderate, but only {sw} "
                f"small-channel breakout(s) cleared the bar; ENTER needs at least "
                f"{config.VERDICT_ENTER_MIN_SMALL_OUTLIERS}"
            )
        else:
            lead = (
                f"saturation {saturation}/100 sits between "
                f"{config.VERDICT_ENTER_MAX_SATURATION} and "
                f"{config.VERDICT_AVOID_MIN_SATURATION}; established channels "
                f"dominate but the niche is not closed"
            )

    reasons = [lead]
    reasons.extend(
        [
            f"{u} unique channels across {n} analyzed videos",
            f"top 3 channels hold {round(c3 * 100)}% of the views in the result set",
            f"{round(f90 * 100)}% of analyzed videos were published in the last "
            f"{config.FRESH_WINDOW_DAYS} days",
            f"{sw} outlier video(s) came from channels under "
            f"{config.SMALL_CHANNEL_SUBS:,} subscribers",
        ]
    )

    signals = {
        "channel_diversity": round(diversity, 3),
        "top3_view_concentration": round(c3, 3),
        "fresh_share_90d": round(f90, 3),
        "small_channel_outliers": sw,
    }
    return saturation, verdict, reasons, signals