660 lines
25 KiB
Python
660 lines
25 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
金融日报 — Daily Financial Dashboard Content Fetcher
|
|||
|
|
Uses Claude API with web_search_20250305 tool to fetch live market data
|
|||
|
|
and inject it into index.html.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
import datetime
|
|||
|
|
import traceback
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import anthropic
|
|||
|
|
|
|||
|
|
# ─── Optional json-repair for robustness ────────────────────────────────────
|
|||
|
|
try:
|
|||
|
|
from json_repair import repair_json
|
|||
|
|
HAS_JSON_REPAIR = True
|
|||
|
|
except ImportError:
|
|||
|
|
HAS_JSON_REPAIR = False
|
|||
|
|
|
|||
|
|
# ─── Configuration ────────────────────────────────────────────────────────────
|
|||
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|||
|
|
INDEX_HTML = ROOT_DIR / "index.html"
|
|||
|
|
DATA_DIR = ROOT_DIR / "data"
|
|||
|
|
ARCHIVE_DIR = ROOT_DIR / "archive"
|
|||
|
|
FEED_FILE = ROOT_DIR / "feed.xml"
|
|||
|
|
|
|||
|
|
MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")
|
|||
|
|
MAX_TOKENS = 16000
|
|||
|
|
MAX_SEARCH = 15
|
|||
|
|
|
|||
|
|
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
|||
|
|
SYSTEM_PROMPT = """你是一位专业的金融数据分析师和新闻编辑。
|
|||
|
|
你的任务是通过网络搜索获取最新的全球金融市场数据,并以严格的JSON格式输出。
|
|||
|
|
|
|||
|
|
重要规则:
|
|||
|
|
1. 只输出JSON,不要有任何其他文字、解释或markdown代码块
|
|||
|
|
2. 所有文本内容使用中文(指标名称、公司名可保留英文/符号)
|
|||
|
|
3. 确保JSON格式完全正确,所有字符串使用双引号
|
|||
|
|
4. 搜索真实的最新数据,不要编造数字
|
|||
|
|
5. 日期格式:date字段用"YYYY年M月D日",report_date用"YYYY-MM-DD",next_meeting用"YYYY-MM-DD"
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
USER_PROMPT_TEMPLATE = """请搜索今天({today})的最新全球金融市场数据,然后按照以下JSON模式输出。
|
|||
|
|
|
|||
|
|
请按顺序搜索以下7类信息:
|
|||
|
|
1. 全球主要股指、商品、加密货币的最新价格和涨跌幅(S&P500、纳斯达克、道琼斯、上证、恒生、日经225、德国DAX、黄金、原油WTI、比特币)
|
|||
|
|
2. 顶级金融领袖最新观点(巴菲特、达利欧、杰米·戴蒙、鲍威尔等,最近1-2周内的言论)
|
|||
|
|
3. 过去24-48小时内的重大市场新闻(至少5条,标注importance: breaking/major/normal)
|
|||
|
|
4. 最新宏观经济数据发布(CPI、NFP、PCE、GDP、PMI、失业率等,最近发布的实际数据)
|
|||
|
|
5. 本周重要财报结果及即将发布的财报(标注beat/miss/pending状态)
|
|||
|
|
6. 今日标普500各行业板块表现和涨跌幅(所有11个GICS行业)
|
|||
|
|
7. 主要央行最新动态(美联储、欧央行、中国人民银行、日本银行、英国央行)
|
|||
|
|
|
|||
|
|
必须输出以下JSON格式(只输出JSON,无其他内容):
|
|||
|
|
|
|||
|
|
{{
|
|||
|
|
"date": "{today}",
|
|||
|
|
"indices": [
|
|||
|
|
{{
|
|||
|
|
"name": "S&P 500",
|
|||
|
|
"symbol": "SPX",
|
|||
|
|
"value": "5,234.18",
|
|||
|
|
"change": "+12.5",
|
|||
|
|
"change_pct": "+0.24%",
|
|||
|
|
"direction": "up"
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"leaders": [
|
|||
|
|
{{
|
|||
|
|
"name": "巴菲特",
|
|||
|
|
"name_en": "Warren Buffett",
|
|||
|
|
"role": "伯克希尔哈撒韦CEO",
|
|||
|
|
"quote": "简短的金句或关键观点引用",
|
|||
|
|
"body": "详细描述其最新观点、发言背景和市场影响,2-3句话",
|
|||
|
|
"tags": ["#价值投资", "#美股"],
|
|||
|
|
"initials": "WB",
|
|||
|
|
"quote_date": "M月D日"
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"news": [
|
|||
|
|
{{
|
|||
|
|
"title": "新闻标题",
|
|||
|
|
"body": "新闻摘要,2-3句话说明事件、影响和市场反应",
|
|||
|
|
"url": "https://原始新闻链接(如无则省略此字段)",
|
|||
|
|
"importance": "breaking",
|
|||
|
|
"tags": ["#美股", "#美联储"]
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"macro": [
|
|||
|
|
{{
|
|||
|
|
"indicator": "CPI YoY",
|
|||
|
|
"value": "3.5%",
|
|||
|
|
"prev": "3.2%",
|
|||
|
|
"direction": "up",
|
|||
|
|
"description": "通胀意外反弹,高于预期的3.4%,降息预期推迟"
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"earnings": [
|
|||
|
|
{{
|
|||
|
|
"company": "Apple",
|
|||
|
|
"ticker": "AAPL",
|
|||
|
|
"report_date": "2025-05-01",
|
|||
|
|
"eps_actual": "1.53",
|
|||
|
|
"eps_est": "1.50",
|
|||
|
|
"beat": true,
|
|||
|
|
"revenue": "$90.8B",
|
|||
|
|
"highlight": "服务收入创历史新高,宣布1100亿美元回购"
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"sectors": [
|
|||
|
|
{{
|
|||
|
|
"name": "科技",
|
|||
|
|
"name_en": "Technology",
|
|||
|
|
"etf": "XLK",
|
|||
|
|
"change_pct": "+1.82%",
|
|||
|
|
"direction": "up",
|
|||
|
|
"note": "AI芯片需求持续驱动"
|
|||
|
|
}}
|
|||
|
|
],
|
|||
|
|
"central_banks": [
|
|||
|
|
{{
|
|||
|
|
"bank": "美联储",
|
|||
|
|
"bank_en": "Federal Reserve",
|
|||
|
|
"action": "维持利率5.25-5.50%不变",
|
|||
|
|
"next_meeting": "2025-06-18",
|
|||
|
|
"rate": "5.25-5.50%",
|
|||
|
|
"bias": "hawkish",
|
|||
|
|
"note": "鲍威尔强调需要看到更多通胀降温证据才会降息"
|
|||
|
|
}}
|
|||
|
|
]
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
请确保:
|
|||
|
|
- indices数组包含至少10个资产
|
|||
|
|
- leaders数组包含3-5位领袖
|
|||
|
|
- news数组包含至少5条新闻
|
|||
|
|
- macro数组包含至少6个指标
|
|||
|
|
- earnings数组包含3-8个公司
|
|||
|
|
- sectors数组包含全部11个标普500行业(使用GICS标准)
|
|||
|
|
- central_banks数组包含至少4家央行(Fed、ECB、PBOC、BOJ)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# ─── HTML rendering ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def render_indices(indices: list) -> str:
|
|||
|
|
html_parts = []
|
|||
|
|
for idx in indices:
|
|||
|
|
direction = idx.get("direction", "neutral")
|
|||
|
|
arrow = "▲" if direction == "up" else ("▼" if direction == "down" else "–")
|
|||
|
|
change_pct = idx.get("change_pct", "0.00%")
|
|||
|
|
html_parts.append(f"""<div class="ticker-card {direction}">
|
|||
|
|
<div class="ticker-name">{idx.get('name','')}</div>
|
|||
|
|
<div class="ticker-value">{idx.get('value','')}</div>
|
|||
|
|
<div class="ticker-change {direction}">{arrow} {change_pct}</div>
|
|||
|
|
</div>""")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _avatar_class(initials: str) -> str:
|
|||
|
|
mapping = {"WB": "wb", "RD": "rd", "JD": "jd", "JP": "jp"}
|
|||
|
|
return mapping.get(initials.upper(), "default")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_leaders(leaders: list) -> str:
|
|||
|
|
html_parts = []
|
|||
|
|
for ldr in leaders:
|
|||
|
|
initials = ldr.get("initials", "??")
|
|||
|
|
avatar_cls = _avatar_class(initials)
|
|||
|
|
tags_html = "".join(f'<span class="tag">{t}</span>' for t in ldr.get("tags", []))
|
|||
|
|
name_en = ldr.get("name_en", "")
|
|||
|
|
name_en_html = f' <span style="color:var(--muted);font-weight:400;font-size:0.78rem;">{name_en}</span>' if name_en else ""
|
|||
|
|
html_parts.append(f"""<div class="leader-card">
|
|||
|
|
<div class="leader-header">
|
|||
|
|
<div class="leader-avatar {avatar_cls}">{initials}</div>
|
|||
|
|
<div class="leader-meta">
|
|||
|
|
<div class="leader-name">{ldr.get('name','')}{name_en_html}</div>
|
|||
|
|
<div class="leader-role">{ldr.get('role','')}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="leader-date">{ldr.get('quote_date','')}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="leader-quote">"{ldr.get('quote','')}"</div>
|
|||
|
|
<div class="leader-body">{ldr.get('body','')}</div>
|
|||
|
|
<div class="tags">{tags_html}</div>
|
|||
|
|
</div>""")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_news(news: list) -> str:
|
|||
|
|
html_parts = []
|
|||
|
|
for item in news:
|
|||
|
|
importance = item.get("importance", "normal")
|
|||
|
|
title = item.get("title", "")
|
|||
|
|
url = item.get("url", "")
|
|||
|
|
title_html = f'<a href="{url}" target="_blank" rel="noopener">{title}</a>' if url else title
|
|||
|
|
tags_html = "".join(f'<span class="tag">{t}</span>' for t in item.get("tags", []))
|
|||
|
|
html_parts.append(f"""<div class="news-item {importance}">
|
|||
|
|
<div class="news-title">{title_html}</div>
|
|||
|
|
<div class="news-body">{item.get('body','')}</div>
|
|||
|
|
<div class="news-footer">
|
|||
|
|
<span class="importance-badge {importance}">{importance.upper()}</span>
|
|||
|
|
<div class="tags">{tags_html}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>""")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_macro(macro: list) -> str:
|
|||
|
|
html_parts = ["<div class=\"macro-grid\">"]
|
|||
|
|
for m in macro:
|
|||
|
|
direction = m.get("direction", "neutral")
|
|||
|
|
arrow_sym = "↑" if direction == "up" else ("↓" if direction == "down" else "→")
|
|||
|
|
arrow_cls = "up" if direction == "up" else ("down" if direction == "down" else "")
|
|||
|
|
html_parts.append(f""" <div class="macro-card">
|
|||
|
|
<div class="macro-indicator">{m.get('indicator','')}</div>
|
|||
|
|
<div class="macro-value">{m.get('value','')}</div>
|
|||
|
|
<div class="macro-prev"><span class="arrow {arrow_cls}">{arrow_sym}</span> 前值: {m.get('prev','')}</div>
|
|||
|
|
<div class="macro-desc">{m.get('description','')}</div>
|
|||
|
|
</div>""")
|
|||
|
|
html_parts.append("</div>")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_earnings(earnings: list) -> str:
|
|||
|
|
html_parts = ["<div class=\"earnings-grid\">"]
|
|||
|
|
for e in earnings:
|
|||
|
|
beat_val = e.get("beat")
|
|||
|
|
if beat_val is None:
|
|||
|
|
status_cls = "pending"
|
|||
|
|
badge_label = "UPCOMING"
|
|||
|
|
elif beat_val:
|
|||
|
|
status_cls = "beat"
|
|||
|
|
badge_label = "BEAT ✓"
|
|||
|
|
else:
|
|||
|
|
status_cls = "miss"
|
|||
|
|
badge_label = "MISS ✗"
|
|||
|
|
|
|||
|
|
eps_actual = e.get("eps_actual", "")
|
|||
|
|
eps_est = e.get("eps_est", "")
|
|||
|
|
|
|||
|
|
if beat_val is None:
|
|||
|
|
eps_block = f""" <div class="earnings-eps">
|
|||
|
|
<div class="eps-item">
|
|||
|
|
<div class="eps-label">EPS EST.</div>
|
|||
|
|
<div class="eps-value est">${eps_est}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="eps-item">
|
|||
|
|
<div class="eps-label">REPORT DATE</div>
|
|||
|
|
<div class="eps-value est" style="font-size:0.75rem;">{e.get('report_date','TBD')}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>"""
|
|||
|
|
else:
|
|||
|
|
eps_block = f""" <div class="earnings-eps">
|
|||
|
|
<div class="eps-item">
|
|||
|
|
<div class="eps-label">EPS ACTUAL</div>
|
|||
|
|
<div class="eps-value actual {status_cls}">${eps_actual}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="eps-item">
|
|||
|
|
<div class="eps-label">EPS EST.</div>
|
|||
|
|
<div class="eps-value est">${eps_est}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>"""
|
|||
|
|
|
|||
|
|
html_parts.append(f""" <div class="earnings-card {status_cls}">
|
|||
|
|
<div class="earnings-ticker">{e.get('ticker','')}</div>
|
|||
|
|
<div class="earnings-company">{e.get('company','')}</div>
|
|||
|
|
<span class="earnings-beat-badge {status_cls}">{badge_label}</span>
|
|||
|
|
{eps_block}
|
|||
|
|
<div class="earnings-revenue">收入: <span>{e.get('revenue','N/A')}</span></div>
|
|||
|
|
<div class="earnings-highlight">{e.get('highlight','')}</div>
|
|||
|
|
</div>""")
|
|||
|
|
html_parts.append("</div>")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_sectors(sectors: list) -> str:
|
|||
|
|
html_parts = ["<div class=\"sectors-list\">"]
|
|||
|
|
# Normalize bar widths: find max abs change
|
|||
|
|
pct_values = []
|
|||
|
|
for s in sectors:
|
|||
|
|
raw = s.get("change_pct", "0%").replace("+", "").replace("%", "").strip()
|
|||
|
|
try:
|
|||
|
|
pct_values.append(abs(float(raw)))
|
|||
|
|
except ValueError:
|
|||
|
|
pct_values.append(0.0)
|
|||
|
|
max_pct = max(pct_values) if pct_values else 1.0
|
|||
|
|
|
|||
|
|
for s, pct_abs in zip(sectors, pct_values):
|
|||
|
|
direction = s.get("direction", "neutral")
|
|||
|
|
bar_width = min(100, int((pct_abs / max(max_pct, 0.01)) * 90) + 10)
|
|||
|
|
bar_cls = "up" if direction == "up" else "down"
|
|||
|
|
pct_label = s.get("change_pct", "0%")
|
|||
|
|
html_parts.append(f""" <div class="sector-row">
|
|||
|
|
<div><div class="sector-name">{s.get('name','')}</div><div class="sector-name-en">{s.get('name_en','')}</div></div>
|
|||
|
|
<div class="sector-etf">{s.get('etf','')}</div>
|
|||
|
|
<div class="sector-bar-wrap"><div class="sector-bar {bar_cls}" style="width:{bar_width}%"></div></div>
|
|||
|
|
<div class="sector-pct {direction}">{pct_label}</div>
|
|||
|
|
</div>""")
|
|||
|
|
html_parts.append("</div>")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_central_banks(central_banks: list) -> str:
|
|||
|
|
html_parts = ["<div class=\"cb-grid\">"]
|
|||
|
|
for cb in central_banks:
|
|||
|
|
bias = cb.get("bias", "neutral")
|
|||
|
|
html_parts.append(f""" <div class="cb-card">
|
|||
|
|
<div class="cb-header">
|
|||
|
|
<div>
|
|||
|
|
<div class="cb-name">{cb.get('bank','')}</div>
|
|||
|
|
<div class="cb-name-en">{cb.get('bank_en','')}</div>
|
|||
|
|
</div>
|
|||
|
|
<span class="bias-badge {bias}">{bias.capitalize()}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="cb-rate">{cb.get('rate','')}</div>
|
|||
|
|
<div class="cb-action">{cb.get('action','')}</div>
|
|||
|
|
<div class="cb-next">下次会议: <span class="font-mono" style="color:var(--neutral)">{cb.get('next_meeting','TBD')}</span></div>
|
|||
|
|
<div class="cb-note">{cb.get('note','')}</div>
|
|||
|
|
</div>""")
|
|||
|
|
html_parts.append("</div>")
|
|||
|
|
return "\n".join(html_parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Anchor injection ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
ANCHOR_RENDERERS = {
|
|||
|
|
"INDICES": render_indices,
|
|||
|
|
"LEADERS": render_leaders,
|
|||
|
|
"NEWS": render_news,
|
|||
|
|
"MACRO": render_macro,
|
|||
|
|
"EARNINGS": render_earnings,
|
|||
|
|
"SECTORS": render_sectors,
|
|||
|
|
"CENTRAL_BANKS": render_central_banks,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def inject_into_html(html: str, data: dict) -> str:
|
|||
|
|
section_map = {
|
|||
|
|
"INDICES": "indices",
|
|||
|
|
"LEADERS": "leaders",
|
|||
|
|
"NEWS": "news",
|
|||
|
|
"MACRO": "macro",
|
|||
|
|
"EARNINGS": "earnings",
|
|||
|
|
"SECTORS": "sectors",
|
|||
|
|
"CENTRAL_BANKS": "central_banks",
|
|||
|
|
}
|
|||
|
|
for anchor, key in section_map.items():
|
|||
|
|
items = data.get(key, [])
|
|||
|
|
if not items:
|
|||
|
|
print(f" ⚠️ No data for section {anchor}, skipping injection")
|
|||
|
|
continue
|
|||
|
|
renderer = ANCHOR_RENDERERS[anchor]
|
|||
|
|
new_content = renderer(items)
|
|||
|
|
pattern = rf"(<!-- {anchor}:START -->).*?(<!-- {anchor}:END -->)"
|
|||
|
|
replacement = rf"\1\n{new_content}\n\2"
|
|||
|
|
html, n = re.subn(pattern, replacement, html, flags=re.DOTALL)
|
|||
|
|
if n == 0:
|
|||
|
|
print(f" ⚠️ Anchor {anchor}:START/END not found in HTML")
|
|||
|
|
else:
|
|||
|
|
print(f" ✅ Injected {len(items)} items into {anchor}")
|
|||
|
|
# Update meta last-updated
|
|||
|
|
now_iso = datetime.datetime.utcnow().isoformat() + "Z"
|
|||
|
|
if 'name="last-updated"' in html:
|
|||
|
|
html = re.sub(r'(<meta name="last-updated" content=")[^"]*(")', rf'\g<1>{now_iso}\2', html)
|
|||
|
|
else:
|
|||
|
|
html = html.replace("</head>", f' <meta name="last-updated" content="{now_iso}">\n</head>')
|
|||
|
|
return html
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Archive generation ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def generate_archive_page(html: str, date_str: str, filename: Path) -> None:
|
|||
|
|
"""Save a static snapshot of the day's content."""
|
|||
|
|
archive_html = html.replace(
|
|||
|
|
"<title>金融日报 | Financial Daily</title>",
|
|||
|
|
f"<title>金融日报 {date_str} | Financial Daily</title>"
|
|||
|
|
).replace(
|
|||
|
|
'href="archive/index.html"',
|
|||
|
|
'href="../index.html"'
|
|||
|
|
).replace(
|
|||
|
|
'href="feed.xml"',
|
|||
|
|
'href="../feed.xml"'
|
|||
|
|
)
|
|||
|
|
filename.write_text(archive_html, encoding="utf-8")
|
|||
|
|
print(f" 📄 Archive snapshot: {filename.name}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def rebuild_archive_index(data_dir: Path, archive_dir: Path) -> None:
|
|||
|
|
"""Rebuild archive/index.html listing all past snapshots."""
|
|||
|
|
index_json = data_dir / "index.json"
|
|||
|
|
if not index_json.exists():
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
entries = json.loads(index_json.read_text(encoding="utf-8"))
|
|||
|
|
except Exception:
|
|||
|
|
entries = []
|
|||
|
|
|
|||
|
|
rows = ""
|
|||
|
|
for entry in sorted(entries, key=lambda x: x.get("date_iso", ""), reverse=True):
|
|||
|
|
date_iso = entry.get("date_iso", "")
|
|||
|
|
date_cn = entry.get("date_cn", date_iso)
|
|||
|
|
rows += f""" <tr>
|
|||
|
|
<td><a href="{date_iso}.html">{date_cn}</a></td>
|
|||
|
|
<td>{entry.get('indices_count', '—')}</td>
|
|||
|
|
<td>{entry.get('news_count', '—')}</td>
|
|||
|
|
<td><a href="../data/{date_iso}.json" style="color:var(--muted);font-size:0.75rem;">JSON</a></td>
|
|||
|
|
</tr>\n"""
|
|||
|
|
|
|||
|
|
archive_index = f"""<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>归档 | Financial Daily Archive</title>
|
|||
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|||
|
|
<style>
|
|||
|
|
:root {{ --bg:#070b14; --card:#0d1526; --border:#1a2a4a; --accent:#00d4aa; --text:#e2e8f0; --muted:#8899aa; --mono:'JetBrains Mono',monospace; --sans:'Inter',system-ui,sans-serif; }}
|
|||
|
|
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
|||
|
|
body {{ font-family:var(--sans); background:var(--bg); color:var(--text); padding:40px 20px; }}
|
|||
|
|
.container {{ max-width:800px; margin:0 auto; }}
|
|||
|
|
h1 {{ font-size:1.6rem; margin-bottom:6px; background:linear-gradient(135deg,var(--accent),#4fc3f7); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }}
|
|||
|
|
p.sub {{ color:var(--muted); font-size:0.8rem; margin-bottom:28px; }}
|
|||
|
|
a {{ color:var(--accent); text-decoration:none; }}
|
|||
|
|
a:hover {{ text-decoration:underline; }}
|
|||
|
|
table {{ width:100%; border-collapse:collapse; }}
|
|||
|
|
th {{ text-align:left; font-size:0.65rem; font-family:var(--mono); color:var(--muted); letter-spacing:0.08em; text-transform:uppercase; padding:8px 12px; border-bottom:1px solid var(--border); }}
|
|||
|
|
td {{ padding:10px 12px; font-size:0.82rem; border-bottom:1px solid rgba(26,42,74,0.5); }}
|
|||
|
|
tr:hover td {{ background:var(--card); }}
|
|||
|
|
.back {{ display:inline-block; margin-bottom:24px; font-size:0.8rem; color:var(--muted); border:1px solid var(--border); padding:5px 14px; border-radius:6px; }}
|
|||
|
|
.back:hover {{ color:var(--accent); border-color:var(--accent); text-decoration:none; }}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<a class="back" href="../index.html">← 返回首页</a>
|
|||
|
|
<h1>历史归档</h1>
|
|||
|
|
<p class="sub">Financial Daily — 每日金融市场数据存档</p>
|
|||
|
|
<table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>日期</th>
|
|||
|
|
<th>指数</th>
|
|||
|
|
<th>新闻</th>
|
|||
|
|
<th>数据</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{rows} </tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
"""
|
|||
|
|
(archive_dir / "index.html").write_text(archive_index, encoding="utf-8")
|
|||
|
|
print(" 📋 Rebuilt archive/index.html")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def update_data_index(data_dir: Path, entry: dict) -> None:
|
|||
|
|
index_json = data_dir / "index.json"
|
|||
|
|
try:
|
|||
|
|
entries = json.loads(index_json.read_text(encoding="utf-8")) if index_json.exists() else []
|
|||
|
|
except Exception:
|
|||
|
|
entries = []
|
|||
|
|
# Upsert by date_iso
|
|||
|
|
existing = {e.get("date_iso"): i for i, e in enumerate(entries)}
|
|||
|
|
date_iso = entry.get("date_iso", "")
|
|||
|
|
if date_iso in existing:
|
|||
|
|
entries[existing[date_iso]] = entry
|
|||
|
|
else:
|
|||
|
|
entries.append(entry)
|
|||
|
|
index_json.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── RSS Feed ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def generate_rss(data: dict, feed_file: Path) -> None:
|
|||
|
|
date_cn = data.get("date", "")
|
|||
|
|
pub_date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|||
|
|
items_xml = ""
|
|||
|
|
for article in data.get("news", []):
|
|||
|
|
title = article.get("title", "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|||
|
|
desc = article.get("body", "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|||
|
|
link = article.get("url", "")
|
|||
|
|
items_xml += f""" <item>
|
|||
|
|
<title>{title}</title>
|
|||
|
|
<description>{desc}</description>
|
|||
|
|
<link>{link}</link>
|
|||
|
|
<pubDate>{pub_date}</pubDate>
|
|||
|
|
</item>
|
|||
|
|
"""
|
|||
|
|
rss = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|||
|
|
<rss version="2.0">
|
|||
|
|
<channel>
|
|||
|
|
<title>金融日报 | Financial Daily</title>
|
|||
|
|
<description>每日全球金融市场摘要,由 Claude AI + Web Search 自动生成</description>
|
|||
|
|
<link>https://your-username.github.io/finance-daily-site/</link>
|
|||
|
|
<language>zh-CN</language>
|
|||
|
|
<lastBuildDate>{pub_date}</lastBuildDate>
|
|||
|
|
<item>
|
|||
|
|
<title>金融日报 {date_cn}</title>
|
|||
|
|
<description>今日金融市场摘要已更新</description>
|
|||
|
|
<pubDate>{pub_date}</pubDate>
|
|||
|
|
</item>
|
|||
|
|
{items_xml} </channel>
|
|||
|
|
</rss>
|
|||
|
|
"""
|
|||
|
|
feed_file.write_text(rss, encoding="utf-8")
|
|||
|
|
print(" 📡 Updated feed.xml")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Claude API call ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def fetch_data_from_claude() -> dict:
|
|||
|
|
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
|||
|
|
today = datetime.datetime.now().strftime("%Y年%-m月%-d日")
|
|||
|
|
user_prompt = USER_PROMPT_TEMPLATE.format(today=today)
|
|||
|
|
|
|||
|
|
print(f"🤖 Calling Claude {MODEL} with web_search tool...")
|
|||
|
|
start = time.time()
|
|||
|
|
|
|||
|
|
response = client.messages.create(
|
|||
|
|
model=MODEL,
|
|||
|
|
max_tokens=MAX_TOKENS,
|
|||
|
|
system=SYSTEM_PROMPT,
|
|||
|
|
tools=[{
|
|||
|
|
"type": "web_search_20250305",
|
|||
|
|
"name": "web_search",
|
|||
|
|
"max_uses": MAX_SEARCH,
|
|||
|
|
}],
|
|||
|
|
messages=[{"role": "user", "content": user_prompt}],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
elapsed = time.time() - start
|
|||
|
|
print(f" ⏱️ API call completed in {elapsed:.1f}s")
|
|||
|
|
|
|||
|
|
# Extract text from response
|
|||
|
|
raw_json = ""
|
|||
|
|
for block in response.content:
|
|||
|
|
if block.type == "text":
|
|||
|
|
raw_json += block.text
|
|||
|
|
|
|||
|
|
print(f" 📦 Raw response length: {len(raw_json)} chars")
|
|||
|
|
|
|||
|
|
# Strip markdown code fences if Claude added them
|
|||
|
|
raw_json = raw_json.strip()
|
|||
|
|
if raw_json.startswith("```"):
|
|||
|
|
raw_json = re.sub(r"^```(?:json)?\s*", "", raw_json)
|
|||
|
|
raw_json = re.sub(r"\s*```$", "", raw_json)
|
|||
|
|
raw_json = raw_json.strip()
|
|||
|
|
|
|||
|
|
# Parse JSON
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
print(" ✅ JSON parsed successfully")
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
print(f" ⚠️ JSON parse error: {e}")
|
|||
|
|
if HAS_JSON_REPAIR:
|
|||
|
|
print(" 🔧 Attempting json-repair...")
|
|||
|
|
try:
|
|||
|
|
repaired = repair_json(raw_json)
|
|||
|
|
data = json.loads(repaired)
|
|||
|
|
print(" ✅ Repaired JSON parsed successfully")
|
|||
|
|
except Exception as e2:
|
|||
|
|
print(f" ❌ json-repair also failed: {e2}")
|
|||
|
|
raise ValueError(f"Could not parse Claude response as JSON: {e}") from e
|
|||
|
|
else:
|
|||
|
|
print(" 💡 Install json-repair for auto-fix: pip install json-repair")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
print("=" * 60)
|
|||
|
|
print("🏦 金融日报 — Financial Daily Content Fetcher")
|
|||
|
|
print("=" * 60)
|
|||
|
|
|
|||
|
|
# Ensure directories exist
|
|||
|
|
DATA_DIR.mkdir(exist_ok=True)
|
|||
|
|
ARCHIVE_DIR.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
# Verify ANTHROPIC_API_KEY
|
|||
|
|
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|||
|
|
print("❌ ANTHROPIC_API_KEY environment variable not set")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# Fetch data from Claude
|
|||
|
|
try:
|
|||
|
|
data = fetch_data_from_claude()
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Failed to fetch data from Claude: {e}")
|
|||
|
|
traceback.print_exc()
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# Determine date strings
|
|||
|
|
now = datetime.datetime.now()
|
|||
|
|
date_iso = now.strftime("%Y-%m-%d")
|
|||
|
|
date_cn = data.get("date", now.strftime("%Y年%-m月%-d日"))
|
|||
|
|
|
|||
|
|
print(f"\n📅 Processing data for: {date_cn} ({date_iso})")
|
|||
|
|
|
|||
|
|
# Save raw JSON
|
|||
|
|
json_path = DATA_DIR / f"{date_iso}.json"
|
|||
|
|
latest_path = DATA_DIR / "latest.json"
|
|||
|
|
json_str = json.dumps(data, ensure_ascii=False, indent=2)
|
|||
|
|
json_path.write_text(json_str, encoding="utf-8")
|
|||
|
|
latest_path.write_text(json_str, encoding="utf-8")
|
|||
|
|
print(f"\n💾 Saved: data/{date_iso}.json + data/latest.json")
|
|||
|
|
|
|||
|
|
# Update data/index.json
|
|||
|
|
update_data_index(DATA_DIR, {
|
|||
|
|
"date_iso": date_iso,
|
|||
|
|
"date_cn": date_cn,
|
|||
|
|
"indices_count": len(data.get("indices", [])),
|
|||
|
|
"news_count": len(data.get("news", [])),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Read index.html
|
|||
|
|
if not INDEX_HTML.exists():
|
|||
|
|
print(f"❌ {INDEX_HTML} not found")
|
|||
|
|
sys.exit(1)
|
|||
|
|
html = INDEX_HTML.read_text(encoding="utf-8")
|
|||
|
|
|
|||
|
|
# Inject content
|
|||
|
|
print("\n🔧 Injecting sections:")
|
|||
|
|
html = inject_into_html(html, data)
|
|||
|
|
|
|||
|
|
# Write updated index.html
|
|||
|
|
INDEX_HTML.write_text(html, encoding="utf-8")
|
|||
|
|
print("\n✅ Updated index.html")
|
|||
|
|
|
|||
|
|
# Generate archive snapshot
|
|||
|
|
archive_html_path = ARCHIVE_DIR / f"{date_iso}.html"
|
|||
|
|
generate_archive_page(html, date_cn, archive_html_path)
|
|||
|
|
rebuild_archive_index(DATA_DIR, ARCHIVE_DIR)
|
|||
|
|
|
|||
|
|
# Generate RSS feed
|
|||
|
|
generate_rss(data, FEED_FILE)
|
|||
|
|
|
|||
|
|
print("\n🎉 Done! Summary:")
|
|||
|
|
print(f" 📊 Indices: {len(data.get('indices', []))}")
|
|||
|
|
print(f" 👔 Leaders: {len(data.get('leaders', []))}")
|
|||
|
|
print(f" 📰 News: {len(data.get('news', []))}")
|
|||
|
|
print(f" 📈 Macro: {len(data.get('macro', []))}")
|
|||
|
|
print(f" 💼 Earnings: {len(data.get('earnings', []))}")
|
|||
|
|
print(f" 🏭 Sectors: {len(data.get('sectors', []))}")
|
|||
|
|
print(f" 🏦 Central Banks:{len(data.get('central_banks', []))}")
|
|||
|
|
print("=" * 60)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|