Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime as dt
|
| 2 |
+
import html
|
| 3 |
+
import textwrap
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
|
| 6 |
+
import feedparser
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
DEFAULT_URL = "https://sachet.ndma.gov.in/cap_public_website/rss/rss_india.xml"
|
| 11 |
+
UA = "MinimalRSS/1.0 (+https://huggingface.co/spaces)"
|
| 12 |
+
|
| 13 |
+
def _fetch(url: str, timeout: int = 12) -> bytes:
|
| 14 |
+
resp = requests.get(url, headers={"User-Agent": UA}, timeout=timeout)
|
| 15 |
+
resp.raise_for_status()
|
| 16 |
+
return resp.content
|
| 17 |
+
|
| 18 |
+
def _truncate(text: str, n: int = 220) -> str:
|
| 19 |
+
text = " ".join(text.split())
|
| 20 |
+
return text if len(text) <= n else text[: n - 1].rstrip() + "…"
|
| 21 |
+
|
| 22 |
+
def _format_time(struct_time) -> str:
|
| 23 |
+
if not struct_time:
|
| 24 |
+
return ""
|
| 25 |
+
# Display in IST by default (Asia/Kolkata = UTC+5:30). We’ll label UTC to avoid timezone assumptions.
|
| 26 |
+
# Feed times are usually UTC; to stay unambiguous, show ISO 8601/Z.
|
| 27 |
+
try:
|
| 28 |
+
return dt.datetime(*struct_time[:6], tzinfo=dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
| 29 |
+
except Exception:
|
| 30 |
+
return ""
|
| 31 |
+
|
| 32 |
+
def render_feed(url: str, max_items: int, show_summaries: bool) -> Tuple[str, str]:
|
| 33 |
+
try:
|
| 34 |
+
raw = _fetch(url.strip() or DEFAULT_URL)
|
| 35 |
+
parsed = feedparser.parse(raw)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
return "", f"⚠️ Could not load the feed. {type(e).__name__}: {e}"
|
| 38 |
+
|
| 39 |
+
title = parsed.feed.get("title", "Feed")
|
| 40 |
+
subtitle = parsed.feed.get("subtitle", "")
|
| 41 |
+
updated = parsed.feed.get("updated_parsed")
|
| 42 |
+
|
| 43 |
+
header_html = f"""
|
| 44 |
+
<div class="header">
|
| 45 |
+
<div class="feed-title">{html.escape(title)}</div>
|
| 46 |
+
{"<div class='feed-sub'>"+html.escape(subtitle)+"</div>" if subtitle else ""}
|
| 47 |
+
<div class="feed-meta">Updated: {html.escape(_format_time(updated) or "—")}</div>
|
| 48 |
+
</div>
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
items_html: List[str] = []
|
| 52 |
+
for entry in parsed.entries[:max_items]:
|
| 53 |
+
etitle = html.escape(entry.get("title", "Untitled"))
|
| 54 |
+
link = entry.get("link", "#")
|
| 55 |
+
published = _format_time(entry.get("published_parsed"))
|
| 56 |
+
summary = entry.get("summary", "") or entry.get("description", "")
|
| 57 |
+
# Remove very long XML artifacts
|
| 58 |
+
summary = html.escape(_truncate(summary, 500))
|
| 59 |
+
|
| 60 |
+
caps = []
|
| 61 |
+
for key in ("category", "tags"):
|
| 62 |
+
if key in entry and entry[key]:
|
| 63 |
+
if key == "category":
|
| 64 |
+
caps.append(str(entry["category"]))
|
| 65 |
+
else:
|
| 66 |
+
for t in entry["tags"]:
|
| 67 |
+
lab = t.get("term") or t.get("label")
|
| 68 |
+
if lab:
|
| 69 |
+
caps.append(str(lab))
|
| 70 |
+
caps = [c for c in [c.strip() for c in caps] if c]
|
| 71 |
+
|
| 72 |
+
cap_html = (
|
| 73 |
+
"<div class='caps'>" + " ".join(f"<span class='cap'>{html.escape(c)}</span>" for c in caps) + "</div>"
|
| 74 |
+
if caps
|
| 75 |
+
else ""
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
item = f"""
|
| 79 |
+
<li class="item">
|
| 80 |
+
<a class="title" href="{html.escape(link)}" target="_blank" rel="noopener noreferrer">{etitle}</a>
|
| 81 |
+
<div class="meta">{("Published: " + published) if published else ""}</div>
|
| 82 |
+
{cap_html}
|
| 83 |
+
{f"<div class='summary'>{summary}</div>" if show_summaries and summary else ""}
|
| 84 |
+
</li>
|
| 85 |
+
"""
|
| 86 |
+
items_html.append(item)
|
| 87 |
+
|
| 88 |
+
if not items_html:
|
| 89 |
+
items_html.append("<li class='item empty'>No items found.</li>")
|
| 90 |
+
|
| 91 |
+
body_html = "<ul class='list'>" + "\n".join(items_html) + "</ul>"
|
| 92 |
+
|
| 93 |
+
full_html = f"""
|
| 94 |
+
<div class="wrap">
|
| 95 |
+
{header_html}
|
| 96 |
+
{body_html}
|
| 97 |
+
</div>
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
return full_html, ""
|
| 101 |
+
|
| 102 |
+
MINIMAL_CSS = """
|
| 103 |
+
:root { --fg:#111; --muted:#666; --bg:#fff; --card:#fafafa; --link:#0b57d0; }
|
| 104 |
+
@media (prefers-color-scheme: dark) {
|
| 105 |
+
:root { --fg:#eee; --muted:#aaa; --bg:#0b0b0b; --card:#141414; --link:#7fb0ff; }
|
| 106 |
+
}
|
| 107 |
+
*{box-sizing:border-box}
|
| 108 |
+
body{{background:var(--bg)}}
|
| 109 |
+
.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}
|
| 110 |
+
.header{margin:12px 0 8px 0}
|
| 111 |
+
.feed-title{font-weight:700;font-size:20px}
|
| 112 |
+
.feed-sub{color:var(--muted);margin-top:2px}
|
| 113 |
+
.feed-meta{color:var(--muted);font-size:13px;margin-top:6px}
|
| 114 |
+
.list{list-style:none;padding:0;margin:16px 0}
|
| 115 |
+
.item{background:var(--card);border:1px solid rgba(127,127,127,.2);border-radius:12px;padding:14px 16px;margin:10px 0}
|
| 116 |
+
.item .title{font-weight:600;text-decoration:none;color:var(--link)}
|
| 117 |
+
.item .title:hover{text-decoration:underline}
|
| 118 |
+
.item .meta{color:var(--muted);font-size:13px;margin-top:6px}
|
| 119 |
+
.caps{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
|
| 120 |
+
.cap{border:1px solid rgba(127,127,127,.25);padding:2px 8px;border-radius:999px;font-size:12px;color:var(--muted)}
|
| 121 |
+
.summary{margin-top:10px;white-space:pre-wrap}
|
| 122 |
+
.empty{color:var(--muted);text-align:center}
|
| 123 |
+
.footer{max-width:920px;margin:8px auto 24px auto;padding:0 16px;color:var(--muted);font:12px/1.4 system-ui}
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
with gr.Blocks(css=MINIMAL_CSS, fill_height=True, theme=gr.themes.Soft()) as demo:
|
| 127 |
+
gr.Markdown("### NDMA Sachet — Minimal RSS Viewer")
|
| 128 |
+
|
| 129 |
+
with gr.Row():
|
| 130 |
+
url_in = gr.Textbox(
|
| 131 |
+
label="RSS URL",
|
| 132 |
+
value=DEFAULT_URL,
|
| 133 |
+
placeholder="Paste an RSS/Atom URL…",
|
| 134 |
+
max_lines=1
|
| 135 |
+
)
|
| 136 |
+
max_items = gr.Slider(5, 50, value=20, step=1, label="Items")
|
| 137 |
+
show_summaries = gr.Checkbox(value=True, label="Show summaries")
|
| 138 |
+
refresh = gr.Button("Refresh", variant="primary")
|
| 139 |
+
|
| 140 |
+
out_html = gr.HTML()
|
| 141 |
+
out_err = gr.Markdown(elem_classes=["footer"])
|
| 142 |
+
|
| 143 |
+
def _go(u, m, s):
|
| 144 |
+
return render_feed(u, int(m), bool(s))
|
| 145 |
+
|
| 146 |
+
# Initial load
|
| 147 |
+
demo.load(_go, [url_in, max_items, show_summaries], [out_html, out_err])
|
| 148 |
+
# Manual refresh
|
| 149 |
+
refresh.click(_go, [url_in, max_items, show_summaries], [out_html, out_err])
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
demo.launch()
|