1 line
16 KiB
JSON
1 line
16 KiB
JSON
{"repo": "butteryiyi/talk", "files": {"app.py": {"a57df361159da4d2bd891669839bed7213702e99": {"t": 1785674067.2076998, "n": 14411, "h": "1f83599701dba475", "c": "# app.py\r\n\"\"\"\r\n主服务器:FastAPI + WebSocket 代理\r\n外部请求通过 HTTP/WebSocket 进入,由内部浏览器通过 DOM 交互执行。\r\n\"\"\"\r\n\r\nimport os\r\nimport sys\r\nimport json\r\nimport asyncio\r\nimport time\r\nfrom datetime import datetime\r\nfrom contextlib import asynccontextmanager\r\nfrom browser_manager import BrowserManager, HEARTBEAT_MARKER\r\n\r\nfrom fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request\r\nfrom fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse\r\nfrom fastapi.security import HTTPBearer\r\nimport uvicorn\r\n\r\nfrom browser_manager import BrowserManager, HEARTBEAT_MARKER\r\nfrom keepalive import KeepaliveService\r\n\r\n# ============================================================\r\n# 全局实例 & 配置\r\n# ============================================================\r\nbrowser_mgr: BrowserManager = None\r\nkeepalive_svc: KeepaliveService = None\r\n\r\nAPI_SECRET_KEY = os.getenv(\"API_SECRET_KEY\", \"123456\")\r\nsecurity = HTTPBearer(auto_error=False)\r\n\r\n\r\ndef verify_api_key(request: Request):\r\n auth_header = request.headers.get(\"authorization\", \"\")\r\n if auth_header.startswith(\"Bearer \"):\r\n token = auth_header[7:].strip()\r\n if token == API_SECRET_KEY:\r\n return True\r\n\r\n x_api_key = request.headers.get(\"x-api-key\", \"\").strip()\r\n if x_api_key == API_SECRET_KEY:\r\n return True\r\n\r\n api_key_param = request.query_params.get(\"api_key\", \"\").strip()\r\n if api_key_param == API_SECRET_KEY:\r\n return True\r\n\r\n raise HTTPException(\r\n status_code=401,\r\n detail={\r\n \"error\": {\r\n \"message\": \"Invalid API key.\",\r\n \"type\": \"invalid_request_error\",\r\n \"code\": \"invalid_api_key\",\r\n }\r\n },\r\n )\r\n\r\n\r\nasync def ensure_browser_ready():\r\n if browser_mgr is None:\r\n raise HTTPException(status_code=503, detail=\"服务正在启动中,请稍后重试\")\r\n\r\n if not browser_mgr.is_ready:\r\n print(\" ⏳ 请求到达,等待浏览器初始化完成...\")\r\n ok = await browser_mgr.wait_until_ready(timeout=180)\r\n if not ok:\r\n raise HTTPException(status_code=503, detail=\"浏览器初始化超时,请稍后重试\")\r\n\r\n if not await browser_mgr.is_alive():\r\n raise HTTPException(status_code=503, detail=\"浏览器会话已断开\")\r\n\r\n # ═══ 请求到达时检查登录状态 ═══\r\n if not browser_mgr.logged_in:\r\n print(\" ⚠️ 请求到达但未登录,尝试重新登录...\")\r\n success = await browser_mgr.re_login()\r\n if not success:\r\n raise HTTPException(\r\n status_code=503,\r\n detail=\"登录状态失效且重新登录失败,请更新 Cookie\"\r\n )\r\n\r\n # 每 20 个请求做一次深度检查\r\n if browser_mgr.total_requests % 20 == 0:\r\n is_ok = await browser_mgr.check_login_status()\r\n if not is_ok:\r\n print(\" ⚠️ 抽查发现登录失效,尝试重新登录...\")\r\n\r\n\r\n@asynccontextmanager\r\nasync def lifespan(app: FastAPI):\r\n global browser_mgr, keepalive_svc\r\n\r\n print(f\"\\n{'='*60}\")\r\n print(f\" 应用启动 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\")\r\n print(f\" 模式: DOM 交互(无需手动 PoW)\")\r\n print(f\" API_SECRET_KEY: {API_SECRET_KEY[:3]}{'*' * (len(API_SECRET_KEY) - 3)}\")\r\n print(f\"{'='*60}\\n\")\r\n\r\n browser_mgr = BrowserManager()\r\n keepalive_svc = KeepaliveService(browser_mgr)\r\n\r\n asyncio.create_task(initialize_background())\r\n\r\n print(\"🚀 服务已就绪(浏览器后台初始化中),等待请求...\\n\")\r\n yield\r\n\r\n print(\"\\n⏹️ 正在关闭服务...\")\r\n if keepalive_svc:\r\n await keepalive_svc.stop()\r\n if browser_mgr:\r\n await browser_mgr.shutdown()\r\n print(\"✅ 服务已安全关闭。\")\r\n\r\n\r\nasync def initialize_background():\r\n global browser_mgr, keepalive_svc\r\n print(\"⏳ 后台任务:开始初始化浏览器...\")\r\n try:\r\n await browser_mgr.initialize()\r\n print(\"✅ 后台任务:浏览器初始化完成。\")\r\n\r\n if keepalive_svc and not keepalive_svc.is_running:\r\n await keepalive_svc.start()\r\n except Exception as e:\r\n print(f\"❌ 后台任务:浏览器初始化失败: {e}\")\r\n import traceback\r\n traceback.print_exc()\r\n\r\n\r\napp = FastAPI(title=\"DeepSeek Proxy\", lifespan=lifespan)\r\n\r\n\r\n# ============================================================\r\n# 健康检查 & 状态页\r\n# ============================================================\r\n@app.get(\"/\")\r\nasync def index():\r\n status = await browser_mgr.get_status() if browser_mgr else {\"status\": \"initializing\"}\r\n uptime = status.get(\"uptime_seconds\", 0)\r\n hours, remainder = divmod(int(uptime), 3600)\r\n minutes, seconds = divmod(remainder, 60)\r\n\r\n ready = status.get(\"ready\", False)\r\n alive = status.get(\"browser_alive\", False)\r\n\r\n if ready and alive:\r\n status_text = \"运行中\"\r\n dot_color = \"#3fb950\"\r\n elif not ready:\r\n status_text = \"初始化中...\"\r\n dot_color = \"#f0ad4e\"\r\n else:\r\n status_text = \"离线\"\r\n dot_color = \"#f85149\"\r\n\r\n html = f\"\"\"\r\n <!DOCTYPE html>\r\n <html>\r\n <head>\r\n <title>DeepSeek Proxy</title>\r\n <meta http-equiv=\"refresh\" content=\"10\">\r\n <style>\r\n body {{ font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9;\r\n display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }}\r\n .card {{ background: #161b22; border: 1px solid #30363d; border-radius: 12px;\r\n padding: 40px; max-width: 500px; width: 90%; }}\r\n h1 {{ color: #58a6ff; margin-top: 0; }}\r\n .status {{ display: flex; align-items: center; gap: 10px; margin: 20px 0; }}\r\n .dot {{ width: 12px; height: 12px; border-radius: 50%; background: {dot_color}; }}\r\n .info {{ background: #21262d; padding: 15px; border-radius: 8px; margin: 10px 0;\r\n font-family: monospace; font-size: 14px; }}\r\n .label {{ color: #8b949e; }}\r\n </style>\r\n </head>\r\n <body>\r\n <div class=\"card\">\r\n <h1>🤖 DeepSeek Proxy</h1>\r\n <div class=\"status\">\r\n <div class=\"dot\"></div>\r\n <span>{status_text}</span>\r\n </div>\r\n <div class=\"info\">\r\n <div><span class=\"label\">运行时间:</span>{hours}h {minutes}m {seconds}s</div>\r\n <div><span class=\"label\">就绪状态:</span>{\"✅ 就绪\" if ready else \"⏳ 初始化中\"}</div>\r\n <div><span class=\"label\">登录状态:</span>{\"✅ 已登录\" if status.get(\"logged_in\") else \"❌ 未登录\"}</div>\r\n <div><span class=\"label\">引擎:</span>{status.get(\"engine\", \"N/A\")}</div>\r\n <div><span class=\"label\">模式:</span>{status.get(\"mode\", \"N/A\")}</div>\r\n <div><span class=\"label\">心跳次数:</span>{status.get(\"heartbeat_count\", 0)}</div>\r\n <div><span class=\"label\">处理请求:</span>{status.get(\"requests_handled\", 0)}</div>\r\n </div>\r\n <p style=\"color: #8b949e; font-size: 12px;\">\r\n POST /v1/chat/completions 发送聊天请求(需要 API Key)<br>\r\n 鉴权方式:Authorization: Bearer <your-api-key>\r\n </p>\r\n </div>\r\n </body>\r\n </html>\r\n \"\"\"\r\n return HTMLResponse(content=html)\r\n\r\n\r\n@app.get(\"/health\")\r\nasync def health():\r\n if browser_mgr and browser_mgr.is_ready:\r\n return {\"status\": \"ok\", \"ready\": True}\r\n return {\"status\": \"initializing\", \"ready\": False}\r\n\r\n\r\n@app.get(\"/status\")\r\nasync def status():\r\n if browser_mgr:\r\n return await browser_mgr.get_status()\r\n return {\"status\": \"initializing\"}\r\n\r\n\r\n@app.get(\"/screenshot\")\r\nasync def screenshot():\r\n if not browser_mgr:\r\n raise HTTPException(status_code=503, detail=\"浏览器未就绪\")\r\n img_base64 = await browser_mgr.take_screenshot_base64()\r\n if img_base64:\r\n html = f\"\"\"\r\n <html><body style=\"background:#000;display:flex;justify-content:center;padding:20px;\">\r\n <img src=\"data:image/png;base64,{img_base64}\" style=\"max-width:100%;border:1px solid #333;\"/>\r\n </body></html>\r\n \"\"\"\r\n return HTMLResponse(content=html)\r\n raise HTTPException(status_code=500, detail=\"截图失败\")\r\n\r\n\r\n@app.get(\"/v1/models\")\r\nasync def list_models(request: Request):\r\n verify_api_key(request)\r\n return {\r\n \"object\": \"list\",\r\n \"data\": [\r\n {\r\n \"id\": \"deepseek-chat\",\r\n \"object\": \"model\",\r\n \"created\": int(time.time()),\r\n \"owned_by\": \"deepseek-proxy\",\r\n }\r\n ],\r\n }\r\n\r\n\r\ndef build_prompt_from_messages(messages: list) -> str:\r\n return json.dumps({\"messages\": messages}, ensure_ascii=False)\r\n\r\n\r\n@app.post(\"/v1/chat/completions\")\r\nasync def chat_completions(request: Request):\r\n verify_api_key(request)\r\n await ensure_browser_ready()\r\n\r\n try:\r\n body = await request.json()\r\n except Exception:\r\n raise HTTPException(status_code=400, detail=\"无效的 JSON 请求体\")\r\n\r\n messages = body.get(\"messages\", [])\r\n if not messages:\r\n raise HTTPException(status_code=400, detail=\"messages 不能为空\")\r\n\r\n stream = body.get(\"stream\", False)\r\n user_prompt = build_prompt_from_messages(messages)\r\n\r\n if not user_prompt:\r\n raise HTTPException(status_code=400, detail=\"未找到有效的用户输入\")\r\n\r\n print(f\"📝 构建的 prompt 长度: {len(user_prompt)} 字符\")\r\n\r\n model = body.get(\"model\", \"deepseek-chat\")\r\n req_id = f\"chatcmpl-{int(time.time()*1000)}\"\r\n\r\n if stream:\r\n async def generate():\r\n async for chunk in browser_mgr.send_message_stream(user_prompt):\r\n # ★ 心跳标记:发 SSE 注释保活\r\n if chunk == HEARTBEAT_MARKER:\r\n yield \": heartbeat\\n\\n\"\r\n continue\r\n\r\n # 正常内容\r\n data = {\r\n \"id\": req_id,\r\n \"object\": \"chat.completion.chunk\",\r\n \"created\": int(time.time()),\r\n \"model\": model,\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"delta\": {\"content\": chunk},\r\n \"finish_reason\": None\r\n }]\r\n }\r\n yield f\"data: {json.dumps(data, ensure_ascii=False)}\\n\\n\"\r\n\r\n # 结束标记\r\n end_data = {\r\n \"id\": req_id,\r\n \"object\": \"chat.completion.chunk\",\r\n \"created\": int(time.time()),\r\n \"model\": model,\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"delta\": {},\r\n \"finish_reason\": \"stop\"\r\n }]\r\n }\r\n yield f\"data: {json.dumps(end_data, ensure_ascii=False)}\\n\\n\"\r\n yield \"data: [DONE]\\n\\n\"\r\n\r\n # ═══ 关键改动:加反缓冲头 ═══\r\n return StreamingResponse(\r\n generate(),\r\n media_type=\"text/event-stream\",\r\n headers={\r\n \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\r\n \"X-Accel-Buffering\": \"no\", # ★ Nginx 禁止缓冲\r\n \"Connection\": \"keep-alive\",\r\n \"Transfer-Encoding\": \"chunked\",\r\n },\r\n )\r\n else:\r\n response_text = await browser_mgr.send_message(user_prompt)\r\n return {\r\n \"id\": req_id,\r\n \"object\": \"chat.completion\",\r\n \"created\": int(time.time()),\r\n \"model\": model,\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"message\": {\"role\": \"assistant\", \"content\": response_text},\r\n \"finish_reason\": \"stop\"\r\n }],\r\n \"usage\": {\r\n \"prompt_tokens\": len(user_prompt),\r\n \"completion_tokens\": len(response_text),\r\n \"total_tokens\": len(user_prompt) + len(response_text)\r\n }\r\n }\r\n\r\n@app.websocket(\"/ws\")\r\nasync def websocket_endpoint(websocket: WebSocket):\r\n await websocket.accept()\r\n print(f\"📡 WebSocket 客户端已连接: {websocket.client}\")\r\n\r\n api_key_param = websocket.query_params.get(\"api_key\", \"\").strip()\r\n authenticated = (api_key_param == API_SECRET_KEY)\r\n\r\n if not authenticated:\r\n try:\r\n first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=10)\r\n try:\r\n payload = json.loads(first_msg)\r\n if payload.get(\"api_key\") == API_SECRET_KEY:\r\n authenticated = True\r\n await websocket.send_json({\"type\": \"auth\", \"status\": \"ok\"})\r\n except json.JSONDecodeError:\r\n pass\r\n except asyncio.TimeoutError:\r\n pass\r\n\r\n if not authenticated:\r\n await websocket.send_json({\r\n \"type\": \"error\",\r\n \"error\": \"Authentication required.\"\r\n })\r\n await websocket.close(code=4001, reason=\"Unauthorized\")\r\n return\r\n\r\n try:\r\n while True:\r\n data = await websocket.receive_text()\r\n try:\r\n payload = json.loads(data)\r\n message = payload.get(\"message\", \"\")\r\n except json.JSONDecodeError:\r\n message = data\r\n\r\n if not message:\r\n await websocket.send_json({\"error\": \"消息不能为空\"})\r\n continue\r\n\r\n if not browser_mgr or not browser_mgr.is_ready:\r\n await websocket.send_json({\"type\": \"info\", \"message\": \"浏览器初始化中,请稍候...\"})\r\n ok = await browser_mgr.wait_until_ready(timeout=180) if browser_mgr else False\r\n if not ok:\r\n await websocket.send_json({\"error\": \"浏览器初始化超时\"})\r\n continue\r\n\r\n if not await browser_mgr.is_alive():\r\n await websocket.send_json({\"error\": \"浏览器会话已断开\"})\r\n continue\r\n\r\n await websocket.send_json({\"type\": \"start\"})\r\n full_response = \"\"\r\n async for chunk in browser_mgr.send_message_stream(message):\r\n # ★ 心跳标记\r\n if chunk == HEARTBEAT_MARKER:\r\n await websocket.send_json({\"type\": \"heartbeat\"})\r\n continue\r\n\r\n full_response += chunk\r\n await websocket.send_json({\"type\": \"chunk\", \"content\": chunk})\r\n await websocket.send_json({\"type\": \"end\", \"full_content\": full_response})\r\n\r\n except WebSocketDisconnect:\r\n print(f\"📡 WebSocket 客户端已断开: {websocket.client}\")\r\n except Exception as e:\r\n print(f\"❌ WebSocket 错误: {e}\")\r\n try:\r\n await websocket.send_json({\"error\": str(e)})\r\n except Exception:\r\n pass\r\n\r\n\r\nif __name__ == \"__main__\":\r\n port = int(os.environ.get(\"PORT\", 7860))\r\n uvicorn.run(app, host=\"0.0.0.0\", port=port, log_level=\"info\")\r\n"}}}} |