|
|
import datetime as dt |
|
|
import html |
|
|
import textwrap |
|
|
from typing import List, Tuple |
|
|
|
|
|
import feedparser |
|
|
import gradio as gr |
|
|
import requests |
|
|
|
|
|
DEFAULT_URL = "https://sachet.ndma.gov.in/cap_public_website/rss/rss_india.xml" |
|
|
UA = "MinimalRSS/1.0 (+https://huggingface.co/spaces)" |
|
|
|
|
|
def _fetch(url: str, timeout: int = 12) -> bytes: |
|
|
resp = requests.get(url, headers={"User-Agent": UA}, timeout=timeout) |
|
|
resp.raise_for_status() |
|
|
return resp.content |
|
|
|
|
|
def _truncate(text: str, n: int = 220) -> str: |
|
|
text = " ".join(text.split()) |
|
|
return text if len(text) <= n else text[: n - 1].rstrip() + "…" |
|
|
|
|
|
def _format_time(struct_time) -> str: |
|
|
if not struct_time: |
|
|
return "" |
|
|
|
|
|
|
|
|
try: |
|
|
return dt.datetime(*struct_time[:6], tzinfo=dt.timezone.utc).isoformat().replace("+00:00", "Z") |
|
|
except Exception: |
|
|
return "" |
|
|
|
|
|
def render_feed(url: str, max_items: int, show_summaries: bool) -> Tuple[str, str]: |
|
|
try: |
|
|
raw = _fetch(url.strip() or DEFAULT_URL) |
|
|
parsed = feedparser.parse(raw) |
|
|
except Exception as e: |
|
|
return "", f"⚠️ Could not load the feed. {type(e).__name__}: {e}" |
|
|
|
|
|
title = parsed.feed.get("title", "Feed") |
|
|
subtitle = parsed.feed.get("subtitle", "") |
|
|
updated = parsed.feed.get("updated_parsed") |
|
|
|
|
|
header_html = f""" |
|
|
<div class="header"> |
|
|
<div class="feed-title">{html.escape(title)}</div> |
|
|
{"<div class='feed-sub'>"+html.escape(subtitle)+"</div>" if subtitle else ""} |
|
|
<div class="feed-meta">Updated: {html.escape(_format_time(updated) or "—")}</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
items_html: List[str] = [] |
|
|
for entry in parsed.entries[:max_items]: |
|
|
etitle = html.escape(entry.get("title", "Untitled")) |
|
|
link = entry.get("link", "#") |
|
|
published = _format_time(entry.get("published_parsed")) |
|
|
summary = entry.get("summary", "") or entry.get("description", "") |
|
|
|
|
|
summary = html.escape(_truncate(summary, 500)) |
|
|
|
|
|
caps = [] |
|
|
for key in ("category", "tags"): |
|
|
if key in entry and entry[key]: |
|
|
if key == "category": |
|
|
caps.append(str(entry["category"])) |
|
|
else: |
|
|
for t in entry["tags"]: |
|
|
lab = t.get("term") or t.get("label") |
|
|
if lab: |
|
|
caps.append(str(lab)) |
|
|
caps = [c for c in [c.strip() for c in caps] if c] |
|
|
|
|
|
cap_html = ( |
|
|
"<div class='caps'>" + " ".join(f"<span class='cap'>{html.escape(c)}</span>" for c in caps) + "</div>" |
|
|
if caps |
|
|
else "" |
|
|
) |
|
|
|
|
|
item = f""" |
|
|
<li class="item"> |
|
|
<a class="title" href="{html.escape(link)}" target="_blank" rel="noopener noreferrer">{etitle}</a> |
|
|
<div class="meta">{("Published: " + published) if published else ""}</div> |
|
|
{cap_html} |
|
|
{f"<div class='summary'>{summary}</div>" if show_summaries and summary else ""} |
|
|
</li> |
|
|
""" |
|
|
items_html.append(item) |
|
|
|
|
|
if not items_html: |
|
|
items_html.append("<li class='item empty'>No items found.</li>") |
|
|
|
|
|
body_html = "<ul class='list'>" + "\n".join(items_html) + "</ul>" |
|
|
|
|
|
full_html = f""" |
|
|
<div class="wrap"> |
|
|
{header_html} |
|
|
{body_html} |
|
|
</div> |
|
|
""" |
|
|
|
|
|
return full_html, "" |
|
|
|
|
|
MINIMAL_CSS = """ |
|
|
:root { --fg:#111; --muted:#666; --bg:#fff; --card:#fafafa; --link:#0b57d0; } |
|
|
@media (prefers-color-scheme: dark) { |
|
|
:root { --fg:#eee; --muted:#aaa; --bg:#0b0b0b; --card:#141414; --link:#7fb0ff; } |
|
|
} |
|
|
*{box-sizing:border-box} |
|
|
body{{background:var(--bg)}} |
|
|
.wrap{max-width:920px;margin:24px auto;padding:0 16px;color:var(--fg);font:16px/1.55 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial} |
|
|
.header{margin:12px 0 8px 0} |
|
|
.feed-title{font-weight:700;font-size:20px} |
|
|
.feed-sub{color:var(--muted);margin-top:2px} |
|
|
.feed-meta{color:var(--muted);font-size:13px;margin-top:6px} |
|
|
.list{list-style:none;padding:0;margin:16px 0} |
|
|
.item{background:var(--card);border:1px solid rgba(127,127,127,.2);border-radius:12px;padding:14px 16px;margin:10px 0} |
|
|
.item .title{font-weight:600;text-decoration:none;color:var(--link)} |
|
|
.item .title:hover{text-decoration:underline} |
|
|
.item .meta{color:var(--muted);font-size:13px;margin-top:6px} |
|
|
.caps{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap} |
|
|
.cap{border:1px solid rgba(127,127,127,.25);padding:2px 8px;border-radius:999px;font-size:12px;color:var(--muted)} |
|
|
.summary{margin-top:10px;white-space:pre-wrap} |
|
|
.empty{color:var(--muted);text-align:center} |
|
|
.footer{max-width:920px;margin:8px auto 24px auto;padding:0 16px;color:var(--muted);font:12px/1.4 system-ui} |
|
|
""" |
|
|
|
|
|
with gr.Blocks(css=MINIMAL_CSS, fill_height=True, theme=gr.themes.Soft()) as demo: |
|
|
gr.Markdown("### NDMA Sachet — Minimal RSS Viewer") |
|
|
|
|
|
with gr.Row(): |
|
|
url_in = gr.Textbox( |
|
|
label="RSS URL", |
|
|
value=DEFAULT_URL, |
|
|
placeholder="Paste an RSS/Atom URL…", |
|
|
max_lines=1 |
|
|
) |
|
|
max_items = gr.Slider(5, 50, value=20, step=1, label="Items") |
|
|
show_summaries = gr.Checkbox(value=True, label="Show summaries") |
|
|
refresh = gr.Button("Refresh", variant="primary") |
|
|
|
|
|
out_html = gr.HTML() |
|
|
out_err = gr.Markdown(elem_classes=["footer"]) |
|
|
|
|
|
def _go(u, m, s): |
|
|
return render_feed(u, int(m), bool(s)) |
|
|
|
|
|
|
|
|
demo.load(_go, [url_in, max_items, show_summaries], [out_html, out_err]) |
|
|
|
|
|
refresh.click(_go, [url_in, max_items, show_summaries], [out_html, out_err]) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch() |
|
|
|