381 lines
14 KiB
Python
381 lines
14 KiB
Python
#!/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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>').replace(/'/g,'''); }
|
||
|
||
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()
|