chore: 将 bsp_monitor 拆出为独立仓库。

监控服务已迁移至 jack/bsp_monitor,不再随 chan 维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:09:43 +08:00
co-authored by Cursor
parent 2e905e7238
commit b31215057e
8 changed files with 0 additions and 912 deletions
-3
View File
@@ -1,3 +0,0 @@
# bsp_monitor 复用 Hermes Agent 的 Telegram bot
# notify.py 从 ~/.hermes/.env 直接读取 TELEGRAM_BOT_TOKEN
# 此处无需重复配置
-1
View File
@@ -1 +0,0 @@
# bsp_monitor - 缠论买卖点监控 (BTC/USDT 1m)
-174
View File
@@ -1,174 +0,0 @@
"""
engine.py - 缠论管线封装:DataFrame → KLU → KLC → BI → SEG → ZS → BSP。
复用 ~/Project/Chan/ 下的 TF_DF 模块,管线步骤对齐 TF_DF.get_bsp_state()。
"""
import sys
import os
from typing import List, Optional
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
import pandas as pd
from ChanEnum import (
Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR,
Chan_ZS_DIR,
)
from ChanBSP import ChanBSP
from ChanBI import ChanBI
# 仅导入类,不触发 TF_DF.__init__
from TF_DF import TF_DF as _TF_DF_Class
class ChanEngine:
"""缠论管线,对齐 TF_DF.get_bsp_state() 的调用顺序。"""
def __init__(self, df: pd.DataFrame):
if df.empty or len(df) < 50:
raise ValueError("DataFrame 至少需要 50 根 K 线")
if "date" not in df.columns and "timestamp" in df.columns:
df["date"] = df["timestamp"]
self.df = df
self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__
# Step 0: 添加 TA 指标 (MACD/EMA/BB/RSI)
self._df_with_indicators = self._tf.add_indicators(df.copy())
# Step 1: KLU — get_klu_list → get_kl_data → cal_kl_data
self.klu_list = self._tf.get_klu_list(self._df_with_indicators)
# Step 2: KLC — 内部已含 ChanMACD.cal_macd_state() + cal_trend()
self.klc_list = self._tf.get_klc_list(self.klu_list)
# Step 3: BI (stroke)
self.bi_list = self._tf.cal_bi_list(self.klc_list)
# Step 4: SEG (segment)
self.seg_list = self._tf.get_seg_list(self.bi_list)
# Step 5: ZS — cal_bi_zs(seg_list) 对齐 get_bsp_state(从线段计算笔中枢)
self.bi_zs_list: List = self._tf.cal_bi_zs(self.seg_list)
# Step 6: BSP (buy/sell points)
self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp(
self.bi_list, self.bi_zs_list
)
def get_second_last_bi(self) -> Optional[ChanBI]:
"""获取倒数第二笔(最新确认的笔)。"""
confirmed = [b for b in self.bi_list if b.is_sure]
if len(confirmed) >= 2:
return confirmed[-2]
elif len(confirmed) == 1:
return confirmed[-1]
return None
def get_bsp_for_bi(self, bi: ChanBI) -> Optional[ChanBSP]:
"""检查某个 Bi 的 end_klc 是否是买卖点。"""
if bi is None or not bi.is_sure:
return None
klc = bi.end_klc
if klc is None:
return None
if klc.bsp and klc.bsp_type != Chan_BSP_TYPE.NONE:
for bsp in self.bsp_list:
if bsp.klc is klc:
return bsp
return None
# ── 格式化 ──
@staticmethod
def _bsp_type_name(t: Chan_BSP_TYPE) -> str:
import ChanEnum
names = {
Chan_BSP_TYPE.B1: "一类买点(B1)",
Chan_BSP_TYPE.B2: "二类买点(B2)",
Chan_BSP_TYPE.B3: "三类买点(B3)",
Chan_BSP_TYPE.S1: "一类卖点(S1)",
Chan_BSP_TYPE.S2: "二类卖点(S2)",
Chan_BSP_TYPE.S3: "三类卖点(S3)",
}
return names.get(t, str(t))
@staticmethod
def _bi_dir_name(d) -> str:
return "⬆️ 向上" if d == Chan_BI_DIR.UP else "⬇️ 向下"
@staticmethod
def _fx_strength_name(klc_fx_type) -> str:
import ChanEnum
names = {
Chan_KLC_FX.TOP0: "TOP0(弱)", Chan_KLC_FX.TOP1: "TOP1(标准)",
Chan_KLC_FX.TOP2: "TOP2(强)", Chan_KLC_FX.TOP3: "TOP3(二类)",
Chan_KLC_FX.TOP4: "TOP4(BB上轨)", Chan_KLC_FX.TOP5: "TOP5",
Chan_KLC_FX.TOP6: "TOP6(高位空)", Chan_KLC_FX.TOP7: "TOP7(背驰)",
Chan_KLC_FX.TOP8: "TOP8(信号线)",
Chan_KLC_FX.BOTTOM0: "BOTTOM0(弱)", Chan_KLC_FX.BOTTOM1: "BOTTOM1(标准)",
Chan_KLC_FX.BOTTOM2: "BOTTOM2(强)", Chan_KLC_FX.BOTTOM3: "BOTTOM3(二类)",
Chan_KLC_FX.BOTTOM4: "BOTTOM4(BB下轨)", Chan_KLC_FX.BOTTOM5: "BOTTOM5(零轴下)",
Chan_KLC_FX.BOTTOM6: "BOTTOM6(高位空)", Chan_KLC_FX.BOTTOM7: "BOTTOM7(背驰)",
Chan_KLC_FX.BOTTOM8: "BOTTOM8(信号线)",
}
return names.get(klc_fx_type, f"UNKNOWN({klc_fx_type})")
@staticmethod
def _utc_to_cst(time_str: str) -> str:
"""UTC 时间字符串 → 东八区 (UTC+8)。"""
from datetime import datetime, timedelta, timezone
dt = datetime.fromisoformat(str(time_str))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
cst = dt.astimezone(timezone(timedelta(hours=8)))
return cst.strftime("%Y-%m-%d %H:%M:%S CST")
def format_bsp_detail(self, bsp: ChanBSP, symbol: str = "BTC/USDT:USDT", tf: str = "1m") -> str:
bi = bsp.bi
klc = bsp.klc
bsp_type = bsp.type
bsp_dir = bsp.dir
emoji = "🟢" if bsp_dir == Chan_BSP_DIR.BUY else "🔴"
dir_label = "买点" if bsp_dir == Chan_BSP_DIR.BUY else "卖点"
symbol_short = symbol.split(":")[0].replace("/", "")
lines = [
f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — <b>{symbol_short} {tf}</b>",
"",
f"⏰ 确认: <code>{self._utc_to_cst(klc.end_time)}</code>",
f"💰 价格: <b>{klc.close:.2f}</b>",
f"📐 笔方向: {self._bi_dir_name(bi.dir)}",
f"📏 笔高度: ${bi.height:.2f} 宽度: {bi.width}K 斜率: {bi.slop:.2f}",
f"🔩 分型强度: {self._fx_strength_name(klc.klc_fx_type)}",
]
if bsp.zs:
zs = bsp.zs
zs_dir = "UP" if hasattr(zs, 'dir') and hasattr(Chan_ZS_DIR, 'UP') and zs.dir == Chan_ZS_DIR.UP else "DOWN"
lines.append(f"🏠 中枢: {zs.zd:.2f} {zs.zg:.2f} ({zs_dir}, #{getattr(zs, 'index', 0) + 1})")
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
lines.append("📊 MACD背驰: 有 (离开段能量 < 进入段)")
if hasattr(klc, 'ema_status') and klc.ema_status:
ema52 = klc.ema_status.get('ema52', {})
if ema52:
pos = str(ema52.get('pos', '?'))
lines.append(f"📈 EMA52: {pos} (值: {klc.ema52:.2f})")
lines.append(f"📋 KLC状态: {klc.klc_state}")
if bi.pre:
prev = bi.pre
lines.extend([
"────",
f"⬅️ 前一笔: {self._bi_dir_name(prev.dir)} "
f"高度: ${prev.height:.2f} 宽度: {prev.width}K",
])
return "\n".join(lines)
-62
View File
@@ -1,62 +0,0 @@
"""
fetcher.py - 从 data_provider HTTP API 拉取 K 线数据。
"""
from typing import List, Optional
import requests
import pandas as pd
import logging
logger = logging.getLogger(__name__)
PROVIDER_URL = "http://103.179.242.166"
PROVIDER_URL = "http://127.0.0.1"
FETCH_LIMIT = 1000
_symbols_cache: Optional[List[str]] = None
# 只推送 BTC,其他币对暂不监控
_SYMBOL_WHITELIST = {"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT"}
def get_symbols() -> list[str]:
"""获取要监控的币对列表(目前只监控 BTC)。"""
global _symbols_cache
if _symbols_cache is not None:
return _symbols_cache
try:
resp = requests.get(f"{PROVIDER_URL}/health", timeout=10)
resp.raise_for_status()
all_symbols = resp.json().get("symbols", [])
_symbols_cache = [s for s in all_symbols if s in _SYMBOL_WHITELIST]
logger.info(f"获取到 {len(all_symbols)} 个币对,过滤后监控 {len(_symbols_cache)} 个: {_symbols_cache}")
except Exception as e:
logger.error(f"获取币对列表失败: {e}")
_symbols_cache = ["BTC/USDT:USDT"]
return _symbols_cache
def fetch_ohlcv(symbol: str, tf: str = "1m") -> pd.DataFrame:
"""从 data_provider API 拉取某个币对最近 FETCH_LIMIT 根 K 线。"""
url = f"{PROVIDER_URL}/api/candles"
params = {
"symbol": symbol,
"tf": tf,
"limit": FETCH_LIMIT,
}
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if not data:
logger.warning(f"{symbol}: API 返回空数据")
return pd.DataFrame()
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df["date"] = df["timestamp"]
df = df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
return df
-220
View File
@@ -1,220 +0,0 @@
#!/usr/bin/env python3
"""
main.py - 缠论多周期买卖点监控。
每整分钟:
1. 从 data_provider 拉取所有币对多周期 K 线
2. 每个币对 × 每个周期独立跑缠论管线
3. 检测新笔确认 → BSP 推送
"""
import asyncio
import logging
import sys
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fetcher import fetch_ohlcv, get_symbols
from engine import ChanEngine
from notify import send_bsp_alert, BOT_TOKEN, CHAT_ID
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
from ChanEnum import Chan_BI_DIR
# from ChanPivotMonitor import ChanPivotMonitor # 暂停中枢监控
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("bsp_monitor")
TIMEFRAMES = ["1m", "5m", "15m", "1h"]
def _short(symbol: str) -> str:
"""BTC/USDT:USDT → BTCUSDT"""
return symbol.split(":")[0].replace("/", "")
def _bi_id(bi) -> Optional[tuple]:
"""笔的稳定标识,基于首K线时间戳。"""
if bi.start_klc is None:
return None
return (bi.start_klc.start_time,)
def _push_bsp(engine: ChanEngine, bsp, symbol: str, tf: str) -> bool:
"""推送 BSP 到 Telegram(带去重)。"""
if bsp.klc is None:
return False
key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}_{tf}"
msg = engine.format_bsp_detail(bsp, symbol, tf)
msg = _escape_html(msg)
if send_bsp_alert(msg, bsp_key=key):
logger.info(f"[{_short(symbol)} {tf}] ✅ BSP: {key}")
return True
return False
@dataclass
class TfState:
"""单个周期的状态。"""
last_bi_id: Optional[tuple] = None
last_df_ts: object = None
first_run: bool = True
# pivot_monitor: ChanPivotMonitor = None # 暂停中枢监控
# def __post_init__(self):
# if self.pivot_monitor is None:
# self.pivot_monitor = ChanPivotMonitor()
@dataclass
class SymbolState:
symbol: str
tfs: dict = field(default_factory=dict)
def __post_init__(self):
self.tfs = {tf: TfState() for tf in TIMEFRAMES}
class BSPMonitor:
def __init__(self):
symbols = get_symbols()
self._states: dict[str, SymbolState] = {
s: SymbolState(symbol=s) for s in symbols
}
logger.info(f"监控 {len(symbols)}×{len(TIMEFRAMES)} 币对×周期: "
f"{', '.join(_short(s) for s in symbols)}")
async def tick(self):
tick_start = time.monotonic()
logger.info("── tick 开始 ──")
for symbol, st in self._states.items():
await self._tick_symbol(symbol, st)
elapsed = (time.monotonic() - tick_start) * 1000
logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──")
async def _tick_symbol(self, symbol: str, st: SymbolState):
name = _short(symbol)
for tf in TIMEFRAMES:
await self._check_tf(symbol, tf, st.tfs[tf], name)
async def _check_tf(self, symbol: str, tf: str, ts: TfState, name: str):
# 1. 拉取 K 线
try:
df = fetch_ohlcv(symbol, tf)
except Exception as e:
logger.error(f"[{name} {tf}] 拉取失败: {e}")
return
if df.empty:
return
# 2. 检查是否有新 K 线
latest_ts = df.iloc[-1]["timestamp"]
if ts.last_df_ts and latest_ts <= ts.last_df_ts:
return
ts.last_df_ts = latest_ts
# 3. 运行缠论管线
try:
engine = ChanEngine(df)
except Exception as e:
logger.error(f"[{name} {tf}] 缠论计算失败: {e}", exc_info=True)
return
# 4. 中枢特征更新(暂停)
# try:
# ts.pivot_monitor.update(engine.bi_zs_list)
# except Exception as e:
# logger.debug(f"[{name} {tf}] 中枢特征更新失败: {e}")
# 5. BSP 检测
confirmed = [b for b in engine.bi_list if b.is_sure]
if len(confirmed) < 2:
return
last_confirmed = confirmed[-1]
current_bi_id = _bi_id(last_confirmed)
if current_bi_id is None:
return
if ts.first_run:
ts.first_run = False
ts.last_bi_id = current_bi_id
bsp = engine.get_bsp_for_bi(last_confirmed)
if bsp:
_push_bsp(engine, bsp, symbol, tf)
logger.info(
f"[{name} {tf}] 首次完成 — "
f"{len(confirmed)} 笔, {len(engine.bsp_list)} BSP"
)
return
if current_bi_id == ts.last_bi_id:
return
ts.last_bi_id = current_bi_id
bi_dir = "⬆️" if last_confirmed.dir == Chan_BI_DIR.UP else "⬇️"
logger.info(f"[{name} {tf}] 新笔确认 — #{len(confirmed)} "
f"{bi_dir} 高度: ${last_confirmed.height:.2f}")
bsp = engine.get_bsp_for_bi(last_confirmed)
if bsp:
_push_bsp(engine, bsp, symbol, tf)
async def run(self):
logger.info("=" * 50)
logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 "
f"× {len(TIMEFRAMES)} 周期 ({', '.join(TIMEFRAMES)})")
logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}")
logger.info("=" * 50)
logger.info("首次运行(初始化)...")
await self.tick()
while True:
now = datetime.now(timezone.utc)
next_minute = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
wait_seconds = max(0.1, (next_minute - now).total_seconds())
logger.info(f"等待 {wait_seconds:.0f}s 到 {next_minute.strftime('%H:%M:%S')}UTC")
await asyncio.sleep(wait_seconds)
try:
await self.tick()
except Exception as e:
logger.error(f"tick 异常: {e}", exc_info=True)
await asyncio.sleep(5)
def _escape_html(msg: str) -> str:
"""HTML 转义,保留已有的 <b>/<code> 标签。"""
msg = msg.replace("&", "&amp;")
msg = msg.replace("<b>", "\x00B\x00").replace("</b>", "\x00/B\x00")
msg = msg.replace("<code>", "\x00C\x00").replace("</code>", "\x00/C\x00")
msg = msg.replace("<", "&lt;").replace(">", "&gt;")
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
return msg
if __name__ == "__main__":
monitor = BSPMonitor()
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
logger.info("收到中断信号,退出")
-61
View File
@@ -1,61 +0,0 @@
"""
notify.py - Telegram 推送。
"""
import logging
import requests
logger = logging.getLogger(__name__)
BOT_TOKEN = "8742822093:AAGzD1vS7ru7ROhgcOjA-UyHb4R8Cfcqv3Q"
CHAT_ID = "580807463"
def send_telegram_message(text: str) -> bool:
"""发送 Telegram 消息(不去重,每次调用都发)。"""
if not BOT_TOKEN or not CHAT_ID:
logger.warning("Telegram 未配置,跳过推送")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
try:
resp = requests.post(
url,
json={
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
timeout=10,
)
resp.raise_for_status()
return True
except Exception as e:
logger.error(f"Telegram 推送失败: {e}")
return False
def send_bsp_alert(text: str, bsp_key: str = "") -> bool:
"""推送 BSP 消息。"""
if not BOT_TOKEN or not CHAT_ID:
logger.warning("Telegram 未配置,跳过推送")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
try:
resp = requests.post(
url,
json={
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
timeout=10,
)
resp.raise_for_status()
logger.info(f"Telegram 推送成功: {bsp_key or 'no-key'}")
return True
except Exception as e:
logger.error(f"Telegram 推送失败: {e}")
return False
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
# bsp_monitor 启动脚本
# 用法: bash run.sh
cd "$(dirname "$0")"
echo "=== bsp_monitor ==="
echo "启动时间: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "监控: BTC/USDT:USDT 1m 缠论买卖点"
echo "推送: Telegram (复用 Hermes bot)"
echo "==================="
exec /usr/bin/python3 -u main.py
-380
View File
@@ -1,380 +0,0 @@
#!/usr/bin/env python3
"""
twitter_web.py — Twitter 监控账号管理 Web 界面。
单文件,零依赖,只用到 Python 标准库。
"""
import json
import os
import sys
import re
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
WATCHLIST_PATH = os.path.join(SCRIPT_DIR, "twitter_watchlist.json")
STATE_PATH = os.path.join(SCRIPT_DIR, "twitter_state.json")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8010
def extract_username(value: str) -> str:
value = value.strip().rstrip("/")
if value.startswith("@"):
return value[1:]
for pattern in [r"(?:twitter\.com|x\.com)/(\w+)(?:/|$)", r"/(\w+)$"]:
m = re.search(pattern, value)
if m:
return m.group(1)
if re.match(r"^\w+$", value):
return value
raise ValueError(f"无法提取用户名: {value}")
def load_json(path):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def save_json(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_watchlist():
return load_json(WATCHLIST_PATH).get("users", [])
def save_watchlist(users):
save_json(WATCHLIST_PATH, {"users": users})
def get_state():
return load_json(STATE_PATH)
HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LVVXH3TL04');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Twitter 监控管理</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d2991d;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:var(--bg); color:var(--text); padding:24px; max-width:680px; margin:auto; }
h1 { font-size:20px; margin-bottom:4px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:20px; }
.add-bar { display:flex; gap:8px; margin-bottom:20px; }
.add-bar input { flex:1; padding:8px 12px; border:1px solid var(--border);
border-radius:6px; background:var(--card); color:var(--text); font-size:14px; outline:none; }
.add-bar input:focus { border-color:var(--accent); }
.add-bar input::placeholder { color:var(--muted); }
button { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px;
font-weight:500; transition:opacity .15s; }
button:hover { opacity:0.85; }
.btn-add { background:var(--accent); color:#fff; }
.btn-edit, .btn-save { background:var(--yellow); color:#000; }
.btn-del { background:var(--red); color:#fff; }
.btn-cancel { background:var(--border); color:var(--text); }
.account { background:var(--card); border:1px solid var(--border); border-radius:8px;
padding:12px 16px; margin-bottom:8px; display:flex; align-items:center; gap:12px; }
.account .name { font-weight:600; min-width:160px; }
.account .name a { color:var(--accent); text-decoration:none; }
.account .name a:hover { text-decoration:underline; }
.account .meta { font-size:12px; color:var(--muted); flex:1; }
.account .actions { display:flex; gap:6px; flex-shrink:0; }
.edit-row { display:flex; gap:6px; align-items:center; width:100%; }
.edit-row input { flex:1; padding:6px 10px; border:1px solid var(--accent);
border-radius:4px; background:var(--bg); color:var(--text); font-size:13px; outline:none; }
.badge { display:inline-block; font-size:11px; padding:2px 8px; border-radius:10px;
background:var(--green); color:#000; margin-left:6px; }
.empty { text-align:center; padding:60px 20px; color:var(--muted); }
.empty p { margin-bottom:8px; }
.toast { position:fixed; bottom:20px; right:20px; padding:10px 20px; border-radius:6px;
font-size:13px; color:#fff; opacity:0; transition:opacity .3s; z-index:100; }
.toast.show { opacity:1; }
.toast.ok { background:var(--green); }
.toast.err { background:var(--red); }
</style>
</head>
<body>
<h1>🐦 Twitter 账号监控</h1>
<p class="sub">管理 twitterapi.io 监控账号 · 增删改查</p>
<div class="add-bar">
<input id="urlInput" type="text" placeholder="输入 Twitter/X 链接或用户名..." autofocus>
<button class="btn-add" onclick="addAccount()"> 添加</button>
</div>
<div id="list"></div>
<div class="toast" id="toast"></div>
<script>
const API = '/twitter/api/accounts';
let editing = null;
async function api(method, path='', body=null) {
const opts = { method, headers:{} };
if (body) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
const r = await fetch(API + path, opts);
const data = await r.json();
if (!r.ok) throw new Error(data.error || '请求失败');
return data;
}
function toast(msg, ok=true) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast ' + (ok?'ok':'err') + ' show';
setTimeout(() => t.classList.remove('show'), 2500);
}
async function load() {
const data = await api('GET');
const div = document.getElementById('list');
if (!data.accounts.length) {
div.innerHTML = '<div class="empty"><p>📭 暂无监控账号</p><p style="font-size:12px;color:var(--muted)">在上方输入 Twitter/X 链接或用户名添加</p></div>';
return;
}
div.innerHTML = data.accounts.map(a => `
<div class="account" id="row-${a.username}">
${editing===a.username ? `
<div class="edit-row">
<input id="editInput" value="${esc(a.display_name || a.username)}" placeholder="备注名称">
<button class="btn-save" onclick="saveEdit('${esc(a.username)}')">保存</button>
<button class="btn-cancel" onclick="cancelEdit()">取消</button>
</div>
` : `
<div class="name">
<a href="https://x.com/${esc(a.username)}" target="_blank">@${esc(a.username)}</a>
${a.display_name && a.display_name !== a.username ? `<span style="color:var(--text)">(${esc(a.display_name)})</span>` : ''}
</div>
<div class="meta">
添加: ${a.added_at?.slice(0,10) || '?'}
${a.last_check ? ` · 上次检查: ${a.last_check}` : ''}
</div>
<div class="actions">
<button class="btn-edit" onclick="startEdit('${esc(a.username)}','${esc(a.display_name||a.username)}')">✏️</button>
<button class="btn-del" onclick="removeAccount('${esc(a.username)}')">🗑</button>
</div>
`}
</div>
`).join('');
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/'/g,'&#39;'); }
async function addAccount() {
const inp = document.getElementById('urlInput');
const val = inp.value.trim();
if (!val) { toast('请输入链接或用户名', false); return; }
try {
const r = await api('POST', '', {url: val});
toast(r.message || '添加成功');
inp.value = '';
load();
} catch(e) { toast(e.message, false); }
}
async function removeAccount(username) {
if (!confirm(`确定删除 @${username}`)) return;
try {
const r = await api('DELETE', '/' + username);
toast(r.message || '已删除');
load();
} catch(e) { toast(e.message, false); }
}
function startEdit(username, name) {
editing = username;
load();
setTimeout(() => {
const inp = document.getElementById('editInput');
if (inp) { inp.focus(); inp.select(); }
}, 50);
}
function cancelEdit() { editing = null; load(); }
async function saveEdit(username) {
const val = document.getElementById('editInput').value.trim();
editing = null;
try {
const r = await api('PUT', '/' + username, {display_name: val});
toast(r.message || '已更新');
load();
} catch(e) { toast(e.message, false); }
}
document.getElementById('urlInput').addEventListener('keydown', e => {
if (e.key === 'Enter') addAccount();
});
load();
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # silent
def _send(self, code, body, content_type="application/json"):
body = body.encode() if isinstance(body, str) else json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", content_type + "; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _json(self, code, data):
self._send(code, data)
def _error(self, code, msg):
self._json(code, {"error": msg})
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path
if path == "/" or path == "/index.html":
self._send(200, HTML, "text/html")
return
if path.startswith("/api/accounts"):
username = path[len("/api/accounts"):].strip("/")
if username:
# GET /api/accounts/<username> — single account
users = get_watchlist()
state = get_state()
for u in users:
if u["username"].lower() == username.lower():
entry = dict(u)
entry["last_check"] = state.get(u["username"], {}).get("last_check")
self._json(200, entry)
return
self._error(404, "账号不存在")
return
# GET /api/accounts — list all
users = get_watchlist()
state = get_state()
accounts = []
for u in users:
entry = dict(u)
sc = state.get(u["username"], {})
ts = sc.get("last_check")
if ts:
try:
ts = datetime.fromisoformat(ts).strftime("%m-%d %H:%M")
except Exception:
pass
else:
ts = "从未"
entry["last_check"] = ts
accounts.append(entry)
self._json(200, {"accounts": accounts})
else:
self._error(404, "Not Found")
def do_POST(self):
path = urlparse(self.path).path
if path != "/api/accounts":
self._error(404, "Not Found")
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
url = body.get("url", "").strip()
if not url:
self._error(400, "缺少 url 参数")
return
try:
username = extract_username(url)
except ValueError:
self._error(400, "无法从输入中提取用户名,请输入 Twitter/X 链接或 @用户名")
return
users = get_watchlist()
if any(u["username"].lower() == username.lower() for u in users):
self._error(409, f"@{username} 已在监控列表中")
return
display_name = body.get("display_name", "").strip() or username
users.append({
"username": username,
"display_name": display_name,
"added_at": datetime.now(timezone.utc).isoformat(),
})
save_watchlist(users)
self._json(201, {"message": f"✅ 已添加 @{username}", "username": username})
def do_PUT(self):
path = urlparse(self.path).path
username = path[len("/api/accounts"):].strip("/")
if not username:
self._error(400, "缺少用户名")
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
display_name = body.get("display_name", "").strip()
users = get_watchlist()
for u in users:
if u["username"].lower() == username.lower():
if display_name:
u["display_name"] = display_name
save_watchlist(users)
self._json(200, {"message": f"✅ @{username} 已更新"})
return
self._error(404, "账号不存在")
def do_DELETE(self):
path = urlparse(self.path).path
username = path[len("/api/accounts"):].strip("/")
if not username:
self._error(400, "缺少用户名")
return
users = get_watchlist()
before = len(users)
users = [u for u in users if u["username"].lower() != username.lower()]
if len(users) < before:
save_watchlist(users)
self._json(200, {"message": f"🗑 已移除 @{username}"})
else:
self._error(404, "账号不存在")
def main():
print(f"🐦 Twitter 监控管理: http://0.0.0.0:{PORT}")
server = HTTPServer(("0.0.0.0", PORT), Handler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n已停止")
server.server_close()
if __name__ == "__main__":
main()