dashboard: clickable news — detail dialogs + on-demand AI summary endpoint
- news_engine: RSS parser captures link/description/publisher (three new optional NewsSentiment fields); _clean_html_text/_safe_link helpers - news.json: headlines carry id = sha1(headline) + enrichment fields - static/news_summary.php (NEW): on-demand DeepSeek gold-impact summary, whitelist-gated against news.json ids, cache-forever in ai/, fail-closed file-based daily cap (150/day), key at /etc/gold-dashboard/deepseek.key; installed to the docroot by install_static - dashboard.html: newswire + calendar detail dialogs (score tiles, source description, article link, AI summary with loading/error states); delegated on stable containers, dialog keyed by id across polls - tests: +6 (357 total); docs: README-dashboard §6 one-time setup (key file, apache-owned ai/, SELinux httpd_can_network_connect), CLAUDE.md Claude-Session: https://claude.ai/code/session_01Aq1jZ6jBsBdEC2xT3u46SA
This commit is contained in:
+130
@@ -5295,6 +5295,97 @@ def _():
|
||||
NewsEngine._source_health = saved
|
||||
|
||||
|
||||
@test("NewsEngine: RSS parser captures link/description/publisher")
|
||||
def _():
|
||||
cfg = NewsConfig()
|
||||
cfg.rss_feeds = ["https://news.google.com/rss/search?q=gold"]
|
||||
ne = NewsEngine(cfg)
|
||||
saved = NewsEngine._source_health
|
||||
NewsEngine._source_health = {}
|
||||
# Google-News-shaped item: redirect <link>, HTML-escaped <description>,
|
||||
# <source> carrying the real publisher.
|
||||
rss = (b"<rss><channel><item>"
|
||||
b"<title>Gold surges as Fed signals rate cuts - Reuters</title>"
|
||||
b"<link>https://news.google.com/rss/articles/CBMiAbC?oc=5</link>"
|
||||
b"<description><a href=\"https://x\">Gold &amp; dollar "
|
||||
b"diverge as the Fed turns dovish.</a></description>"
|
||||
b"<source url=\"https://www.reuters.com\">Reuters</source>"
|
||||
b"</item></channel></rss>")
|
||||
|
||||
class _Sess:
|
||||
def get(self, url, timeout=None):
|
||||
return type("R", (), {"status_code": 200, "content": rss})()
|
||||
|
||||
ne._session = _Sess()
|
||||
try:
|
||||
heads = ne._fetch_news_sentiment_raw()
|
||||
assert len(heads) == 1, heads
|
||||
h = heads[0]
|
||||
assert h.link == "https://news.google.com/rss/articles/CBMiAbC?oc=5"
|
||||
# tags stripped, entities (incl. double-encoded &amp;) decoded
|
||||
assert h.description == "Gold & dollar diverge as the Fed turns dovish."
|
||||
assert h.publisher == "Reuters"
|
||||
finally:
|
||||
NewsEngine._source_health = saved
|
||||
|
||||
|
||||
@test("NewsEngine: enrichment fields default None / reject unsafe values")
|
||||
def _():
|
||||
cfg = NewsConfig()
|
||||
cfg.rss_feeds = ["https://feed.example/rss"]
|
||||
ne = NewsEngine(cfg)
|
||||
saved = NewsEngine._source_health
|
||||
NewsEngine._source_health = {}
|
||||
long_link = b"https://a.example/" + b"x" * 2100
|
||||
rss = (b"<rss><channel>"
|
||||
# bare item: no link/description/source at all
|
||||
b"<item><title>Dollar steady before Fed minutes</title></item>"
|
||||
# javascript: link + description that merely echoes the title
|
||||
b"<item><title>Gold slips on strong dollar data</title>"
|
||||
b"<link>javascript:alert(1)</link>"
|
||||
b"<description>Gold slips on strong dollar data</description></item>"
|
||||
# over-long link
|
||||
b"<item><title>Fed officials split on rate cuts</title>"
|
||||
b"<link>" + long_link + b"</link></item>"
|
||||
b"</channel></rss>")
|
||||
|
||||
class _Sess:
|
||||
def get(self, url, timeout=None):
|
||||
return type("R", (), {"status_code": 200, "content": rss})()
|
||||
|
||||
ne._session = _Sess()
|
||||
try:
|
||||
heads = ne._fetch_news_sentiment_raw()
|
||||
assert len(heads) == 3, heads
|
||||
by_title = {h.headline: h for h in heads}
|
||||
bare = by_title["Dollar steady before Fed minutes"]
|
||||
assert bare.link is None and bare.description is None \
|
||||
and bare.publisher is None
|
||||
js = by_title["Gold slips on strong dollar data"]
|
||||
assert js.link is None # non-http(s) scheme rejected
|
||||
assert js.description is None # identical to title → dropped
|
||||
assert by_title["Fed officials split on rate cuts"].link is None
|
||||
finally:
|
||||
NewsEngine._source_health = saved
|
||||
|
||||
|
||||
@test("NewsEngine: _clean_html_text strips/unescapes/truncates")
|
||||
def _():
|
||||
from news_engine import _clean_html_text, _safe_link
|
||||
assert _clean_html_text(None) is None
|
||||
assert _clean_html_text("") is None
|
||||
assert _clean_html_text("<p></p>") is None
|
||||
assert _clean_html_text("<p>Fed <b>holds</b>\n rates</p>") == "Fed holds rates"
|
||||
assert _clean_html_text("&lt;b&gt;x&lt;/b&gt; & y") == "x & y"
|
||||
long = _clean_html_text("a" * 600)
|
||||
assert len(long) == 501 and long.endswith("…")
|
||||
assert _safe_link(" https://a.example/x ") == "https://a.example/x"
|
||||
assert _safe_link("ftp://a.example/x") is None
|
||||
assert _safe_link("javascript:alert(1)") is None
|
||||
assert _safe_link("https://" + "x" * 2100) is None
|
||||
assert _safe_link(None) is None
|
||||
|
||||
|
||||
@test("/status renders news/calendar/AI health with ⚠️ degradations")
|
||||
def _():
|
||||
import telegram_bot as tb
|
||||
@@ -5540,6 +5631,45 @@ def _():
|
||||
assert payload["headlines"][1]["score"] is None # NaN → None
|
||||
assert payload["events"][0]["event"] == "ISM"
|
||||
assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1}
|
||||
# Every headline carries the 40-hex id the PHP endpoint whitelists on.
|
||||
import re as _re
|
||||
assert all(_re.fullmatch(r"[0-9a-f]{40}", h["id"])
|
||||
for h in payload["headlines"])
|
||||
|
||||
|
||||
@test("dashboard: headline_to_dict id matches LLM cache scheme + enrichment")
|
||||
def _():
|
||||
import hashlib
|
||||
h = NewsSentiment(
|
||||
headline="Gold surges", source="http://feed/b",
|
||||
timestamp=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc),
|
||||
currencies_mentioned=["XAU"], sentiment_score=0.6,
|
||||
keywords=["surge"], sentiment_by_currency={"XAU": 0.6},
|
||||
link="https://x.example/a", description="Body.", publisher="Reuters")
|
||||
d = dpub.headline_to_dict(h)
|
||||
# Same sha1(headline) scheme as NewsEngine._llm_sent_cache — the id is
|
||||
# the join key news_summary.php validates against.
|
||||
assert d["id"] == hashlib.sha1(b"Gold surges").hexdigest()
|
||||
assert d["link"] == "https://x.example/a"
|
||||
assert d["description"] == "Body."
|
||||
assert d["publisher"] == "Reuters"
|
||||
# Pre-enrichment NewsSentiment (defaults) still serializes cleanly.
|
||||
bare = NewsSentiment(
|
||||
headline="Old", source="s", timestamp=None,
|
||||
currencies_mentioned=[], sentiment_score=0.0, keywords=[])
|
||||
d2 = dpub.headline_to_dict(bare)
|
||||
assert d2["link"] is None and d2["description"] is None \
|
||||
and d2["publisher"] is None
|
||||
json.dumps([d, d2], allow_nan=False)
|
||||
|
||||
|
||||
@test("dashboard: install_static installs page + PHP endpoint atomically")
|
||||
def _():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pub = dpub.DashboardPublisher(out_dir=tmp)
|
||||
pub.install_static()
|
||||
assert sorted(os.listdir(tmp)) == \
|
||||
["dashboard.html", "news_summary.php"] # both installed, no .tmp
|
||||
|
||||
|
||||
@test("dashboard: publish is atomic and leaves no temp file")
|
||||
|
||||
Reference in New Issue
Block a user