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 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aq1jZ6jBsBdEC2xT3u46SA
This commit is contained in:
@@ -9,6 +9,7 @@ A perfect technical setup can be destroyed by an unexpected NFP print.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
@@ -103,6 +104,44 @@ def _parse_release_value(raw: Optional[str]) -> Optional[float]:
|
||||
return float(match.group(1)) * multiplier
|
||||
|
||||
|
||||
_DESC_MAX_CHARS = 500
|
||||
_MAX_LINK_CHARS = 2048
|
||||
|
||||
|
||||
def _clean_html_text(raw: Optional[str],
|
||||
limit: int = _DESC_MAX_CHARS) -> Optional[str]:
|
||||
"""RSS <description> → plain text: unescape entities, strip tags,
|
||||
collapse whitespace, truncate. Unescape and strip both run twice
|
||||
because feeds routinely double-encode (&lt;p&gt; wrapping
|
||||
real markup) — the second unescape can reveal fresh tags."""
|
||||
if not raw:
|
||||
return None
|
||||
text = html.unescape(raw)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > limit:
|
||||
text = text[:limit].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _safe_link(raw: Optional[str]) -> Optional[str]:
|
||||
"""Article URL for display: http(s) only (an RSS <link> is untrusted
|
||||
and lands in an href), bounded length (Google News redirect URLs run
|
||||
1-2 KB; anything past that is bloat, not a link)."""
|
||||
if not raw:
|
||||
return None
|
||||
link = raw.strip()
|
||||
if not link.lower().startswith(("http://", "https://")):
|
||||
return None
|
||||
if len(link) > _MAX_LINK_CHARS:
|
||||
return None
|
||||
return link
|
||||
|
||||
|
||||
@dataclass
|
||||
class EconomicEvent:
|
||||
"""Single economic calendar event."""
|
||||
@@ -155,6 +194,11 @@ class NewsSentiment:
|
||||
# Per-currency score: monetary-policy keywords flip sign for XAU
|
||||
# (hawkish is dollar-bullish but gold-bearish).
|
||||
sentiment_by_currency: Dict[str, float] = field(default_factory=dict)
|
||||
# Display-only enrichment (dashboard detail dialog). Optional with
|
||||
# defaults so existing constructor calls keep working.
|
||||
link: Optional[str] = None # article URL (Google News = redirect)
|
||||
description: Optional[str] = None # plain text, tags stripped, ≤500 chars
|
||||
publisher: Optional[str] = None # Google News <source> text, else None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -742,6 +786,21 @@ class NewsEngine:
|
||||
currencies = self._extract_currencies(title)
|
||||
|
||||
if currencies:
|
||||
link_el = item.find("link")
|
||||
desc_el = item.find("description")
|
||||
src_el = item.find("source")
|
||||
# itertext() survives CDATA and stray child
|
||||
# elements where .text stops at the first child.
|
||||
desc = _clean_html_text(
|
||||
"".join(desc_el.itertext())
|
||||
if desc_el is not None else None)
|
||||
# Google News descriptions are often just the
|
||||
# headline again inside an anchor — worthless.
|
||||
if desc and desc.strip() == title.strip():
|
||||
desc = None
|
||||
publisher = None
|
||||
if src_el is not None:
|
||||
publisher = (src_el.text or "").strip() or None
|
||||
headlines.append(NewsSentiment(
|
||||
headline=title,
|
||||
source=feed_url,
|
||||
@@ -753,6 +812,10 @@ class NewsEngine:
|
||||
cur: self._analyze_headline_sentiment(title, cur)
|
||||
for cur in currencies
|
||||
},
|
||||
link=_safe_link(
|
||||
link_el.text if link_el is not None else None),
|
||||
description=desc,
|
||||
publisher=publisher,
|
||||
))
|
||||
|
||||
ok_feeds += 1
|
||||
|
||||
Reference in New Issue
Block a user