Files
stock-info-crawler/app/api/server.py
MH Hung e89567643b feat(openinsider): 新增 OpenInsider 內部人交易爬蟲,支援多標的與每日排程
- 新增 app/crawlers/openinsider.py,來源 http://openinsider.com/search?q={symbol}

- 支援多標的:以 SYMBOLS=PLTR,NVDA,... 同時追多檔(或使用 SYMBOL 單一)

- runner: 多實例排程與啟動;/check 會依序觸發全部爬蟲

- API: /info、/stats、/check、/notify_test 支援多爬蟲回應

- config/base: 新增 RUN_DAILY_AT 每日固定時間;未設定則用 CHECK_INTERVAL

- notifications: 新增 send_custom_email、send_text_webhook、send_text_discord

- README 與 .env.template 更新;.env 改為 CRAWLER_TYPE=openinsider

- 移除 quiver_insiders 爬蟲與相關設定

BREAKING CHANGE: 不再支援 CRAWLER_TYPE=quiver_insiders;請改用 openinsider。
2025-09-04 22:32:29 +08:00

116 lines
4.2 KiB
Python

from __future__ import annotations
from datetime import datetime
from flask import Flask, jsonify, request
from app.services import notifications as notif
def create_app(crawler) -> Flask:
app = Flask(__name__)
# Support single crawler or a list of crawlers
crawlers = None
if isinstance(crawler, (list, tuple)):
crawlers = list(crawler)
@app.get('/health')
def health():
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
@app.get('/stats')
def stats():
if crawlers is not None:
return jsonify({
(getattr(c, 'symbol', getattr(c, 'name', f"crawler_{i}")) or f"crawler_{i}"):
c.stats for i, c in enumerate(crawlers)
})
if crawler:
return jsonify(crawler.stats)
return jsonify({"error": "Crawler not initialized"}), 500
@app.get('/info')
def info():
if crawlers is not None:
out = []
for c in crawlers:
out.append({
"name": getattr(c, 'name', 'unknown'),
"type": c.__class__.__name__,
"symbol": getattr(c, 'symbol', None),
"schedule": getattr(c.config, 'run_daily_at', None) or f"every {c.config.check_interval}s",
})
return jsonify(out)
if not crawler:
return jsonify({"error": "Crawler not initialized"}), 500
return jsonify({
"name": getattr(crawler, 'name', 'unknown'),
"type": crawler.__class__.__name__,
"symbol": getattr(crawler, 'symbol', None),
"schedule": getattr(crawler.config, 'run_daily_at', None) or f"every {crawler.config.check_interval}s",
})
@app.get('/check')
def manual_check():
if crawlers is not None:
results = []
for c in crawlers:
r = c.run_check() or []
results.append({
"symbol": getattr(c, 'symbol', None),
"new": len(r)
})
return jsonify({"results": results})
if not crawler:
return jsonify({"error": "Crawler not initialized"}), 500
result = crawler.run_check() or []
return jsonify({"result": f"Found {len(result)} new picks"})
@app.get('/notify_test')
def notify_test():
channel = (request.args.get('channel') or 'email').lower()
target = request.args.get('target')
test_pick = [notif.build_test_pick()]
def _send_for(c):
if channel == 'email':
if not c.config.email:
return {"error": "Email config not set"}
notif.send_email(test_pick, c.config.email)
elif channel == 'webhook':
if not c.config.webhook_url:
return {"error": "Webhook URL not set"}
notif.send_webhook(test_pick, c.config.webhook_url)
elif channel == 'discord':
if not c.config.discord_webhook:
return {"error": "Discord webhook not set"}
notif.send_discord(test_pick, c.config.discord_webhook)
else:
return {"error": f"Unsupported channel: {channel}"}
return {"result": f"Test notification sent via {channel}"}
if crawlers is not None:
results = {}
for c in crawlers:
key = getattr(c, 'symbol', getattr(c, 'name', 'unknown'))
if target and key != target:
continue
try:
results[key] = _send_for(c)
except Exception as e:
c.logger.error(f"測試通知發送失敗({key}): {e}")
results[key] = {"error": str(e)}
return jsonify(results)
if not crawler:
return jsonify({"error": "Crawler not initialized"}), 500
try:
res = _send_for(crawler)
if 'error' in res:
return jsonify(res), 400
return jsonify(res)
except Exception as e:
crawler.logger.error(f"測試通知發送失敗: {e}")
return jsonify({"error": str(e)}), 500
return app