hmtxj
i18n: UI全中文化
e944f59
"""
Hugging Face Space - Gemini Business Cookie Refresher
Theme: macOS Native + Developer Hacker Aesthetic
"""
import gradio as gr
import subprocess
import threading
import time
import os
import json
import requests
import base64
import yaml
import html
import socket
from datetime import datetime
# --- 全局状态管理 ---
class LogSystem:
def __init__(self):
self.logs = [] # 存储结构: {"group": "Title", "status": "pending|success|error", "lines": [], "start_time": ""}
self.current_group_idx = -1
self.lock = threading.Lock()
def start_group(self, title):
with self.lock:
timestamp = datetime.now().strftime("%H:%M:%S")
self.logs.append({
"group": title,
"status": "running",
"lines": [],
"start_time": timestamp,
"id": str(int(time.time() * 1000))
})
self.current_group_idx = len(self.logs) - 1
def end_group(self, status="success"):
with self.lock:
if self.current_group_idx >= 0:
self.logs[self.current_group_idx]["status"] = status
def log(self, msg, level="info"):
"""
level: info (white/green), warn (yellow), error (red), debug (dim)
"""
with self.lock:
timestamp = datetime.now().strftime("%H:%M:%S")
clean_msg = msg.strip()
if not clean_msg:
return
# 如果没有活跃分组,自动创建一个
if self.current_group_idx == -1:
self.start_group("System Message")
self.logs[self.current_group_idx]["lines"].append({
"time": timestamp,
"msg": clean_msg,
"level": level
})
# 保持内存占用合理(增加到 100 条)
if len(self.logs) > 100:
self.logs = self.logs[-100:]
def get_html(self):
"""生成 Hacker 风格的 HTML 日志"""
html_content = '<div class="terminal-content">'
with self.lock:
# 倒序显示,最新的在最上面
for group in reversed(self.logs):
status_icon = "⏳"
status_class = "running"
if group["status"] == "success":
status_icon = "✅"
status_class = "success"
elif group["status"] == "error":
status_icon = "❌"
status_class = "error"
# Hacker Style details
html_content += f'''
<details class="log-group {status_class}" open>
<summary>
<span class="group-icon">{status_icon}</span>
<span class="group-title">{group["group"]}</span>
<span class="group-time">[{group["start_time"]}]</span>
</summary>
<div class="log-lines">
'''
for line in group["lines"]:
color_class = f"log-{line['level']}"
msg = html.escape(line['msg'])
# 关键词高亮
msg = msg.replace("Success", '<span class="glow-success">Success</span>')
msg = msg.replace("Failed", '<span class="glow-error">Failed</span>')
msg = msg.replace("Error", '<span class="glow-error">Error</span>')
html_content += f'''
<div class="log-line {color_class}">
<span class="line-time">[{line['time']}]</span>
<span class="line-msg">{msg}</span>
</div>
'''
html_content += '</div></details>'
html_content += '</div>'
return html_content
# --- 实例化日志系统 ---
logger = LogSystem()
is_refreshing = False
is_paused = False # 暂停状态
is_stopping = False # 停止状态
last_refresh_time = None
refresh_start_time = None # 任务开始时间(用于计算运行时长)
current_account_idx = 0 # 当前处理的账号索引
total_accounts = 0 # 总账号数
is_authenticated = False # 是否已登录
# 管理密码(从环境变量读取,默认为空表示无需密码)
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "").strip()
# 信号文件路径(用于与 refresh_accounts.py 脚本通信)
PAUSE_SIGNAL_FILE = "/tmp/gemini_pause_signal"
STOP_SIGNAL_FILE = "/tmp/gemini_stop_signal"
# --- CSS 样式定义 (macOS + Hacker: High Contrast Edition) ---
CUSTOM_CSS = """
/* 字体定义 */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600&display=swap');
:root {
/* --- 浅色模式变量 (默认) --- */
--bg-color: #f5f5f7; /* macOS Light Gray */
--card-bg: #ffffff;
--border-color: #e1e1e1;
--text-primary: #333333;
--text-secondary: #666666;
--term-bg: #f8f9fa;
--term-text: #333;
--term-border: #ddd;
--term-green: #27ae60;
--term-blue: #2980b9;
--term-red: #c0392b;
--term-yellow: #d35400;
--term-text-dim: #999;
--shadow-color: rgba(0, 0, 0, 0.1);
--log-group-bg: rgba(0, 0, 0, 0.03);
--log-group-hover: rgba(0, 0, 0, 0.05);
/* Gradio Override */
--body-background-fill: var(--bg-color);
--block-background-fill: var(--card-bg);
--block-border-color: var(--border-color);
}
/* --- 深色模式变量 --- */
body.dark-theme {
--bg-color: #121212;
--card-bg: #1e1e1e;
--border-color: #333;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--term-bg: #0a0a0a;
--term-text: #eee;
--term-border: #333;
--term-green: #2ecc71;
--term-blue: #3498db;
--term-red: #e74c3c;
--term-yellow: #f1c40f;
--term-text-dim: #999;
--shadow-color: rgba(0, 0, 0, 0.5);
--log-group-bg: rgba(255,255,255,0.03);
--log-group-hover: rgba(255,255,255,0.05);
/* Gradio Override */
--body-background-fill: var(--bg-color);
--block-background-fill: var(--card-bg);
--block-border-color: var(--border-color);
}
/* 核心修复:强制移除 Gradio 默认背景图案 */
body, .gradio-container {
background: var(--bg-color) !important;
background-image: none !important;
}
/* 隐藏无关元素 */
footer { display: none !important; }
/* 模拟 App 窗口容器 - Reverted to Centered Card */
.main-panel {
width: 100% !important;
max-width: 1200px !important; /* Increased width */
height: auto !important;
background: var(--card-bg) !important;
border: 1px solid var(--border-color) !important;
border-radius: 16px !important;
box-shadow: 0 30px 60px var(--shadow-color) !important;
overflow: hidden !important;
margin: 40px auto !important; /* Vertically/Horizontally centered */
}
/* Reset Gradio's internal layout constraints so they fit our card */
.gradio-container {
display: flex !important;
justify-content: center !important;
align-items: flex-start !important; /* Start from top with margin */
overflow-y: auto !important;
}
.gradio-container > * {
max-width: 1200px !important;
width: 100% !important;
}
/* 隐藏无关元素 */
footer { display: none !important; }
/* 模拟 App 窗口容器 */
.main-panel {
width: 100% !important;
max-width: none !important;
height: 100vh !important;
background: var(--card-bg) !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow: hidden !important;
margin: 0 !important;
}
/* 窗口标题栏控制点 */
.window-controls {
display: flex;
gap: 8px;
padding: 12px 18px;
background: var(--card-bg); /* 与卡片背景一致 */
border-bottom: 1px solid var(--border-color);
align-items: center;
justify-content: space-between;
user-select: none;
}
.controls-left { display: flex; gap: 8px; align-items: center; }
.dot { width: 12px; height: 12px; border-radius: 50%; }
.dot.red { background: #ff5f56; border: 1px solid #e0443e; }
.dot.yellow { background: #ffbd2e; border: 1px solid #dea123; }
.dot.green { background: #27c93f; border: 1px solid #1aab29; }
/* 主题切换按钮 */
#theme-toggle {
background: rgba(255, 255, 255, 0.08); /* 极淡的半透明背景 */
border: none;
color: var(--text-secondary);
padding: 6px 12px; /* 稍微加大一点点击区域 */
border-radius: 20px; /* Pill shape */
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.2s;
font-family: 'Inter', sans-serif;
outline: none;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 6px;
}
#theme-toggle:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
transform: translateY(-1px); /* 微妙的悬停上浮 */
}
#theme-toggle:active {
transform: translateY(0);
}
/* 深色模式下的按钮适配 */
body.dark-theme #theme-toggle {
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
}
body.dark-theme #theme-toggle:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
/* 终端窗口样式 */
.terminal-window {
background-color: var(--term-bg) !important;
border: 1px solid var(--term-border) !important;
border-top: none !important; /* 与上方无缝连接视觉 */
border-radius: 0 0 8px 8px !important;
padding: 0 !important;
height: 500px !important;
overflow-y: auto !important;
font-family: 'JetBrains Mono', 'Consolas', monospace !important;
font-size: 13px !important;
color: var(--term-text) !important;
position: relative;
transition: background-color 0.3s;
}
.terminal-content { padding: 15px; }
/* 日志分组 */
details.log-group {
margin-bottom: 8px;
border-left: 2px solid var(--border-color);
padding-left: 12px;
background: var(--log-group-bg);
border-radius: 0 4px 4px 0;
}
details.log-group[open] {
border-left-color: var(--term-blue);
background: rgba(52, 152, 219, 0.05);
}
details.log-group summary {
cursor: pointer;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
list-style: none;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
opacity: 0.9;
}
details.log-group summary::marker { display: none; }
.group-time {
color: var(--term-text-dim);
font-size: 0.85em;
margin-left: auto;
font-family: 'JetBrains Mono';
}
/* 日志行 */
.log-lines { margin-top: 5px; }
.log-line {
padding: 2px 0;
line-height: 1.5;
display: flex;
gap: 12px;
font-family: 'JetBrains Mono', monospace;
}
.line-time { color: var(--term-text-dim); min-width: 75px; font-size: 12px;}
/* 颜色 */
.log-info { color: var(--term-text); opacity: 0.9; }
.log-warn { color: var(--term-yellow); }
.log-error { color: var(--term-red); background: rgba(231, 76, 60, 0.05); }
.log-debug { color: var(--term-text-dim); }
.glow-success { color: var(--term-green); font-weight: bold; }
.glow-error { color: var(--term-red); font-weight: bold; }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #666; }
/* Action Button */
.action-btn {
font-family: 'Inter', sans-serif !important;
font-weight: 600 !important;
background: linear-gradient(180deg, #34d058 0%, #28a745 100%) !important;
border: 1px solid rgba(0,0,0,0.1) !important;
color: white !important;
border-radius: 6px !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;
}
.action-btn:hover {
filter: brightness(1.05);
}
/* Stop Button (红色样式) */
button[variant="stop"], .stop-btn {
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%) !important;
border: 1px solid rgba(0,0,0,0.15) !important;
color: white !important;
}
button[variant="stop"]:hover, .stop-btn:hover {
filter: brightness(1.1);
}
/* Status Badges */
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
font-family: 'Inter';
border: 1px solid var(--border-color);
}
.status-active {
background: rgba(52, 152, 219, 0.1);
color: var(--term-blue);
border-color: var(--term-blue);
}
.last-run {
color: var(--text-secondary);
font-family: 'Inter';
font-size: 11px;
margin-top: 4px;
text-align: right;
}
/* 脉冲动画 - 运行中指示 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes progress-bar {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}
.running-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
animation: pulse 1.5s infinite ease-in-out;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--term-blue);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 进度条 */
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--border-color);
border-radius: 2px;
margin: 8px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--term-blue), var(--term-green), var(--term-blue));
background-size: 200% 100%;
animation: progress-bar 2s linear infinite;
border-radius: 2px;
transition: width 0.5s ease;
}
/* 运行时间显示 */
.runtime-display {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--term-green);
background: var(--term-bg);
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--term-border);
}
/* 登录界面 */
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: 0 20px 40px var(--shadow-color);
text-align: center;
}
.login-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.login-input {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-color);
color: var(--text-primary);
margin-bottom: 16px;
outline: none;
transition: border-color 0.2s;
}
.login-input:focus {
border-color: var(--term-blue);
}
.login-btn {
width: 100%;
padding: 12px;
font-size: 16px;
font-weight: 600;
color: white;
background: linear-gradient(180deg, #34d058 0%, #28a745 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: filter 0.2s;
}
.login-btn:hover {
filter: brightness(1.1);
}
.login-error {
color: var(--term-red);
font-size: 14px;
margin-top: 12px;
}
/* 隐藏内容 (未登录时) */
.blur-content {
filter: blur(10px);
pointer-events: none;
user-select: none;
}
/* 锁定提示 */
.locked-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
border-radius: 8px;
}
.locked-overlay span {
font-size: 48px;
}
"""
# --- 核心逻辑 ---
def parse_v2ray_uri(uri):
"""解析单个 V2Ray/Hysteria2 URI 并转换为 Clash 代理配置"""
import urllib.parse
try:
if uri.startswith('hysteria2://'):
# hysteria2://uuid@host:port/?params#name
parts = uri.replace('hysteria2://', '').split('#')
name = urllib.parse.unquote(parts[1]) if len(parts) > 1 else 'Hysteria2'
auth_host = parts[0].split('?')[0]
uuid, host_port = auth_host.split('@')
host, port = host_port.rsplit(':', 1)
return {
'name': name,
'type': 'hysteria2',
'server': host,
'port': int(port),
'password': uuid,
'skip-cert-verify': True
}
elif uri.startswith('vless://'):
# vless://uuid@host:port?params#name
parts = uri.replace('vless://', '').split('#')
name = urllib.parse.unquote(parts[1]) if len(parts) > 1 else 'VLESS'
auth_host = parts[0].split('?')[0]
params_str = parts[0].split('?')[1] if '?' in parts[0] else ''
params = dict(urllib.parse.parse_qsl(params_str))
uuid, host_port = auth_host.split('@')
host, port = host_port.rsplit(':', 1)
proxy = {
'name': name,
'type': 'vless',
'server': host,
'port': int(port),
'uuid': uuid,
'tls': params.get('security', 'none') != 'none',
'skip-cert-verify': True,
'network': params.get('type', 'tcp')
}
if params.get('security') == 'reality':
proxy['reality-opts'] = {
'public-key': params.get('pbk', ''),
'short-id': params.get('sid', '')
}
proxy['servername'] = params.get('sni', '')
proxy['client-fingerprint'] = params.get('fp', 'chrome')
proxy['flow'] = params.get('flow', '')
if params.get('type') == 'ws':
proxy['ws-opts'] = {
'path': urllib.parse.unquote(params.get('path', '/')),
'headers': {'Host': params.get('host', host)}
}
proxy['servername'] = params.get('sni', host)
return proxy
return None
except Exception as e:
return None
def convert_uris_to_clash(content):
"""将 V2Ray URI 列表转换为 Clash YAML 配置"""
lines = content.strip().split('\n')
proxies = []
for line in lines:
line = line.strip()
if not line:
continue
proxy = parse_v2ray_uri(line)
if proxy:
proxies.append(proxy)
if not proxies:
return None
# 构建 Clash 配置
config = {
'mixed-port': 7890,
'allow-lan': True,
'mode': 'global',
'external-controller': '127.0.0.1:9090',
'proxies': proxies,
'proxy-groups': [{
'name': 'GLOBAL',
'type': 'select',
'proxies': [p['name'] for p in proxies]
}]
}
import yaml
return yaml.dump(config, allow_unicode=True)
def parse_subscription(sub_url):
"""解析订阅链接"""
logger.log(f"Downloading subscription: {sub_url[:30]}...", "info")
try:
resp = requests.get(sub_url, timeout=30, verify=False)
content = resp.text
# Base64 解码检查
try:
decoded = base64.b64decode(content).decode('utf-8')
content = decoded
logger.log("Base64 decoded successfully", "info")
except:
pass
# 检查是否是 URI 列表(V2Ray 格式)
if content.startswith('hysteria2://') or content.startswith('vless://') or content.startswith('vmess://'):
logger.log("Detected V2Ray URI list, converting to Clash format...", "info")
clash_yaml = convert_uris_to_clash(content)
if clash_yaml:
logger.log(f"Converted {len(content.strip().split(chr(10)))} nodes to Clash format", "success")
return clash_yaml
else:
logger.log("Failed to convert URI list to Clash format", "error")
return None
return content
except Exception as e:
logger.log(f"Download failed: {str(e)}", "error")
return None
def setup_clash():
"""设置 Clash 代理"""
logger.start_group("🛠️ Setup Proxy (Clash)")
sub_url = os.environ.get("CLASH_SUB_URL", "")
clash_config = os.environ.get("CLASH_CONFIG", "")
try:
if clash_config:
logger.log("Using CLASH_CONFIG env var", "info")
with open("config.yaml", "w", encoding='utf-8') as f:
f.write(clash_config)
logger.log("config.yaml written", "success")
elif sub_url:
logger.log(f"Downloading sub: {sub_url}", "info")
content = parse_subscription(sub_url)
if content:
# 验证内容是否为有效的 YAML
try:
import yaml
test_config = yaml.safe_load(content)
if not isinstance(test_config, dict) or 'proxies' not in test_config:
logger.log("Downloaded content is not a valid Clash config (missing 'proxies')", "error")
logger.log(f"Content preview: {content[:200]}...", "warn")
logger.end_group("error")
return False
except yaml.YAMLError as e:
logger.log(f"Downloaded content is not valid YAML: {e}", "error")
logger.log(f"Content preview: {content[:200]}...", "warn")
logger.end_group("error")
return False
with open("config.yaml", "w", encoding='utf-8') as f:
f.write(content)
logger.log("Subscription saved to config.yaml", "success")
else:
logger.log("Failed to download subscription", "error")
logger.end_group("error")
return False
else:
logger.log("No proxy config found (CLASH_CONFIG/CLASH_SUB_URL)", "warn")
logger.end_group("error")
return False
# 调用 update_clash_config.py 配置代理(选择节点、设置端口等)
logger.log("Configuring Clash (update_clash_config.py)...", "info")
try:
config_result = subprocess.run(
["python", "update_clash_config.py", "config.yaml"],
capture_output=True, text=True, timeout=30
)
if config_result.returncode == 0:
for line in config_result.stdout.strip().split('\n'):
if line.strip():
logger.log(line.strip(), "info")
else:
logger.log(f"Config update failed: {config_result.stderr}", "warn")
except Exception as e:
logger.log(f"Config update error: {e}", "warn")
# 启动 Clash(不再静默错误输出)
logger.log("Starting Clash process...", "info")
clash_process = subprocess.Popen(
["/usr/local/bin/clash", "-f", "config.yaml"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# 等待更长时间让 Clash 启动
time.sleep(5)
# 检查进程是否存活
if clash_process.poll() is not None:
# 进程已退出,读取错误信息
stdout, stderr = clash_process.communicate()
logger.log(f"Clash exited unexpectedly!", "error")
if stderr:
logger.log(f"Clash error: {stderr[:500]}", "error")
logger.end_group("error")
return False
logger.log("Clash process running, waiting for proxy to be ready...", "info")
# 健康检查:验证代理端口是否可用
proxy_ready = False
max_retries = 10
for i in range(max_retries):
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('127.0.0.1', 7890))
sock.close()
if result == 0:
proxy_ready = True
logger.log(f"✅ Proxy port 7890 is ready! (attempt {i+1}/{max_retries})", "success")
break
else:
logger.log(f"Waiting for proxy... ({i+1}/{max_retries})", "debug")
time.sleep(2)
except Exception as e:
logger.log(f"Health check error: {e}", "debug")
time.sleep(2)
if not proxy_ready:
logger.log("❌ Proxy port 7890 not responding after 20 seconds!", "error")
logger.end_group("error")
return False
# 额外测试:通过代理访问外网(使用 HTTP 避免 SSL 问题)
test_urls = [
("http://ip-api.com/json", "IP-API"), # 纯 HTTP,无 SSL
("http://httpbin.org/ip", "HTTPBin"), # 备用
]
proxy_test_passed = False
for test_url, test_name in test_urls:
try:
test_resp = requests.get(
test_url,
proxies={"http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890"},
timeout=15
)
if test_resp.status_code == 200:
logger.log(f"✅ Proxy test passed via {test_name}!", "success")
# 尝试解析返回的 IP 信息
try:
ip_info = test_resp.json()
if 'query' in ip_info: # ip-api 格式
logger.log(f" Proxy IP: {ip_info.get('query')} ({ip_info.get('country', 'Unknown')})", "info")
elif 'origin' in ip_info: # httpbin 格式
logger.log(f" Proxy IP: {ip_info.get('origin')}", "info")
except:
pass
proxy_test_passed = True
break
else:
logger.log(f"⚠️ {test_name} returned status {test_resp.status_code}", "warn")
except Exception as e:
logger.log(f"⚠️ {test_name} test failed: {str(e)[:100]}", "warn")
if not proxy_test_passed:
logger.log("⚠️ All proxy connectivity tests failed, but port is open - continuing anyway", "warn")
logger.log("Clash started and verified", "success")
logger.end_group("success")
return True
except Exception as e:
logger.log(f"Proxy setup error: {e}", "error")
logger.end_group("error")
return False
def execute_script(script_name, args=[], timeout=3600):
"""通用脚本执行器,支持实时日志抓取"""
command = ["python", "-u", script_name] + args # -u 禁用缓冲
try:
# 尝试设置显示环境变量
env = os.environ.copy()
env["PROXY_URL"] = "http://127.0.0.1:7890"
env["DISPLAY"] = ":99"
env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
text=False, # Binary mode for manual decoding
bufsize=1
)
# 实时读取
for line_bytes in iter(process.stdout.readline, b''):
if line_bytes:
# Robust decoding logic - 优先 UTF-8(Linux/HF 环境)
line = ""
try:
line = line_bytes.decode('utf-8')
except UnicodeDecodeError:
try:
# Windows 备用
line = line_bytes.decode('gb18030')
except UnicodeDecodeError:
line = line_bytes.decode('utf-8', errors='replace')
if line.strip():
# 简单的日志级别判断
level = "info"
if "Error" in line or "Exception" in line or "失败" in line or "Traceback" in line:
level = "error"
elif "Warning" in line:
level = "warn"
logger.log(line.strip(), level)
process.wait()
return process.returncode
except Exception as e:
logger.log(f"Execution error: {e}", "error")
return -1
def run_refresh_logic():
"""执行完整的刷新流程"""
global is_refreshing, last_refresh_time, refresh_start_time, current_account_idx, total_accounts
if is_refreshing:
return
is_refreshing = True
refresh_start_time = time.time() # 记录开始时间
current_account_idx = 0
total_accounts = 0
# 0. 确保代理已设置
logger.start_group("🔍 Pre-flight Check")
# 检查代理端口是否可用
import socket
proxy_ok = False
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('127.0.0.1', 7890))
sock.close()
proxy_ok = (result == 0)
except:
pass
if not proxy_ok:
logger.log("Proxy not ready, attempting to setup...", "warn")
logger.end_group("running")
if not setup_clash():
logger.start_group("❌ Pre-flight Failed")
logger.log("Cannot proceed without working proxy!", "error")
logger.end_group("error")
is_refreshing = False
last_refresh_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return
else:
logger.log("✅ Proxy is ready", "success")
logger.end_group("success")
# 1. 刷新账号
logger.start_group("🔄 Refresh Accounts")
try:
code = execute_script("refresh_accounts.py", ["--force"], timeout=3600)
if code == 0:
logger.log("Refresh script completed", "success")
logger.end_group("success")
# 2. 同步数据
logger.start_group("📤 Sync to 2API")
sync_code = execute_script("sync_to_db.py", timeout=300)
if sync_code == 0:
logger.log("Sync completed", "success")
logger.end_group("success")
else:
logger.log("Sync script failed", "error")
logger.end_group("error")
else:
logger.log("Refresh script failed", "error")
logger.end_group("error")
except Exception as e:
logger.log(f"Workflow exception: {str(e)}", "error")
logger.end_group("error")
finally:
is_refreshing = False
last_refresh_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
logger.start_group("🏁 Workflow Finished")
logger.log(f"Completed at {last_refresh_time}", "info")
logger.log("⚠️ Check logs above for success/failure details!", "warn")
logger.end_group("success")
def manual_start():
global is_paused
if is_refreshing:
return "Already running"
if is_paused:
return "Task is paused"
threading.Thread(target=run_refresh_logic).start()
return "Started"
def toggle_pause():
"""切换暂停状态(同时通知正在运行的脚本)"""
global is_paused
is_paused = not is_paused
# 创建或删除暂停信号文件(通知 refresh_accounts.py)
try:
if is_paused:
with open(PAUSE_SIGNAL_FILE, 'w') as f:
f.write(str(time.time()))
else:
if os.path.exists(PAUSE_SIGNAL_FILE):
os.remove(PAUSE_SIGNAL_FILE)
except Exception as e:
logger.log(f"信号文件操作失败: {e}", "warn")
status = "⛸️ 已暂停" if is_paused else "▶️ 已恢复"
logger.log(f"{status} - {'脚本将在下一个账号暂停' if is_paused else '脚本继续运行'}", "warn" if is_paused else "info")
return get_pause_btn_text()
def get_pause_btn_text():
"""获取暂停按钮文字"""
return "▶️ 继续" if is_paused else "⛸️ 暂停"
def stop_task():
"""彻底停止任务,保存数据并刷新状态"""
global is_stopping, is_refreshing, is_paused
if not is_refreshing:
logger.log("⚠️ 没有正在运行的任务", "warn")
return "没有运行中的任务"
is_stopping = True
logger.start_group("🛑 Stopping Task")
logger.log("正在停止任务...", "warn")
# 创建停止信号文件(通知 refresh_accounts.py)
try:
with open(STOP_SIGNAL_FILE, 'w') as f:
f.write(str(time.time()))
logger.log("已发送停止信号", "info")
except Exception as e:
logger.log(f"信号文件操作失败: {e}", "error")
# 等待脚本响应(最多 10 秒)
for i in range(10):
time.sleep(1)
if not is_refreshing:
break
logger.log(f"等待脚本停止... ({i+1}/10)", "debug")
# 清理信号文件
try:
if os.path.exists(STOP_SIGNAL_FILE):
os.remove(STOP_SIGNAL_FILE)
if os.path.exists(PAUSE_SIGNAL_FILE):
os.remove(PAUSE_SIGNAL_FILE)
except:
pass
# 强制同步数据到数据库
logger.log("正在保存数据到数据库...", "info")
try:
sync_code = execute_script("sync_to_db.py", timeout=60)
if sync_code == 0:
logger.log("✅ 数据已保存", "success")
else:
logger.log("⚠️ 数据保存可能失败", "warn")
except Exception as e:
logger.log(f"数据保存错误: {e}", "error")
# 重置状态
is_stopping = False
is_refreshing = False
is_paused = False
logger.log("任务已停止", "info")
logger.end_group("success")
return "任务已停止"
def get_status_html():
"""生成带动态效果的状态 HTML"""
if is_stopping:
return '''
<div class="running-indicator">
<div class="spinner" style="border-color: #e74c3c; border-top-color: transparent;"></div>
<span style="color: #e74c3c; font-weight: 600;">🛑 正在停止...</span>
</div>
'''
if is_refreshing:
# 计算运行时间
runtime_str = ""
if refresh_start_time:
elapsed = time.time() - refresh_start_time
mins = int(elapsed // 60)
secs = int(elapsed % 60)
runtime_str = f"{mins:02d}:{secs:02d}"
# 计算进度
progress_pct = 0
progress_text = ""
if total_accounts > 0:
progress_pct = min(100, int((current_account_idx / total_accounts) * 100))
progress_text = f"{current_account_idx}/{total_accounts}"
return f'''
<div style="text-align: center;">
<div class="status-badge" style="background: rgba(241, 196, 15, 0.1); color: #f1c40f; border-color: #f1c40f;">
⛸️ 已暂停
</div>
<div class="runtime-display" style="margin-top: 8px; color: #f1c40f;">
⏱️ {runtime_str} | 📊 {progress_text}
</div>
</div>
'''
return f'''
<div style="text-align: center;">
<div class="running-indicator">
<div class="spinner"></div>
<span style="color: var(--term-blue); font-weight: 600;">🔄 运行中</span>
</div>
<div class="runtime-display" style="margin-top: 8px;">
⏱️ {runtime_str} | 📊 {progress_text}
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: {progress_pct}%;"></div>
</div>
</div>
'''
if is_paused:
return '<div class="status-badge" style="background: rgba(241, 196, 15, 0.1); color: #f1c40f; border-color: #f1c40f;">⛸️ 已暂停</div>'
return '<div class="status-badge status-idle">💤 空闲</div>'
def get_time_str():
return f"上次运行: {last_refresh_time or '从未运行'}"
def check_auth(password):
"""验证管理密码"""
global is_authenticated
if not ADMIN_PASSWORD: # 未设置密码则无需验证
is_authenticated = True
return True, ""
if password == ADMIN_PASSWORD:
is_authenticated = True
return True, ""
return False, "❌ 密码错误"
def logout():
"""登出"""
global is_authenticated
is_authenticated = False
return gr.update(visible=True), gr.update(visible=False)
def scheduler_loop():
"""定时任务循环"""
global last_refresh_success, is_paused
time.sleep(60)
while True:
# 检查暂停状态
if is_paused:
logger.start_group("⛸️ Scheduled Task Skipped")
logger.log("任务已暂停,跳过本次执行", "warn")
logger.end_group("warn")
time.sleep(60 * 60) # 1 小时后再检查
continue
logger.start_group("⏰ Scheduled Task Triggered")
logger.end_group("success")
# 记录开始时间,用于判断是否成功
start_logs_count = len(logger.logs)
run_refresh_logic()
# 检查日志中是否有失败标记(简单判断:如果最近的日志包含失败相关关键词)
has_failure = False
with logger.lock:
for log_group in logger.logs[start_logs_count:]:
if log_group.get("status") == "error":
has_failure = True
break
for line in log_group.get("lines", []):
if "❌" in line.get("msg", "") or "失败" in line.get("msg", "") or "代理预检失败" in line.get("msg", ""):
has_failure = True
break
if has_failure:
wait_hours = 1
logger.log(f"⚠️ 检测到失败,{wait_hours} 小时后重试...", "warn")
else:
wait_hours = 11
logger.log(f"✅ 刷新完成,{wait_hours} 小时后运行下一次", "info")
time.sleep(wait_hours * 60 * 60)
# --- 初始化 ---
def init_app():
# 启动后台线程
t = threading.Thread(target=scheduler_loop, daemon=True)
t.start()
# 初始化代理(同步执行,确保启动前代理就绪)
logger.start_group("🚀 App Initialization")
logger.log("Starting Gemini Business Refresher...", "info")
logger.end_group("success")
# 延迟启动代理设置(在后台线程中执行,不阻塞 UI 加载)
def delayed_proxy_setup():
time.sleep(5) # 等待 Gradio UI 加载完成
setup_clash()
threading.Thread(target=delayed_proxy_setup, daemon=True).start()
init_app()
# --- Gradio UI 构建 ---
JS_CODE = """
function() {
// 初始化颜色模式 - 默认浅色(CSS 已默认浅色,只需处理深色)
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
}
}
"""
with gr.Blocks() as app:
# ========== 登录界面 ==========
with gr.Column(visible=bool(ADMIN_PASSWORD)) as login_page:
gr.HTML('''
<div class="login-container">
<div class="login-title">🔐 Gemini 账号刷新器</div>
<div class="login-subtitle">请输入管理密码</div>
</div>
''')
with gr.Column(elem_classes=["login-container"]):
password_input = gr.Textbox(
label="",
placeholder="输入密码...",
type="password",
elem_classes=["login-input"]
)
login_btn = gr.Button("🔓 登录", elem_classes=["login-btn"])
login_error = gr.HTML("")
# ========== 主界面 ==========
with gr.Column(visible=not bool(ADMIN_PASSWORD)) as main_page:
with gr.Row(elem_classes=["main-panel"]):
with gr.Column(scale=1):
# 模拟 macOS 窗口标题栏 + 主题切换 + 登出按钮
gr.HTML("""
<div class="window-controls">
<div class="controls-left">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
<div style="margin-left: 10px; color: var(--text-secondary); font-size: 13px; font-weight: 600;">Gemini 刷新器</div>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="theme-toggle">🌘 深色</button>
</div>
</div>
<script>
// 定义全局切换函数
function toggleTheme() {
document.body.classList.toggle('dark-theme');
var isDark = document.body.classList.contains('dark-theme');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
var btn = document.getElementById('theme-toggle');
if(btn) btn.innerText = isDark ? '☀️ 浅色' : '🌘 深色';
}
// 初始化主题 - 默认浅色(CSS 已默认浅色)
(function() {
var savedTheme = localStorage.getItem('theme');
// 只有明确存了 'dark' 才用深色
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
}
// 绑定点击事件
var btn = document.getElementById('theme-toggle');
if (btn) {
btn.onclick = toggleTheme;
// 更新按钮文字
var isDark = document.body.classList.contains('dark-theme');
btn.innerText = isDark ? '☀️ 浅色' : '🌘 深色';
}
// 延迟再检查一次(防止 Gradio 动态加载覆盖)
setTimeout(function() {
var btn2 = document.getElementById('theme-toggle');
if (btn2 && !btn2.onclick) {
btn2.onclick = toggleTheme;
}
var isDark2 = document.body.classList.contains('dark-theme');
if (btn2) btn2.innerText = isDark2 ? '☀️ 浅色' : '🌘 深色';
}, 500);
})();
</script>
""")
# 控制台区域
with gr.Row(elem_classes=["p-4"]):
with gr.Column(scale=3):
gr.Markdown("### 💎 Gemini 账号刷新器\n自动化 Cookie 刷新 & 数据库同步系统")
with gr.Column(scale=2):
# 状态显示 - 每秒刷新
status_indicator = gr.HTML(value=get_status_html, every=1)
last_run_text = gr.HTML(value=get_time_str, every=5, elem_classes=["last-run"])
with gr.Column(scale=1):
# 操作按钮
with gr.Row():
refresh_btn = gr.Button("🚀 执行刷新", variant="primary", elem_classes=["action-btn"])
pause_btn = gr.Button(get_pause_btn_text(), variant="secondary", elem_classes=["action-btn"])
stop_btn = gr.Button("🛑 停止", variant="stop", elem_classes=["action-btn"])
# 登出按钮
logout_btn = gr.Button("🚪 登出", size="sm", visible=bool(ADMIN_PASSWORD))
# 终端日志窗口
log_display = gr.HTML(
elem_classes=["terminal-window"],
value=logger.get_html,
every=1 # 每秒刷新一次 HTML 内容
)
# ========== 事件绑定 ==========
def do_login(password):
success, error = check_auth(password)
if success:
return gr.update(visible=False), gr.update(visible=True), ""
return gr.update(visible=True), gr.update(visible=False), f'<div class="login-error">{error}</div>'
def do_logout():
global is_authenticated
is_authenticated = False
return gr.update(visible=True), gr.update(visible=False)
login_btn.click(
fn=do_login,
inputs=[password_input],
outputs=[login_page, main_page, login_error]
)
password_input.submit(
fn=do_login,
inputs=[password_input],
outputs=[login_page, main_page, login_error]
)
logout_btn.click(fn=do_logout, outputs=[login_page, main_page])
refresh_btn.click(fn=manual_start, outputs=None)
pause_btn.click(fn=toggle_pause, outputs=pause_btn)
stop_btn.click(fn=stop_task, outputs=None)
if __name__ == "__main__":
# Gradio 6.0+ compatibility
app.launch(
server_name="0.0.0.0",
server_port=7860,
css=CUSTOM_CSS,
js=JS_CODE
)
# Force Update 01/20/2026 20:40:00 - UI全中文化