tubesentry 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tubesentry/__init__.py +96 -0
- tubesentry/cli.py +180 -0
- tubesentry/contracts/__init__.py +51 -0
- tubesentry/contracts/exceptions.py +21 -0
- tubesentry/contracts/schemas.py +135 -0
- tubesentry/contracts/taxonomy.py +66 -0
- tubesentry/core/__init__.py +22 -0
- tubesentry/core/auditor.py +336 -0
- tubesentry/core/heuristics.py +238 -0
- tubesentry/core/seed_db.py +140 -0
- tubesentry/io/__init__.py +13 -0
- tubesentry/io/fixtures.py +202 -0
- tubesentry/io/ingestor.py +379 -0
- tubesentry/mcp_server.py +206 -0
- tubesentry/pro/__init__.py +8 -0
- tubesentry/pro/bridge.py +77 -0
- tubesentry/pro/installer.py +132 -0
- tubesentry/py.typed +1 -0
- tubesentry-0.1.0.dist-info/METADATA +234 -0
- tubesentry-0.1.0.dist-info/RECORD +23 -0
- tubesentry-0.1.0.dist-info/WHEEL +4 -0
- tubesentry-0.1.0.dist-info/entry_points.txt +3 -0
- tubesentry-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from tubesentry.contracts.schemas import (
|
|
10
|
+
AuditSummaryReport,
|
|
11
|
+
ClassifiedChannelRecord,
|
|
12
|
+
WasteCategoryBreakdown,
|
|
13
|
+
)
|
|
14
|
+
from tubesentry.contracts.taxonomy import CATEGORY_DESCRIPTIONS, WasteCategory
|
|
15
|
+
from tubesentry.core.heuristics import evaluate_heuristics_detailed
|
|
16
|
+
from tubesentry.core.seed_db import lookup_seed_channel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_topic_category(topic_str: str) -> str:
|
|
20
|
+
"""Extracts clean topic name from Wikipedia URL or Freebase string."""
|
|
21
|
+
if "wiki/" in topic_str:
|
|
22
|
+
topic_name = topic_str.split("wiki/", 1)[1]
|
|
23
|
+
elif "/" in topic_str:
|
|
24
|
+
topic_name = topic_str.rsplit("/", 1)[-1]
|
|
25
|
+
else:
|
|
26
|
+
topic_name = topic_str
|
|
27
|
+
return topic_name.replace("_", " ").strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_free_audit(
|
|
31
|
+
df: pd.DataFrame,
|
|
32
|
+
target_languages: list[str] | None = None,
|
|
33
|
+
target_cpa: float | None = None,
|
|
34
|
+
print_banner: bool = True,
|
|
35
|
+
currency: str = "EUR",
|
|
36
|
+
) -> AuditSummaryReport:
|
|
37
|
+
"""Executes the free diagnostic placement audit using the bundled seed DB and heuristics.
|
|
38
|
+
|
|
39
|
+
Reveals the exact total wasted spend across all 6 risk categories, impression-weighted metrics,
|
|
40
|
+
and the top 25 worst offending channels unmasked, while masking the tail behind Pro upgrade notices.
|
|
41
|
+
"""
|
|
42
|
+
total_placements = len(df)
|
|
43
|
+
total_spend = float(df["cost"].sum()) if not df.empty and "cost" in df.columns else 0.0
|
|
44
|
+
total_impressions = int(df["impressions"].sum()) if not df.empty and "impressions" in df.columns else 0
|
|
45
|
+
|
|
46
|
+
classified_records: list[ClassifiedChannelRecord] = []
|
|
47
|
+
|
|
48
|
+
# Aggregate by unique channel_id or raw_placement to avoid duplicate rows for the same channel
|
|
49
|
+
grouped = (
|
|
50
|
+
df.groupby(
|
|
51
|
+
["raw_placement", "channel_id", "channel_title"],
|
|
52
|
+
dropna=False,
|
|
53
|
+
as_index=False,
|
|
54
|
+
)
|
|
55
|
+
.agg(
|
|
56
|
+
{
|
|
57
|
+
"cost": "sum",
|
|
58
|
+
"impressions": "sum",
|
|
59
|
+
"views": "sum",
|
|
60
|
+
"clicks": "sum",
|
|
61
|
+
"conversions": "sum",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
.sort_values(by="cost", ascending=False)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
category_stats: dict[str, dict[str, Any]] = defaultdict(
|
|
68
|
+
lambda: {
|
|
69
|
+
"wasted_spend": 0.0,
|
|
70
|
+
"channel_count": 0,
|
|
71
|
+
"impressions": 0,
|
|
72
|
+
"views": 0,
|
|
73
|
+
"clicks": 0,
|
|
74
|
+
"conversions": 0.0,
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Accumulator for impression-weighted quality scoring (1 to 10 scale)
|
|
79
|
+
total_quality_points = 0.0
|
|
80
|
+
|
|
81
|
+
for _, row in grouped.iterrows():
|
|
82
|
+
raw_pl = str(row["raw_placement"]) if pd.notna(row["raw_placement"]) else ""
|
|
83
|
+
ch_id = str(row["channel_id"]) if pd.notna(row["channel_id"]) else None
|
|
84
|
+
title = str(row["channel_title"]) if pd.notna(row["channel_title"]) else raw_pl
|
|
85
|
+
cost = float(row["cost"])
|
|
86
|
+
impr = int(row["impressions"])
|
|
87
|
+
views = int(row["views"])
|
|
88
|
+
clicks = int(row["clicks"])
|
|
89
|
+
conv = float(row["conversions"])
|
|
90
|
+
|
|
91
|
+
# 1. Check Seed DB
|
|
92
|
+
category: str = "CLEAN"
|
|
93
|
+
confidence: float = 0.0
|
|
94
|
+
reason: str = "Clean"
|
|
95
|
+
is_young_kids = False
|
|
96
|
+
granular_sub = "CLEAN_AUDIENCE"
|
|
97
|
+
|
|
98
|
+
if ch_id:
|
|
99
|
+
seed_match = lookup_seed_channel(ch_id)
|
|
100
|
+
if seed_match:
|
|
101
|
+
category = seed_match["category"]
|
|
102
|
+
confidence = seed_match["confidence"]
|
|
103
|
+
reason = seed_match["reason"]
|
|
104
|
+
if category == "MADE_FOR_KIDS":
|
|
105
|
+
is_young_kids = True
|
|
106
|
+
granular_sub = "YOUNG_KIDS_TODDLER"
|
|
107
|
+
|
|
108
|
+
# 2. Check Open-Core Heuristics if not matched in seed
|
|
109
|
+
if category == "CLEAN":
|
|
110
|
+
h_res = evaluate_heuristics_detailed(
|
|
111
|
+
channel_title=title,
|
|
112
|
+
raw_placement=raw_pl,
|
|
113
|
+
impressions=impr,
|
|
114
|
+
views=views,
|
|
115
|
+
clicks=clicks,
|
|
116
|
+
cost=cost,
|
|
117
|
+
conversions=conv,
|
|
118
|
+
target_languages=target_languages,
|
|
119
|
+
target_cpa=target_cpa,
|
|
120
|
+
)
|
|
121
|
+
if h_res["category"] != "CLEAN":
|
|
122
|
+
category = h_res["category"]
|
|
123
|
+
confidence = h_res["confidence"]
|
|
124
|
+
reason = h_res["reason"]
|
|
125
|
+
is_young_kids = h_res.get("is_young_kids", False)
|
|
126
|
+
granular_sub = h_res.get("granular_sub_category", category)
|
|
127
|
+
|
|
128
|
+
# Compute placement quality points (1.0 to 10.0 scale)
|
|
129
|
+
if category == "CLEAN":
|
|
130
|
+
row_quality = 10.0
|
|
131
|
+
elif is_young_kids:
|
|
132
|
+
row_quality = 1.0
|
|
133
|
+
elif category in ("MADE_FOR_KIDS", "AI_SLOP_CLICK_FARM"):
|
|
134
|
+
row_quality = 1.5
|
|
135
|
+
elif category in ("PASSIVE_AUDIO_MUSIC", "FOREIGN_SCRIPT_LEAK"):
|
|
136
|
+
row_quality = 2.0
|
|
137
|
+
elif category == "LOW_AGE_GAMING":
|
|
138
|
+
row_quality = 2.5
|
|
139
|
+
else: # PERFORMANCE_SINKHOLE
|
|
140
|
+
row_quality = 3.5
|
|
141
|
+
|
|
142
|
+
total_quality_points += row_quality * max(impr, 1)
|
|
143
|
+
|
|
144
|
+
if category != "CLEAN":
|
|
145
|
+
rec = ClassifiedChannelRecord(
|
|
146
|
+
channel_id=ch_id or "UC_UNKNOWN_HANDLE",
|
|
147
|
+
channel_title=title,
|
|
148
|
+
waste_category=category, # type: ignore[arg-type]
|
|
149
|
+
waste_confidence=round(confidence, 2),
|
|
150
|
+
made_for_kids_official=(category == "MADE_FOR_KIDS"),
|
|
151
|
+
is_young_kids=is_young_kids,
|
|
152
|
+
granular_sub_category=granular_sub,
|
|
153
|
+
thumbnail_url=None,
|
|
154
|
+
detected_language=None,
|
|
155
|
+
detected_country=None,
|
|
156
|
+
topic_categories=[],
|
|
157
|
+
cost=round(cost, 2),
|
|
158
|
+
impressions=impr,
|
|
159
|
+
views=views,
|
|
160
|
+
clicks=clicks,
|
|
161
|
+
conversions=round(conv, 2),
|
|
162
|
+
reason_summary=reason,
|
|
163
|
+
is_masked=False,
|
|
164
|
+
)
|
|
165
|
+
classified_records.append(rec)
|
|
166
|
+
|
|
167
|
+
stats = category_stats[category]
|
|
168
|
+
stats["wasted_spend"] += cost
|
|
169
|
+
stats["channel_count"] += 1
|
|
170
|
+
stats["impressions"] += impr
|
|
171
|
+
stats["views"] += views
|
|
172
|
+
stats["clicks"] += clicks
|
|
173
|
+
stats["conversions"] += conv
|
|
174
|
+
|
|
175
|
+
# Sort offending channels descending by wasted cost
|
|
176
|
+
classified_records.sort(key=lambda x: x.cost, reverse=True)
|
|
177
|
+
|
|
178
|
+
total_wasted_spend = sum(stats["wasted_spend"] for stats in category_stats.values())
|
|
179
|
+
waste_percentage = (total_wasted_spend / total_spend * 100.0) if total_spend > 0 else 0.0
|
|
180
|
+
total_waste_channels = len(classified_records)
|
|
181
|
+
|
|
182
|
+
total_wasted_impressions = sum(stats["impressions"] for stats in category_stats.values())
|
|
183
|
+
wasted_impressions_percentage = (
|
|
184
|
+
(total_wasted_impressions / total_impressions * 100.0) if total_impressions > 0 else 0.0
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
weight_denom = max(total_impressions, len(grouped))
|
|
188
|
+
weighted_quality_score = round(total_quality_points / weight_denom, 1) if weight_denom > 0 else 10.0
|
|
189
|
+
weighted_quality_score = max(1.0, min(10.0, weighted_quality_score))
|
|
190
|
+
|
|
191
|
+
# Free Tier Masking Invariant:
|
|
192
|
+
# First 25 channels are completely unmasked.
|
|
193
|
+
# Channels 26+ are masked with UC•••••••••••• and locked placeholder title.
|
|
194
|
+
top_offenders: list[ClassifiedChannelRecord] = []
|
|
195
|
+
masked_count = 0
|
|
196
|
+
|
|
197
|
+
for idx, rec in enumerate(classified_records):
|
|
198
|
+
if idx < 25:
|
|
199
|
+
top_offenders.append(rec)
|
|
200
|
+
else:
|
|
201
|
+
masked_count += 1
|
|
202
|
+
top_offenders.append(
|
|
203
|
+
rec.model_copy(
|
|
204
|
+
update={
|
|
205
|
+
"channel_id": "UC••••••••••••",
|
|
206
|
+
"channel_title": "[LOCKED IN FREE TIER — Upgrade to TubeSentry Pro]",
|
|
207
|
+
"is_masked": True,
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
breakdowns: dict[str, WasteCategoryBreakdown] = {}
|
|
213
|
+
for cat_enum in WasteCategory:
|
|
214
|
+
cat_key = cat_enum.value
|
|
215
|
+
if cat_key == "CLEAN":
|
|
216
|
+
continue
|
|
217
|
+
stats = category_stats[cat_key]
|
|
218
|
+
spend_pct = (stats["wasted_spend"] / total_spend * 100.0) if total_spend > 0 else 0.0
|
|
219
|
+
breakdowns[cat_key] = WasteCategoryBreakdown(
|
|
220
|
+
category=cat_key, # type: ignore[arg-type]
|
|
221
|
+
wasted_spend=round(stats["wasted_spend"], 2),
|
|
222
|
+
wasted_spend_percentage=round(spend_pct, 2),
|
|
223
|
+
channel_count=int(stats["channel_count"]),
|
|
224
|
+
impressions=int(stats["impressions"]),
|
|
225
|
+
views=int(stats["views"]),
|
|
226
|
+
clicks=int(stats["clicks"]),
|
|
227
|
+
conversions=round(stats["conversions"], 2),
|
|
228
|
+
description=CATEGORY_DESCRIPTIONS.get(cat_enum, ""),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
coppa_kids_spend = category_stats["MADE_FOR_KIDS"]["wasted_spend"]
|
|
232
|
+
annual_runrate = total_wasted_spend * 12.0
|
|
233
|
+
|
|
234
|
+
banner_text = _build_diagnostic_banner(
|
|
235
|
+
total_placements=total_placements,
|
|
236
|
+
total_spend=total_spend,
|
|
237
|
+
total_wasted_spend=total_wasted_spend,
|
|
238
|
+
waste_percentage=waste_percentage,
|
|
239
|
+
total_waste_channels=total_waste_channels,
|
|
240
|
+
total_impressions=total_impressions,
|
|
241
|
+
total_wasted_impressions=total_wasted_impressions,
|
|
242
|
+
wasted_impressions_percentage=wasted_impressions_percentage,
|
|
243
|
+
weighted_quality_score=weighted_quality_score,
|
|
244
|
+
breakdowns=breakdowns,
|
|
245
|
+
masked_count=masked_count,
|
|
246
|
+
currency=currency,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if print_banner:
|
|
250
|
+
console = Console(stderr=False)
|
|
251
|
+
console.print(banner_text)
|
|
252
|
+
|
|
253
|
+
return AuditSummaryReport(
|
|
254
|
+
total_placements_audited=total_placements,
|
|
255
|
+
total_spend=round(total_spend, 2),
|
|
256
|
+
total_wasted_spend=round(total_wasted_spend, 2),
|
|
257
|
+
wasted_spend_percentage=round(waste_percentage, 2),
|
|
258
|
+
total_waste_channels=total_waste_channels,
|
|
259
|
+
total_impressions_audited=total_impressions,
|
|
260
|
+
total_wasted_impressions=total_wasted_impressions,
|
|
261
|
+
wasted_impressions_percentage=round(wasted_impressions_percentage, 2),
|
|
262
|
+
weighted_quality_score=round(weighted_quality_score, 1),
|
|
263
|
+
top_topic_categories=[],
|
|
264
|
+
currency_code=currency,
|
|
265
|
+
category_breakdowns=breakdowns,
|
|
266
|
+
top_offenders=top_offenders,
|
|
267
|
+
masked_offenders_count=masked_count,
|
|
268
|
+
is_pro_audit=False,
|
|
269
|
+
coppa_kids_risk_spend=round(coppa_kids_spend, 2),
|
|
270
|
+
annualized_recoverable_runrate=round(annual_runrate, 2),
|
|
271
|
+
upgrade_prompt=banner_text,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_diagnostic_banner(
|
|
276
|
+
total_placements: int,
|
|
277
|
+
total_spend: float,
|
|
278
|
+
total_wasted_spend: float,
|
|
279
|
+
waste_percentage: float,
|
|
280
|
+
total_waste_channels: int,
|
|
281
|
+
total_impressions: int,
|
|
282
|
+
total_wasted_impressions: int,
|
|
283
|
+
wasted_impressions_percentage: float,
|
|
284
|
+
weighted_quality_score: float,
|
|
285
|
+
breakdowns: dict[str, WasteCategoryBreakdown],
|
|
286
|
+
masked_count: int,
|
|
287
|
+
currency: str = "EUR",
|
|
288
|
+
) -> str:
|
|
289
|
+
"""Constructs the Rich upgrade diagnostic banner string matching spec Section 4.1."""
|
|
290
|
+
sym = "€" if currency == "EUR" else ("$" if currency == "USD" else currency)
|
|
291
|
+
|
|
292
|
+
lines = [
|
|
293
|
+
"=" * 84,
|
|
294
|
+
" TUBESENTRY OPEN-CORE v0.1.0 — YOUTUBE PLACEMENT WASTE DIAGNOSTIC",
|
|
295
|
+
"=" * 84,
|
|
296
|
+
f" Audited Placements: {total_placements:,} placements | Total Spend: {sym} {total_spend:,.2f}",
|
|
297
|
+
f" Identified Wasted Spend: {sym} {total_wasted_spend:,.2f} ({waste_percentage:.1f}% of budget) across {total_waste_channels:,} channels",
|
|
298
|
+
f" Impression Waste Impact: {total_wasted_impressions:,} imps ({wasted_impressions_percentage:.1f}% wasted) | Placement Quality: {weighted_quality_score:.1f}/10",
|
|
299
|
+
"-" * 84,
|
|
300
|
+
" [!] WASTED BUDGET BY CATEGORY:",
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
# Sort categories by wasted spend descending
|
|
304
|
+
sorted_cats = sorted(breakdowns.values(), key=lambda b: b.wasted_spend, reverse=True)
|
|
305
|
+
idx = 1
|
|
306
|
+
category_labels = {
|
|
307
|
+
"MADE_FOR_KIDS": "COPPA / Toddler",
|
|
308
|
+
"PASSIVE_AUDIO_MUSIC": "Lofi / Sleep / BG",
|
|
309
|
+
"FOREIGN_SCRIPT_LEAK": "Cyrillic / Arabic / Asian",
|
|
310
|
+
"LOW_AGE_GAMING": "Roblox / Minecraft",
|
|
311
|
+
"AI_SLOP_CLICK_FARM": "Faceless AI / Slop",
|
|
312
|
+
"PERFORMANCE_SINKHOLE": "High CPA / 0 Conv",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for b in sorted_cats:
|
|
316
|
+
if b.wasted_spend > 0:
|
|
317
|
+
label = category_labels.get(b.category, "")
|
|
318
|
+
lines.append(
|
|
319
|
+
f" {idx}. [{b.category:<20}] {sym} {b.wasted_spend:>10,.2f} ({b.wasted_spend_percentage:>4.1f}%) — {b.channel_count:>5,} channels ({label})"
|
|
320
|
+
)
|
|
321
|
+
idx += 1
|
|
322
|
+
|
|
323
|
+
lines.extend(
|
|
324
|
+
[
|
|
325
|
+
"-" * 84,
|
|
326
|
+
" [!] FREE TIER EXPORT LIMIT:",
|
|
327
|
+
f" Showing Top 25 worst offending channels below. {masked_count:,} additional waste channels are",
|
|
328
|
+
" masked (`UC••••••••••••`).",
|
|
329
|
+
"",
|
|
330
|
+
f" [*] Unlock All {total_waste_channels:,} Flagged Channels, 65k Google Ads Exclusion CSV & HTML Deck:",
|
|
331
|
+
" >>> tubesentry auth --key TS_PRO_...",
|
|
332
|
+
" Get an instant Single Audit Pass (€199) or Agency License: https://buy.polar.sh/polar_cl_lRuifr3uJS0LR0k6frhg3JEKBRZNQq75hv7cS2am9cX",
|
|
333
|
+
"=" * 84,
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Compiled regexes for high-performance vectorized evaluation
|
|
8
|
+
YOUNG_KIDS_REGEX = re.compile(
|
|
9
|
+
r"(?i)\b(cocomelon|bebefinn|chuchu\s*tv|peppa\s*pig|super\s*simple\s*songs|"
|
|
10
|
+
r"little\s*baby\s*bum|pinkfong|baby\s*shark|vlad\s*and\s*niki|diana\s*and\s*roma|"
|
|
11
|
+
r"nursery\s*rhymes?|kinderlieder|canciones\s*infantiles|comptines|canzoni\s*per\s*bambini|"
|
|
12
|
+
r"el\s*reino\s*infantil|gallina\s*pintadita|toy\s*unboxing|learn\s*colors|alphabet\s*song|"
|
|
13
|
+
r"toddler|kindergarten|sing-along|lullaby|schlaflied|baby\s*songs?)\b"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
KIDS_REGEX = re.compile(
|
|
17
|
+
r"(?i)\b(nursery\s*rhymes?|kinderlieder|canciones\s*infantiles|comptines|"
|
|
18
|
+
r"canzoni\s*per\s*bambini|cocomelon|bebefinn|chuchu\s*tv|peppa\s*pig|"
|
|
19
|
+
r"paw\s*patrol|blippi|super\s*simple\s*songs|little\s*baby\s*bum|pinkfong|"
|
|
20
|
+
r"baby\s*shark|vlad\s*and\s*niki|diana\s*and\s*roma|el\s*reino\s*infantil|"
|
|
21
|
+
r"gallina\s*pintadita|toy\s*unboxing|learn\s*colors|alphabet\s*song|"
|
|
22
|
+
r"toddler|kindergarten|sing-along|lullaby|schlaflied|baby\s*songs?)\b"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PASSIVE_MUSIC_REGEX = re.compile(
|
|
26
|
+
r"(?i)\b(lofi|lo-fi|relaxing\s*music|sleep\s*music|rain\s*sounds|white\s*noise|"
|
|
27
|
+
r"study\s*beats|ambient\s*music|meditation\s*music|healing\s*frequency|432\s*hz|"
|
|
28
|
+
r"528\s*hz|10\s*hours|8\s*hours|karaoke|instrumental\s*mix|deep\s*focus|"
|
|
29
|
+
r"coffee\s*shop\s*jazz|schlafmusik|música\s*para\s*dormir)\b"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
LOW_AGE_GAMING_REGEX = re.compile(
|
|
33
|
+
r"(?i)\b(roblox|brookhaven|blox\s*fruits|minecraft\s*pe|brawl\s*stars|gacha\s*life|"
|
|
34
|
+
r"gacha\s*club|skibidi\s*toilet|fnf|friday\s*night\s*funkin|among\s*us\s*animation|"
|
|
35
|
+
r"subway\s*surfers|stumble\s*guys|pk\s*xd|toca\s*life|toca\s*boca|geometry\s*dash)\b"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
AI_SLOP_REGEX = re.compile(
|
|
39
|
+
r"(?i)\b(reddit\s*stories|aita\s*stories|ai\s*facts|satisfying\s*slime|"
|
|
40
|
+
r"hydraulic\s*press\s*shorts|family\s*guy\s*clips|subway\s*surfers\s*reddit)\b"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Unicode non-Latin script ranges:
|
|
44
|
+
# Cyrillic: \u0400-\u04FF
|
|
45
|
+
# Arabic: \u0600-\u06FF
|
|
46
|
+
# Devanagari (Hindi): \u0900-\u097F
|
|
47
|
+
# Bengali: \u0980-\u09FF
|
|
48
|
+
# Thai: \u0E00-\u0E7F
|
|
49
|
+
# Hangul: \uAC00-\uD7AF, \u1100-\u11FF
|
|
50
|
+
# CJK: \u4E00-\u9FFF, \u3400-\u4DBF
|
|
51
|
+
NON_LATIN_SCRIPT_PATTERN = re.compile(
|
|
52
|
+
r"[\u0400-\u04FF\u0600-\u06FF\u0900-\u097F\u0980-\u09FF\u0E00-\u0E7F"
|
|
53
|
+
r"\uAC00-\uD7AF\u1100-\u11FF\u4E00-\u9FFF\u3400-\u4DBF]"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_non_latin_ratio(text: str) -> float:
|
|
58
|
+
"""Computes the ratio of non-Latin script characters to total alphabetic letters.
|
|
59
|
+
|
|
60
|
+
Returns float in [0.0, 1.0].
|
|
61
|
+
"""
|
|
62
|
+
if not text:
|
|
63
|
+
return 0.0
|
|
64
|
+
|
|
65
|
+
total_letters = 0
|
|
66
|
+
non_latin_letters = 0
|
|
67
|
+
|
|
68
|
+
for char in text:
|
|
69
|
+
if unicodedata.category(char).startswith("L"):
|
|
70
|
+
total_letters += 1
|
|
71
|
+
if NON_LATIN_SCRIPT_PATTERN.match(char):
|
|
72
|
+
non_latin_letters += 1
|
|
73
|
+
|
|
74
|
+
if total_letters == 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
return non_latin_letters / total_letters
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_young_kids_text(text: str) -> bool:
|
|
80
|
+
"""Checks if text specifically targets toddlers / under 10 (nursery rhymes, CoCoMelon, etc.)."""
|
|
81
|
+
return bool(YOUNG_KIDS_REGEX.search(text)) if text else False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_made_for_kids_text(text: str) -> bool:
|
|
85
|
+
"""Checks if text contains toddler or Made-for-Kids keywords."""
|
|
86
|
+
return bool(KIDS_REGEX.search(text)) if text else False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_passive_music_text(text: str) -> bool:
|
|
90
|
+
"""Checks if text contains passive audio / background music keywords."""
|
|
91
|
+
if not text:
|
|
92
|
+
return False
|
|
93
|
+
if text.endswith((" - Topic", "VEVO", " VEVO")):
|
|
94
|
+
return True
|
|
95
|
+
return bool(PASSIVE_MUSIC_REGEX.search(text))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_low_age_gaming_text(text: str) -> bool:
|
|
99
|
+
"""Checks if text contains underage gaming keywords."""
|
|
100
|
+
return bool(LOW_AGE_GAMING_REGEX.search(text)) if text else False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_ai_slop_text(text: str) -> bool:
|
|
104
|
+
"""Checks if text contains faceless AI slop keywords."""
|
|
105
|
+
return bool(AI_SLOP_REGEX.search(text)) if text else False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def evaluate_heuristics_detailed(
|
|
109
|
+
channel_title: str,
|
|
110
|
+
raw_placement: str = "",
|
|
111
|
+
impressions: int = 0,
|
|
112
|
+
views: int = 0,
|
|
113
|
+
clicks: int = 0,
|
|
114
|
+
cost: float = 0.0,
|
|
115
|
+
conversions: float = 0.0,
|
|
116
|
+
target_languages: list[str] | None = None,
|
|
117
|
+
target_cpa: float | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Evaluates placement heuristics with granular sub-category and toddler detection.
|
|
120
|
+
|
|
121
|
+
Returns dict with keys:
|
|
122
|
+
category, confidence, reason, is_young_kids, granular_sub_category
|
|
123
|
+
"""
|
|
124
|
+
[lang.lower()[:2] for lang in (target_languages or ["de", "en"])]
|
|
125
|
+
combined_text = f"{channel_title} {raw_placement}"
|
|
126
|
+
|
|
127
|
+
# Rule 1: MADE_FOR_KIDS & YOUNG_KIDS
|
|
128
|
+
if is_made_for_kids_text(combined_text):
|
|
129
|
+
young = is_young_kids_text(combined_text)
|
|
130
|
+
sub_cat = "YOUNG_KIDS_TODDLER" if young else "GENERAL_KIDS_CARTOON"
|
|
131
|
+
reason = (
|
|
132
|
+
"Matched toddler / under-10 nursery rhyme & brainrot lexicon."
|
|
133
|
+
if young
|
|
134
|
+
else "Matched Made-for-Kids toddler/nursery rhyme lexicon."
|
|
135
|
+
)
|
|
136
|
+
return {
|
|
137
|
+
"category": "MADE_FOR_KIDS",
|
|
138
|
+
"confidence": 0.95 if young else 0.92,
|
|
139
|
+
"reason": reason,
|
|
140
|
+
"is_young_kids": young,
|
|
141
|
+
"granular_sub_category": sub_cat,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Rule 2: PASSIVE_AUDIO_MUSIC
|
|
145
|
+
if is_passive_music_text(channel_title):
|
|
146
|
+
return {
|
|
147
|
+
"category": "PASSIVE_AUDIO_MUSIC",
|
|
148
|
+
"confidence": 0.90,
|
|
149
|
+
"reason": "Matched passive audio/background music streams.",
|
|
150
|
+
"is_young_kids": False,
|
|
151
|
+
"granular_sub_category": "PASSIVE_AUDIO_STREAM",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Rule 3: LOW_AGE_GAMING
|
|
155
|
+
if is_low_age_gaming_text(combined_text):
|
|
156
|
+
return {
|
|
157
|
+
"category": "LOW_AGE_GAMING",
|
|
158
|
+
"confidence": 0.85,
|
|
159
|
+
"reason": "Matched underage gaming franchise lexicon (Roblox/Minecraft/etc.).",
|
|
160
|
+
"is_young_kids": False,
|
|
161
|
+
"granular_sub_category": "LOW_AGE_GAMING_SINK",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Rule 4: FOREIGN_SCRIPT_LEAK
|
|
165
|
+
ratio = compute_non_latin_ratio(channel_title)
|
|
166
|
+
if ratio >= 0.25:
|
|
167
|
+
return {
|
|
168
|
+
"category": "FOREIGN_SCRIPT_LEAK",
|
|
169
|
+
"confidence": 0.95,
|
|
170
|
+
"reason": f"Non-Latin script ratio {ratio:.1%} in title (Cyrillic/Arabic/Asian scripts).",
|
|
171
|
+
"is_young_kids": False,
|
|
172
|
+
"granular_sub_category": "GEO_LANGUAGE_LEAK",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Rule 5: AI_SLOP_CLICK_FARM
|
|
176
|
+
if is_ai_slop_text(combined_text) and conversions == 0:
|
|
177
|
+
return {
|
|
178
|
+
"category": "AI_SLOP_CLICK_FARM",
|
|
179
|
+
"confidence": 0.82,
|
|
180
|
+
"reason": "Matched faceless AI content lexicon with 0 conversions.",
|
|
181
|
+
"is_young_kids": False,
|
|
182
|
+
"granular_sub_category": "AI_SLOP_FARM",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Rule 6: PERFORMANCE_SINKHOLE
|
|
186
|
+
vtr = (views / max(impressions, 1)) if impressions > 0 else 0.0
|
|
187
|
+
if (
|
|
188
|
+
target_cpa is not None
|
|
189
|
+
and target_cpa > 0
|
|
190
|
+
and cost >= 3.0 * target_cpa
|
|
191
|
+
and conversions == 0
|
|
192
|
+
and vtr < 0.15
|
|
193
|
+
):
|
|
194
|
+
return {
|
|
195
|
+
"category": "PERFORMANCE_SINKHOLE",
|
|
196
|
+
"confidence": 0.80,
|
|
197
|
+
"reason": f"Placement spend (€{cost:.2f}) >= 3x target CPA (€{target_cpa:.2f}) with 0 conv and VTR {vtr:.1%}.",
|
|
198
|
+
"is_young_kids": False,
|
|
199
|
+
"granular_sub_category": "HIGH_CPA_SINKHOLE",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"category": "CLEAN",
|
|
204
|
+
"confidence": 0.0,
|
|
205
|
+
"reason": "No waste patterns detected.",
|
|
206
|
+
"is_young_kids": False,
|
|
207
|
+
"granular_sub_category": "CLEAN_AUDIENCE",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def evaluate_heuristics(
|
|
212
|
+
channel_title: str,
|
|
213
|
+
raw_placement: str = "",
|
|
214
|
+
impressions: int = 0,
|
|
215
|
+
views: int = 0,
|
|
216
|
+
clicks: int = 0,
|
|
217
|
+
cost: float = 0.0,
|
|
218
|
+
conversions: float = 0.0,
|
|
219
|
+
target_languages: list[str] | None = None,
|
|
220
|
+
target_cpa: float | None = None,
|
|
221
|
+
) -> tuple[str, float, str]:
|
|
222
|
+
"""Applies open-core deterministic heuristic rules to evaluate placement waste.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
tuple[waste_category, confidence, reason_summary]
|
|
226
|
+
"""
|
|
227
|
+
res = evaluate_heuristics_detailed(
|
|
228
|
+
channel_title=channel_title,
|
|
229
|
+
raw_placement=raw_placement,
|
|
230
|
+
impressions=impressions,
|
|
231
|
+
views=views,
|
|
232
|
+
clicks=clicks,
|
|
233
|
+
cost=cost,
|
|
234
|
+
conversions=conversions,
|
|
235
|
+
target_languages=target_languages,
|
|
236
|
+
target_cpa=target_cpa,
|
|
237
|
+
)
|
|
238
|
+
return (res["category"], res["confidence"], res["reason"])
|