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(""" """, 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'{w}', 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(""" """, unsafe_allow_html=True) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # HERO + INPUT # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• st.markdown("""
AI-Powered Detection ยท Hindi ยท Hinglish ยท English
Stop getting played
by the algorithm.
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.
""", 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("""
Try: CarryMinati ABP News BB Ki Vines Dhruv Rathee Physics Wallah Elvish Yadav
""", 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('
', unsafe_allow_html=True) # โ”€โ”€ VERDICT BANNER โ”€โ”€ if is_cb: st.markdown(f"""
๐ŸŽฏ
Clickbait
This title is designed to manipulate clicks
{prob:.1%}
CONFIDENCE
""", unsafe_allow_html=True) else: st.markdown(f"""
โœ…
Not Clickbait
This video title accurately describes its content
{1-prob:.1%}
CONFIDENCE
""", 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 = '
๐ŸŸก highlighted = detected bait terms
' if found else "" st.markdown(f"""
Video
{hl}
{bnote}
๐Ÿ“บ {meta['channel']} ยท {meta['date']}
๐Ÿ‘ {fmt_n(meta['views'])} ๐Ÿ‘ {fmt_n(meta['likes'])} ๐Ÿ’ฌ {fmt_n(meta['comments'])} โฑ {fmt_d(meta['duration'])}
""", unsafe_allow_html=True) with right: # Confidence bars only st.markdown(f"""
Prediction confidence
Clickbait{p_cb:.1%}
Not Clickbait{p_safe:.1%}
""", 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'
{ico}
{nm}
{val}
{sb}
' st.markdown(f"""
Signal breakdown
{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)}
""", unsafe_allow_html=True) # โ”€โ”€ HOW IT WORKS โ”€โ”€ st.markdown("""
How it works
Step 1
๐Ÿ“ก
Fetch
YouTube Data API v3 pulls title, thumbnail, stats, and tags live
Step 2
๐Ÿง 
Embed
MuRIL encodes text (768-d) ยท CLIP ViT-B/32 encodes thumbnail (512-d)
Step 3
๐Ÿ”—
Fuse
L2-normalised embeddings + 12 metadata features โ†’ 1292-d vector
Step 4
โšก
Classify
XGBoost predicts probability ยท AUC-ROC 0.9952 ยท Macro F1 0.9609
""", unsafe_allow_html=True) st.markdown('
', unsafe_allow_html=True) st.markdown(""" """, unsafe_allow_html=True)