import json
import html
import datetime as dt
from typing import Dict, List, Tuple
import feedparser
import gradio as gr
import plotly.graph_objects as go
import requests
# -----------------------------
# Config
# -----------------------------
BASE_RSS = "https://sachet.ndma.gov.in/cap_public_website/rss"
UA = "SachetMapRSS/1.1 (+https://huggingface.co/spaces)"
# Known tricky slugs or long names -> explicit mapping to RSS file names (without '.xml')
MANUAL_SLUGS: Dict[str, str] = {
"andaman and nicobar islands": "andaman_and_nicobar_islands",
"dadra and nagar haveli and daman and diu": "dadra_and_nagar_haveli_and_daman_and_diu",
"jammu and kashmir": "jammu_and_kashmir",
"nct of delhi": "delhi",
"delhi": "delhi",
"daman and diu": "dadra_and_nagar_haveli_and_daman_and_diu", # legacy handle
"ladakh": "ladakh",
"lakshadweep": "lakshadweep",
"puducherry": "puducherry",
"dadra and nagar haveli": "dadra_and_nagar_haveli_and_daman_and_diu",
}
# State/UT centroids (approx.) for display markers (lat, lon).
# 36 entities: 28 States + 8 UTs
STATES = [
("Andhra Pradesh", 15.9, 79.7),
("Arunachal Pradesh", 28.1, 94.6),
("Assam", 26.1, 92.9),
("Bihar", 25.9, 85.2),
("Chhattisgarh", 21.3, 82.0),
("Goa", 15.3, 74.0),
("Gujarat", 22.3, 70.8),
("Haryana", 29.1, 76.0),
("Himachal Pradesh", 31.8, 77.2),
("Jharkhand", 23.7, 85.5),
("Karnataka", 14.5, 75.8),
("Kerala", 10.3, 76.5),
("Madhya Pradesh", 23.8, 78.8),
("Maharashtra", 19.7, 75.7),
("Manipur", 24.7, 93.8),
("Meghalaya", 25.5, 91.3),
("Mizoram", 23.2, 92.8),
("Nagaland", 26.1, 94.3),
("Odisha", 20.5, 84.4),
("Punjab", 31.0, 75.4),
("Rajasthan", 26.9, 73.9),
("Sikkim", 27.6, 88.5),
("Tamil Nadu", 11.1, 78.6),
("Telangana", 17.9, 79.6),
("Tripura", 23.8, 91.3),
("Uttar Pradesh", 27.0, 80.9),
("Uttarakhand", 30.1, 79.0),
("West Bengal", 23.2, 87.9),
# UTs
("Andaman and Nicobar Islands", 11.7, 92.7),
("Chandigarh", 30.7, 76.8),
("Dadra and Nagar Haveli and Daman and Diu", 20.3, 73.0),
("Delhi", 28.6, 77.2),
("Jammu and Kashmir", 33.2, 75.0),
("Ladakh", 34.2, 77.6),
("Lakshadweep", 10.8, 72.6),
("Puducherry", 11.9, 79.8),
]
NATIONAL_FEED = f"{BASE_RSS}/rss_india.xml"
# -----------------------------
# Helpers
# -----------------------------
def _slugify(name: str) -> str:
key = name.strip().lower()
if key in MANUAL_SLUGS:
return MANUAL_SLUGS[key]
import re
s = re.sub(r"[^a-z0-9]+", "_", key).strip("_")
return s
def state_to_feed(name: str, mapping_override: Dict[str, str] | None = None) -> str:
"""
Build the RSS URL for a state/UT. Accepts optional overrides:
{'Tamil Nadu': 'https://.../rss_tamil_nadu.xml'}
"""
if mapping_override and name in mapping_override:
return mapping_override[name].strip()
slug = _slugify(name)
return f"{BASE_RSS}/rss_{slug}.xml"
def fetch_bytes(url: str, timeout: int = 12) -> bytes:
r = requests.get(url, headers={"User-Agent": UA}, timeout=timeout)
r.raise_for_status()
return r.content
def iso_utc(t) -> str:
if not t:
return ""
try:
return dt.datetime(*t[:6], tzinfo=dt.timezone.utc).isoformat().replace("+00:00", "Z")
except Exception:
return ""
def parse_feed(url: str, max_items: int = 20) -> Tuple[str, List[Dict]]:
"""
Returns (feed_title, items)
items: [{title, link, time, summary}]
"""
raw = fetch_bytes(url)
parsed = feedparser.parse(raw)
feed_title = parsed.feed.get("title") or "Feed"
items = []
for e in parsed.entries[:max_items]:
title = e.get("title", "Untitled")
link = e.get("link", "#")
t = iso_utc(e.get("published_parsed") or e.get("updated_parsed"))
summary = e.get("summary", "") or e.get("description", "")
items.append({
"title": title,
"link": link,
"time": t,
"summary": " ".join(summary.split())
})
return feed_title, items
def render_items_html(feed_title: str, items: List[Dict]) -> str:
out = [f"
{html.escape(feed_title)}
"]
if not items:
out.append("No items found.
")
return "\n".join(out)
out.append("")
for it in items:
t = f"{html.escape(it['time'])}
" if it["time"] else ""
s = f"{html.escape(it['summary'][:500])}
" if it["summary"] else ""
out.append(
"- "
f"{html.escape(it['title'])}"
f"{t}{s}"
"
"
)
out.append("
")
return "\n".join(out)
# -----------------------------
# Plotly Map (display only)
# -----------------------------
def make_map() -> go.Figure:
lats = [lat for _, lat, _ in STATES]
lons = [lon for _, _, lon in STATES]
names = [name for name, _, _ in STATES]
fig = go.Figure(
data=go.Scattergeo(
lat=lats,
lon=lons,
text=names,
customdata=names, # for potential future use
mode="markers+text",
textposition="top center",
marker=dict(size=8),
hovertemplate="%{customdata}",
)
)
fig.update_geos(
scope="asia",
projection_type="natural earth",
showcountries=True,
countrycolor="rgba(120,120,120,0.4)",
showsubunits=False,
lataxis_showgrid=True,
lonaxis_showgrid=True,
fitbounds="locations",
visible=True,
resolution=110
)
# Center roughly over India
fig.update_geos(center=dict(lat=22.5, lon=80.0), lataxis_range=[6, 36], lonaxis_range=[68, 98])
fig.update_layout(
margin=dict(l=20, r=20, t=10, b=10),
dragmode=False
)
return fig
# -----------------------------
# Gradio App
# -----------------------------
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; }
}
.wrap { max-width: 1100px; margin: 0 auto; }
.feedbox { background: var(--card); border: 1px solid rgba(127,127,127,.25); border-radius: 12px; padding: 12px 14px; }
.hdr { font-weight: 700; font-size: 18px; margin-bottom: 8px; color: var(--fg); }
.list { list-style: none; padding: 0; margin: 0; }
.card { padding: 10px 0; border-top: 1px solid rgba(127,127,127,.2); }
.card:first-child { border-top: none; }
.ttl { color: var(--link); text-decoration: none; font-weight: 600; }
.ttl:hover { text-decoration: underline; }
.time { color: var(--muted); font-size: 12px; margin-top: 2px; }
.sum { margin-top: 6px; color: var(--fg); white-space: pre-wrap; }
.empty { color: var(--muted); }
.err { color: #b00020; }
.badge { display:inline-block; padding:2px 8px; border:1px solid rgba(127,127,127,.25); border-radius:999px; font-size:12px; color:var(--muted); }
"""
STATE_CHOICES = ["India (National Feed)"] + [s[0] for s in STATES]
with gr.Blocks(css=CSS, fill_height=True, theme=gr.themes.Soft()) as demo:
gr.Markdown("## SACHET — India Map of State/UT RSS Feeds (Minimal)")
with gr.Row():
map_plot = gr.Plot(value=make_map(), label="Reference Map (static)")
with gr.Column(scale=1, min_width=320):
state_dropdown = gr.Dropdown(
choices=STATE_CHOICES,
value="India (National Feed)",
label="Select State/UT"
)
selected = gr.Textbox(label="Selected", interactive=False)
feed_url_box = gr.Textbox(label="Resolved Feed URL", interactive=False)
max_items = gr.Slider(5, 50, value=20, step=1, label="Items")
refresh = gr.Button("Load/Refresh", variant="primary")
gr.Markdown(
"### Optional: Custom mapping\n"
"Paste JSON as `{ \"Tamil Nadu\": \"https://.../rss_tamil_nadu.xml\", ... }`"
)
mapping_json = gr.Code(language="json", value="", lines=6)
error_box = gr.Markdown(elem_classes=["err"])
with gr.Row():
with gr.Column():
feed_html = gr.HTML(elem_classes=["feedbox"])
# -------------------------
# Actions
# -------------------------
def resolve_and_render(state_name: str, items_n: int, mapping_text: str):
# Normalize state label
state = (state_name or "").strip()
if not state:
return "", "", "", "Select a state/UT.
"
# Optional mapping overrides (user input)
overrides = {}
if mapping_text:
try:
overrides = json.loads(mapping_text)
if not isinstance(overrides, dict):
overrides = {}
except Exception:
overrides = {}
# Resolve URL
if state.lower().startswith("india"):
url = NATIONAL_FEED
else:
url = state_to_feed(state, overrides)
# Try fetch and parse; fallback to national if error
try:
title, items = parse_feed(url, max_items=int(items_n))
html_out = render_items_html(f"{state} — {title}", items)
return state, url, "", html_out
except Exception as e:
# Try a secondary slug variant if not India
if not state.lower().startswith("india"):
alt_url = url.replace("_", "")
try:
title, items = parse_feed(alt_url, max_items=int(items_n))
html_out = render_items_html(f"{state} — {title}", items)
return state, alt_url, "Note: primary URL failed, used fallback.", html_out
except Exception:
pass
# Final fallback: national
try:
title, items = parse_feed(NATIONAL_FEED, max_items=int(items_n))
html_out = (
f"Could not load {html.escape(url)}. "
f"Showing national feed instead.
"
) + render_items_html(f"India — {title}", items)
return state, url, f"⚠️ {type(e).__name__}: {e}", html_out
except Exception as e3:
return state, url, f"⚠️ {type(e3).__name__}: {e3}", "No data.
"
def initial_load(n_items: int):
try:
title, items = parse_feed(NATIONAL_FEED, max_items=int(n_items))
return render_items_html(f"India — {title}", items)
except Exception as e:
return f"Could not load national feed. {html.escape(str(e))}
"
# Initial content
demo.load(lambda n: initial_load(n), [max_items], [feed_html])
# Wire dropdown and button
state_dropdown.change(
resolve_and_render,
[state_dropdown, max_items, mapping_json],
[selected, feed_url_box, error_box, feed_html],
)
refresh.click(
resolve_and_render,
[state_dropdown, max_items, mapping_json],
[selected, feed_url_box, error_box, feed_html],
)
if __name__ == "__main__":
demo.launch()