Spaces:
Sleeping
Sleeping
| 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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) |