feat: add investment broadcast, subscription system & GoatCounter analytics

- scripts/daily-investment.py: Telegram 每日投资早报 (yfinance, 8 类别, 趋势分析)
- scripts/handle_subscriptions.py: Telegram bot 订阅处理器 (/subscribe /unsubscribe)
- data/investment-subscribers.json: 订阅者数据
- .github/workflows/investment-broadcast.yml: 每日 8:30 AM Toronto 推送
- .github/workflows/subscription-handler.yml: 每 30 分钟处理订阅请求
- index.html: 加入 Telegram 订阅按钮 + 订阅人数显示 + GoatCounter 统计
This commit is contained in:
GJ
2026-05-03 14:25:41 -04:00
parent 7c109377f0
commit d5d11f1a4a
6 changed files with 560 additions and 1 deletions
@@ -0,0 +1,41 @@
name: Investment Daily Broadcast
on:
schedule:
# 8:30 AM Toronto (EDT = UTC-4) → 12:30 UTC, weekdays
- cron: '30 12 * * 1-5'
# Weekend: 9:00 AM (13:00 UTC)
- cron: '0 13 * * 0,6'
workflow_dispatch:
permissions:
contents: write
jobs:
broadcast:
runs-on: ubuntu-latest
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
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 yfinance requests pytz
- name: Run investment broadcast
run: python scripts/daily-investment.py
- name: Commit subscriber stats
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add data/investment-subscribers.json
git diff --cached --quiet || git commit -m "chore: broadcast stats $(date -u +'%Y-%m-%d %H:%M UTC')"
git push
@@ -0,0 +1,39 @@
name: Subscription Handler
on:
schedule:
# Every 30 minutes
- cron: '*/30 * * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
handle:
runs-on: ubuntu-latest
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
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 requests
- name: Process subscription updates
run: python scripts/handle_subscriptions.py
- name: Commit changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add data/investment-subscribers.json data/telegram-offset.json
git diff --cached --quiet || git commit -m "chore: subscription update $(date -u +'%H:%M UTC')"
git push
+14
View File
@@ -0,0 +1,14 @@
{
"subscribers": {
"8727904480": {
"active": true,
"joined": "2026-05-03",
"name": "Yang Bai"
}
},
"stats": {
"total_broadcasts": 0,
"last_broadcast": null,
"broadcast_history": []
}
}
+24 -1
View File
@@ -2017,12 +2017,16 @@
<p>每个交易日自动更新,开盘前 + 收盘后各一次</p>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<a href="https://t.me/GGmanm_bot?start=subscribe" target="_blank" class="sub-btn" style="background:linear-gradient(135deg,#229ED9,#1a8bbf);color:#fff;display:flex;align-items:center;gap:6px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.44 13.55l-2.955-.924c-.643-.204-.657-.643.136-.953l11.566-4.461c.537-.194 1.006.131.707.009z"/></svg>
Telegram 订阅每日早报
</a>
<a href="feed.xml" target="_blank" class="sub-btn rss-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0"><circle cx="6.18" cy="17.82" r="2.18"/><path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56zm0 5.66v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z"/></svg>
RSS 订阅
</a>
<a href="https://yang1bai.github.io/finance-daily-site/feed.xml" id="copy-rss" onclick="copyRSS(event)" class="sub-btn copy-btn">📋 复制链接</a>
<span style="font-size:0.7rem;color:var(--dimmed);">邮件订阅配置中…</span>
<span id="subscriber-count" style="font-size:0.7rem;color:var(--dimmed);"></span>
</div>
</div>
@@ -2391,7 +2395,26 @@
});
// ── Subscriber count ──────────────────────────────────────────────────────
(function loadSubCount() {
const el = document.getElementById('subscriber-count');
if (!el) return;
fetch('data/investment-subscribers.json')
.then(r => r.json())
.then(d => {
const count = Object.values(d.subscribers || {}).filter(s => s.active).length;
if (count > 0) el.textContent = `📱 ${count} 人已订阅`;
})
.catch(() => {});
})();
</script>
<!-- GoatCounter analytics (privacy-friendly) -->
<!-- To activate: create free account at https://www.goatcounter.com/ -->
<!-- Then replace YOURCODE below with your GoatCounter site code -->
<script data-goatcounter="https://YOURCODE.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""
Daily Investment Briefing
每日投资早报 — 每天早上 8:30 AM Toronto 推送
"""
import yfinance as yf
import requests
import json
import os
from datetime import datetime
import pytz
TORONTO_TZ = pytz.timezone("America/Toronto")
# Telegram 配置
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN", "8518954174:AAHIWuxR4DDTtqxqjFzeUi33WxFUtnLyQQc")
TELEGRAM_CHAT_ID = "8727904480"
# 主要追踪标的
WATCHLIST = {
"美股指数": {
"S&P 500": "^GSPC",
"纳斯达克": "^IXIC",
"道琼斯": "^DJI",
},
"AI & 科技": {
"英伟达 NVDA": "NVDA",
"费城半导体 SOX": "^SOX",
"QQQ": "QQQ",
},
"新能源 ETF": {
"锂电 LIT": "LIT",
"清洁能源 ICLN": "ICLN",
"太阳能 TAN": "TAN",
"铀矿 URA": "URA",
},
"A股/港股": {
"沪深300 ETF": "510300.SS",
"恒生ETF": "2800.HK",
"新能源 ETF": "159806.SZ",
},
"大宗商品": {
"黄金": "GC=F",
"白银": "SI=F",
"": "HG=F",
"原油": "CL=F",
"天然气": "NG=F",
},
"新能源金属": {
"钯金": "PA=F",
"铂金": "PL=F",
},
"加密": {
"BTC-USD": "BTC-USD",
"ETH-USD": "ETH-USD",
},
}
def fmt_change(pct: float) -> str:
if pct >= 2:
arrow = "🚀 +"
elif pct >= 0:
arrow = "🟢 +"
elif pct >= -2:
arrow = "🔴 "
else:
arrow = "💥 "
return f"{arrow}{pct:.2f}%"
def trend_label(pct_5d: float, pct_30d: float) -> str:
"""简单趋势判断"""
if pct_30d > 10 and pct_5d > 2:
return "📈 强势上行"
elif pct_30d > 5 and pct_5d > 0:
return "↗️ 温和上涨"
elif pct_30d < -10 and pct_5d < -2:
return "📉 持续下行"
elif pct_30d < -5 and pct_5d < 0:
return "↘️ 弱势调整"
elif abs(pct_30d) < 3:
return "➡️ 横盘整理"
elif pct_5d > 0 and pct_30d < 0:
return "⚡ 超跌反弹"
elif pct_5d < 0 and pct_30d > 0:
return "⚠️ 短线回调"
else:
return "〰️ 震荡"
def fetch_quotes() -> dict:
results = {}
for category, items in WATCHLIST.items():
results[category] = {}
for name, ticker in items.items():
try:
t = yf.Ticker(ticker)
hist = t.history(period="1mo")
if len(hist) < 2:
results[category][name] = {"error": "no data"}
continue
last = hist["Close"].iloc[-1]
prev = hist["Close"].iloc[-2]
d30 = hist["Close"].iloc[0]
pct_1d = (last - prev) / prev * 100
pct_5d = (last - hist["Close"].iloc[-6]) / hist["Close"].iloc[-6] * 100 if len(hist) >= 6 else None
pct_30d = (last - d30) / d30 * 100
results[category][name] = {
"price": last,
"pct_1d": pct_1d,
"pct_5d": pct_5d,
"pct_30d": pct_30d,
}
except Exception as e:
results[category][name] = {"error": str(e)[:60]}
return results
def fmt_price(price: float) -> str:
if price > 10000:
return f"{price:,.0f}"
elif price > 100:
return f"{price:,.2f}"
elif price > 1:
return f"{price:.3f}"
else:
return f"{price:.5f}"
def build_message(quotes: dict) -> str:
now = datetime.now(TORONTO_TZ)
date_str = now.strftime("%Y-%m-%d %a")
lines = [
f"📊 *每日投资早报* — {date_str}",
"",
]
for category, items in quotes.items():
lines.append(f"━━━ *{category}* ━━━")
for name, data in items.items():
if "error" in data:
lines.append(f" {name}: —")
continue
price_str = fmt_price(data["price"])
change_str = fmt_change(data["pct_1d"])
pct_5d = data.get("pct_5d")
pct_30d = data.get("pct_30d")
# 主行:价格 + 日涨跌
line = f" *{name}*: {price_str} {change_str}"
lines.append(line)
# 副行:5日/月涨跌 + 趋势
if pct_5d is not None and pct_30d is not None:
sign5 = "+" if pct_5d >= 0 else ""
sign30 = "+" if pct_30d >= 0 else ""
trend = trend_label(pct_5d, pct_30d)
lines.append(
f" 5日:{sign5}{pct_5d:.1f}% 月:{sign30}{pct_30d:.1f}% {trend}"
)
lines.append("")
# 市场情绪
changes_1d = [
d["pct_1d"]
for cat in quotes.values()
for d in cat.values()
if "pct_1d" in d
]
if changes_1d:
avg = sum(changes_1d) / len(changes_1d)
pos = sum(1 for x in changes_1d if x > 0)
neg = sum(1 for x in changes_1d if x < 0)
if avg > 1.5:
mood = "🌞 全面上涨,市场情绪亢奋,注意追高风险"
elif avg > 0.3:
mood = "🌤 整体偏多,风险资产强劲,可适量布局"
elif avg > -0.3:
mood = "😐 市场分化,观望为主,等待方向选择"
elif avg > -1.5:
mood = "☁️ 整体偏弱,短线谨慎,关注支撑位"
else:
mood = "⛈ 普跌行情,控制仓位,避免左侧接刀"
lines.append(f"*市场情绪:* {mood}")
lines.append(f"今日 {pos} 涨 / {neg} 跌(共 {len(changes_1d)} 标的)")
lines.append("")
lines.append("_数据来源:Yahoo Finance · 趋势仅供参考,非投资建议_")
return "\n".join(lines)
SUBS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "investment-subscribers.json")
def load_subscribers() -> list:
"""返回所有活跃订阅者的 chat_id 列表"""
try:
with open(SUBS_FILE) as f:
data = json.load(f)
return [uid for uid, s in data["subscribers"].items() if s.get("active")]
except Exception:
return [TELEGRAM_CHAT_ID] # fallback
def save_broadcast_stat(sent: int, total: int):
try:
with open(SUBS_FILE) as f:
data = json.load(f)
stats = data.setdefault("stats", {})
stats["total_broadcasts"] = stats.get("total_broadcasts", 0) + 1
stats["last_broadcast"] = datetime.now(TORONTO_TZ).strftime("%Y-%m-%d %H:%M")
history = stats.setdefault("broadcast_history", [])
history.append({
"date": stats["last_broadcast"],
"sent": sent,
"total": total,
})
if len(history) > 30:
stats["broadcast_history"] = history[-30:]
with open(SUBS_FILE, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"Stat save failed: {e}")
def broadcast(message: str):
"""广播给所有活跃订阅者"""
url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage"
subscribers = load_subscribers()
sent = 0
for chat_id in subscribers:
try:
resp = requests.post(url, json={
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}, timeout=15)
if resp.ok:
sent += 1
else:
print(f"Failed {chat_id}: {resp.status_code}")
except Exception as e:
print(f"Error {chat_id}: {e}")
print(f"✅ Broadcast: {sent}/{len(subscribers)} delivered")
save_broadcast_stat(sent, len(subscribers))
def main():
print(f"Fetching quotes at {datetime.now(TORONTO_TZ).isoformat()}...")
quotes = fetch_quotes()
message = build_message(quotes)
print(message)
print("\n--- Broadcasting ---")
broadcast(message)
if __name__ == "__main__":
main()
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
Telegram 订阅处理器
每 30 分钟由 GitHub Actions 触发,处理 @GGmanm_bot 的订阅/退订请求。
用户命令:
/subscribe 或 /start subscribe → 加入每日投资早报
/unsubscribe → 取消订阅
/start → 欢迎消息 + 订阅说明
"""
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import requests
# ─── Config ──────────────────────────────────────────────────────────────────
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN", "8518954174:AAHIWuxR4DDTtqxqjFzeUi33WxFUtnLyQQc")
API_BASE = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}"
ROOT_DIR = Path(__file__).resolve().parent.parent
SUBS_FILE = ROOT_DIR / "data" / "investment-subscribers.json"
OFFSET_FILE = ROOT_DIR / "data" / "telegram-offset.json"
WELCOME_MSG = """👋 欢迎关注 *金融日报* @GGmanm_bot
📊 每个交易日自动推送每日投资早报,包含:
• 全球主要指数涨跌
• AI & 科技 / 新能源 / 加密行情
• A股 / 港股最新动态
• 大宗商品(黄金、原油、铜)
• 趋势判断 + 市场情绪
发送 /subscribe 开始接收每日推送
发送 /unsubscribe 取消订阅
🌐 网页版:https://yang1bai.github.io/finance-daily-site/"""
SUBSCRIBE_OK = "✅ 订阅成功!明天早上 8:30 Toronto 时间,你将收到第一份每日投资早报。\n\n发送 /unsubscribe 可随时取消。"
ALREADY_SUBBED = "👌 你已经订阅了,无需重复操作。\n\n发送 /unsubscribe 可取消订阅。"
UNSUBSCRIBE_OK = "🔕 已取消订阅,你不会再收到每日早报推送。\n\n发送 /subscribe 可随时重新订阅。"
NOT_SUBBED = "️ 你当前没有活跃订阅。\n\n发送 /subscribe 开始接收每日投资早报。"
# ─── Helpers ─────────────────────────────────────────────────────────────────
def load_subscribers() -> dict:
try:
return json.loads(SUBS_FILE.read_text(encoding="utf-8"))
except Exception:
return {"subscribers": {}, "stats": {}}
def save_subscribers(data: dict):
SUBS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def load_offset() -> int:
try:
return json.loads(OFFSET_FILE.read_text())["offset"]
except Exception:
return 0
def save_offset(offset: int):
OFFSET_FILE.write_text(json.dumps({"offset": offset}))
def send_message(chat_id: str | int, text: str):
resp = requests.post(f"{API_BASE}/sendMessage", json={
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
}, timeout=10)
return resp.ok
def get_updates(offset: int) -> list:
resp = requests.get(f"{API_BASE}/getUpdates", params={
"offset": offset,
"timeout": 5,
"limit": 100,
}, timeout=15)
if resp.ok:
return resp.json().get("result", [])
return []
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
print("🤖 Telegram Subscription Handler starting...")
offset = load_offset()
updates = get_updates(offset)
if not updates:
print(" ✅ No new updates")
return
data = load_subscribers()
subs = data.setdefault("subscribers", {})
changed = False
for update in updates:
update_id = update["update_id"]
offset = update_id + 1
msg = update.get("message") or update.get("edited_message")
if not msg:
continue
chat_id = str(msg["chat"]["id"])
text = (msg.get("text") or "").strip()
name = msg.get("from", {}).get("first_name", "朋友")
print(f" 📨 [{chat_id}] {text!r}")
# /start (with optional deep-link payload)
if text.startswith("/start"):
payload = text[6:].strip()
if payload in ("subscribe", "sub"):
# Deep-link subscribe
if subs.get(chat_id, {}).get("active"):
send_message(chat_id, ALREADY_SUBBED)
else:
subs[chat_id] = {"active": True, "joined": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "name": name}
send_message(chat_id, SUBSCRIBE_OK)
changed = True
print(f" ✅ Subscribed: {chat_id}")
else:
send_message(chat_id, WELCOME_MSG)
# /subscribe
elif text in ("/subscribe", "/sub"):
if subs.get(chat_id, {}).get("active"):
send_message(chat_id, ALREADY_SUBBED)
else:
subs[chat_id] = {"active": True, "joined": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "name": name}
send_message(chat_id, SUBSCRIBE_OK)
changed = True
print(f" ✅ Subscribed: {chat_id}")
# /unsubscribe
elif text in ("/unsubscribe", "/unsub", "/stop"):
if subs.get(chat_id, {}).get("active"):
subs[chat_id]["active"] = False
subs[chat_id]["left"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
send_message(chat_id, UNSUBSCRIBE_OK)
changed = True
print(f" 🔕 Unsubscribed: {chat_id}")
else:
send_message(chat_id, NOT_SUBBED)
# /status
elif text == "/status":
sub_info = subs.get(chat_id)
if sub_info and sub_info.get("active"):
stats = data.get("stats", {})
last_bc = stats.get("last_broadcast", "暂无")
msg_text = f"✅ 你已订阅每日投资早报\n📅 加入时间:{sub_info.get('joined','未知')}\n📡 上次推送:{last_bc}"
else:
msg_text = "❌ 你当前未订阅\n\n发送 /subscribe 开始接收每日早报"
send_message(chat_id, msg_text)
save_offset(offset)
if changed:
save_subscribers(data)
count = sum(1 for s in subs.values() if s.get("active"))
print(f" 💾 Saved subscribers ({count} active)")
else:
print(f" ️ No subscription changes ({sum(1 for s in subs.values() if s.get('active'))} active)")
if __name__ == "__main__":
main()