init: finance-daily-site — Bloomberg风格金融日报
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
name: Daily Financial Dashboard Update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Pre-market: 08:00 ET (12:00 UTC) — every day
|
||||
- cron: '0 12 * * *'
|
||||
# Post-market: 18:00 ET (22:00 UTC) — weekdays only
|
||||
- cron: '0 22 * * 1-5'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_MODEL: ${{ vars.ANTHROPIC_MODEL || 'claude-sonnet-4-5' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install anthropic json-repair
|
||||
|
||||
- name: Fetch & inject content
|
||||
run: python scripts/fetch_content.py
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "chore: daily update $(date -u +'%Y-%m-%d %H:%M UTC')"
|
||||
git push
|
||||
@@ -0,0 +1,112 @@
|
||||
# 📈 金融日报 | Financial Daily
|
||||
|
||||
每日由 Claude API + Web Search 自动汇总七大模块:**全球指数** · **领袖观点** · **市场要闻** · **宏观数据** · **财报追踪** · **行业轮动** · **央行动态**
|
||||
|
||||
## 在线访问
|
||||
|
||||
**主站:** https://yang1bai.github.io/finance-daily-site/
|
||||
|
||||
**历史归档:** https://yang1bai.github.io/finance-daily-site/archive/
|
||||
|
||||
**RSS 订阅:** https://yang1bai.github.io/finance-daily-site/feed.xml
|
||||
|
||||
---
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 💹 **全球指数** | S&P 500 / NASDAQ / DOW / 上证 / 恒生 / 日经 / DAX / 黄金 / 原油 / BTC |
|
||||
| 👔 **领袖观点** | 巴菲特、达利欧、戴蒙等金融领袖最新言论 |
|
||||
| 📰 **市场要闻** | 过去 24-48 小时重磅市场新闻,含原文链接 |
|
||||
| 📊 **宏观数据** | CPI、NFP、PCE、GDP、PMI 等关键经济指标 |
|
||||
| 💼 **财报追踪** | 本周主要公司财报结果(超预期/不及预期) |
|
||||
| 🏭 **行业轮动** | S&P 500 十一大行业涨跌表现 |
|
||||
| 🏦 **央行动态** | 美联储、欧央行、中国人民银行、日央行最新动态 |
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
.
|
||||
├── index.html # 主页(Bloomberg 风格暗色 UI)
|
||||
├── feed.xml # RSS/Atom 订阅(自动生成)
|
||||
├── .nojekyll
|
||||
├── README.md
|
||||
├── data/
|
||||
│ ├── index.json # 归档日期列表(自动生成)
|
||||
│ ├── latest.json # 最新一期原始 JSON
|
||||
│ └── YYYY-MM-DD.json # 每日原始数据归档
|
||||
├── archive/
|
||||
│ ├── index.html # 归档目录页(自动生成)
|
||||
│ └── YYYY-MM-DD.html # 每日 HTML 快照
|
||||
├── scripts/
|
||||
│ ├── fetch_content.py # 调用 Claude API + web_search 抓取内容
|
||||
│ └── icons.py # SVG 图标定义
|
||||
└── .github/workflows/update.yml # 每日两次 cron 触发,自动 commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署清单(按顺序操作一次即可)
|
||||
|
||||
### 1. 推送代码到仓库
|
||||
|
||||
将本目录所有文件推送到你的 GitHub 仓库 `main` 分支。注意 `.nojekyll` 是隐藏文件别遗漏。
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "init: finance-daily-site"
|
||||
git remote add origin https://github.com/Yang1Bai/finance-daily-site.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 2. 配置 Anthropic API Key
|
||||
|
||||
仓库 → **Settings → Secrets and variables → Actions** → **New repository secret**:
|
||||
|
||||
- **Name**:`ANTHROPIC_API_KEY`
|
||||
- **Secret**:粘贴你的 API key(以 `sk-ant-` 开头)
|
||||
|
||||
### 3. 打开 Actions 写权限
|
||||
|
||||
仓库 → **Settings → Actions → General** → 滚到底 → **Workflow permissions** → 选 **Read and write permissions** → Save。
|
||||
|
||||
### 4. 启用 GitHub Pages
|
||||
|
||||
仓库 → **Settings → Pages** → Source 选 *Deploy from a branch* → 分支 `main` / `(root)` → Save。
|
||||
|
||||
### 5. 手动触发一次验证
|
||||
|
||||
仓库 → **Actions** → 左侧 **Daily Financial Dashboard Update** → 右上 **Run workflow** → 点绿色按钮。等 1-2 分钟看到绿色 ✅ 后访问网站。
|
||||
|
||||
---
|
||||
|
||||
## 成本估算
|
||||
|
||||
模型默认 `claude-sonnet-4-5`,启用 `web_search`(最多 15 次):
|
||||
|
||||
| 运行频率 | 单次成本 | 月费用 |
|
||||
|---------|---------|--------|
|
||||
| 工作日 2次/天 + 周末 1次/天 | ~$0.20-0.30 | **~$7-9/月** |
|
||||
| 仅工作日 1次/天 | ~$0.20-0.30 | **~$4-5/月** |
|
||||
|
||||
如想降低成本,将 `update.yml` 中的 `ANTHROPIC_MODEL` 改为 `claude-haiku-4-5`,可降至约 **$1-2/月**。
|
||||
|
||||
---
|
||||
|
||||
## 自定义
|
||||
|
||||
- **调整内容偏好**:编辑 `scripts/fetch_content.py` 中的 `USER_PROMPT_TEMPLATE`
|
||||
- **修改运行时间**:编辑 `.github/workflows/update.yml` 中的 `cron` 表达式
|
||||
- **添加/删除模块**:在 `index.html` 中添加对应注释锚点,并在 `fetch_content.py` 中添加渲染函数
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 指数数据依赖 Claude web_search,非实时报价(有数分钟延迟)
|
||||
- 新闻 URL 为 Claude 搜索结果,偶尔可能失效
|
||||
- 财报 EPS 数据以搜索结果为准,如需精确数据建议接入 Financial Modeling Prep 等 API
|
||||
+1407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,659 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
SVG icon library for 金融日报 | Financial Daily
|
||||
Usage: from scripts.icons import ICONS; print(ICONS['arrow_up'])
|
||||
"""
|
||||
|
||||
ICONS = {
|
||||
"arrow_up": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>""",
|
||||
|
||||
"arrow_down": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>""",
|
||||
|
||||
"arrow_right": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>""",
|
||||
|
||||
"trending_up": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>""",
|
||||
|
||||
"trending_down": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/></svg>""",
|
||||
|
||||
"bank": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 20 7 4 7"/></svg>""",
|
||||
|
||||
"newspaper": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>""",
|
||||
|
||||
"chart_bar": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>""",
|
||||
|
||||
"dollar": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>""",
|
||||
|
||||
"globe": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>""",
|
||||
|
||||
"user": """<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>""",
|
||||
|
||||
"calendar": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>""",
|
||||
|
||||
"rss": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>""",
|
||||
|
||||
"alert": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>""",
|
||||
|
||||
"external_link": """<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>""",
|
||||
|
||||
"refresh": """<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>""",
|
||||
}
|
||||
|
||||
|
||||
def get_icon(name: str, size: int = 14, color: str = "currentColor") -> str:
|
||||
"""Return an SVG icon string with optional size override."""
|
||||
icon = ICONS.get(name, "")
|
||||
if not icon:
|
||||
return ""
|
||||
icon = re.sub(r'width="\d+"', f'width="{size}"', icon)
|
||||
icon = re.sub(r'height="\d+"', f'height="{size}"', icon)
|
||||
if color != "currentColor":
|
||||
icon = icon.replace('stroke="currentColor"', f'stroke="{color}"')
|
||||
return icon
|
||||
|
||||
|
||||
# Allow direct import without regex at module level
|
||||
import re # noqa: E402 (import at end intentional — avoid circular at top)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Available icons:")
|
||||
for name in ICONS:
|
||||
print(f" {name}")
|
||||
Reference in New Issue
Block a user