click_bait / src /streamlit_app.py
aarayann's picture
Update src/streamlit_app.py
bf3af8f verified
Raw
History Blame Contribute Delete
25.2 kB
import streamlit as st
import numpy as np
import pickle, json, re, requests, torch, os
import open_clip
from PIL import Image
from io import BytesIO
from transformers import AutoTokenizer, AutoModel
from sklearn.preprocessing import normalize
st.set_page_config(
page_title="Catch-Bait โ€” Indian YouTube Clickbait Detector",
page_icon="๐ŸŽฏ",
layout="wide",
initial_sidebar_state="collapsed",
)
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Inter:wght@300;400;500;600&display=swap');
html, body, [class*="css"] { font-family: 'Inter', sans-serif; background: #f5f5f0 !important; color: #1a1a1a; }
.stApp { background: #f5f5f0 !important; }
#MainMenu, footer, header { visibility: hidden; }
section[data-testid="stSidebar"] { display: none !important; }
.block-container { padding: 0 !important; max-width: 100% !important; }
[data-testid="stAppViewContainer"] > section { padding: 0 !important; }
[data-testid="stAppViewBlockContainer"] { padding: 0 !important; background: transparent !important; max-width: 100% !important; }
[data-testid="stVerticalBlock"] { gap: 0 !important; background: transparent !important; }
[data-testid="stVerticalBlockBorderWrapper"] { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; }
[data-testid="stHorizontalBlock"] { gap: 1.5rem !important; align-items: flex-end !important; background: transparent !important; padding: 0 !important; }
[data-testid="column"] { background: transparent !important; padding: 0 !important; min-width: 0 !important; }
.element-container { margin: 0 !important; padding: 0 !important; background: transparent !important; }
div.row-widget { margin: 0 !important; padding: 0 !important; }
.stTextInput > div { background: transparent !important; }
.stTextInput > div > div { background: transparent !important; }
.stTextInput > div > div > input {
background: #f8f8f4 !important; border: 1.5px solid #e0e0da !important;
border-radius: 10px !important; color: #1a1a1a !important;
font-family: 'Inter', sans-serif !important; font-size: 1rem !important;
padding: 0 1rem !important; height: 46px !important; box-sizing: border-box !important;
}
.stTextInput > div > div > input:focus {
border-color: #2563eb !important;
box-shadow: 0 0 0 3px rgba(37,99,235,0.10) !important;
background: #fff !important;
}
.stTextInput > div > div > input::placeholder { color: #bbb !important; }
.stTextInput label { display: none !important; }
.stButton > button {
background: #1a1a1a !important; color: #fff !important;
border: none !important; border-radius: 10px !important;
font-family: 'Inter', sans-serif !important; font-weight: 600 !important;
font-size: 0.95rem !important; height: 46px !important;
padding: 0 1.6rem !important; width: 100% !important;
transition: background 0.15s !important;
}
.stButton > button:hover { background: #2563eb !important; }
/* NAV */
.nav {
background: #fff; border-bottom: 1px solid #e8e8e4;
padding: 0 2.5rem; height: 58px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
}
.nav-brand { display: flex; align-items: center; gap: 0.6rem; }
.nav-logo { width: 34px; height: 34px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 17px; }
.nav-name { font-weight: 600; font-size: 0.95rem; color: #1a1a1a; }
.nav-sub { font-size: 0.69rem; color: #aaa; }
.nav-chips { display: flex; gap: 0.4rem; }
.chip { font-size: 0.68rem; font-weight: 500; padding: 0.22rem 0.65rem; border: 1px solid #ddd; border-radius: 999px; color: #666; background: #fafafa; }
.chip-blue { border-color: #bfdbfe; color: #2563eb; background: #eff6ff; }
/* HERO + INPUT */
.page-top { max-width: 1100px; margin: 0 auto; padding: 2.5rem 2.5rem 0; }
.eyebrow { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; color: #2563eb; margin-bottom: 0.8rem; }
.hero-h {
font-family: 'Playfair Display', serif;
font-size: 2.8rem; font-weight: 900; line-height: 1.08; letter-spacing: -0.025em;
color: #0f0f0f; margin-bottom: 0.65rem;
}
.hero-h em { font-style: italic; color: #2563eb; }
.hero-p { font-size: 0.92rem; color: #888; line-height: 1.65; max-width: 600px; margin-bottom: 1.5rem; }
.inp-card {
background: #fff; border: 1px solid #e0e0da;
border-radius: 14px; padding: 1.1rem 1.3rem 1rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.05); margin-bottom: 2rem;
}
.try-row { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.65rem; flex-wrap: wrap; }
.try-lbl { font-size: 0.7rem; color: #bbb; }
.try-chip { font-size: 0.69rem; font-weight: 500; padding: 0.18rem 0.6rem; border: 1px solid #e0e0da; border-radius: 999px; color: #777; background: #fafaf8; }
/* RESULTS */
.res-wrap { max-width: 1100px; margin: 0 auto; padding: 0 2.5rem 4rem; }
/* VERDICT */
.v-banner { border-radius: 14px; padding: 1.4rem 2rem; display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.2rem; }
.v-cb { background: #fff1f1; border: 2px solid #fecaca; }
.v-safe { background: #f0fdf4; border: 2px solid #bbf7d0; }
.v-left { display: flex; align-items: center; gap: 1rem; }
.v-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; }
.vi-cb { background: #fecaca; }
.vi-safe { background: #bbf7d0; }
.v-title { font-family: 'Playfair Display', serif; font-size: 1.6rem; font-weight: 700; line-height: 1.1; }
.vt-cb { color: #dc2626; }
.vt-safe { color: #16a34a; }
.v-sub { font-size: 0.8rem; color: #999; margin-top: 0.15rem; }
.v-conf-num { font-family: 'Playfair Display', serif; font-size: 2.8rem; font-weight: 900; line-height: 1; letter-spacing: -0.03em; }
.vc-cb { color: #dc2626; }
.vc-safe { color: #16a34a; }
.v-conf-lbl { font-size: 0.63rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #bbb; margin-top: 0.1rem; }
/* CARD */
.card { background: #fff; border: 1px solid #e8e8e4; border-radius: 14px; padding: 1.3rem 1.5rem; box-shadow: 0 1px 5px rgba(0,0,0,0.03); margin-bottom: 1rem; }
.card-ttl { font-size: 0.65rem; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: #bbb; margin-bottom: 0.9rem; }
/* VIDEO */
.vid-row { display: flex; gap: 1.1rem; align-items: flex-start; }
.vid-thumb { flex-shrink: 0; width: 200px; }
.vid-thumb img { width: 100%; border-radius: 9px; display: block; aspect-ratio: 16/9; object-fit: cover; }
.vid-info { flex: 1; min-width: 0; }
.vid-title-txt { font-family: 'Playfair Display', serif; font-size: 1.05rem; font-weight: 700; line-height: 1.42; color: #0f0f0f; margin-bottom: 0.3rem; }
.hl-bait { background: #fef3c7; border-radius: 3px; padding: 0 2px; color: #92400e; font-style: italic; }
.bait-note { font-size: 0.67rem; color: #bbb; font-style: italic; margin-top: 0.1rem; }
.vid-ch { font-size: 0.77rem; color: #999; margin: 0.5rem 0 0.65rem; }
.stat-row { display: flex; gap: 0.45rem; flex-wrap: wrap; }
.stat-pill { display: inline-flex; align-items: center; gap: 0.22rem; font-size: 0.72rem; font-weight: 500; color: #666; background: #f5f5f0; border: 1px solid #e8e8e4; border-radius: 999px; padding: 0.2rem 0.6rem; }
/* PROB BARS */
.prob-row { margin-bottom: 0.85rem; }
.prob-row:last-child { margin-bottom: 0; }
.prob-lr { display: flex; justify-content: space-between; font-size: 0.85rem; font-weight: 500; color: #444; margin-bottom: 0.3rem; }
.pct-cb { color: #dc2626; font-weight: 700; font-size: 0.95rem; }
.pct-safe { color: #16a34a; font-weight: 700; font-size: 0.95rem; }
.bar-bg { height: 7px; background: #f0f0ec; border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; }
.b-cb { background: #ef4444; }
.b-safe { background: #22c55e; }
/* SIGNAL GRID */
.sig-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.65rem; }
.sig-cell { background: #fafaf8; border: 1px solid #e8e8e4; border-radius: 11px; padding: 0.85rem 0.9rem; border-top: 3px solid transparent; }
.st-red { border-top-color: #ef4444; }
.st-amber { border-top-color: #f59e0b; }
.st-green { border-top-color: #22c55e; }
.sig-ico { font-size: 1.1rem; margin-bottom: 0.25rem; }
.sig-nm { font-size: 0.61rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: #bbb; margin-bottom: 0.15rem; }
.sig-v { font-family: 'Playfair Display', serif; font-size: 1.1rem; font-weight: 700; color: #1a1a1a; }
.sig-sb { font-size: 0.66rem; color: #bbb; margin-top: 0.06rem; }
/* ARCH */
.arch-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 0.7rem; }
.arch-cell { background: #fafaf8; border: 1px solid #e8e8e4; border-radius: 11px; padding: 1rem; }
.arch-step { font-size: 0.61rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: #bbb; margin-bottom: 0.4rem; }
.arch-ico { font-size: 1.2rem; margin-bottom: 0.3rem; }
.arch-nm { font-size: 0.88rem; font-weight: 600; color: #1a1a1a; margin-bottom: 0.22rem; }
.arch-desc { font-size: 0.72rem; color: #999; line-height: 1.5; }
/* FOOTER */
.page-footer { text-align: center; padding: 1.5rem 0 1rem; border-top: 1px solid #e8e8e4; margin-top: 2rem; font-size: 0.71rem; color: #ccc; line-height: 1.65; }
</style>
""", unsafe_allow_html=True)
# โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
HINDI_BAIT = [
"เคธเคšเฅเคšเคพเคˆ","เค–เฅเคฒเคพเคธเคพ","เคงเคฎเคพเค•เคพ","เคคเคฌเคพเคนเฅ€","เคšเฅŒเค‚เค•เคพเคจเฅ‡","เคนเฅˆเคฐเคพเคจ","เค•เฅเคฏเคพ เคนเฅเค†",
"เค†เคช เคจเคนเฅ€เค‚ เคœเคพเคจเคคเฅ‡","เคธเคพเคฎเคจเฅ‡ เค†เคˆ","เคฌเคกเคผเคพ เค–เฅเคฒเคพเคธเคพ","เคธเคจเคธเคจเฅ€","เคตเคฟเคธเฅเคซเฅ‹เคŸ",
"เคธเคš","เคเฅ‚เค ","เค…เคธเคฒเฅ€","เคชเคฐเฅเคฆเคพเคซเคพเคถ","shocking","exposed","leaked",
"you won't believe","watch till end","must watch","viral",
"mind-blowing","breaking","exclusive","secret","truth","reveals",
"biggest","never seen","unbelievable","crazy","insane",
]
# โ”€โ”€ Load models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@st.cache_resource(show_spinner=False)
def load_all():
clf = pickle.load(open("src/clickbait_xgb_v1.pkl", "rb"))
cfg = json.load(open("src/feature_config.json"))
tok = AutoTokenizer.from_pretrained("google/muril-base-cased")
txt = AutoModel.from_pretrained("google/muril-base-cased").eval()
cm, _, cp = open_clip.create_model_and_transforms("ViT-B-32", pretrained="openai")
cm.eval()
return clf, cfg, tok, txt, cm, cp
clf, cfg, tokenizer, text_model, clip_model, clip_prep = load_all()
YT_KEY = os.environ.get("YOUTUBE_API_KEY", "")
# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def mean_pool(out, mask):
t = out.last_hidden_state
m = mask.unsqueeze(-1).float()
return (t * m).sum(1) / m.sum(1).clamp(min=1e-9)
def text_embed(text):
enc = tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=128)
with torch.no_grad(): out = text_model(**enc)
return mean_pool(out, enc["attention_mask"]).squeeze().numpy()
def img_embed(pil):
t = clip_prep(pil).unsqueeze(0)
with torch.no_grad(): e = clip_model.encode_image(t)
return e.squeeze().numpy()
def meta_vec(title, views=0, likes=0, comments=0, dur=0, tags="", h=5):
t = str(title); v = max(float(views), 1)
return np.array([
np.log1p(v), float(likes)/v, float(comments)/v,
min(dur/3600, 24), len(t)/100,
sum(1 for c in t if c.isupper())/max(len(t), 1),
min(len(re.findall(r'[\U00010000-\U0010ffff]', t))/5, 1),
min(sum(1 for w in HINDI_BAIT if w in t.lower())/5, 1),
int("?" in t), int("!" in t),
min(len([x for x in str(tags).split(",") if x.strip()])/20, 1),
float(h)/10,
], dtype=np.float32)
def h_score(title):
t = str(title).lower(); s = 0
s += min(sum(1 for w in HINDI_BAIT if w in t)*1.5, 4)
eng = re.sub(r'[^\x00-\x7F]+', '', title)
if len(eng) > 5 and sum(1 for c in eng if c.isupper())/len(eng) > 0.35: s += 2
if re.search(r'\?.*!|!.*\?', title): s += 1.5
ec = len(re.findall(r'[\U00010000-\U0010ffff]', title))
if ec > 2: s += min(ec*0.5, 2)
return min(s, 10)
def parse_dur(d):
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', str(d))
return (int(m.group(1) or 0)*3600 + int(m.group(2) or 0)*60 + int(m.group(3) or 0)) if m else 0
def run_predict(title, pil, views, likes, comments, dur, tags, hs):
te = normalize(text_embed(title).reshape(1,-1))[0]
ie = normalize(img_embed(pil).reshape(1,-1))[0]
me = meta_vec(title, views, likes, comments, dur, tags, hs)
X = np.concatenate([te, ie, me]).reshape(1,-1)
prob = float(clf.predict_proba(X)[0][1])
return prob
def fetch_meta(vid_id):
r = requests.get("https://www.googleapis.com/youtube/v3/videos",
params={"part":"snippet,statistics,contentDetails","id":vid_id,"key":YT_KEY},
timeout=12).json()
if not r.get("items"): return None
item = r["items"][0]
s, st, cd = item["snippet"], item["statistics"], item["contentDetails"]
return {
"title": s.get("title",""),
"channel": s.get("channelTitle",""),
"date": s.get("publishedAt","")[:10],
"views": int(st.get("viewCount",0) or 0),
"likes": int(st.get("likeCount",0) or 0),
"comments": int(st.get("commentCount",0) or 0),
"duration": parse_dur(cd.get("duration","")),
"tags": ",".join(s.get("tags",[])),
"thumb": (s.get("thumbnails",{}).get("maxres") or
s.get("thumbnails",{}).get("high",{})).get("url",""),
}
def extract_id(url):
m = re.search(r"(?:v=|youtu\.be/|embed/)([a-zA-Z0-9_-]{11})", url)
return m.group(1) if m else None
def fmt_n(n):
if n >= 1_000_000: return f"{n/1e6:.1f}M"
if n >= 1_000: return f"{n/1e3:.1f}K"
return str(n)
def fmt_d(s):
h, r = divmod(int(s), 3600); m, sc = divmod(r, 60)
return f"{h}h {m}m" if h else f"{m}m {sc}s"
def highlight(title):
out = title
for w in sorted(HINDI_BAIT, key=len, reverse=True):
if w.lower() in out.lower():
out = re.compile(re.escape(w), re.I).sub(f'<span class="hl-bait">{w}</span>', out, count=1)
return out
def sig_top(raw, hi, md):
return "st-red" if raw >= hi else "st-amber" if raw >= md else "st-green"
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# NAV
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
st.markdown("""
<div class="nav">
<div class="nav-brand">
<div class="nav-logo">๐ŸŽฏ</div>
<div>
<div class="nav-name">Catch-Bait</div>
<div class="nav-sub">Indian YouTube ยท Multimodal AI</div>
</div>
</div>
<div class="nav-chips">
<span class="chip">MuRIL ยท Hindi/Hinglish</span>
<span class="chip">CLIP ViT-B/32</span>
<span class="chip">XGBoost</span>
<span class="chip chip-blue">AUC-ROC 0.9952</span>
</div>
</div>
""", unsafe_allow_html=True)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# HERO + INPUT
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
st.markdown("""
<div class="page-top">
<div class="eyebrow">AI-Powered Detection ยท Hindi ยท Hinglish ยท English</div>
<div class="hero-h">Stop getting played<br>by <em>the algorithm.</em></div>
<div class="hero-p">Paste any Indian YouTube URL. Catch-Bait analyses the title, thumbnail,
and engagement signals using a multimodal model trained on 2,066 videos from 50+ Indian channels.</div>
<div class="inp-card">
""", unsafe_allow_html=True)
c1, c2 = st.columns([5, 1])
with c1:
url = st.text_input("u", label_visibility="collapsed",
placeholder="https://www.youtube.com/watch?v=...", key="u")
with c2:
go = st.button("Analyse โ†’")
st.markdown("""
<div class="try-row">
<span class="try-lbl">Try:</span>
<span class="try-chip">CarryMinati</span>
<span class="try-chip">ABP News</span>
<span class="try-chip">BB Ki Vines</span>
<span class="try-chip">Dhruv Rathee</span>
<span class="try-chip">Physics Wallah</span>
<span class="try-chip">Elvish Yadav</span>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ANALYSIS
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
if go and url:
if not YT_KEY:
st.error("โš ๏ธ YOUTUBE_API_KEY not found in Space secrets.")
st.stop()
vid_id = extract_id(url)
if not vid_id:
st.error("Could not parse a YouTube video ID from that URL.")
st.stop()
with st.spinner("Fetching video metadataโ€ฆ"):
meta = fetch_meta(vid_id)
if not meta:
st.error("Video not found. Check the URL or try another video.")
st.stop()
with st.spinner("Loading thumbnailโ€ฆ"):
t_url = meta["thumb"] or f"https://img.youtube.com/vi/{vid_id}/hqdefault.jpg"
pil = Image.open(BytesIO(requests.get(t_url, timeout=12).content)).convert("RGB")
with st.spinner("Running multimodal inferenceโ€ฆ"):
hs = h_score(meta["title"])
prob = run_predict(
meta["title"], pil, meta["views"], meta["likes"],
meta["comments"], meta["duration"], meta["tags"], hs)
is_cb = prob >= 0.5
p_cb = prob
p_safe = 1 - p_cb
st.markdown('<div class="res-wrap">', unsafe_allow_html=True)
# โ”€โ”€ VERDICT BANNER โ”€โ”€
if is_cb:
st.markdown(f"""
<div class="v-banner v-cb">
<div class="v-left">
<div class="v-icon vi-cb">๐ŸŽฏ</div>
<div>
<div class="v-title vt-cb">Clickbait</div>
<div class="v-sub">This title is designed to manipulate clicks</div>
</div>
</div>
<div style="text-align:right">
<div class="v-conf-num vc-cb">{prob:.1%}</div>
<div class="v-conf-lbl">CONFIDENCE</div>
</div>
</div>""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div class="v-banner v-safe">
<div class="v-left">
<div class="v-icon vi-safe">โœ…</div>
<div>
<div class="v-title vt-safe">Not Clickbait</div>
<div class="v-sub">This video title accurately describes its content</div>
</div>
</div>
<div style="text-align:right">
<div class="v-conf-num vc-safe">{1-prob:.1%}</div>
<div class="v-conf-lbl">CONFIDENCE</div>
</div>
</div>""", unsafe_allow_html=True)
# โ”€โ”€ TWO COLUMNS: Video info LEFT, Confidence + Signals RIGHT โ”€โ”€
left, right = st.columns([1.2, 1])
with left:
hl = highlight(meta["title"])
found = [w for w in HINDI_BAIT if w.lower() in meta["title"].lower()]
bnote = '<div class="bait-note">๐ŸŸก highlighted = detected bait terms</div>' if found else ""
st.markdown(f"""
<div class="card">
<div class="card-ttl">Video</div>
<div class="vid-row">
<div class="vid-thumb"><img src="{t_url}" loading="lazy"/></div>
<div class="vid-info">
<div class="vid-title-txt">{hl}</div>
{bnote}
<div class="vid-ch">๐Ÿ“บ {meta['channel']} ยท {meta['date']}</div>
<div class="stat-row">
<span class="stat-pill">๐Ÿ‘ {fmt_n(meta['views'])}</span>
<span class="stat-pill">๐Ÿ‘ {fmt_n(meta['likes'])}</span>
<span class="stat-pill">๐Ÿ’ฌ {fmt_n(meta['comments'])}</span>
<span class="stat-pill">โฑ {fmt_d(meta['duration'])}</span>
</div>
</div>
</div>
</div>""", unsafe_allow_html=True)
with right:
# Confidence bars only
st.markdown(f"""
<div class="card">
<div class="card-ttl">Prediction confidence</div>
<div class="prob-row">
<div class="prob-lr"><span>Clickbait</span><span class="pct-cb">{p_cb:.1%}</span></div>
<div class="bar-bg"><div class="bar-fill b-cb" style="width:{p_cb*100:.1f}%"></div></div>
</div>
<div class="prob-row">
<div class="prob-lr"><span>Not Clickbait</span><span class="pct-safe">{p_safe:.1%}</span></div>
<div class="bar-bg"><div class="bar-fill b-safe" style="width:{p_safe*100:.1f}%"></div></div>
</div>
</div>""", unsafe_allow_html=True)
# โ”€โ”€ SIGNAL GRID โ€” full width below columns โ”€โ”€
caps_r = sum(1 for c in meta["title"] if c.isupper()) / max(len(meta["title"]), 1)
emoji_n = len(re.findall(r'[\U00010000-\U0010ffff]', meta["title"]))
like_r = meta["likes"] / max(meta["views"], 1)
tag_n = len([x for x in meta["tags"].split(",") if x.strip()])
def sc_html(ico, nm, val, sb, raw, hi, md):
tc = sig_top(raw, hi, md)
return f'<div class="sig-cell {tc}"><div class="sig-ico">{ico}</div><div class="sig-nm">{nm}</div><div class="sig-v">{val}</div><div class="sig-sb">{sb}</div></div>'
st.markdown(f"""
<div class="card">
<div class="card-ttl">Signal breakdown</div>
<div class="sig-grid">
{sc_html("๐Ÿ”ค","Bait words",str(len(found)),", ".join(found[:2]) or "none",len(found),3,1)}
{sc_html("๐Ÿ” ","Caps ratio",f"{caps_r:.0%}","of English chars",caps_r,0.4,0.2)}
{sc_html("๐Ÿ˜ฎ","Emojis",str(emoji_n),"in title",emoji_n,3,1)}
{sc_html("โ“","Question","Yes" if "?" in meta["title"] else "No","curiosity gap",1 if "?" in meta["title"] else 0,1,0.5)}
{sc_html("โ—","Exclamation","Yes" if "!" in meta["title"] else "No","urgency signal",1 if "!" in meta["title"] else 0,1,0.5)}
{sc_html("๐Ÿ“ˆ","Like rate",f"{like_r:.2%}","likes / view",like_r,0.08,0.03)}
{sc_html("๐Ÿท๏ธ","Tag count",str(tag_n),"metadata tags",0,0,-1)}
{sc_html("๐ŸŽฏ","H-score",f"{hs:.1f}/10","heuristic pre-filter",hs,6,3)}
</div>
</div>""", unsafe_allow_html=True)
# โ”€โ”€ HOW IT WORKS โ”€โ”€
st.markdown("""
<div class="card">
<div class="card-ttl">How it works</div>
<div class="arch-grid">
<div class="arch-cell"><div class="arch-step">Step 1</div><div class="arch-ico">๐Ÿ“ก</div><div class="arch-nm">Fetch</div><div class="arch-desc">YouTube Data API v3 pulls title, thumbnail, stats, and tags live</div></div>
<div class="arch-cell"><div class="arch-step">Step 2</div><div class="arch-ico">๐Ÿง </div><div class="arch-nm">Embed</div><div class="arch-desc">MuRIL encodes text (768-d) ยท CLIP ViT-B/32 encodes thumbnail (512-d)</div></div>
<div class="arch-cell"><div class="arch-step">Step 3</div><div class="arch-ico">๐Ÿ”—</div><div class="arch-nm">Fuse</div><div class="arch-desc">L2-normalised embeddings + 12 metadata features โ†’ 1292-d vector</div></div>
<div class="arch-cell"><div class="arch-step">Step 4</div><div class="arch-ico">โšก</div><div class="arch-nm">Classify</div><div class="arch-desc">XGBoost predicts probability ยท AUC-ROC 0.9952 ยท Macro F1 0.9609</div></div>
</div>
</div>
""", unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("""
<div class="page-footer">
Catch-Bait ยท Multimodal Indian YouTube Clickbait Detection<br>
MuRIL + CLIP ViT-B/32 + XGBoost ยท 2,066 videos ยท 50+ channels ยท Hindi ยท Hinglish ยท English
</div>
""", unsafe_allow_html=True)