| """ |
| 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 = [] |
| 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 |
| }) |
| |
| 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" |
| |
| |
| 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() |
|
|
| |
| PAUSE_SIGNAL_FILE = "/tmp/gemini_pause_signal" |
| STOP_SIGNAL_FILE = "/tmp/gemini_stop_signal" |
|
|
| |
| 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://'): |
| |
| 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://'): |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| try: |
| decoded = base64.b64decode(content).decode('utf-8') |
| content = decoded |
| logger.log("Base64 decoded successfully", "info") |
| except: |
| pass |
| |
| |
| 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: |
| |
| 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 |
|
|
| |
| 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") |
|
|
| |
| 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 |
| ) |
| |
| |
| 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 |
| |
| |
| test_urls = [ |
| ("http://ip-api.com/json", "IP-API"), |
| ("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") |
| |
| try: |
| ip_info = test_resp.json() |
| if 'query' in ip_info: |
| logger.log(f" Proxy IP: {ip_info.get('query')} ({ip_info.get('country', 'Unknown')})", "info") |
| elif 'origin' in ip_info: |
| 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 |
| |
| 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, |
| bufsize=1 |
| ) |
| |
| |
| for line_bytes in iter(process.stdout.readline, b''): |
| if line_bytes: |
| |
| line = "" |
| try: |
| line = line_bytes.decode('utf-8') |
| except UnicodeDecodeError: |
| try: |
| |
| 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 |
| |
| |
| 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") |
| |
| |
| 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") |
| |
| |
| 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 |
| |
| |
| 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") |
| |
| |
| 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") |
| |
| |
| 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) |
| 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") |
| |
| |
| def delayed_proxy_setup(): |
| time.sleep(5) |
| setup_clash() |
| |
| threading.Thread(target=delayed_proxy_setup, daemon=True).start() |
|
|
| init_app() |
|
|
| |
| 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): |
| |
| 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 |
| ) |
| |
| |
| 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__": |
| |
| app.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| css=CUSTOM_CSS, |
| js=JS_CODE |
| ) |
| |
|
|