diff options
| -rw-r--r-- | components/news.py | 445 |
1 files changed, 374 insertions, 71 deletions
diff --git a/components/news.py b/components/news.py index cb43ea8..90f0ddb 100644 --- a/components/news.py +++ b/components/news.py @@ -1,29 +1,36 @@ -"""News feed with sentiment badges.""" -import streamlit as st -from datetime import datetime -from services.news_service import get_company_news, get_news_sentiment +"""News tab rendered as a client-side HTML surface.""" +from datetime import date as _date +from datetime import datetime as _dt +from html import escape as _esc + +import streamlit.components.v1 as components + +from services.data_service import get_company_info, get_latest_price from services.fmp_service import get_company_news as get_fmp_news -from utils.security import escape_html, validate_outbound_url +from services.news_service import get_company_news, get_news_sentiment +from utils.security import validate_outbound_url +_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} -def _sentiment_badge(sentiment: str) -> str: - badges = { - "bullish": "๐ข Bullish (heuristic)", - "bearish": "๐ด Bearish (heuristic)", - "neutral": "โช Neutral (heuristic)", - } - return badges.get(sentiment.lower(), "โช Neutral (heuristic)") + +def _safe_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None def _classify_sentiment(article: dict) -> str: - """Classify sentiment from Finnhub article data.""" - # Finnhub doesn't return per-article sentiment, use headline heuristics - positive = ["beats", "surges", "rises", "gains", "profit", "record", "upgrade", - "buy", "outperform", "growth", "strong", "higher", "rally"] - negative = ["misses", "falls", "drops", "loss", "cut", "downgrade", "sell", - "underperform", "weak", "lower", "decline", "warning", "layoff"] + positive = [ + "beats", "surges", "rises", "gains", "profit", "record", "upgrade", "buy", "outperform", + "growth", "strong", "higher", "rally", "raises", "expands", "upside", + ] + negative = [ + "misses", "falls", "drops", "loss", "cut", "downgrade", "sell", "underperform", "weak", + "lower", "decline", "warning", "layoff", "lawsuit", "probe", "cuts", + ] headline = (article.get("headline") or article.get("title") or "").lower() - summary = (article.get("summary") or "").lower() + summary = (article.get("summary") or article.get("text") or "").lower() text = headline + " " + summary pos = sum(1 for w in positive if w in text) neg = sum(1 for w in negative if w in text) @@ -34,66 +41,362 @@ def _classify_sentiment(article: dict) -> str: return "neutral" -def _fmt_time(timestamp) -> str: +def _normalize_dt(raw): + if raw is None: + return None + if isinstance(raw, _dt): + return raw + if isinstance(raw, _date): + return _dt(raw.year, raw.month, raw.day) + if isinstance(raw, (int, float)): + try: + return _dt.utcfromtimestamp(float(raw)) + except Exception: + return None + + text = str(raw).strip() + if not text: + return None + + norm = text.replace("Z", "+00:00") try: - if isinstance(timestamp, (int, float)): - return datetime.utcfromtimestamp(timestamp).strftime("%b %d, %Y") - return str(timestamp)[:10] + dt = _dt.fromisoformat(norm) + if dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + return dt except Exception: - return "" + pass + + for fmt in ( + "%Y-%m-%d", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%d %H:%M", + "%m/%d/%Y", + ): + try: + return _dt.strptime(text[: len(fmt)], fmt) + except Exception: + pass + + if len(text) >= 10: + try: + return _dt.strptime(text[:10], "%Y-%m-%d") + except Exception: + pass + + return None def render_news(ticker: str): - # Overall sentiment summary from Finnhub - sentiment_data = get_news_sentiment(ticker) - if sentiment_data: - buzz = sentiment_data.get("buzz", {}) - score = sentiment_data.get("sentiment", {}) - col1, col2, col3 = st.columns(3) - col1.metric("Articles (7d)", buzz.get("articlesInLastWeek", "โ")) - bull_pct = score.get("bullishPercent") - bear_pct = score.get("bearishPercent") - col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct is not None else "โ") - col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct is not None else "โ") - st.caption("Sentiment tags below are rule-based headline heuristics, not model-scored article sentiment.") - st.divider() + import json as _json - # Fetch articles โ Finnhub first, FMP as fallback - articles = get_company_news(ticker) - if not articles: - articles = get_fmp_news(ticker) + info = get_company_info(ticker) or {} + price = get_latest_price(ticker) + sentiment_data = get_news_sentiment(ticker) or {} - if not articles: - st.info("No recent news found.") - return + # Finnhub first, FMP fallback. + raw_articles = get_company_news(ticker) or [] + provider = "finnhub" + if not raw_articles: + raw_articles = get_fmp_news(ticker) or [] + provider = "fmp" - for article in articles: - headline = article.get("headline") or article.get("title", "No title") - source = article.get("source") or article.get("site", "") - url = validate_outbound_url(article.get("url") or article.get("newsURL")) - timestamp = article.get("datetime") or article.get("publishedDate", "") - summary = article.get("summary") or article.get("text") or "" - headline_html = escape_html(headline) + rows = [] + for i, article in enumerate(raw_articles): + article = article or {} + title = str(article.get("headline") or article.get("title") or "").strip() + if not title: + title = "Untitled item" + source = str(article.get("source") or article.get("site") or "").strip() or "Unknown source" + summary = str(article.get("summary") or article.get("text") or "").strip() + dt_obj = _normalize_dt(article.get("datetime") or article.get("publishedDate")) + ts = int(dt_obj.timestamp()) if dt_obj else None + date_iso = dt_obj.strftime("%Y-%m-%d") if dt_obj else None + date_pretty = dt_obj.strftime("%b %d, %Y") if dt_obj else "โ" + time_pretty = dt_obj.strftime("%H:%M") if dt_obj else "โ" + url = validate_outbound_url(article.get("url") or article.get("newsURL")) sentiment = _classify_sentiment(article) - badge = _sentiment_badge(sentiment) - time_str = _fmt_time(timestamp) - with st.container(): - col1, col2 = st.columns([5, 1]) - with col1: - if url: - st.markdown( - f'<strong><a href="{escape_html(url)}" target="_blank" rel="noopener noreferrer">{headline_html}</a></strong>', - unsafe_allow_html=True, - ) - else: - st.markdown(f"<strong>{headline_html}</strong>", unsafe_allow_html=True) - meta = " ยท ".join(filter(None, [source, time_str])) - if meta: - st.caption(meta) - if summary: - st.caption(summary[:200] + ("โฆ" if len(summary) > 200 else "")) - with col2: - st.markdown(badge) - st.divider() + rows.append( + { + "id": i, + "title": title, + "summary": summary, + "source": source, + "url": url, + "sentiment": sentiment, + "date": date_iso, + "date_pretty": date_pretty, + "time_pretty": time_pretty, + "ts": ts, + } + ) + + rows.sort(key=lambda r: (r.get("ts") is not None, r.get("ts") or 0), reverse=True) + + buzz = sentiment_data.get("buzz") or {} + score = sentiment_data.get("sentiment") or {} + bull_pct = score.get("bullishPercent") + bear_pct = score.get("bearishPercent") + weekly_articles = buzz.get("articlesInLastWeek") + + bull_str = f"{float(bull_pct) * 100:.1f}%" if _safe_float(bull_pct) is not None else "โ" + bear_str = f"{float(bear_pct) * 100:.1f}%" if _safe_float(bear_pct) is not None else "โ" + + cur_num = _safe_float(info.get("currentPrice") or info.get("regularMarketPrice") or price) + prev_num = _safe_float(info.get("previousClose")) + if cur_num is not None and prev_num is not None and prev_num > 0: + chg_pct = (cur_num - prev_num) / prev_num * 100.0 + chg_sign = "+" if chg_pct >= 0 else "" + chg_arrow = "โฒ" if chg_pct >= 0 else "โผ" + chg_str = chg_arrow + " " + chg_sign + "{:.2f}%".format(chg_pct) + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str = "โ" + chg_cls = "" + + exchange_raw = str(info.get("exchange") or "") + exchange = _XMAP.get(exchange_raw, exchange_raw) or "โ" + co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper()) + price_str = "${:,.2f}".format(cur_num) if cur_num is not None else "โ" + + rows_js = "const NEWS_ROWS=" + _json.dumps(rows) + ";" + + n_rows = max(len(rows), 18) + height = 1240 + n_rows * 28 + + _ROOT = ( + "<style>*,*::before,*::after{box-sizing:border-box}" + ":root{" + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + "}" + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);" + "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + "</style>" + ) + + fonts_link = ( + "<link rel='preconnect' href='https://fonts.googleapis.com'>" + "<link href='https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500" + "&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'" + " rel='stylesheet'>" + ) + + _NEWS_CSS = """<style> +.nw-wrap{background:var(--ink-0);min-height:100vh} +.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.eyebrow-lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.val-ctx{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1);background:var(--ink-1)} +.val-ctx .sym{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;letter-spacing:var(--tr-tight)} +.val-ctx .name{font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--fg-2);margin-left:calc(-1 * var(--sp-1));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:48ch} +.val-ctx .eyebrow-ctx{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.val-ctx .meta{display:flex;gap:var(--sp-4);margin-left:auto;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.val-ctx .meta span{white-space:nowrap} +.val-ctx .meta .px{color:var(--fg-1);font-size:var(--fs-14)} +.val-ctx .meta .chg-pos{color:var(--positive)} +.val-ctx .meta .chg-neg{color:var(--negative)} +.nw-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-5)} +.nw-lede{display:grid;grid-template-columns:1.6fr 1fr;gap:var(--sp-5);align-items:stretch;background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-5)} +.nw-lede .left{display:flex;flex-direction:column;gap:var(--sp-2)} +.nw-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:var(--tr-tight);line-height:1.1;color:var(--fg-1);margin:var(--sp-1) 0 0} +.nw-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:68ch;margin:0} +.nw-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end} +.kr-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)} +.kr-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-source .v{font-family:var(--font-mono);font-size:var(--fs-14);font-variant-numeric:tabular-nums;color:var(--fg-1);font-weight:500} +.nw-controls{display:grid;grid-template-columns:1fr 1fr 1fr;gap:var(--sp-4);padding:var(--sp-3) var(--sp-4);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)} +.ctl-group{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap} +.ctl-lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-4);margin-right:var(--sp-2)} +.ctl-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide);transition:background .15s ease,color .15s ease,border-color .15s ease} +.ctl-btn:hover{background:var(--ink-3);color:var(--fg-2)} +.ctl-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)} +.nw-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--sp-3)} +.nw-kpi{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-1)} +.nw-kpi .lbl{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:500} +.nw-kpi .v{font-family:var(--font-mono);font-size:var(--fs-24);line-height:1.1;font-variant-numeric:tabular-nums;color:var(--fg-1)} +.nw-table-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.nw-table-head{padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--line-1);font-family:var(--font-sans);font-size:var(--fs-12);font-weight:600;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-3)} +.nw-grid-head,.nw-grid-row{display:grid;grid-template-columns:0.8fr 1.1fr 3.2fr 1.4fr 0.7fr} +.nw-grid-head{background:var(--ink-2);border-bottom:1px solid var(--line-1)} +.nw-grid-head div{padding:8px var(--sp-3);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.nw-grid-row{border-bottom:1px solid var(--line-1);transition:background .15s ease} +.nw-grid-row:last-child{border-bottom:none} +.nw-grid-row:hover{background:rgba(194,170,122,0.04)} +.nw-grid-row .date{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--fg-2);white-space:nowrap} +.nw-grid-row .src{font-family:var(--font-sans);color:var(--fg-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.nw-grid-row .title{display:flex;flex-direction:column;gap:6px;min-width:0} +.nw-grid-row .title a,.nw-grid-row .title .t{font-family:var(--font-sans);font-size:var(--fs-14);font-weight:500;line-height:1.45;color:var(--fg-1);text-decoration:none} +.nw-grid-row .title a:hover{color:var(--brass-bright)} +.nw-grid-row .summary{font-family:var(--font-sans);font-size:var(--fs-12);line-height:1.45;color:var(--fg-3);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.sent{display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:var(--r-full);font-family:var(--font-mono);font-size:11px;font-weight:500} +.sent.bull{background:var(--positive-bg);color:var(--positive)} +.sent.bear{background:var(--negative-bg);color:var(--negative)} +.sent.neu{background:var(--ink-2);color:var(--fg-3);border:1px solid var(--line-2)} +.nw-open{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;color:var(--brass);text-decoration:none;border-bottom:1px solid var(--brass-deep)} +.nw-open:hover{color:var(--brass-bright)} +.nw-empty{padding:var(--sp-7);text-align:center;color:var(--fg-3);font-size:var(--fs-14);font-family:var(--font-sans);line-height:1.6} +.va-foot{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3);line-height:1.6;padding:var(--sp-3) var(--sp-5);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-1)} +@media (max-width:1100px){ + .nw-lede{grid-template-columns:1fr} + .nw-lede .right{grid-template-columns:1fr} + .nw-controls{grid-template-columns:1fr} + .nw-kpis{grid-template-columns:1fr 1fr} + .nw-grid-head,.nw-grid-row{grid-template-columns:1fr 1.2fr 2.5fr 1fr 0.8fr} +} +</style>""" + + ctx_html = ( + '<div class="val-ctx">' + + '<span class="sym">' + + _esc(ticker.upper()) + + "</span>" + + '<span class="name">' + + co_name + + "</span>" + + '<span class="eyebrow-ctx" style="margin-left:12px">News</span>' + + '<div class="meta">' + + "<span>" + + _esc(exchange) + + "</span>" + + '<span class="px num">' + + price_str + + "</span>" + + '<span class="' + + chg_cls + + ' num">' + + chg_str + + "</span>" + + "</div></div>" + ) + + lede_html = ( + '<section class="nw-lede">' + + '<div class="left">' + + '<span class="eyebrow-lbl">Coverage</span>' + + '<div class="ttl">What the tape is saying</div>' + + '<p class="sub">Headline flow for ' + + _esc(ticker.upper()) + + '. Read source mix, timing, and sentiment tags in one stream. Sentiment chips are rule-based heuristics from headline and summary language.</p>' + + "</div>" + + '<div class="right">' + + '<div class="kr-source"><span class="lbl">Primary feed</span><span class="v">' + + _esc(provider) + + "</span></div>" + + '<div class="kr-source"><span class="lbl">Articles (7d)</span><span class="v num">' + + (str(weekly_articles) if weekly_articles is not None else "โ") + + "</span></div>" + + '<div class="kr-source"><span class="lbl">Loaded rows</span><span class="v num">' + + str(len(rows)) + + "</span></div>" + + "</div></section>" + ) + + controls_html = ( + '<div class="nw-controls">' + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Range</span>' + + '<button class="ctl-btn active" onclick="setRange(\'all\',this)">All</button>' + + '<button class="ctl-btn" onclick="setRange(\'24h\',this)">24H</button>' + + '<button class="ctl-btn" onclick="setRange(\'7d\',this)">7D</button>' + + '<button class="ctl-btn" onclick="setRange(\'30d\',this)">30D</button>' + + "</div>" + + '<div class="ctl-group" id="nw-source-buttons"><span class="ctl-lbl">Source</span></div>' + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Sentiment</span>' + + '<button class="ctl-btn active" onclick="setSentiment(\'all\',this)">All</button>' + + '<button class="ctl-btn" onclick="setSentiment(\'bullish\',this)">Bullish</button>' + + '<button class="ctl-btn" onclick="setSentiment(\'neutral\',this)">Neutral</button>' + + '<button class="ctl-btn" onclick="setSentiment(\'bearish\',this)">Bearish</button>' + + "</div></div>" + ) + + foot_html = ( + '<div class="va-foot">' + + "<span>News feed sourced from Finnhub (with FMP fallback) ยท Sentiment chips are heuristic labels, not model-scored article sentiment ยท Outbound links are validated before render</span>" + + "</div>" + ) + + js = ( + "<script>" + + rows_js + + "var activeRange='all';var activeSent='all';var activeSource='all';" + + "function esc(s){if(s===null||s===undefined)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}" + + "function escAttr(s){return esc(s).replace(/\\\"/g,'"').replace(/'/g,''');}" + + "function relAge(ts){if(!ts)return 'โ';var now=Math.floor(Date.now()/1000);var d=Math.max(0,now-ts);if(d<3600)return Math.floor(d/60)+'m ago';if(d<86400)return Math.floor(d/3600)+'h ago';if(d<86400*7)return Math.floor(d/86400)+'d ago';return Math.floor(d/(86400*7))+'w ago';}" + + "function sourceList(){var s={};(NEWS_ROWS||[]).forEach(function(r){if(r.source)s[r.source]=1;});return Object.keys(s).sort(function(a,b){return a.localeCompare(b);});}" + + "function buildSourceButtons(){var host=document.getElementById('nw-source-buttons');if(!host)return;host.innerHTML='<span class=\"ctl-lbl\">Source</span>';" + + "var all=document.createElement('button');all.className='ctl-btn active';all.textContent='All';all.onclick=function(){setSource('all',all);};host.appendChild(all);" + + "sourceList().slice(0,8).forEach(function(src){var b=document.createElement('button');b.className='ctl-btn';b.textContent=src;b.onclick=function(){setSource(src,b);};host.appendChild(b);});}" + + "function setBtnState(group,btn){var root=btn&&btn.parentElement; if(!root)return; root.querySelectorAll('.ctl-btn').forEach(function(x){x.classList.remove('active');});btn.classList.add('active');}" + + "function cutoff(range){var now=Math.floor(Date.now()/1000);if(range==='24h')return now-86400;if(range==='7d')return now-86400*7;if(range==='30d')return now-86400*30;return null;}" + + "function filtered(){var c=cutoff(activeRange);return (NEWS_ROWS||[]).filter(function(r){if(c&&(!r.ts||r.ts<c))return false;if(activeSent!=='all'&&r.sentiment!==activeSent)return false;if(activeSource!=='all'&&r.source!==activeSource)return false;return true;});}" + + "function setRange(v,btn){activeRange=v;setBtnState('range',btn);renderAll();}" + + "function setSentiment(v,btn){activeSent=v;setBtnState('sent',btn);renderAll();}" + + "function setSource(v,btn){activeSource=v;setBtnState('source',btn);renderAll();}" + + "function sentimentClass(s){if(s==='bullish')return 'bull';if(s==='bearish')return 'bear';return 'neu';}" + + "function sentimentLabel(s){if(s==='bullish')return 'Bullish';if(s==='bearish')return 'Bearish';return 'Neutral';}" + + "function renderKPIs(rows){var m={bullish:0,bearish:0,neutral:0};rows.forEach(function(r){m[r.sentiment]=(m[r.sentiment]||0)+1;});" + + "var src={};rows.forEach(function(r){src[r.source]=1;});var srcCount=Object.keys(src).length;" + + "var newest='โ';if(rows.length&&rows[0].date_pretty){newest=rows[0].date_pretty+' ยท '+rows[0].time_pretty;}" + + "var html='';" + + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Filtered Items</div><div class=\"v\">'+rows.length.toLocaleString()+'</div></div>';" + + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Bullish</div><div class=\"v\">'+(m.bullish||0).toLocaleString()+'</div></div>';" + + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Neutral</div><div class=\"v\">'+(m.neutral||0).toLocaleString()+'</div></div>';" + + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Bearish</div><div class=\"v\">'+(m.bearish||0).toLocaleString()+'</div></div>';" + + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Active Sources</div><div class=\"v\">'+srcCount.toLocaleString()+'</div><div class=\"lbl\" style=\"text-transform:none;letter-spacing:0;color:var(--fg-3)\">'+esc(newest)+'</div></div>';" + + "document.getElementById('nw-kpis').innerHTML=html;}" + + "function rowHtml(r){var link='โ';if(r.url){link='<a class=\"nw-open\" href=\"'+escAttr(r.url)+'\" target=\"_blank\" rel=\"noopener noreferrer\">Open</a>';};" + + "var ttl=r.url?('<a href=\"'+escAttr(r.url)+'\" target=\"_blank\" rel=\"noopener noreferrer\">'+esc(r.title)+'</a>'):('<span class=\"t\">'+esc(r.title)+'</span>');" + + "var summary=r.summary?('<div class=\"summary\">'+esc(r.summary)+'</div>'):'';" + + "return '<div class=\"nw-grid-row\">'" + + "+'<div class=\"date\">'+esc(r.date_pretty||'โ')+'<br><span style=\"color:var(--fg-4)\">'+esc(relAge(r.ts))+'</span></div>'" + + "+'<div class=\"src\">'+esc(r.source||'Unknown source')+'</div>'" + + "+'<div class=\"title\">'+ttl+summary+'</div>'" + + "+'<div><span class=\"sent '+sentimentClass(r.sentiment)+'\">'+sentimentLabel(r.sentiment)+'</span></div>'" + + "+'<div>'+link+'</div>'" + + "+'</div>';}" + + "function renderTable(rows){var head='<div class=\"nw-table-head\">Recent coverage</div><div class=\"nw-grid-head\"><div>Date</div><div>Source</div><div>Headline</div><div>Tone</div><div>Link</div></div>';" + + "if(!rows.length){document.getElementById('nw-table').innerHTML=head+'<div class=\"nw-empty\">No news items match the current filters.<br>Try widening the date range or switching source and sentiment filters.</div>';return;}" + + "var body='';rows.forEach(function(r){body+=rowHtml(r);});document.getElementById('nw-table').innerHTML=head+body;}" + + "function renderAll(){var rows=filtered();rows.sort(function(a,b){return (b.ts||0)-(a.ts||0);});renderKPIs(rows);renderTable(rows);}" + + "function boot(){buildSourceButtons();renderAll();}" + + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',boot);}else{boot();}" + + "</script>" + ) + + doc = ( + "<!doctype html><html><head><meta charset='utf-8'>" + + fonts_link + + _ROOT + + _NEWS_CSS + + "</head><body><div class='nw-wrap'>" + + ctx_html + + '<div class="nw-body">' + + lede_html + + controls_html + + '<div id="nw-kpis" class="nw-kpis"></div>' + + '<div class="nw-table-card"><div id="nw-table"></div></div>' + + foot_html + + "</div>" + + js + + "</div></body></html>" + ) + + components.html(doc, height=height, scrolling=False) |
