diff --git a/backend/damai_proxy_config.py b/backend/damai_proxy_config.py index 4589ac9..e631df0 100644 --- a/backend/damai_proxy_config.py +++ b/backend/damai_proxy_config.py @@ -56,7 +56,7 @@ PROXY_POOL = [ "server": "http://210.51.27.194:50001", "username": "hb6su3", "password": "acv2ciow", - "enabled": False + "enabled": True } ] diff --git a/backend/error_screenshot.png b/backend/error_screenshot.png index 139a17b..d431bb4 100644 Binary files a/backend/error_screenshot.png and b/backend/error_screenshot.png differ diff --git a/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png b/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png deleted file mode 100644 index 32d0565..0000000 Binary files a/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png and /dev/null differ diff --git a/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png b/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png deleted file mode 100644 index d19566e..0000000 Binary files a/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png and /dev/null differ diff --git a/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png b/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png new file mode 100644 index 0000000..d5c80f7 Binary files /dev/null and b/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png differ diff --git a/backend/main.py b/backend/main.py index a48e28c..78254e5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ from config import init_config, get_config from dotenv import load_dotenv load_dotenv() # 从 .env 文件加载环境变量(可选,用于覆盖配置文件) -from fastapi import FastAPI, HTTPException, File, UploadFile, Form +from fastapi import FastAPI, HTTPException, File, UploadFile, Form, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, Dict, Any, List @@ -51,6 +51,75 @@ scheduler = None # 全局阿里云短信服务实例 sms_service = None +# WebSocket连接管理器 +class ConnectionManager: + def __init__(self): + # session_id -> WebSocket连接 + self.active_connections: Dict[str, WebSocket] = {} + # session_id -> 消息队列(用于缓存连接建立前的消息) + self.pending_messages: Dict[str, list] = {} + + async def connect(self, session_id: str, websocket: WebSocket): + await websocket.accept() + self.active_connections[session_id] = websocket + print(f"[WebSocket] 新连接: {session_id}", file=sys.stderr) + print(f"[WebSocket] 当前活跃连接数: {len(self.active_connections)}", file=sys.stderr) + + # 立即检查缓存消息(不等待) + if session_id in self.pending_messages: + pending_count = len(self.pending_messages[session_id]) + print(f"[WebSocket] 发现缓存消息: {pending_count} 条", file=sys.stderr) + print(f"[WebSocket] 缓存消息内容: {self.pending_messages[session_id]}", file=sys.stderr) + + # 等待100ms让前端监听器就绪 + await asyncio.sleep(0.1) + + for idx, message in enumerate(self.pending_messages[session_id]): + try: + print(f"[WebSocket] 准备发送第{idx+1}条消息...", file=sys.stderr) + await websocket.send_json(message) + print(f"[WebSocket] 已发送缓存消息 [{idx+1}/{pending_count}]: {message.get('type')}", file=sys.stderr) + # 每条消息间隔100ms + await asyncio.sleep(0.1) + except Exception as e: + print(f"[WebSocket] 发送缓存消息失败: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + + del self.pending_messages[session_id] + print(f"[WebSocket] 缓存消息已清空: {session_id}", file=sys.stderr) + else: + print(f"[WebSocket] 没有缓存消息: {session_id}", file=sys.stderr) + + def disconnect(self, session_id: str): + if session_id in self.active_connections: + del self.active_connections[session_id] + print(f"[WebSocket] 断开连接: {session_id}", file=sys.stderr) + # 清理缓存消息 + if session_id in self.pending_messages: + del self.pending_messages[session_id] + + async def send_message(self, session_id: str, message: dict): + if session_id in self.active_connections: + try: + await self.active_connections[session_id].send_json(message) + print(f"[WebSocket] 发送消息到 {session_id}: {message.get('type')}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] 发送消息失败 {session_id}: {str(e)}", file=sys.stderr) + self.disconnect(session_id) + else: + # WebSocket还未连接,缓存消息 + print(f"[WebSocket] 连接尚未建立,缓存消息: {session_id}", file=sys.stderr) + if session_id not in self.pending_messages: + self.pending_messages[session_id] = [] + self.pending_messages[session_id].append(message) + # 最多缓存10条消息 + if len(self.pending_messages[session_id]) > 10: + self.pending_messages[session_id].pop(0) + +# 全局WebSocket管理器 +ws_manager = ConnectionManager() + async def fetch_proxy_from_pool() -> Optional[str]: """从代理池接口获取一个代理地址(http://ip:port),获取失败返回None""" @@ -97,6 +166,7 @@ class SendCodeRequest(BaseModel): phone: str country_code: str = "+86" login_page: Optional[str] = None # 登录页面:creator 或 home,为None时使用配置文件默认值 + session_id: Optional[str] = None # 可选:前端生成的session_id,用于WebSocket通知 class VerifyCodeRequest(BaseModel): phone: str @@ -288,10 +358,14 @@ async def send_code(request: SendCodeRequest): 支持选择从创作者中心或小红书首页登录 并发支持:为每个请求分配独立的浏览器实例 """ - # 使用随机UUID作为session_id,确保每次都创建全新浏览器,完全不复用 - import uuid - session_id = f"xhs_login_{uuid.uuid4().hex}" - print(f"[发送验证码] 创建全新浏览器实例 session_id={session_id}, phone={request.phone}", file=sys.stderr) + # 使用前端传递的session_id,如果没有则生成新的 + if request.session_id: + session_id = request.session_id + print(f"[发送验证码] 使用前端传递的session_id={session_id}, phone={request.phone}", file=sys.stderr) + else: + import uuid + session_id = f"xhs_login_{uuid.uuid4().hex}" + print(f"[发送验证码] 前端未传session_id,生成新的session_id={session_id}, phone={request.phone}", file=sys.stderr) # 获取配置中的默认login_page,如果API传入了则优先使用API参数 config = get_config() @@ -312,9 +386,24 @@ async def send_code(request: SendCodeRequest): result = await request_login_service.send_verification_code( phone=request.phone, country_code=request.country_code, - login_page=login_page # 传递登录页面参数 + login_page=login_page, # 传递登录页面参数 + session_id=session_id # 传递session_id用于WebSocket通知 ) + # 检查是否需要验证(发送验证码时触发风控) + if result.get("need_captcha"): + print(f"[发送验证码] 检测到需要扫码验证,保持session {session_id} 的浏览器继续运行", file=sys.stderr) + return BaseResponse( + code=0, # 成功返回二维码 + message=result.get("message", "需要扫码验证"), + data={ + "need_captcha": True, + "captcha_type": result.get("captcha_type"), + "qrcode_image": result.get("qrcode_image"), + "session_id": session_id + } + ) + if result["success"]: # 验证浏览器是否已保存到池中 if browser_pool and session_id in browser_pool.temp_browsers: @@ -835,7 +924,22 @@ async def login(request: LoginRequest): login_page=login_page # 传递登录页面参数 ) - # 释放临时浏览器(无论成功还是失败) + # 检查是否需要扫码验证 + if result.get("need_captcha"): + # 需要扫码验证,不释放浏览器,保持session_id对应的浏览器继续运行 + print(f"[登录验证] 检测到需要扫码验证,保持session {session_id} 的浏览器继续运行", file=sys.stderr) + return BaseResponse( + code=0, # 成功返回二维码 + message=result.get("message", "需要扫码验证"), + data={ + "need_captcha": True, + "captcha_type": result.get("captcha_type"), + "qrcode_image": result.get("qrcode_image"), + "session_id": session_id + } + ) + + # 释放临时浏览器(仅在登录成功或失败时释放) if session_id and browser_pool: try: await browser_pool.release_temp_browser(session_id) @@ -1217,6 +1321,228 @@ async def upload_images(files: List[UploadFile] = File(...)): "data": None } +async def handle_send_code_ws(session_id: str, phone: str, country_code: str, login_page: str, websocket: WebSocket): + """ + 异步处理WebSocket发送验证码请求 + """ + try: + print(f"[WebSocket-SendCode] 开始处理: session={session_id}, phone={phone}", file=sys.stderr) + + # 创建登录服务实例 + request_login_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, + session_id=session_id + ) + + # 调用登录服务发送验证码 + result = await request_login_service.send_verification_code( + phone=phone, + country_code=country_code, + login_page=login_page, + session_id=session_id + ) + + # 检查是否需要验证(发送验证码时触发风控) + if result.get("need_captcha"): + print(f"[WebSocket-SendCode] 检测到风控,需要扫码", file=sys.stderr) + await websocket.send_json({ + "type": "need_captcha", + "captcha_type": result.get("captcha_type"), + "qrcode_image": result.get("qrcode_image"), + "message": result.get("message", "需要扫码验证") + }) + print(f"[WebSocket-SendCode] 已推送风控信息", file=sys.stderr) + return + + if result["success"]: + print(f"[WebSocket-SendCode] 验证码发送成功", file=sys.stderr) + await websocket.send_json({ + "type": "code_sent", + "success": True, + "message": "验证码已发送,请在小红书APP中查看" + }) + else: + print(f"[WebSocket-SendCode] 发送失败: {result.get('error')}", file=sys.stderr) + await websocket.send_json({ + "type": "code_sent", + "success": False, + "message": result.get("error", "发送验证码失败") + }) + except Exception as e: + print(f"[WebSocket-SendCode] 异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + try: + await websocket.send_json({ + "type": "code_sent", + "success": False, + "message": f"发送验证码失败: {str(e)}" + }) + except: + pass + +async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_code: str, login_page: str, websocket: WebSocket): + """ + 异步处理WebSocket验证码验证请求 + """ + try: + print(f"[WebSocket-VerifyCode] 开始验证: session={session_id}, phone={phone}, code={code}", file=sys.stderr) + + # 从浏览器池中获取之前的浏览器实例 + if session_id not in browser_pool.temp_browsers: + print(f"[WebSocket-VerifyCode] 未找到session: {session_id}", file=sys.stderr) + await websocket.send_json({ + "type": "login_result", + "success": False, + "message": "会话已过期,请重新发送验证码" + }) + return + + # 获取浏览器实例 + browser_data = browser_pool.temp_browsers[session_id] + request_login_service = browser_data['service'] + + # 调用登录服务验证登录 + result = await request_login_service.login_with_code( + phone=phone, + code=code, + country_code=country_code, + login_page=login_page + ) + + # 检查是否需要验证(登录时触发风控) + if result.get("need_captcha"): + print(f"[WebSocket-VerifyCode] 登录时检测到风控", file=sys.stderr) + await websocket.send_json({ + "type": "need_captcha", + "captcha_type": result.get("captcha_type"), + "qrcode_image": result.get("qrcode_image"), + "message": result.get("message", "需要扫码验证") + }) + return + + if result["success"]: + print(f"[WebSocket-VerifyCode] 登录成功", file=sys.stderr) + + # 获取storage_state + storage_state = result.get("storage_state") + + # 保存storage_state到文件 + storage_state_path = None + if storage_state: + import os + os.makedirs('storage_states', exist_ok=True) + storage_state_path = f"storage_states/{phone}_state.json" + + import json + with open(storage_state_path, 'w', encoding='utf-8') as f: + json.dump(storage_state, f, ensure_ascii=False, indent=2) + print(f"[WebSocket-VerifyCode] 已保存storage_state: {storage_state_path}", file=sys.stderr) + + # 推送登录成功消息 + await websocket.send_json({ + "type": "login_success", + "success": True, + "storage_state": storage_state, + "storage_state_path": storage_state_path, + "message": "登录成功" + }) + + # 释放浏览器 + try: + await browser_pool.release_temp_browser(session_id) + print(f"[WebSocket-VerifyCode] 已释放浏览器: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket-VerifyCode] 释放浏览器失败: {str(e)}", file=sys.stderr) + else: + print(f"[WebSocket-VerifyCode] 登录失败: {result.get('error')}", file=sys.stderr) + await websocket.send_json({ + "type": "login_result", + "success": False, + "message": result.get("error", "登录失败") + }) + except Exception as e: + print(f"[WebSocket-VerifyCode] 异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + try: + await websocket.send_json({ + "type": "login_result", + "success": False, + "message": f"登录失败: {str(e)}" + }) + except: + pass + +@app.websocket("/ws/login/{session_id}") +async def websocket_login(websocket: WebSocket, session_id: str): + """ + WebSocket端点:实时监听登录状态 + 用于扫码验证后的实时通知 + """ + await ws_manager.connect(session_id, websocket) + + try: + # 保持连接,等待消息或断开 + while True: + # 接收客户端消息(ping/pong保持连接) + data = await websocket.receive_text() + print(f"[WebSocket] 收到客户端消息 {session_id}: {data}", file=sys.stderr) + + # 处理ping消息 + if data == "ping": + await websocket.send_text("pong") + else: + # 尝试解析JSON消息 + try: + import json + msg = json.loads(data) + msg_type = msg.get('type', 'unknown') + print(f"[WebSocket] 解析消息类型: {msg_type}", file=sys.stderr) + + # 处理测试消息 + if msg_type == 'test': + print(f"[WebSocket] 收到测试消息: {msg.get('message')}", file=sys.stderr) + # 回复测试消息 + await websocket.send_json({ + "type": "test_response", + "message": "Test message received by backend successfully!", + "timestamp": data + }) + print(f"[WebSocket] 已回复测试消息", file=sys.stderr) + + # 处理发送验证码消息 + elif msg_type == 'send_code': + phone = msg.get('phone') + country_code = msg.get('country_code', '+86') + login_page = msg.get('login_page', 'creator') + print(f"[WebSocket] 收到发送验证码请求: phone={phone}", file=sys.stderr) + + # 启动异步任务处理发送验证码 + asyncio.create_task(handle_send_code_ws(session_id, phone, country_code, login_page, websocket)) + + # 处理验证码验证消息 + elif msg_type == 'verify_code': + phone = msg.get('phone') + code = msg.get('code') + country_code = msg.get('country_code', '+86') + login_page = msg.get('login_page', 'creator') + print(f"[WebSocket] 收到验证码验证请求: phone={phone}, code={code}", file=sys.stderr) + + # 启动异步任务处理验证码验证 + asyncio.create_task(handle_verify_code_ws(session_id, phone, code, country_code, login_page, websocket)) + + except json.JSONDecodeError: + print(f"[WebSocket] 无法解析为JSON: {data}", file=sys.stderr) + + except WebSocketDisconnect: + ws_manager.disconnect(session_id) + print(f"[WebSocket] 客户端断开: {session_id}", file=sys.stderr) + except Exception as e: + ws_manager.disconnect(session_id) + print(f"[WebSocket] 连接异常 {session_id}: {str(e)}", file=sys.stderr) + if __name__ == "__main__": import uvicorn @@ -1227,7 +1553,9 @@ if __name__ == "__main__": debug = config.get_bool('server.debug', False) reload = config.get_bool('server.reload', False) - print(f"[\u542f\u52a8\u670d\u52a1] \u4e3b\u673a: {host}, \u7aef\u53e3: {port}, \u8c03\u8bd5: {debug}, \u70ed\u91cd\u8f7d: {reload}") + print(f"[启动服务] 主机: {host}, 端口: {port}, 调试: {debug}, 热重载: {reload}") + print(f"[WebSocket] WebSocket服务地址: ws://{host}:{port}/ws/login/{{session_id}}") + print(f"[WebSocket] 示例: ws://{host}:{port}/ws/login/xhs_login_xxxxx") uvicorn.run( app, diff --git a/backend/replace_print_with_logger.py b/backend/replace_print_with_logger.py new file mode 100644 index 0000000..ab5c548 --- /dev/null +++ b/backend/replace_print_with_logger.py @@ -0,0 +1,65 @@ +""" +批量替换 xhs_login.py 中的 print 为 logger +""" +import re + +def replace_print_to_logger(content): + """将 print 语句替换为对应的 logger 语句""" + + # 替换规则:根据内容判断日志级别 + def determine_log_level_and_replace(match): + text = match.group(1) + + # 错误相关 + if any(keyword in text for keyword in ['失败', '错误', '异常', '❌', 'error', 'Error', 'failed', 'Failed']): + return f'logger.error({text})' + + # 警告相关 + elif any(keyword in text for keyword in ['警告', '⚠️', 'warning', 'Warning', '未找到', '检测到']): + return f'logger.warning({text})' + + # 成功相关 + elif any(keyword in text for keyword in ['成功', '✅', 'success', 'Success', '已', '完成']): + return f'logger.success({text})' + + # 调试相关 + elif any(keyword in text for keyword in ['调试', 'debug', 'Debug', '查找', '正在', '开始']): + return f'logger.debug({text})' + + # 默认 info + else: + return f'logger.info({text})' + + # 匹配 print(xxx, file=sys.stderr) + pattern1 = r'print\((.*?),\s*file=sys\.stderr\)' + content = re.sub(pattern1, determine_log_level_and_replace, content) + + # 匹配普通 print(xxx) + pattern2 = r'print\((.*?)\)(?!\s*#.*logger)' + content = re.sub(pattern2, determine_log_level_and_replace, content) + + return content + + +def main(): + # 读取文件 + with open('xhs_login.py', 'r', encoding='utf-8') as f: + content = f.read() + + # 替换 + new_content = replace_print_to_logger(content) + + # 备份原文件 + with open('xhs_login.py.bak', 'w', encoding='utf-8') as f: + f.write(content) + + # 写入新文件 + with open('xhs_login.py', 'w', encoding='utf-8') as f: + f.write(new_content) + + print("✅ 替换完成!") + print("原文件已备份到 xhs_login.py.bak") + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 5fb2625..0981584 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ alibabacloud_credentials==0.3.4 alibabacloud_tea_openapi==0.3.9 alibabacloud_tea_util==0.3.13 loguru==0.7.2 +websockets==12.0 diff --git a/backend/xhs_login.py b/backend/xhs_login.py index c65c449..530a60e 100644 --- a/backend/xhs_login.py +++ b/backend/xhs_login.py @@ -40,7 +40,7 @@ async def download_image(url: str) -> str: 本地文件路径 """ try: - print(f"下载网络图片: {url}", file=sys.stderr) + logger.info(f"下载网络图片: {url}") async with aiohttp.ClientSession() as session: async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response: if response.status == 200: @@ -63,12 +63,12 @@ async def download_image(url: str) -> str: with open(temp_file, 'wb') as f: f.write(await response.read()) - print(f"✅ 图片下载成功: {temp_file}", file=sys.stderr) + logger.success(f"✅ 图片下载成功: {temp_file}") return str(temp_file) else: raise Exception(f"HTTP {response.status}") except Exception as e: - print(f"⚠️ 下载图片失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 下载图片失败: {str(e)}") raise @@ -122,20 +122,20 @@ class XHSLoginService: try: with open('login_state.json', 'r', encoding='utf-8') as f: login_state = json.load(f) - print("✅ 加载到保存的登录状态", file=sys.stderr) + logger.success("✅ 加载到保存的登录状态") # 使用保存的配置 cookies = login_state.get('cookies', cookies) if not user_agent and login_state.get('user_agent'): user_agent = login_state['user_agent'] except Exception as e: - print(f"⚠️ 加载登录状态失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 加载登录状态失败: {str(e)}") # 使用浏览器池 if self.use_pool and self.browser_pool: # 扫码登录使用页面隔离模式 if self.use_page_isolation and self.session_id: - print(f"[页面隔离模式] 获取扫码登录页面 (session_id={self.session_id})", file=sys.stderr) + logger.info(f"[页面隔离模式] 获取扫码登录页面 (session_id={self.session_id})") # 获取或创建页面 self.page = await self.browser_pool.get_qrcode_page(self.session_id) @@ -144,11 +144,11 @@ class XHSLoginService: self.browser = self.browser_pool.browser self.context = self.browser_pool.context - print("浏览器初始化成功(页面隔离模式)", file=sys.stderr) + logger.success("浏览器初始化成功(页面隔离模式)") return # 普通浏览器池模式 - print(f"[浏览器池模式] 从浏览器池获取实例 (session_id={self.session_id}, headless={self.headless})", file=sys.stderr) + logger.info(f"[浏览器池模式] 从浏览器池获取实例 (session_id={self.session_id}, headless={self.headless})") self.browser, self.context, self.page = await self.browser_pool.get_browser( cookies=cookies, proxy=proxy, user_agent=user_agent, session_id=self.session_id, headless=self.headless # 传递headless参数 @@ -161,42 +161,42 @@ class XHSLoginService: # 检查page状态,如果是空白页或已关闭,重新创建page try: current_url = self.page.url - print(f"当前URL: {current_url}", file=sys.stderr) + logger.info(f"当前URL: {current_url}") if current_url == 'about:blank' or current_url == '': - print("[浏览器池] 检测到空白页面,重新创建page", file=sys.stderr) + logger.warning("[浏览器池] 检测到空白页面,重新创建page") try: # 关闭旧page await self.page.close() except Exception as e: - print(f"[浏览器池] 关闭旧page失败: {str(e)}", file=sys.stderr) + logger.error(f"[浏览器池] 关闭旧page失败: {str(e)}") # 创建新page self.page = await self.context.new_page() - print(f"[浏览器池] 已创建新page, 新URL: {self.page.url}", file=sys.stderr) + logger.success(f"[浏览器池] 已创建新page, 新URL: {self.page.url}") # 更新浏览器池中保存的page引用 if self.session_id and self.session_id in self.browser_pool.temp_browsers: self.browser_pool.temp_browsers[self.session_id]["page"] = self.page - print("[浏览器池] 已更新浏览器池中的page引用", file=sys.stderr) + logger.success("[浏览器池] 已更新浏览器池中的page引用") except Exception as e: - print(f"[浏览器池] 检查page状态异常: {str(e)}", file=sys.stderr) + logger.error(f"[浏览器池] 检查page状态异常: {str(e)}") # 如果有localStorage/sessionStorage,恢复它们 if login_state: await self._restore_storage(login_state) - print("浏览器初始化成功(池模式)", file=sys.stderr) + logger.success("浏览器初始化成功(池模式)") return # 传统模式(每次新建) - print("[传统模式] 创建新浏览器实例", file=sys.stderr) + logger.info("[传统模式] 创建新浏览器实例") # Windows环境下,需要设置事件循环策略 if sys.platform == 'win32': try: asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) except Exception as e: - print(f"警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + logger.error(f"警告: 设置事件循环策略失败: {str(e)}") self.playwright = await async_playwright().start() @@ -353,12 +353,12 @@ class XHSLoginService: ], }); """) - print("✅ 已注入反检测脚本", file=sys.stderr) + logger.success("✅ 已注入反检测脚本") # 如果提供了Cookies,注入到浏览器上下文 if cookies: await self.context.add_cookies(cookies) - print(f"已注入 {len(cookies)} 个Cookie", file=sys.stderr) + logger.success(f"已注入 {len(cookies)} 个Cookie") # 创建新页面 self.page = await self.context.new_page() @@ -380,7 +380,7 @@ class XHSLoginService: # 注册路由拦截,匹配所有请求 await self.page.route('**/*', block_detection_requests) - print("✅ 已启用请求拦截,阻止检测自动化", file=sys.stderr) + logger.success("✅ 已启用请求拦截,阻止检测自动化") # 添加页面跳转监控,检测无限跳转 self.redirect_count = 0 @@ -394,7 +394,7 @@ class XHSLoginService: if current_time - self.last_redirect_time < 1: # 1秒内连续重定向 self.redirect_count += 1 if self.redirect_count > 5: - print(f"⚠️ 检测到频繁重定向 ({self.redirect_count}次),可能是无限跳转", file=sys.stderr) + logger.warning(f"⚠️ 检测到频繁重定向 ({self.redirect_count}次),可能是无限跳转") else: self.redirect_count = 0 self.last_redirect_time = current_time @@ -405,10 +405,10 @@ class XHSLoginService: if login_state: await self._restore_storage(login_state) - print("浏览器初始化成功(传统模式)", file=sys.stderr) + logger.success("浏览器初始化成功(传统模式)") except Exception as e: - print(f"浏览器初始化失败: {str(e)}", file=sys.stderr) + logger.error(f"浏览器初始化失败: {str(e)}") raise async def _restore_storage(self, login_state: dict): @@ -416,7 +416,7 @@ class XHSLoginService: try: # 首先访问小红书的任意页面,以便注入storage target_url = login_state.get('url', 'https://www.xiaohongshu.com') - print(f"正在访问 {target_url} 以注入storage...", file=sys.stderr) + logger.debug(f"正在访问 {target_url} 以注入storage...") # 设置更短的超时时间,避免长时间等待 try: @@ -426,11 +426,11 @@ class XHSLoginService: # 检查是否被重定向到登录页 current_url = self.page.url if 'login' in current_url.lower(): - print("⚠️ 检测到被重定向到登录页,跳过storage恢复", file=sys.stderr) + logger.warning("⚠️ 检测到被重定向到登录页,跳过storage恢复") return except Exception as e: - print(f"⚠️ 访问页面失败: {str(e)},跳过storage恢复", file=sys.stderr) + logger.error(f"⚠️ 访问页面失败: {str(e)},跳过storage恢复") return # 恢夏localStorage @@ -439,8 +439,8 @@ class XHSLoginService: try: await self.page.evaluate(f'localStorage.setItem("{key}", {json.dumps(value)})') except Exception as e: - print(f"⚠️ 设置localStorage {key} 失败: {str(e)}", file=sys.stderr) - print(f"✅ 已恢复 {len(login_state['localStorage'])} 个localStorage项", file=sys.stderr) + logger.error(f"⚠️ 设置localStorage {key} 失败: {str(e)}") + logger.success(f"✅ 已恢复 {len(login_state['localStorage'])} 个localStorage项") # 恢夏sessionStorage if login_state.get('sessionStorage'): @@ -448,11 +448,11 @@ class XHSLoginService: try: await self.page.evaluate(f'sessionStorage.setItem("{key}", {json.dumps(value)})') except Exception as e: - print(f"⚠️ 设置sessionStorage {key} 失败: {str(e)}", file=sys.stderr) - print(f"✅ 已恢复 {len(login_state['sessionStorage'])} 个sessionStorage项", file=sys.stderr) + logger.error(f"⚠️ 设置sessionStorage {key} 失败: {str(e)}") + logger.success(f"✅ 已恢复 {len(login_state['sessionStorage'])} 个sessionStorage项") except Exception as e: - print(f"⚠️ 恢夏storage失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 恢夏storage失败: {str(e)}") async def init_browser_with_storage_state(self, storage_state_path: str, proxy: Optional[dict] = None): """ @@ -466,14 +466,14 @@ class XHSLoginService: if not os.path.exists(storage_state_path): raise Exception(f"storage_state文件不存在: {storage_state_path}") - print(f"✅ 使用 storage_state 初始化浏览器: {storage_state_path}", file=sys.stderr) + logger.success(f"✅ 使用 storage_state 初始化浏览器: {storage_state_path}") # Windows环境下,需要设置事件循环策略 if sys.platform == 'win32': try: asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) except Exception as e: - print(f"警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + logger.error(f"警告: 设置事件循环策略失败: {str(e)}") self.playwright = await async_playwright().start() @@ -494,7 +494,7 @@ class XHSLoginService: # 使用storage_state创建上下文(Playwright原生API) self.context = await self.browser.new_context(storage_state=storage_state_path) - print(f"✅ 已使用 storage_state 创建浏览器上下文", file=sys.stderr) + logger.success(f"✅ 已使用 storage_state 创建浏览器上下文") # 添加反检测脚本 await self.context.add_init_script(""" @@ -508,7 +508,7 @@ class XHSLoginService: runtime: {} }; """) - print("✅ 已注入反检测脚本", file=sys.stderr) + logger.success("✅ 已注入反检测脚本") # 创建页面 self.page = await self.context.new_page() @@ -527,12 +527,12 @@ class XHSLoginService: await route.continue_() await self.page.route('**/*', block_detection_requests) - print("✅ 已启用请求拦截,阻止检测自动化", file=sys.stderr) + logger.success("✅ 已启用请求拦截,阻止检测自动化") - print("✅ 浏览器初始化成功(storage_state模式)", file=sys.stderr) + logger.success("✅ 浏览器初始化成功(storage_state模式)") except Exception as e: - print(f"浏览器初始化失败: {str(e)}", file=sys.stderr) + logger.error(f"浏览器初始化失败: {str(e)}") raise async def close_browser(self): @@ -540,7 +540,7 @@ class XHSLoginService: try: # 浏览器池模式:不关闭浏览器,保持复用 if self.use_pool and self.browser_pool: - print("[浏览器池模式] 保留浏览器实例供下次复用", file=sys.stderr) + logger.info("[浏览器池模式] 保留浏览器实例供下次复用") # 仅清理当前服务的引用,浏览器池保持运行 self.browser = None self.context = None @@ -548,7 +548,7 @@ class XHSLoginService: return # 传统模式:完全关闭 - print("[传统模式] 完全关闭浏览器", file=sys.stderr) + logger.info("[传统模式] 完全关闭浏览器") if self.page: await self.page.close() if self.context: @@ -557,9 +557,9 @@ class XHSLoginService: await self.browser.close() if self.playwright: await self.playwright.stop() - print("浏览器已关闭", file=sys.stderr) + logger.success("浏览器已关闭") except Exception as e: - print(f"关闭浏览器异常: {str(e)}", file=sys.stderr) + logger.error(f"关闭浏览器异常: {str(e)}") async def extract_verification_qrcode(self) -> Optional[str]: """ @@ -572,7 +572,7 @@ class XHSLoginService: if not self.page: return None - print("正在提取验证二维码...", file=sys.stderr) + logger.debug("正在提取验证二维码...") # 尝试查找二维码图片元素 qrcode_selectors = [ @@ -589,18 +589,18 @@ class XHSLoginService: try: qrcode_img = await self.page.wait_for_selector(selector, timeout=3000) if qrcode_img: - print(f"✅ 找到二维码图片: {selector}", file=sys.stderr) + logger.success(f"✅ 找到二维码图片: {selector}") # 获取图片src属性 src = await qrcode_img.get_attribute('src') if src: # 如果是base64格式,直接返回 if src.startswith('data:image'): - print("✅ 二维码已是base64格式,直接返回", file=sys.stderr) + logger.success("✅ 二维码已是base64格式,直接返回") return src # 如果是URL,尝试下载并转换为base64 - print(f"二维码是URL格式: {src[:100]}...", file=sys.stderr) + logger.info(f"二维码是URL格式: {src[:100]}...") try: async with aiohttp.ClientSession() as session: async with session.get(src, timeout=aiohttp.ClientTimeout(total=10)) as response: @@ -611,33 +611,204 @@ class XHSLoginService: # 根据内容类型确定格式 content_type = response.headers.get('Content-Type', 'image/png') base64_str = f"data:{content_type};base64,{img_base64}" - print("✅ 成功下载并转换为base64", file=sys.stderr) + logger.success("✅ 成功下载并转换为base64") return base64_str except Exception as e: - print(f"⚠️ 下载二维码图片失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 下载二维码图片失败: {str(e)}") # 如果src方法失败,尝试截图 - print("尝试截取二维码区域...", file=sys.stderr) + logger.info("尝试截取二维码区域...") screenshot_bytes = await qrcode_img.screenshot() if screenshot_bytes: import base64 img_base64 = base64.b64encode(screenshot_bytes).decode('utf-8') base64_str = f"data:image/png;base64,{img_base64}" - print("✅ 成功截取二维码并转换为base64", file=sys.stderr) + logger.success("✅ 成功截取二维码并转换为base64") return base64_str break except Exception as e: - print(f"尝试选择器 {selector} 失败: {str(e)}", file=sys.stderr) + logger.error(f"尝试选择器 {selector} 失败: {str(e)}") continue - print("⚠️ 未找到二维码图片", file=sys.stderr) + logger.warning("⚠️ 未找到二维码图片") return None except Exception as e: - print(f"⚠️ 提取二维码失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 提取二维码失败: {str(e)}") return None + async def _monitor_qrcode_scan(self, session_id: str): + """ + 后台监听扫码后的页面跳转和二维码失效 + 通过监听小红书API https://edith.xiaohongshu.com/api/redcaptcha/v2/qr/status/query + 来精准判断二维码状态: + - status=1: 未过期,等待扫码 + - status=5: 已扫码,等待确认 + - 其他: 失效或已完成 + + Args: + session_id: 会话 ID + """ + try: + logger.info(f"[WebSocket] 开始监听扫码状态: {session_id}") + + if not self.page: + logger.error(f"[WebSocket] 页面对象不存在: {session_id}") + return + + # 用于存储最新的二维码状态 + latest_qr_status = {"status": 1, "scanned": False} + # 标记是否已推送失效消息 + expired_notified = False + + # 设置响应监听,拦截二维码状态查询API + async def handle_qr_status_response(response): + try: + if '/api/redcaptcha/v2/qr/status/query' in response.url: + json_data = await response.json() + if json_data.get('success') and 'data' in json_data: + status = json_data['data'].get('status') + latest_qr_status['status'] = status + + if status == 5: + latest_qr_status['scanned'] = True + logger.info(f"[WebSocket] 检测到二维码已扫描,等待确认: status={status}") + elif status == 1: + logger.debug(f"[WebSocket] 二维码未过期,等待扫码: status={status}") + else: + logger.info(f"[WebSocket] 二维码状态: status={status}") + except Exception as e: + logger.error(f"[WebSocket] 解析二维码状态响应失败: {str(e)}") + + # 注册API响应监听 + self.page.on('response', handle_qr_status_response) + logger.info(f"[WebSocket] 已注册二维码状态API监听") + + # 最多监吡5分钟 + for i in range(600): # 600 * 0.5 = 300秒 = 5分钟 + await asyncio.sleep(0.5) + + try: + current_url = self.page.url + + # 1. 检测是否跳转回首页(不再是captcha/verify页) + if 'captcha' not in current_url.lower() and 'verify' not in current_url.lower(): + # 检查是否跳转到小红书首页 + if 'xiaohongshu.com' in current_url: + logger.success(f"[WebSocket] 检测到扫码完成,页面跳转回: {current_url}") + + # 通过WebSocket推送扫码成功消息 + try: + from main import ws_manager + await ws_manager.send_message(session_id, { + "type": "qrcode_scan_success", + "message": "扫码验证完成,请重新发送验证码" + }) + logger.success(f"[WebSocket] 已推送扫码成功消息: {session_id}") + except Exception as ws_error: + logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") + + break + + # 2. 检测二维码是否失效(通过API状态判断) + if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): + # 如果已经推送过失效消息,跳过后续检测 + if expired_notified: + continue + + # 如果状态不是1和5,说明二维码可能已失效 + if latest_qr_status['status'] not in [1, 5]: + logger.warning(f"[WebSocket] 检测到二维码失效: status={latest_qr_status['status']}") + + # 通过WebSocket推送失效消息 + try: + from main import ws_manager + await ws_manager.send_message(session_id, { + "type": "qrcode_expired", + "message": "二维码已失效,请重新发送验证码" + }) + logger.success(f"[WebSocket] 已推送二维码失效消息: {session_id}") + expired_notified = True # 标记已推送 + except Exception as ws_error: + logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") + + break # 退出监听循环 + + # 备用方案:检查页面文本(以防API未返回) + try: + expired_selectors = [ + 'text="已过期"', + 'text="二维码已失效"', + 'text="二维码过期"', + ] + + for selector in expired_selectors: + expired_elem = await self.page.query_selector(selector) + if expired_elem: + is_visible = await expired_elem.is_visible() + if is_visible: + # 进一步检查元素文本内容 + text_content = await expired_elem.text_content() + # 只在明确显示"已过期"或"已失效"时才认为失效,忽略"二维码X分钟失效"这种提示 + if text_content and ('已过期' in text_content or '已失效' in text_content): + logger.warning(f"[WebSocket] DOM检测到二维码失效: {selector}, 文本: {text_content}") + + # 通过WebSocket推送失效消息 + try: + from main import ws_manager + await ws_manager.send_message(session_id, { + "type": "qrcode_expired", + "message": "二维码已失效,请重新发送验证码" + }) + logger.success(f"[WebSocket] 已推送二维码失效消息: {session_id}") + expired_notified = True # 标记已推送 + except Exception as ws_error: + logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") + + # 退出所有循环 + break + + # 如果检测到失效,退出外层循环 + if expired_notified: + break + + except Exception as e: + # 页面可能已关闭,忽略错误 + pass + + # 每30秒打印一次状态 + if i > 0 and i % 60 == 0: + logger.info(f"[WebSocket] 扫码监听中... ({i // 2}秒) URL: {current_url}, QR_Status: {latest_qr_status['status']}") + + except Exception as e: + logger.error(f"[WebSocket] 监听异常: {str(e)}") + break + + # 超时5分钟未扫码,通知前端关闭弹窗 + logger.warning(f"[WebSocket] 扫码监听超时(5分钟): {session_id}") + try: + from main import ws_manager + await ws_manager.send_message(session_id, { + "type": "qrcode_expired", + "message": "二维码已超时,请重新发送验证码" + }) + logger.success(f"[WebSocket] 已推送超时消息: {session_id}") + except Exception as ws_error: + logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") + + logger.info(f"[WebSocket] 监听任务结束: {session_id}") + + except Exception as e: + logger.error(f"[WebSocket] 监听任务异常: {str(e)}") + finally: + # 清理监听器 + try: + if self.page: + self.page.remove_listener('response', handle_qr_status_response) + logger.info(f"[WebSocket] 已移除API监听器") + except Exception as e: + logger.error(f"[WebSocket] 移除监听器失败: {str(e)}") async def _navigate_with_qrcode_listener(self, url: str, timeout: int = 120): """ 带有二维码API监听的页面导航 @@ -684,6 +855,13 @@ class XHSLoginService: max_wait = timeout * 10 # 每次等待0.1秒 while not qrcode_api_detected and wait_count < max_wait: + # 每次循环检查URL是否被风控跳转 + current_url = self.page.url + if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): + logger.warning(f"[页面导航] 检测到风控页面跳转: {current_url}") + logger.warning("[页面导航] 立即停止等待二维码API") + break + await asyncio.sleep(0.1) wait_count += 1 @@ -703,7 +881,7 @@ class XHSLoginService: except Exception: pass - async def send_verification_code(self, phone: str, country_code: str = "+86", login_page: str = "creator") -> Dict[str, Any]: + async def send_verification_code(self, phone: str, country_code: str = "+86", login_page: str = "creator", session_id: str = None) -> Dict[str, Any]: """ 发送验证码 @@ -711,6 +889,7 @@ class XHSLoginService: phone: 手机号 country_code: 国家区号 login_page: 登录页面类型,creator(创作者中心) 或 home(小红书首页) + session_id: 会话ID,用于WebSocket通知 Returns: Dict containing success status and error message if any @@ -736,53 +915,81 @@ class XHSLoginService: current_url = self.page.url if self.page else "" if self.use_pool and self.browser_pool and self.browser_pool.is_preheated: if login_url in current_url: - print(f"✅ 浏览器已预热在{page_name}登录页,直接使用!", file=sys.stderr) + logger.success(f"✅ 浏览器已预热在{page_name}登录页,直接使用!") else: # 页面变了,重新访问登录页 - print(f"[预热] 页面已变更 ({current_url}),重新访问{page_name}登录页...", file=sys.stderr) + logger.success(f"[预热] 页面已变更 ({current_url}),重新访问{page_name}登录页...") await self._navigate_with_qrcode_listener(login_url) else: # 未预热或不是池模式,使用监听机制访问页面 - print(f"正在访问{page_name}登录页...", file=sys.stderr) - - # 先验证代理IP(如果配置了代理) - if hasattr(self, 'proxy') and self.proxy: - try: - print(f"[代理验证] 配置的代理: {self.proxy.get('server', '未知')}", file=sys.stderr) - print(f"[代理验证] 正在访问 IP 查询网站...", file=sys.stderr) - - await self.page.goto('https://httpbin.org/ip', timeout=15000) - ip_info = await self.page.locator('body').inner_text() - print(f"[代理验证] 当前 IP 信息:\n{ip_info}", file=sys.stderr) - - # 简单解析IP地址 - import json - try: - ip_data = json.loads(ip_info) - current_ip = ip_data.get('origin', '未知') - proxy_host = self.proxy.get('server', '').split('://')[-1].split(':')[0] - if proxy_host in current_ip or current_ip in self.proxy.get('server', ''): - print(f"[代理验证] ✅ 代理生效,当前IP: {current_ip}", file=sys.stderr) - else: - print(f"[代理验证] ⚠️ 当前IP ({current_ip}) 与代理IP ({proxy_host}) 不匹配", file=sys.stderr) - except: - print(f"[代理验证] IP信息: {ip_info}", file=sys.stderr) - except Exception as e: - print(f"[代理验证] 验证失败: {str(e)}", file=sys.stderr) - else: - print(f"[代理验证] 未配置代理,使用本机IP", file=sys.stderr) + logger.debug(f"正在访问{page_name}登录页...") await self._navigate_with_qrcode_listener(login_url) - print(f"✅ 已进入{page_name}登录页面", file=sys.stderr) + logger.success(f"✅ 已进入{page_name}登录页面") + + # 立即检查是否被风控跳转到验证页面 + current_url = self.page.url + logger.info(f"[风控检测] 当前URL: {current_url}") + + # 检查是否在风控页面 + if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): + logger.warning("="*50) + logger.warning(f"⚠️ 发送验证码阶段检测到风控页面!") + logger.warning(f"完整URL: {current_url}") + logger.warning("="*50) + + # 等待页面加载完成 + logger.info("等待验证页面加载完成...") + await asyncio.sleep(1) + + # 尝试提取二维码 + logger.info("开始提取二维码...") + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.success("✅ 成功提取验证二维码") + logger.info(f"二维码数据长度: {len(qrcode_data)} 字符") + logger.info("返回二维码给前端,等待用户扫码后重新调用接口") + + # 启动后台任务监听页面跳转,扫码完成后通知前端 + asyncio.create_task(self._monitor_qrcode_scan(session_id)) + logger.info(f"[WebSocket] 已启动扫码监听任务: {session_id}") + + return { + "success": False, + "error": "需要验证", + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "message": "发送验证码时触发风控,需要扫码验证。扫码后页面会自动跳转回首页,请重新点击发送验证码" + } + else: + logger.error("⚠️ 检测到验证页面但未提取到二维码") + logger.info("尝试保存截图...") + try: + await self.page.screenshot(path='logs/captcha_page_sendcode.png') + logger.success("截图已保存到 logs/captcha_page_sendcode.png") + except Exception as e: + logger.error(f"保存截图失败: {str(e)}") + return { + "success": False, + "error": "验证页面异常", + "need_captcha": True, + "captcha_type": "unknown", + "message": "检测到验证页面但无法提取二维码" + } + + # 检查是否已经在首页(扫码后跳转回来的) + if login_page == "home" and login_url in current_url: + logger.success("✅ 已在首页,风控已解除,继续正常登录流程") # 根据登录页面类型处理协议复选框 if login_page == "home": # 小红书首页需要主动触发登录框 - print("处理小红书首页登录流程...", file=sys.stderr) + logger.info("处理小红书首页登录流程...") try: # 首先尝试触发登录框(点击登录按钮) - print("查找并点击登录按钮以弹出登录框...", file=sys.stderr) + logger.debug("查找并点击登录按钮以弹出登录框...") login_trigger_selectors = [ '.login', # 常见的登录按钮class 'text="登录"', @@ -800,30 +1007,30 @@ class XHSLoginService: # 检查是否可见 is_visible = await login_btn.is_visible() if is_visible: - print(f"✅ 找到登录触发按钮: {selector}", file=sys.stderr) + logger.success(f"✅ 找到登录触发按钮: {selector}") await login_btn.click() - print("✅ 已点击登录按钮,等待登录框弹出...", file=sys.stderr) + logger.success("✅ 已点击登录按钮,等待登录框弹出...") await asyncio.sleep(0.5) # 从1秒减少到0.5秒 login_triggered = True break except Exception as e: - print(f"尝试选择器 {selector} 失败: {str(e)}", file=sys.stderr) + logger.error(f"尝试选择器 {selector} 失败: {str(e)}") continue if not login_triggered: - print("⚠️ 未找到登录触发按钮,假设登录框已存在", file=sys.stderr) + logger.warning("⚠️ 未找到登录触发按钮,假设登录框已存在") # 等待登录弹窗中的元素加载 - print("等待登录弹窗中的元素加载...", file=sys.stderr) + logger.info("等待登录弹窗中的元素加载...") # 直接等待手机号输入框出现(说明登录框已弹出) phone_input_ready = False try: await self.page.wait_for_selector('input[placeholder="输入手机号"]', timeout=3000) # 从to 8秒减少到3秒 phone_input_ready = True - print("✅ 登录弹窗已弹出,手机号输入框就绪", file=sys.stderr) + logger.success("✅ 登录弹窗已弹出,手机号输入框就绪") except Exception: - print("⚠️ 等待登录弹窗超时,尝试继续...", file=sys.stderr) + logger.warning("⚠️ 等待登录弹窗超时,尝试继续...") # 检查是否需要点击“手机号登录”选项卡(如果有多个登录方式) phone_login_tab_selectors = [ @@ -840,18 +1047,18 @@ class XHSLoginService: # 检查是否已经选中 is_active = await phone_login_tab.evaluate('el => el.classList.contains("active") || el.parentElement.classList.contains("active")') if not is_active: - print(f"✅ 找到手机号登录选项卡: {selector}", file=sys.stderr) + logger.success(f"✅ 找到手机号登录选项卡: {selector}") await phone_login_tab.click() - print("✅ 已点击手机号登录选项卡", file=sys.stderr) + logger.success("✅ 已点击手机号登录选项卡") await asyncio.sleep(0.3) # 从0.5秒减少到0.3秒 else: - print("✅ 手机号登录选项卡已选中", file=sys.stderr) + logger.success("✅ 手机号登录选项卡已选中") break except Exception: continue if not phone_login_tab: - print("✅ 未找到手机号登录选项卡,可能已经是手机号登录界面", file=sys.stderr) + logger.warning("✅ 未找到手机号登录选项卡,可能已经是手机号登录界面") # 查找并点击协议复选框(小红书首页特有) agreement_selectors = [ @@ -868,18 +1075,18 @@ class XHSLoginService: # 检查是否已勾选 is_checked = await agreement_checkbox.evaluate('el => el.classList.contains("checked") || el.querySelector(".checked") !== null') if not is_checked: - print(f"✅ 找到协议复选框: {selector}", file=sys.stderr) + logger.success(f"✅ 找到协议复选框: {selector}") await agreement_checkbox.click() - print("✅ 已勾选协议", file=sys.stderr) + logger.success("✅ 已勾选协议") await asyncio.sleep(0.2) else: - print("✅ 协议已勾选", file=sys.stderr) + logger.success("✅ 协议已勾选") break if not agreement_checkbox: - print("⚠️ 未找到协议复选框,尝试继续...", file=sys.stderr) + logger.warning("⚠️ 未找到协议复选框,尝试继续...") except Exception as e: - print(f"处理首页登录流程失败: {str(e)}", file=sys.stderr) + logger.error(f"处理首页登录流程失败: {str(e)}") else: # 创作者中心登录流程 # 根据记忆:小红书登录跳过协议复选框,无需处理 @@ -888,14 +1095,14 @@ class XHSLoginService: agreement_btn = await self.page.query_selector('text="同意并继续"') if agreement_btn: await agreement_btn.click() - print(f"✅ 已点击协议按钮", file=sys.stderr) + logger.success(f"✅ 已点击协议按钮") await asyncio.sleep(0.3) except Exception: pass # 无协议弹窗(正常情况) # 输入手机号 try: - print("查找手机号输入框...", file=sys.stderr) + logger.debug("查找手机号输入框...") # 根据登录页面类型选择不同的选择器 if login_page == "home": @@ -921,7 +1128,7 @@ class XHSLoginService: for selector in phone_input_selectors: phone_input = await self.page.query_selector(selector) if phone_input: - print(f"✅ 找到手机号输入框: {selector}", file=sys.stderr) + logger.success(f"✅ 找到手机号输入框: {selector}") # 清空并输入手机号(使用原生JS,避免上下文销毁) await self.page.evaluate(f''' @@ -937,24 +1144,24 @@ class XHSLoginService: }} ''', selector) - print(f"✅ 已输入手机号: {phone}", file=sys.stderr) + logger.success(f"✅ 已输入手机号: {phone}") await asyncio.sleep(0.3) break if not phone_input: # 打印页面信息用于调试 - print("⚠️ 未找到手机号输入框,打印页面信息...", file=sys.stderr) - print(f"页面URL: {self.page.url}", file=sys.stderr) + logger.warning("⚠️ 未找到手机号输入框,打印页面信息...") + logger.info(f"页面URL: {self.page.url}") # 查找所有input元素 inputs = await self.page.query_selector_all('input') - print(f"页面上找到 {len(inputs)} 个input元素", file=sys.stderr) + logger.info(f"页面上找到 {len(inputs)} 个input元素") for i, inp in enumerate(inputs[:5]): try: placeholder = await inp.get_attribute('placeholder') input_type = await inp.get_attribute('type') name = await inp.get_attribute('name') class_name = await inp.get_attribute('class') - print(f"Input {i+1}: type={input_type}, placeholder={placeholder}, name={name}, class={class_name}", file=sys.stderr) + logger.info(f"Input {i+1}: type={input_type}, placeholder={placeholder}, name={name}, class={class_name}") except Exception: pass @@ -977,7 +1184,7 @@ class XHSLoginService: # 点击发送验证码按钮 try: - print("查找发送验证码按钮...", file=sys.stderr) + logger.debug("查找发送验证码按钮...") # 等待页面稳定(输入手机号后可能有动态渲染) await asyncio.sleep(0.3) # 从0.5秒减少到0.3秒 @@ -1006,7 +1213,7 @@ class XHSLoginService: for selector in selectors: send_code_btn = await self.page.query_selector(selector) if send_code_btn: - print(f"✅ 找到发送验证码按钮: {selector}", file=sys.stderr) + logger.success(f"✅ 找到发送验证码按钮: {selector}") send_code_selector = selector break @@ -1022,12 +1229,12 @@ class XHSLoginService: # 获取按钮文本内容 btn_text = await send_code_btn.inner_text() btn_text = btn_text.strip() if btn_text else "" - print(f"📝 按钮文本: '{btn_text}'", file=sys.stderr) + logger.info(f"📝 按钮文本: '{btn_text}'") # 检查按钮是否处于倒计时状态 # 倒计时状态通常显示为: "59s", "58s", "60秒后重新获取" 等 if btn_text and (btn_text[-1] == 's' or '秒' in btn_text or btn_text.isdigit()): - print(f"⚠️ 按钮处于倒计时状态: {btn_text}", file=sys.stderr) + logger.warning(f"⚠️ 按钮处于倒计时状态: {btn_text}") return { "success": False, "error": f"验证码发送过于频繁,请{btn_text}后再试" @@ -1036,7 +1243,7 @@ class XHSLoginService: # 检查按钮文本是否为期望的"获取验证码"或"发送验证码" expected_texts = ["获取验证码", "发送验证码", "重新发送"] if btn_text not in expected_texts: - print(f"⚠️ 按钮文本不符合预期: '{btn_text}', 期望: {expected_texts}", file=sys.stderr) + logger.warning(f"⚠️ 按钮文本不符合预期: '{btn_text}', 期望: {expected_texts}") return { "success": False, "error": f"按钮状态异常(当前文本: {btn_text}),请刷新页面重试" @@ -1046,25 +1253,25 @@ class XHSLoginService: if login_page == "home": class_name = await send_code_btn.get_attribute('class') or "" if 'active' not in class_name: - print(f"⚠️ 按钮未激活状态: class={class_name}", file=sys.stderr) + logger.warning(f"⚠️ 按钮未激活状态: class={class_name}") return { "success": False, "error": "按钮未激活,请检查手机号是否正确输入" } - print(f"✅ 按钮已激活: class={class_name}", file=sys.stderr) + logger.success(f"✅ 按钮已激活: class={class_name}") # 在点击前再次确保元素有效(页面DOM可能在检查过程中更新) try: # 使用 page.click 直接通过选择器点击,避免元素句柄失效问题 await self.page.click(send_code_selector, timeout=5000) - print("✅ 已点击发送验证码", file=sys.stderr) + logger.success("✅ 已点击发送验证码") except Exception as click_error: # 如果直接点击失败,尝试重新获取元素点击 - print(f"⚠️ 直接点击失败: {str(click_error)}, 尝试重新获取元素", file=sys.stderr) + logger.error(f"⚠️ 直接点击失败: {str(click_error)}, 尝试重新获取元素") send_code_btn = await self.page.query_selector(send_code_selector) if send_code_btn: await send_code_btn.click() - print("✅ 重新获取元素后点击成功", file=sys.stderr) + logger.success("✅ 重新获取元素后点击成功") else: raise Exception("按钮元素已失效,无法点击") @@ -1074,12 +1281,12 @@ class XHSLoginService: # 检查当前页面URL是否包含captcha(验证页面) current_url = self.page.url if 'captcha' in current_url or 'verify' in current_url: - print(f"⚠️ 检测到验证页面: {current_url}", file=sys.stderr) + logger.warning(f"⚠️ 检测到验证页面: {current_url}") # 尝试提取二维码图片 qrcode_data = await self.extract_verification_qrcode() if qrcode_data: - print("✅ 成功提取验证二维码", file=sys.stderr) + logger.success("✅ 成功提取验证二维码") return { "success": False, "need_captcha": True, @@ -1097,9 +1304,9 @@ class XHSLoginService: # 直接返回成功,不再检测滑块 logger.info(f"[发送验证码] 成功 - 手机号: {phone}") - print("\n✅ 验证码发送流程完成,请查看手机短信", file=sys.stderr) - print("请在小程序中输入收到的验证码并点击登录\n", file=sys.stderr) - print("[响应即将返回] success=True, message=验证码发送成功", file=sys.stderr) + logger.success("\n✅ 验证码发送流程完成,请查看手机短信") + logger.info("请在小程序中输入收到的验证码并点击登录\n") + logger.success("[响应即将返回] success=True, message=验证码发送成功") return { "success": True, @@ -1125,15 +1332,15 @@ class XHSLoginService: except Exception as e: error_msg = str(e) logger.error(f"[发送验证码] 异常 - 手机号: {phone}, 错误: {error_msg}") - print(f"\n❌ 发送验证码异常: {error_msg}", file=sys.stderr) - print(f"当前页面URL: {self.page.url if self.page else 'N/A'}", file=sys.stderr) + logger.error(f"\n❌ 发送验证码异常: {error_msg}") + logger.info(f"当前页面URL: {self.page.url if self.page else 'N/A'}") # 打印调试信息 if self.page: try: - print("尝试截图保存错误状态...", file=sys.stderr) + logger.error("尝试截图保存错误状态...") await self.page.screenshot(path='error_screenshot.png') - print("✅ 错误状态已截图保存到 error_screenshot.png", file=sys.stderr) + logger.error("✅ 错误状态已截图保存到 error_screenshot.png") except Exception: pass @@ -1164,7 +1371,7 @@ class XHSLoginService: # 输入验证码 try: - print("查找验证码输入框...", file=sys.stderr) + logger.debug("查找验证码输入框...") # 根据登录页面类型选择不同的选择器 if login_page == "home": @@ -1189,7 +1396,7 @@ class XHSLoginService: try: code_input = await self.page.wait_for_selector(selector, timeout=2000) if code_input: - print(f"✅ 找到验证码输入框: {selector}", file=sys.stderr) + logger.success(f"✅ 找到验证码输入框: {selector}") break except Exception: continue @@ -1204,7 +1411,7 @@ class XHSLoginService: await asyncio.sleep(0.2) await code_input.press('Control+A') await code_input.type(code, delay=50) - print(f"✅ 已输入验证码: {code}", file=sys.stderr) + logger.success(f"✅ 已输入验证码: {code}") await asyncio.sleep(0.5) except Exception as e: return { @@ -1214,7 +1421,7 @@ class XHSLoginService: # 点击登录按钮 try: - print("查找登录按钮...", file=sys.stderr) + logger.debug("查找登录按钮...") # 根据登录页面类型选择不同的选择器 if login_page == "home": @@ -1242,21 +1449,21 @@ class XHSLoginService: try: login_btn = await self.page.wait_for_selector(selector, timeout=2000) if login_btn: - print(f"✅ 找到登录按钮: {selector}", file=sys.stderr) + logger.success(f"✅ 找到登录按钮: {selector}") break except Exception: continue if not login_btn: # 打印所有按钮用于调试 - print("⚠️ 未找到登录按钮,打印所有按钮...", file=sys.stderr) + logger.warning("⚠️ 未找到登录按钮,打印所有按钮...") buttons = await self.page.query_selector_all('button') - print(f"页面上找到 {len(buttons)} 个按钮", file=sys.stderr) + logger.info(f"页面上找到 {len(buttons)} 个按钮") for i, btn in enumerate(buttons[:10]): try: text = await btn.inner_text() classes = await btn.get_attribute('class') - print(f"按钮 {i+1}: 文本=[{text.strip()}] class=[{classes}]", file=sys.stderr) + logger.info(f"按钮 {i+1}: 文本=[{text.strip()}] class=[{classes}]") except Exception: pass @@ -1265,35 +1472,249 @@ class XHSLoginService: "error": "未找到登录按钮" } + # 优化:在点击登录前注册API监听,避免错过直接登录成功的API响应 + logger.info("[登录检测] 开始监听user/me API...") + login_success = False + user_me_data = None + + # 添加:拦截小红书反检测脚本请求(异步处理,不阻塞) + async def handle_shield_script(route): + try: + # 快速放行,不阻塞后续流程 + await route.continue_() + + # 异步记录日志 + request = route.request + url = request.url + logger.warning(f"[反检测拦截] 监听到小红书反检测脚本请求: {url}") + except Exception as e: + logger.error(f"[反检测拦截] 处理失败: {str(e)}") + await route.continue_() + + # 注册小红书反检测脚本监听 + await self.page.route('**/api/sec/v1/scripting*', handle_shield_script) + logger.info("[反检测拦截] 已注册小红书反检测脚本监听") + + # 设置路由监听用户信息API + async def handle_user_me(route): + nonlocal login_success, user_me_data + try: + request = route.request + logger.info(f"[登录检测] 监听到用户信息API: {request.url}") + + response = await route.fetch() + body = await response.body() + try: + data = json.loads(body.decode('utf-8')) + logger.info(f"[登录检测] API响应: {json.dumps(data, ensure_ascii=False)}") + + # 检查是否登录成功:code=0, success=true, guest=false + if (data.get('code') == 0 and + data.get('success') == True and + data.get('data', {}).get('guest') == False): + login_success = True + user_me_data = data.get('data') + logger.success(f"✅ 检测到登录成功,用户: {user_me_data.get('nickname')}") + + # 通过WebSocket推送登录成功消息 + if session_id: + try: + from main import ws_manager + await ws_manager.send_message(session_id, { + "type": "login_success", + "user_info": user_me_data + }) + logger.info(f"[WebSocket] 已推送登录成功消息: {session_id}") + except Exception as ws_error: + logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") + except Exception as e: + logger.error(f"[登录检测] 解析响应失败: {str(e)}") + await route.fulfill(response=response) + except Exception as e: + logger.error(f"[登录检测] 处理API请求失败: {str(e)}") + await route.continue_() + + # 注册路由监听 + await self.page.route('**/api/sns/web/v2/user/me', handle_user_me) + logger.info("[登录检测] 已注册用户信息API监听") + + # 点击登录按钮 + logger.info("="*50) + logger.info("开始点击登录按钮") + logger.info(f"点击前URL: {self.page.url}") await login_btn.click() - print("✅ 已点击登录按钮", file=sys.stderr) + logger.success("✅ 已点击登录按钮") + + # 立即检查URL变化 + await asyncio.sleep(0.2) + logger.info(f"点击后0.2秒URL: {self.page.url}") # 优化:简化协议处理,减少等待 - await asyncio.sleep(0.5) + await asyncio.sleep(0.3) + logger.info(f"点击后0.5秒URL: {self.page.url}") try: popup_btn = await self.page.query_selector('text="同意并继续"') if popup_btn: await popup_btn.click() - print(f"✅ 已点击登录后的协议弹窗", file=sys.stderr) + logger.success(f"✅ 已点击登录后的协议弹窗") await asyncio.sleep(0.3) + logger.info(f"点击协议后URL: {self.page.url}") except Exception: pass # 无弹窗 - # 优化:直接检测URL跳转,不等待元素 - print("正在等待登录跳转...", file=sys.stderr) - for i in range(16): # 从20次减少到16次,最多等待8秒 + # 优化:持续检测URL变化,最多等待10秒 + logger.info("="*50) + logger.info("开始持续检测URL变化...") + captcha_detected = False + for i in range(20): # 20 * 0.5 = 10秒 await asyncio.sleep(0.5) current_url = self.page.url - # 严格检查:必须跳转离开登录页 - if 'login' not in current_url: - # 已离开登录页,检查是否到达有效页面 - if 'creator.xiaohongshu.com' in current_url or 'www.xiaohongshu.com' in current_url: - print(f"✅ 登录成功,跳转到: {current_url}", file=sys.stderr) - # 优化:减少等待时间 - await asyncio.sleep(0.5) # 从1秒减少到0.5秒 + # 每次都打印URL,方便看到变化 + if i == 0 or i % 2 == 0: # 每秒打印一次 + logger.info(f"[检测{i+1}/20] 当前URL: {current_url}") + + # 1. 检查URL是否包含captcha或verify + if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): + logger.warning("="*50) + logger.warning(f"⚠️ 检测到验证页面跳转!") + logger.warning(f"完整URL: {current_url}") + logger.warning("="*50) + captcha_detected = True + + # 等待页面加载完成 + logger.info("等待验证页面加载完成...") + await asyncio.sleep(1) + logger.info(f"等待后URL: {self.page.url}") + + # 尝试提取二维码 + logger.info("开始提取二维码...") + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.success("✅ 成功提取验证二维码,返回给前端") + logger.info(f"二维码数据长度: {len(qrcode_data)} 字符") + # 注意:不移除API监听,保持session_id对应的浏览器继续运行 + return { + "success": False, + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "session_id": session_id, # 返回session_id,供后续轮询使用 + "message": "需要扫码验证,请使用小红书APP扫描二维码" + } + else: + logger.error("⚠️ 检测到验证页面但未提取到二维码") + logger.info("尝试保存截图...") + try: + await self.page.screenshot(path='logs/captcha_page.png') + logger.success("截图已保存到 logs/captcha_page.png") + except Exception as e: + logger.error(f"保存截图失败: {str(e)}") + break + + # 2. 检查是否直接登录成功(URL跳转到explore或creator) + if 'explore' in current_url or 'creator' in current_url: + logger.success("="*50) + logger.success(f"✅ 检测到登录成功URL跳转") + logger.success(f"完整URL: {current_url}") + logger.success("="*50) + break + + if not captcha_detected: + logger.info("="*50) + logger.info(f"10秒检测结束,未检测到验证页面") + logger.info(f"最终URL: {self.page.url}") + logger.info("="*50) + + # 2. 即使URL没变,也要检测页面上是否出现二维码弹窗 + logger.info("检测页面上是否出现扫码验证...") + qrcode_selectors = [ + '.qrcode-img', + 'img.qrcode-img', + '.qrcode-container img', + 'img[src*="data:image"]', + 'img[src*="qrcode"]', + 'img[alt*="二维码"]', + 'img[alt*="qrcode"]', + ] + + for selector in qrcode_selectors: + try: + qrcode_elem = await self.page.query_selector(selector) + if qrcode_elem: + logger.warning(f"⚠️ 检测到页面上出现二维码: {selector}") + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.success("✅ 成功提取扫码验证二维码,返回给前端") + # 注意:不移除API监听,保持session_id对应的浏览器继续运行 + return { + "success": False, + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "session_id": session_id, # 返回session_id,供后续轮询使用 + "message": "需要扫码验证,请使用小红书APP扫描二维码" + } break - else: + except Exception: + continue + + logger.info("未检测到扫码验证,继续等待登录...") + + # 等待URL跳转或API响应(最多30秒) + logger.info("[登录检测] 等待扫码完成或登录跳转...") + url_jumped = False + for i in range(60): # 60 * 0.5 = 30秒 + await asyncio.sleep(0.5) + + # 如果捕获到user/me API,说明登录成功 + if login_success: + logger.success(f"✅ 通过API确认登录成功") + break + + current_url = self.page.url + + # 检查URL是否跳转 + if 'login' not in current_url: + if 'creator.xiaohongshu.com' in current_url or 'www.xiaohongshu.com' in current_url: + if not url_jumped: + logger.success(f"✅ URL已跳转: {current_url}") + url_jumped = True + # URL跳转后继续等待API响应,最多再等待10秒 + logger.info("[登录检测] URL已跳转,继续等待user/me API...") + + # 移除路由监听 + try: + await self.page.unroute('**/api/sns/web/v2/user/me') + logger.info("[登录检测] 已移除用户信息API监听") + except Exception: + pass + + # 如果没有捕获到API,但URL已跳转,尝试通过Cookie判断 + if not login_success and url_jumped: + logger.warning("[登录检测] 未捕获到user/me API,尝试通过Cookie判断...") + cookies = await self.context.cookies() + cookies_dict = {cookie['name']: cookie['value'] for cookie in cookies} + + # 检查关键Cookie是否存在 + key_cookies = ['web_session', 'webId', 'a1'] + has_key_cookies = all(key in cookies_dict for key in key_cookies) + + if has_key_cookies: + logger.success(f"✅ 检测到关键Cookie,判定登录成功") + login_success = True + # 没有user_me_data,后续会从localStorage获取 + else: + logger.error(f"❌ 未检测到关键Cookie: {list(cookies_dict.keys())}") + + # 移除路由监听 + try: + await self.page.unroute('**/api/sns/web/v2/user/me') + logger.info("[登录检测] 已移除用户信息API监听") + except Exception: + pass + + if not login_success: # 8秒后还在登录页,可能验证码错误 if 'login' in self.page.url: # 保存错误截图 @@ -1306,6 +1727,17 @@ class XHSLoginService: "success": False, "error": "登录失败,请检查验证码是否正确" } + else: + # URL已跳转但Cookie不对 + await save_error_screenshot( + self.page, + "login_failed_no_cookie", + "登录失败,未获取到登录Cookie" + ) + return { + "success": False, + "error": "登录失败,未获取到有效的登录信息" + } except Exception as e: # 保存错误截图 await save_error_screenshot( @@ -1320,14 +1752,14 @@ class XHSLoginService: # 检查是否登录成功 # 优化:已经通过URL跳转检查,但需要再次确认页面稳定 - print("✅ 登录成功,正在确认页面稳定性...", file=sys.stderr) + logger.success("✅ 登录成功,正在确认页面稳定性...") # 优化:减少等待时间 await asyncio.sleep(1) # 从2秒减少到1秒 final_url = self.page.url if 'login' in final_url: - print("⚠️ 检测到页面被重定向回登录页,Cookie可能被小红书拒绝", file=sys.stderr) + logger.warning("⚠️ 检测到页面被重定向回登录页,Cookie可能被小红书拒绝") await save_error_screenshot( self.page, "login_redirect_back", @@ -1338,7 +1770,7 @@ class XHSLoginService: "error": "登录失败:小红书检测到异常登录行为,请稍后再试或使用手动登录" } - print(f"✅ 页面稳定,最终URL: {final_url}", file=sys.stderr) + logger.success(f"✅ 页面稳定,最终URL: {final_url}") # 获取Cookies cookies = await self.context.cookies() @@ -1349,75 +1781,86 @@ class XHSLoginService: cookies_dict = {cookie['name']: cookie['value'] for cookie in cookies} # 打印重要的Cookies - print(f"\n========== Cookies 信息 ==========", file=sys.stderr) - print(f"共获取到 {len(cookies)} 个Cookie", file=sys.stderr) + logger.info(f"\n========== Cookies 信息 ==========") + logger.info(f"共获取到 {len(cookies)} 个Cookie") # 打印所有Cookie名称 - print(f"\nCookie名称列表: {list(cookies_dict.keys())}", file=sys.stderr) + logger.info(f"\nCookie名称列表: {list(cookies_dict.keys())}") # 完整打印所有Cookies(键值对格式) - print(f"\n完整Cookies内容(键值对格式):", file=sys.stderr) + logger.info(f"\n完整Cookies内容(键值对格式):") for name, value in cookies_dict.items(): - print(f" {name}: {value}", file=sys.stderr) + logger.info(f" {name}: {value}") - print(f"\n================================\n", file=sys.stderr) + logger.info(f"\n================================\n") - # 获取用户信息(从页面或API) + # 获取用户信息(优先使用API返回的user_me_data) user_info = {} - try: - # 优化:减少等待时间,直接获取localStorage - # await asyncio.sleep(0.5) # 删除不必要的等待 - - # 从 localStorage 获取用户信息(最关键) - storage = await self.page.evaluate('() => JSON.stringify(localStorage)') - storage_dict = json.loads(storage) - - # 提取有用的localStorage数据 - useful_keys = ['b1', 'b1b1', 'p1'] - for key in useful_keys: - if key in storage_dict: - try: - value = storage_dict[key] - if value and value.strip(): - user_info[key] = json.loads(value) if value.startswith('{') or value.startswith('[') else value - except: - user_info[key] = storage_dict[key] - - # 获取用户数据 - for key, value in storage_dict.items(): - if 'user' in key.lower(): - try: - user_info['user_data'] = json.loads(value) - break - except: - pass - - print(f"✅ 获取到用户信息: {list(user_info.keys())}", file=sys.stderr) - - except Exception as e: - print(f"⚠️ 获取用户信息失败: {str(e)}", file=sys.stderr) + if user_me_data: + # 使用API返回的用户信息 + user_info = { + 'nickname': user_me_data.get('nickname'), + 'desc': user_me_data.get('desc'), + 'gender': user_me_data.get('gender'), + 'avatar': user_me_data.get('images'), + 'red_id': user_me_data.get('red_id'), + 'user_id': user_me_data.get('user_id'), + 'guest': user_me_data.get('guest') + } + logger.success(f"✅ 使用API返回的用户信息: {user_info.get('nickname')}") + else: + # 备用方案:从localStorage获取 + try: + # 从 localStorage 获取用户信息(最关键) + storage = await self.page.evaluate('() => JSON.stringify(localStorage)') + storage_dict = json.loads(storage) + + # 提取有用的localStorage数据 + useful_keys = ['b1', 'b1b1', 'p1'] + for key in useful_keys: + if key in storage_dict: + try: + value = storage_dict[key] + if value and value.strip(): + user_info[key] = json.loads(value) if value.startswith('{') or value.startswith('[') else value + except: + user_info[key] = storage_dict[key] + + # 获取用户数据 + for key, value in storage_dict.items(): + if 'user' in key.lower(): + try: + user_info['user_data'] = json.loads(value) + break + except: + pass + + logger.success(f"✅ 从 localStorage 获取到用户信息: {list(user_info.keys())}") + + except Exception as e: + logger.error(f"⚠️ 获取用户信息失败: {str(e)}") # 获取当前URL(可能包含token等信息) current_url = self.page.url - print(f"当前URL: {current_url}", file=sys.stderr) + logger.info(f"当前URL: {current_url}") # 获取完整的localStorage数据 localStorage_data = {} try: storage = await self.page.evaluate('() => JSON.stringify(localStorage)') localStorage_data = json.loads(storage) - print(f"✅ 获取到 {len(localStorage_data)} 个localStorage项", file=sys.stderr) + logger.success(f"✅ 获取到 {len(localStorage_data)} 个localStorage项") except Exception as e: - print(f"⚠️ 获取localStorage失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 获取localStorage失败: {str(e)}") # 获取sessionStorage数据 sessionStorage_data = {} try: session_storage = await self.page.evaluate('() => JSON.stringify(sessionStorage)') sessionStorage_data = json.loads(session_storage) - print(f"✅ 获取到 {len(sessionStorage_data)} 个sessionStorage项", file=sys.stderr) + logger.success(f"✅ 获取到 {len(sessionStorage_data)} 个sessionStorage项") except Exception as e: - print(f"⚠️ 获取sessionStorage失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 获取sessionStorage失败: {str(e)}") # 保存完整的登录状态(包含Cookies、localStorage、sessionStorage) try: @@ -1434,13 +1877,13 @@ class XHSLoginService: # 保存到文件(兼容旧版) with open('login_state.json', 'w', encoding='utf-8') as f: json.dump(login_state, f, ensure_ascii=False, indent=2) - print("✅ 已保存完整登录状态到 login_state.json 文件", file=sys.stderr) - print(f" 包含: {len(cookies)} 个Cookies, {len(localStorage_data)} 个localStorage, {len(sessionStorage_data)} 个sessionStorage", file=sys.stderr) + logger.success("✅ 已保存完整登录状态到 login_state.json 文件") + logger.info(f" 包含: {len(cookies)} 个Cookies, {len(localStorage_data)} 个localStorage, {len(sessionStorage_data)} 个sessionStorage") # 兼容性:同时保存单独的cookies.json文件 with open('cookies.json', 'w', encoding='utf-8') as f: json.dump(cookies, f, ensure_ascii=False, indent=2) - print("✅ 已保存 Cookies 到 cookies.json 文件(兼容旧版)", file=sys.stderr) + logger.success("✅ 已保存 Cookies 到 cookies.json 文件(兼容旧版)") # 新增:使用Playwright原生storage_state保存(按手机号命名) storage_state_dir = 'storage_states' @@ -1450,11 +1893,11 @@ class XHSLoginService: # 使用Playwright原生API保存storage_state storage_state_data = await self.context.storage_state(path=storage_state_path) - print(f"✅ 已保存 Playwright Storage State 到: {storage_state_path}", file=sys.stderr) - print(f" 此文件包含完整的浏览器上下文状态,可用于后续免登录恢复", file=sys.stderr) + logger.success(f"✅ 已保存 Playwright Storage State 到: {storage_state_path}") + logger.info(f" 此文件包含完整的浏览器上下文状态,可用于后续免登录恢复") except Exception as e: - print(f"保存登录状态文件失败: {str(e)}", file=sys.stderr) + logger.error(f"保存登录状态文件失败: {str(e)}") return { "success": True, @@ -1469,7 +1912,7 @@ class XHSLoginService: } except Exception as e: - print(f"登录异常: {str(e)}", file=sys.stderr) + logger.error(f"登录异常: {str(e)}") # 保存错误截图(通用错误) await save_error_screenshot( self.page, @@ -1530,13 +1973,13 @@ class XHSLoginService: "error": "页面未初始化" } - print("正在验证登录状态...", file=sys.stderr) + logger.debug("正在验证登录状态...") # 确定要访问的URL target_url = url or 'https://creator.xiaohongshu.com/' page_name = "创作者平台" if "creator" in target_url else "小红书首页" - print(f"访问{page_name}...", file=sys.stderr) + logger.info(f"访问{page_name}...") # 重置跳转计数器 self.redirect_count = 0 @@ -1548,7 +1991,7 @@ class XHSLoginService: # 检查是否发生了频繁跳转 if self.redirect_count > 5: - print(f"❌ 检测到无限跳转 ({self.redirect_count}次重定向),Cookie已失效", file=sys.stderr) + logger.error(f"❌ 检测到无限跳转 ({self.redirect_count}次重定向),Cookie已失效") return { "success": True, "logged_in": False, @@ -1558,9 +2001,9 @@ class XHSLoginService: "url": self.page.url } - print(f"✅ 已访问{page_name},当前URL: {self.page.url}", file=sys.stderr) + logger.success(f"✅ 已访问{page_name},当前URL: {self.page.url}") except Exception as e: - print(f"访问{page_name}失败: {str(e)}", file=sys.stderr) + logger.error(f"访问{page_name}失败: {str(e)}") return { "success": False, "logged_in": False, @@ -1570,7 +2013,7 @@ class XHSLoginService: # 检查是否被重定向到登录页(未登录状态) current_url = self.page.url if 'login' in current_url.lower(): - print("❌ 未登录状态(被重定向到登录页)", file=sys.stderr) + logger.error("❌ 未登录状态(被重定向到登录页)") return { "success": True, "logged_in": False, @@ -1581,7 +2024,7 @@ class XHSLoginService: # 如果成功访问目标页面且未被重定向到登录页,说明已登录 if 'xiaohongshu.com' in current_url and 'login' not in current_url.lower(): - print(f"✅ 已登录状态(成功访问{page_name})", file=sys.stderr) + logger.success(f"✅ 已登录状态(成功访问{page_name})") # 获取当前的Cookies cookies = await self.context.cookies() @@ -1605,7 +2048,7 @@ class XHSLoginService: except: pass except Exception as e: - print(f"获取用户信息失败: {str(e)}", file=sys.stderr) + logger.error(f"获取用户信息失败: {str(e)}") return { "success": True, @@ -1617,7 +2060,7 @@ class XHSLoginService: "url": current_url } else: - print("❌ 未登录状态(URL异常)", file=sys.stderr) + logger.error("❌ 未登录状态(URL异常)") return { "success": True, "logged_in": False, @@ -1627,7 +2070,7 @@ class XHSLoginService: } except Exception as e: - print(f"验证登录状态异常: {str(e)}", file=sys.stderr) + logger.error(f"验证登录状态异常: {str(e)}") return { "success": False, "logged_in": False, @@ -1661,7 +2104,7 @@ class XHSLoginService: """ try: # ========== 内容验证 ========== - print("\n========== 开始验证发布内容 ==========", file=sys.stderr) + logger.debug("\n========== 开始验证发布内容 ==========") # 1. 验证标题长度 if not title or len(title.strip()) == 0: @@ -1679,7 +2122,7 @@ class XHSLoginService: "error": f"标题超出限制:当前宽度 {title_width},平台限制 40", "error_type": "validation_error" } - print(f"✅ 标题验证通过: 宽度 {title_width}/40", file=sys.stderr) + logger.success(f"✅ 标题验证通过: 宽度 {title_width}/40") # 2. 验证内容长度 if not content or len(content.strip()) == 0: @@ -1696,7 +2139,7 @@ class XHSLoginService: "error": f"内容超出限制:当前 {content_length} 个字,最多 1000 个字", "error_type": "validation_error" } - print(f"✅ 内容验证通过: {content_length}/1000 个字", file=sys.stderr) + logger.success(f"✅ 内容验证通过: {content_length}/1000 个字") # 3. 验证图片数量 images_count = len(images) if images else 0 @@ -1712,33 +2155,33 @@ class XHSLoginService: "error": f"图片超出限制:当前 {images_count} 张,最多 18 张", "error_type": "validation_error" } - print(f"✅ 图片数量验证通过: {images_count}/18 张", file=sys.stderr) + logger.success(f"✅ 图片数量验证通过: {images_count}/18 张") - print("✅ 所有验证通过,开始发布\n", file=sys.stderr) + logger.success("✅ 所有验证通过,开始发布\n") # ========== 开始发布流程 ========== # 如果提供了Cookie且使用浏览器池,创建独立的context和page if cookies: - print("✅ 检测到Cookie,将创建独立的浏览器环境", file=sys.stderr) + logger.warning("✅ 检测到Cookie,将创建独立的浏览器环境") # 调试:打印cookies格式 if cookies and len(cookies) > 0: - print(f" Cookie格式检查: 类型={type(cookies).__name__}, 数量={len(cookies)}", file=sys.stderr) + logger.info(f" Cookie格式检查: 类型={type(cookies).__name__}, 数量={len(cookies)}") if isinstance(cookies, list) and len(cookies) > 0: first_cookie = cookies[0] - print(f" 第一个cookie字段: {list(first_cookie.keys()) if isinstance(first_cookie, dict) else 'not dict'}", file=sys.stderr) + logger.info(f" 第一个cookie字段: {list(first_cookie.keys()) if isinstance(first_cookie, dict) else 'not dict'}") if isinstance(first_cookie, dict): # 检查关键字段的类型 for key in ['name', 'value', 'expires', 'sameSite']: if key in first_cookie: val = first_cookie[key] - print(f" {key}: type={type(val).__name__}, value={val}", file=sys.stderr) + logger.info(f" {key}: type={type(val).__name__}, value={val}") # 使用浏览器池模式:复用主浏览器,但为发布创建独立的context if self.use_pool and self.browser_pool: - print("[浏览器池模式] 复用主浏览器实例", file=sys.stderr) + logger.info("[浏览器池模式] 复用主浏览器实例") # 从池中获取浏览器(仅获取browser实例) self.browser, _, _ = await self.browser_pool.get_browser() - print("[浏览器池] 复用主浏览器实例", file=sys.stderr) + logger.info("[浏览器池] 复用主浏览器实例") # 为发布任务创建全新的context(不复用预热的context) context_kwargs = { @@ -1746,16 +2189,16 @@ class XHSLoginService: "user_agent": user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', } self.context = await self.browser.new_context(**context_kwargs) - print("[浏览器池模式] 为发布创建独立的context(避免污染预热环境)", file=sys.stderr) + logger.info("[浏览器池模式] 为发布创建独立的context(避免污染预热环境)") # 注入Cookie到新的context await self.context.add_cookies(cookies) - print(f"✅ 已注入 {len(cookies)} 个Cookie", file=sys.stderr) + logger.success(f"✅ 已注入 {len(cookies)} 个Cookie") # 创建发布页面 - print("[浏览器池模式] 创建发布专用页面", file=sys.stderr) + logger.info("[浏览器池模式] 创建发布专用页面") self.page = await self.context.new_page() - print("✅ 发布页面创建成功\n", file=sys.stderr) + logger.success("✅ 发布页面创建成功\n") elif not self.page: # 非池模式且页面不存在,初始化浏览器 @@ -1763,12 +2206,12 @@ class XHSLoginService: else: # 非池模式但页面已存在,添加Cookie await self.context.add_cookies(cookies) - print(f"✅ 已注入 {len(cookies)} 个Cookie", file=sys.stderr) + logger.success(f"✅ 已注入 {len(cookies)} 个Cookie") # 如果没有Cookie且没有page,尝试使用池 if not self.page: if self.use_pool and self.browser_pool: - print("[浏览器池模式] 获取浏览器实例", file=sys.stderr) + logger.info("[浏览器池模式] 获取浏览器实例") self.browser, self.context, self.page = await self.browser_pool.get_browser(proxy=proxy, user_agent=user_agent) else: return { @@ -1776,14 +2219,14 @@ class XHSLoginService: "error": "页面未初始化,请先登录或提供Cookie" } - print("\n========== 开始发布笔记 ==========", file=sys.stderr) - print(f"标题: {title}", file=sys.stderr) - print(f"内容: {content[:50]}..." if len(content) > 50 else f"内容: {content}", file=sys.stderr) - print(f"图片数量: {len(images) if images else 0}", file=sys.stderr) - print(f"话题: {topics if topics else []}", file=sys.stderr) + logger.debug("\n========== 开始发布笔记 ==========") + logger.info(f"标题: {title}") + logger.info(f"内容: {content[:50]}..." if len(content) > 50 else f"内容: {content}") + logger.info(f"图片数量: {len(images) if images else 0}") + logger.info(f"话题: {topics if topics else []}") # 优化:直接访问图文发布页面URL,跳过点击tab步骤 - print("访问创作者平台图文发布页面...", file=sys.stderr) + logger.info("访问创作者平台图文发布页面...") publish_url = 'https://creator.xiaohongshu.com/publish/publish?source=official&from=menu&target=image' @@ -1792,9 +2235,9 @@ class XHSLoginService: for attempt in range(2): try: if attempt > 0: - print(f"第 {attempt + 1} 次尝试加载页面...", file=sys.stderr) + logger.info(f"第 {attempt + 1} 次尝试加载页面...") else: - print("开始加载页面...", file=sys.stderr) + logger.debug("开始加载页面...") # 使用更宽松的等待条件,不等待networkidle await self.page.goto( @@ -1811,21 +2254,21 @@ class XHSLoginService: # 先打印URL信息,但不立即判定为错误 if current_url != publish_url: - print(f"⚠️ 检测到页面跳转: {current_url}", file=sys.stderr) - print(f"⚠️ 期望页面: {publish_url}", file=sys.stderr) + logger.warning(f"⚠️ 检测到页面跳转: {current_url}") + logger.warning(f"⚠️ 期望页面: {publish_url}") # 关键优化:等待5秒,给小红书时间自动重定向回发布页 if 'redirectReason' in current_url or 'login' in current_url: - print("🔄 检测到重定向参数,等待5秒让小红书自动重定向...", file=sys.stderr) + logger.warning("🔄 检测到重定向参数,等待5秒让小红书自动重定向...") await asyncio.sleep(5) # 再次检查最终URL final_url = self.page.url - print(f"🔍 最终页面URL: {final_url}", file=sys.stderr) + logger.info(f"🔍 最终页面URL: {final_url}") # 如果最终还是在发布页,则认为成功 if 'publish/publish' in final_url: - print("✅ 自动重定向成功,已到达发布页", file=sys.stderr) + logger.success("✅ 自动重定向成功,已到达发布页") current_url = final_url # 更新当前URL elif 'login' in final_url and 'publish' not in final_url: # 真的停留在登录页,Cookie失效 @@ -1837,10 +2280,10 @@ class XHSLoginService: # 最终检查:只要URL中包含'publish/publish',就认为在发布页 if 'publish/publish' not in current_url: - print(f"❌ 页面最终未到达发布页: {current_url}", file=sys.stderr) + logger.error(f"❌ 页面最终未到达发布页: {current_url}") # 其他跳转,重试 if attempt < 1: - print("等待3秒后重试...", file=sys.stderr) + logger.info("等待3秒后重试...") await asyncio.sleep(3) continue else: @@ -1852,23 +2295,23 @@ class XHSLoginService: # 验证页面是否加载成功(检查是否有上传控件) upload_check = await self.page.query_selector('input[type="file"]') if upload_check: - print(f"✅ 已进入图文发布页面: {current_url}", file=sys.stderr) + logger.success(f"✅ 已进入图文发布页面: {current_url}") page_loaded = True break else: - print("⚠️ 页面加载完成但未找到上传控件,可能需要重试", file=sys.stderr) + logger.warning("⚠️ 页面加载完成但未找到上传控件,可能需要重试") if attempt < 1: # 还有重试机会 await asyncio.sleep(2) continue else: # 最后一次尝试也失败了,继续执行看看 - print("⚠️ 未找到上传控件,但继续执行", file=sys.stderr) + logger.warning("⚠️ 未找到上传控件,但继续执行") page_loaded = True break except Exception as e: error_msg = f"访问发布页面失败(尝试{attempt + 1}/2): {str(e)}" - print(f"❌ {error_msg}", file=sys.stderr) + logger.error(f"❌ {error_msg}") # 保存错误截图 try: @@ -1877,12 +2320,12 @@ class XHSLoginService: screenshot_path = f"error_screenshots/{timestamp}_{error_type}.png" os.makedirs('error_screenshots', exist_ok=True) await self.page.screenshot(path=screenshot_path, full_page=True) - print(f"📸 已保存错误截图: {screenshot_path}", file=sys.stderr) + logger.error(f"📸 已保存错误截图: {screenshot_path}") except Exception as screenshot_error: - print(f"⚠️ 保存截图失败: {screenshot_error}", file=sys.stderr) + logger.error(f"⚠️ 保存截图失败: {screenshot_error}") if attempt < 1: # 还有重试机会 - print("等待3秒后重试...", file=sys.stderr) + logger.info("等待3秒后重试...") await asyncio.sleep(3) continue else: @@ -1903,7 +2346,7 @@ class XHSLoginService: # 上传图片(如果有) if images and len(images) > 0: try: - print(f"开始上传 {len(images)} 张图片...", file=sys.stderr) + logger.debug(f"开始上传 {len(images)} 张图片...") # 预处理图片:将网络图片下载到本地 local_images = [] @@ -1923,7 +2366,7 @@ class XHSLoginService: if '/' in img_path and not img_path.startswith('/'): # 可能是OSS相对路径(如 20251221/xxx.png),补充前缀 img_path = oss_prefix + img_path - print(f" 检测到相对路径,补充OSS前缀: {original_path} -> {img_path}", file=sys.stderr) + logger.warning(f" 检测到相对路径,补充OSS前缀: {original_path} -> {img_path}") if img_path.startswith('http://') or img_path.startswith('https://'): # 网络图片,需要下载 @@ -1932,7 +2375,7 @@ class XHSLoginService: local_images.append(local_path) downloaded_files.append(local_path) # 记录以便后续清理 except Exception as e: - print(f"⚠️ 下载图片 {img_path} 失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 下载图片 {img_path} 失败: {str(e)}") return { "success": False, "error": f"下载图片失败: {str(e)}" @@ -1941,13 +2384,13 @@ class XHSLoginService: # 本地图片,直接使用 local_images.append(img_path) - print(f"✅ 图片预处理完成,共 {len(local_images)} 张本地图片", file=sys.stderr) + logger.success(f"✅ 图片预处理完成,共 {len(local_images)} 张本地图片") # 优化:减少等待时间 await asyncio.sleep(0.5) # 优化:直接使用最常见的选择器,先用query_selector快速查找 - print("查找图片上传控件...", file=sys.stderr) + logger.debug("查找图片上传控件...") upload_selectors = [ 'input[type="file"][accept*="image"]', 'input[type="file"]', @@ -1962,7 +2405,7 @@ class XHSLoginService: # 优化:使用query_selector代替wait_for_selector,更快 file_input = await self.page.query_selector(selector) if file_input: - print(f"找到文件上传控件: {selector}", file=sys.stderr) + logger.info(f"找到文件上传控件: {selector}") break except Exception: continue @@ -1973,7 +2416,7 @@ class XHSLoginService: try: file_input = await self.page.wait_for_selector(selector, timeout=3000) if file_input: - print(f"找到文件上传控件: {selector}", file=sys.stderr) + logger.info(f"找到文件上传控件: {selector}") break except Exception: continue @@ -1981,18 +2424,18 @@ class XHSLoginService: if file_input: # 批量上传图片(使用本地图片) images_count = len(local_images) - print(f"正在上传 {images_count} 张本地图片: {local_images}", file=sys.stderr) + logger.debug(f"正在上传 {images_count} 张本地图片: {local_images}") # 验证文件是否存在 for img_path in local_images: if not os.path.exists(img_path): - print(f"⚠️ 警告: 图片文件不存在: {img_path}", file=sys.stderr) + logger.warning(f"⚠️ 警告: 图片文件不存在: {img_path}") else: file_size = os.path.getsize(img_path) / 1024 - print(f" ✅ 文件存在: {img_path} ({file_size:.1f}KB)", file=sys.stderr) + logger.success(f" ✅ 文件存在: {img_path} ({file_size:.1f}KB)") await file_input.set_input_files(local_images) - print(f"已设置文件路径,等待上传...", file=sys.stderr) + logger.success(f"已设置文件路径,等待上传...") # 等待一下让页面处理文件 await asyncio.sleep(1) @@ -2007,7 +2450,7 @@ class XHSLoginService: try: # 检查页面是否还有效 if self.page.is_closed(): - print("检测到页面已关闭", file=sys.stderr) + logger.warning("检测到页面已关闭") page_destroyed = True break @@ -2028,33 +2471,33 @@ class XHSLoginService: if uploaded_count > 0: # 检查是否所有图片都已上传 if uploaded_count >= images_count: - print(f"✅ 所有图片上传完成!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 所有图片上传完成!共 {uploaded_count} 张") upload_success = True break # 每秒打印一次进度(避免刷屏) if i % 2 == 0: - print(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)", file=sys.stderr) + logger.info(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)") except Exception as e: error_msg = str(e) # 检查是否是页面跳转/销毁导致的异常 if 'context was destroyed' in error_msg.lower() or 'navigation' in error_msg.lower(): - print(f"检测到页面跳转: {error_msg}", file=sys.stderr) + logger.error(f"检测到页面跳转: {error_msg}") page_destroyed = True break - print(f"检测上传状态异常: {e}", file=sys.stderr) + logger.error(f"检测上传状态异常: {e}") # 连续异常可能说明页面有问题,等待更长时间 if i > 10: # 5秒后还在异常 await asyncio.sleep(1) # 如果页面被销毁,尝试等待重定向完成 if page_destroyed: - print("⚠️ 页面发生跳转,检查当前URL...", file=sys.stderr) + logger.warning("⚠️ 页面发生跳转,检查当前URL...") await asyncio.sleep(3) # 检查跳转后的URL current_url = self.page.url - print(f"跳转后的URL: {current_url}", file=sys.stderr) + logger.info(f"跳转后的URL: {current_url}") # 如果跳转到登录页,说明Cookie失效 if 'login' in current_url: @@ -2072,17 +2515,17 @@ class XHSLoginService: # 如果仍然在发布页,重新检查图片 if 'publish/publish' in current_url: - print("✅ 仍在发布页,重新检查图片...", file=sys.stderr) + logger.success("✅ 仍在发布页,重新检查图片...") try: uploaded_images = await self.page.query_selector_all('img[src*="blob:"], img[src*="data:image"], [class*="image"][class*="item"] img') uploaded_count = len(uploaded_images) if uploaded_count >= images_count: - print(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张") upload_success = True else: - print(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片", file=sys.stderr) + logger.warning(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片") except Exception as e: - print(f"页面稳定后检测失败: {e}", file=sys.stderr) + logger.error(f"页面稳定后检测失败: {e}") else: # 跳转到其他页面 # 清理临时文件 @@ -2097,30 +2540,30 @@ class XHSLoginService: } if upload_success: - print(f"✅ 图片上传成功!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 图片上传成功!共 {uploaded_count} 张") await asyncio.sleep(0.5) # 优化:从2秒减少到0.5秒 # 清理下载的临时文件 for temp_file in downloaded_files: try: os.remove(temp_file) - print(f"✅ 已清理临时文件: {temp_file}", file=sys.stderr) + logger.success(f"✅ 已清理临时文件: {temp_file}") except Exception: pass else: - print(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...", file=sys.stderr) + logger.warning(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...") else: - print("未找到隐藏的file input,尝试查找可点击的上传区域...", file=sys.stderr) + logger.warning("未找到隐藏的file input,尝试查找可点击的上传区域...") # 调试: 打印页面上所有包含upload的元素 try: all_elements = await self.page.query_selector_all('[class*="upload"], [id*="upload"]') - print(f"\u627e到 {len(all_elements)} 个包含upload的元素", file=sys.stderr) + logger.info(f"\u627e到 {len(all_elements)} 个包含upload的元素") for i, el in enumerate(all_elements[:10]): # 只看前10个 try: tag_name = await el.evaluate('el => el.tagName') class_name = await el.evaluate('el => el.className') - print(f" [{i+1}] {tag_name} class='{class_name}'", file=sys.stderr) + logger.info(f" [{i+1}] {tag_name} class='{class_name}'") except Exception: pass except Exception: @@ -2142,16 +2585,16 @@ class XHSLoginService: try: area = await self.page.wait_for_selector(selector, timeout=2000) if area: - print(f"找到上传区域: {selector}", file=sys.stderr) + logger.info(f"找到上传区域: {selector}") await area.click() await asyncio.sleep(0.5) # 点击后再次查找file input file_input = await self.page.wait_for_selector('input[type="file"]', timeout=2000) if file_input: images_count = len(local_images) - print(f"正在上传 {images_count} 张本地图片: {local_images}", file=sys.stderr) + logger.debug(f"正在上传 {images_count} 张本地图片: {local_images}") await file_input.set_input_files(local_images) - print(f"已设置文件路径,等待上传...", file=sys.stderr) + logger.success(f"已设置文件路径,等待上传...") # 等待一下让页面处理文件 await asyncio.sleep(1) @@ -2166,7 +2609,7 @@ class XHSLoginService: try: # 检查页面是否还有效 if self.page.is_closed(): - print("检测到页面已关闭", file=sys.stderr) + logger.warning("检测到页面已关闭") page_destroyed = True break @@ -2182,51 +2625,51 @@ class XHSLoginService: if uploaded_count > 0: if uploaded_count >= images_count: - print(f"✅ 所有图片上传完成!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 所有图片上传完成!共 {uploaded_count} 张") upload_success = True break # 每秒打印一次进度 if i % 2 == 0: - print(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)", file=sys.stderr) + logger.info(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)") except Exception as e: error_msg = str(e) if 'context was destroyed' in error_msg.lower() or 'navigation' in error_msg.lower(): - print(f"检测到页面跳转: {error_msg}", file=sys.stderr) + logger.error(f"检测到页面跳转: {error_msg}") page_destroyed = True break - print(f"检测上传状态异常: {e}", file=sys.stderr) + logger.error(f"检测上传状态异常: {e}") if i > 10: await asyncio.sleep(1) # 如果页面被销毁,尝试等待重定向完成 if page_destroyed: - print("⚠️ 页面发生跳转,等待页面稳定...", file=sys.stderr) + logger.warning("⚠️ 页面发生跳转,等待页面稳定...") await asyncio.sleep(3) try: uploaded_images = await self.page.query_selector_all('img[src*="blob:"], img[src*="data:image"], [class*="image"][class*="item"] img') uploaded_count = len(uploaded_images) if uploaded_count >= images_count: - print(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张") upload_success = True else: - print(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片", file=sys.stderr) + logger.warning(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片") except Exception as e: - print(f"页面稳定后检测失败: {e}", file=sys.stderr) + logger.error(f"页面稳定后检测失败: {e}") if upload_success: - print(f"✅ 图片上传成功!共 {uploaded_count} 张", file=sys.stderr) + logger.success(f"✅ 图片上传成功!共 {uploaded_count} 张") await asyncio.sleep(0.5) # 优化:0.5秒 # 清理下载的临时文件 for temp_file in downloaded_files: try: os.remove(temp_file) - print(f"✅ 已清理临时文件: {temp_file}", file=sys.stderr) + logger.success(f"✅ 已清理临时文件: {temp_file}") except Exception: pass else: - print(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...", file=sys.stderr) + logger.warning(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...") clicked = True break @@ -2234,15 +2677,15 @@ class XHSLoginService: continue if not clicked: - print("⚠️ 未找到任何上传控件,跳过图片上传", file=sys.stderr) + logger.warning("⚠️ 未找到任何上传控件,跳过图片上传") except Exception as e: - print(f"上传图片失败: {str(e)}", file=sys.stderr) + logger.error(f"上传图片失败: {str(e)}") # 不中断流程,继续发布文字 # 输入标题和内容 try: - print("开始输入文字内容...", file=sys.stderr) + logger.debug("开始输入文字内容...") # 查找标题输入框(使用显式等待确保元素可交互) title_selectors = [ @@ -2263,7 +2706,7 @@ class XHSLoginService: is_visible = await title_input.is_visible() if is_visible: await asyncio.sleep(0.2) # 优化:减少等待时间 - print(f"找到标题输入框: {selector}", file=sys.stderr) + logger.info(f"找到标题输入框: {selector}") break else: title_input = None @@ -2281,7 +2724,7 @@ class XHSLoginService: ) if title_input: await asyncio.sleep(0.2) - print(f"找到标题输入框: {selector}", file=sys.stderr) + logger.info(f"找到标题输入框: {selector}") break except Exception: continue @@ -2290,9 +2733,9 @@ class XHSLoginService: await title_input.click() await asyncio.sleep(0.3) await title_input.fill(title) - print(f"已输入标题: {title}", file=sys.stderr) + logger.success(f"已输入标题: {title}") else: - print("未找到标题输入框,可能不需要单独标题", file=sys.stderr) + logger.warning("未找到标题输入框,可能不需要单独标题") # 查找内容输入框(正文)(使用显式等待确保元素可交互) content_selectors = [ @@ -2316,7 +2759,7 @@ class XHSLoginService: is_visible = await content_input.is_visible() if is_visible: await asyncio.sleep(0.2) # 优化:减少等待时间 - print(f"找到内容输入框: {selector}", file=sys.stderr) + logger.info(f"找到内容输入框: {selector}") break else: content_input = None @@ -2334,7 +2777,7 @@ class XHSLoginService: ) if content_input: await asyncio.sleep(0.2) - print(f"找到内容输入框: {selector}", file=sys.stderr) + logger.info(f"找到内容输入框: {selector}") break except Exception: continue @@ -2357,12 +2800,12 @@ class XHSLoginService: # 如果判断失败,尝试直接fill await content_input.fill(content) - print("已输入笔记内容", file=sys.stderr) + logger.success("已输入笔记内容") await asyncio.sleep(0.2) # 优化:减少等待时间 # 添加话题标签 if topics: - print(f"添加话题标签: {topics}", file=sys.stderr) + logger.info(f"添加话题标签: {topics}") for topic in topics: # 在内容末尾添加话题 topic_text = f" #{topic}" @@ -2376,13 +2819,13 @@ class XHSLoginService: except Exception: # 如果添加失败,继续下一个 pass - print(f"已添加 {len(topics)} 个话题标签", file=sys.stderr) + logger.success(f"已添加 {len(topics)} 个话题标签") await asyncio.sleep(0.5) # 优化:减少等待时间 # 单独在话题输入框中模拟人类方式输入标签 if topics: - print("尝试在话题输入框中逐个输入标签...", file=sys.stderr) + logger.info("尝试在话题输入框中逐个输入标签...") tag_input_selectors = [ 'input[placeholder*="话题"]', 'input[placeholder*="#"]', @@ -2396,7 +2839,7 @@ class XHSLoginService: try: tag_input = await self.page.query_selector(selector) if tag_input: - print(f"找到话题输入框: {selector}", file=sys.stderr) + logger.info(f"找到话题输入框: {selector}") break except Exception: continue @@ -2407,7 +2850,7 @@ class XHSLoginService: try: tag_input = await self.page.wait_for_selector(selector, timeout=2000) if tag_input: - print(f"找到话题输入框: {selector}", file=sys.stderr) + logger.info(f"找到话题输入框: {selector}") break except Exception: continue @@ -2441,16 +2884,16 @@ class XHSLoginService: continue if suggestion: await suggestion.click() - print(f"✅ 已选择联想话题: {topic}", file=sys.stderr) + logger.success(f"✅ 已选择联想话题: {topic}") else: # 没有联想列表时,通过回车确认 await tag_input.press("Enter") - print(f"✅ 未找到联想列表,使用回车确认话题: {topic}", file=sys.stderr) + logger.warning(f"✅ 未找到联想列表,使用回车确认话题: {topic}") await asyncio.sleep(0.3) # 优化:减少等待时间 except Exception as e: - print(f"添加话题 {topic} 到输入框失败: {str(e)}", file=sys.stderr) + logger.error(f"添加话题 {topic} 到输入框失败: {str(e)}") else: - print("⚠️ 未找到话题输入框,已退回到在正文中追加 #话题 的方式", file=sys.stderr) + logger.warning("⚠️ 未找到话题输入框,已退回到在正文中追加 #话题 的方式") else: return { "success": False, @@ -2473,7 +2916,7 @@ class XHSLoginService: # 点击发布按钮 try: - print("查找发布按钮...", file=sys.stderr) + logger.debug("查找发布按钮...") submit_selectors = [ 'button:has-text("发布笔记")', 'button:has-text("发布")', @@ -2491,7 +2934,7 @@ class XHSLoginService: # 检查按钮是否可点击 is_disabled = await submit_btn.evaluate('el => el.disabled') if not is_disabled: - print(f"找到发布按钮: {selector}", file=sys.stderr) + logger.info(f"找到发布按钮: {selector}") break else: submit_btn = None @@ -2508,35 +2951,35 @@ class XHSLoginService: try: # 监听发布笔记的API响应 if '/web_api/sns/v2/note' in response.url: - print(f"✅ 捕获到发布API响应: {response.url}", file=sys.stderr) + logger.success(f"✅ 捕获到发布API响应: {response.url}") if response.status == 200: try: data = await response.json() - print(f"API响应数据: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr) + logger.info(f"API响应数据: {json.dumps(data, ensure_ascii=False)}") if data.get('success') and data.get('data'): note_id = data['data'].get('id') # 优先使用share_link,如果没有则使用note_id拼接 if 'share_link' in data: share_link = data['share_link'] - print(f"✅ 获取到笔记链接: {share_link}", file=sys.stderr) + logger.success(f"✅ 获取到笔记链接: {share_link}") elif note_id: share_link = f"https://www.xiaohongshu.com/discovery/item/{note_id}" - print(f"✅ 根据ID生成笔记链接: {share_link}", file=sys.stderr) + logger.success(f"✅ 根据ID生成笔记链接: {share_link}") except Exception as e: - print(f"解析API响应失败: {str(e)}", file=sys.stderr) + logger.error(f"解析API响应失败: {str(e)}") except Exception as e: - print(f"处理响应失败: {str(e)}", file=sys.stderr) + logger.error(f"处理响应失败: {str(e)}") # 添加响应监听器 self.page.on('response', handle_response) await submit_btn.click() - print("✅ 已点击发布按钮", file=sys.stderr) + logger.success("✅ 已点击发布按钮") await asyncio.sleep(3) # 等待更长时间以捕获API响应 # 检查是否出现社区规范限制提示 - print("检查是否有社区规范限制...", file=sys.stderr) + logger.info("检查是否有社区规范限制...") try: # 尝试查找各种可能的错误提示 error_selectors = [ @@ -2556,7 +2999,7 @@ class XHSLoginService: error_el = await self.page.wait_for_selector(selector, timeout=2000) if error_el: error_text = await error_el.inner_text() - print(f"❌ 检测到错误提示: {error_text}", file=sys.stderr) + logger.error(f"❌ 检测到错误提示: {error_text}") return { "success": False, "error": f"发布失败: {error_text}", @@ -2566,26 +3009,26 @@ class XHSLoginService: except Exception: continue except Exception as e: - print(f"检查错误提示异常: {str(e)}", file=sys.stderr) + logger.error(f"检查错误提示异常: {str(e)}") # 检查是否发布成功 - print("检查发布结果...", file=sys.stderr) + logger.info("检查发布结果...") try: await asyncio.sleep(2) # 等待发布完成 # 如果捕获到了真实的笔记链接,直接返回 if share_link: - print(f"✅ 发布成功,获取到笔记链接: {share_link}", file=sys.stderr) + logger.success(f"✅ 发布成功,获取到笔记链接: {share_link}") # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面 if self.use_pool and self.browser_pool and cookies: try: - print("[浏览器池模式] 关闭发布专用页面", file=sys.stderr) + logger.info("[浏览器池模式] 关闭发布专用页面") await self.page.close() self.page = None - print("✅ 发布页面已关闭", file=sys.stderr) + logger.success("✅ 发布页面已关闭") except Exception as e: - print(f"⚠️ 关闭页面失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 关闭页面失败: {str(e)}") return { "success": True, @@ -2613,7 +3056,7 @@ class XHSLoginService: success_el = await self.page.wait_for_selector(selector, timeout=3000) if success_el: success_text = await success_el.inner_text() - print(f"✅ 检测到发布成功提示: {success_text}", file=sys.stderr) + logger.warning(f"✅ 检测到发布成功提示: {success_text}") publish_success = True break except Exception: @@ -2624,27 +3067,27 @@ class XHSLoginService: if not publish_success: # 如果还在发布页面,可能是发布失败 if 'publish' in current_url.lower(): - print("⚠️ 未检测到成功提示,但继续执行", file=sys.stderr) + logger.warning("⚠️ 未检测到成功提示,但继续执行") else: - print("✅ URL已变化,似乎发布成功", file=sys.stderr) + logger.success("✅ URL已变化,似乎发布成功") publish_success = True - print(f"发布后URL: {current_url}", file=sys.stderr) + logger.info(f"发布后URL: {current_url}") # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面和context if self.use_pool and self.browser_pool and cookies: try: - print("[浏览器池模式] 关闭发布专用环境", file=sys.stderr) + logger.info("[浏览器池模式] 关闭发布专用环境") if self.page: await self.page.close() self.page = None - print("✅ 发布页面已关闭", file=sys.stderr) + logger.success("✅ 发布页面已关闭") if self.context: await self.context.close() self.context = None - print("✅ 发布context已关闭(预热环境保持不受影响)", file=sys.stderr) + logger.success("✅ 发布context已关闭(预热环境保持不受影响)") except Exception as e: - print(f"⚠️ 关闭发布环境失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 关闭发布环境失败: {str(e)}") return { "success": True, @@ -2652,22 +3095,22 @@ class XHSLoginService: "url": current_url } except Exception as e: - print(f"检查发布结果异常: {str(e)}", file=sys.stderr) + logger.error(f"检查发布结果异常: {str(e)}") # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面和context if self.use_pool and self.browser_pool and cookies: try: - print("[浏览器池模式] 关闭发布专用环境", file=sys.stderr) + logger.info("[浏览器池模式] 关闭发布专用环境") if self.page: await self.page.close() self.page = None - print("✅ 发布页面已关闭", file=sys.stderr) + logger.success("✅ 发布页面已关闭") if self.context: await self.context.close() self.context = None - print("✅ 发布context已关闭(预热环境保持不受影响)", file=sys.stderr) + logger.success("✅ 发布context已关闭(预热环境保持不受影响)") except Exception as e2: - print(f"⚠️ 关闭发布环境失败: {str(e2)}", file=sys.stderr) + logger.error(f"⚠️ 关闭发布环境失败: {str(e2)}") # 即使检查异常,也返回成功(因为按钮已点击) return { @@ -2688,7 +3131,7 @@ class XHSLoginService: } except Exception as e: - print(f"发布笔记异常: {str(e)}", file=sys.stderr) + logger.error(f"发布笔记异常: {str(e)}") return { "success": False, "error": str(e) @@ -2794,7 +3237,7 @@ class XHSLoginService: return qrcode_result except Exception as e: - print(f"启动扫码登录失败: {str(e)}", file=sys.stderr) + logger.error(f"启动扫码登录失败: {str(e)}") return { "success": False, "error": str(e) @@ -2934,13 +3377,13 @@ class XHSLoginService: storage = await self.page.evaluate('() => JSON.stringify(localStorage)') localStorage_data = json.loads(storage) except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6localStorage\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6localStorage\u5931\u8d25: {str(e)}") try: session_storage = await self.page.evaluate('() => JSON.stringify(sessionStorage)') sessionStorage_data = json.loads(session_storage) except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6sessionStorage\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6sessionStorage\u5931\u8d25: {str(e)}") result["login_state"] = { "cookies": result["cookies_full"], @@ -2949,16 +3392,16 @@ class XHSLoginService: "url": current_url, "timestamp": time.time() } - print("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001", file=sys.stderr) + logger.info("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001") except Exception as e: - print(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}") return result # 如果API请求失败,退而求其次使用页面元素检测 - print("\u26a0\ufe0f API\u68c0\u6d4b\u5931\u8d25,\u4f7f\u7528\u9875\u9762\u5143\u7d20\u68c0\u6d4b", file=sys.stderr) + logger.info("\u26a0\ufe0f API\u68c0\u6d4b\u5931\u8d25,\u4f7f\u7528\u9875\u9762\u5143\u7d20\u68c0\u6d4b") current_url = self.page.url - print(f"\u5f53\u524dURL: {current_url}", file=sys.stderr) + logger.info(f"\u5f53\u524dURL: {current_url}") # 方法2: 检查\u4e8c\u7ef4\u7801\u662f\u5426\u8fd8\u5728(如\u679c\u4e8c\u7ef4\u7801\u6d88\u5931\u4e86,\u8bf4\u660e\u53ef\u80fd\u767b\u5f55\u4e86) qrcode_exists = False @@ -3002,11 +3445,11 @@ class XHSLoginService: except Exception: pass - print(f"\u767b\u5f55\u72b6\u6001\u68c0\u6d4b: \u4e8c\u7ef4\u7801\u5b58\u5728={qrcode_exists}, \u767b\u5f55\u6846\u5173\u95ed={login_modal_closed}, \u6709\u7528\u6237\u4fe1\u606f={has_user_info}", file=sys.stderr) + logger.info(f"\u767b\u5f55\u72b6\u6001\u68c0\u6d4b: \u4e8c\u7ef4\u7801\u5b58\u5728={qrcode_exists}, \u767b\u5f55\u6846\u5173\u95ed={login_modal_closed}, \u6709\u7528\u6237\u4fe1\u606f={has_user_info}") # 综合\u5224\u65ad: \u4e8c\u7ef4\u7801\u6d88\u5931 \u4e14 (\u767b\u5f55\u6846\u5173\u95ed \u6216 \u6709\u7528\u6237\u4fe1\u606f) if not qrcode_exists and (login_modal_closed or has_user_info): - print("\u2705 \u68c0\u6d4b\u5230\u626b\u7801\u767b\u5f55\u6210\u529f!(\u4e8c\u7ef4\u7801\u5df2\u6d88\u5931)", file=sys.stderr) + logger.info("\u2705 \u68c0\u6d4b\u5230\u626b\u7801\u767b\u5f55\u6210\u529f!(\u4e8c\u7ef4\u7801\u5df2\u6d88\u5931)") result["login_success"] = True # 等\u5f85\u9875\u9762\u7a33\u5b9a @@ -3018,9 +3461,9 @@ class XHSLoginService: cookies_dict = {cookie['name']: cookie['value'] for cookie in cookies} result["cookies"] = cookies_dict result["cookies_full"] = cookies - print(f"\u2705 \u5df2\u83b7\u53d6 {len(cookies)} \u4e2aCookie", file=sys.stderr) + logger.info(f"\u2705 \u5df2\u83b7\u53d6 {len(cookies)} \u4e2aCookie") except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6Cookie\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6Cookie\u5931\u8d25: {str(e)}") # 获\u53d6\u7528\u6237\u4fe1\u606f try: @@ -3039,9 +3482,9 @@ class XHSLoginService: user_info[key] = storage_dict[key] result["user_info"] = user_info - print(f"\u2705 \u5df2\u83b7\u53d6\u7528\u6237\u4fe1\u606f: {list(user_info.keys())}", file=sys.stderr) + logger.info(f"\u2705 \u5df2\u83b7\u53d6\u7528\u6237\u4fe1\u606f: {list(user_info.keys())}") except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6\u7528\u6237\u4fe1\u606f\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6\u7528\u6237\u4fe1\u606f\u5931\u8d25: {str(e)}") # 获\u53d6\u5b8c\u6574\u7684\u767b\u5f55\u72b6\u6001 try: @@ -3052,13 +3495,13 @@ class XHSLoginService: storage = await self.page.evaluate('() => JSON.stringify(localStorage)') localStorage_data = json.loads(storage) except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6localStorage\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6localStorage\u5931\u8d25: {str(e)}") try: session_storage = await self.page.evaluate('() => JSON.stringify(sessionStorage)') sessionStorage_data = json.loads(session_storage) except Exception as e: - print(f"\u26a0\ufe0f \u83b7\u53d6sessionStorage\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u83b7\u53d6sessionStorage\u5931\u8d25: {str(e)}") result["login_state"] = { "cookies": result["cookies_full"], @@ -3067,9 +3510,9 @@ class XHSLoginService: "url": current_url, "timestamp": time.time() } - print("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001", file=sys.stderr) + logger.info("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001") except Exception as e: - print(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}", file=sys.stderr) + logger.info(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}") return result @@ -3103,9 +3546,9 @@ class XHSLoginService: img_base64 = base64.b64encode(img_data).decode('utf-8') content_type = response.headers.get('Content-Type', 'image/png') result["qrcode_image"] = f"data:{content_type};base64,{img_base64}" - print("✅ 成功下载并转换二维码", file=sys.stderr) + logger.success("✅ 成功下载并转换二维码") except Exception as e: - print(f"⚠️ 下载二维码失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 下载二维码失败: {str(e)}") # 如果还是没有图片,尝试截图 if not result["qrcode_image"]: @@ -3115,9 +3558,9 @@ class XHSLoginService: import base64 img_base64 = base64.b64encode(screenshot_bytes).decode('utf-8') result["qrcode_image"] = f"data:image/png;base64,{img_base64}" - print("✅ 成功截取二维码", file=sys.stderr) + logger.success("✅ 成功截取二维码") except Exception as e: - print(f"⚠️ 截取二维码失败: {str(e)}", file=sys.stderr) + logger.error(f"⚠️ 截取二维码失败: {str(e)}") break except Exception as e: @@ -3130,7 +3573,7 @@ class XHSLoginService: } # 提取状态信息 - print("正在提取二维码状态...", file=sys.stderr) + logger.debug("正在提取二维码状态...") status_selectors = [ '.status', '.qrcode-status', @@ -3144,31 +3587,31 @@ class XHSLoginService: # 检查状态是否可见 is_visible = await status_el.is_visible() if not is_visible: - print("二维码状态元素不可见,说明二维码有效", file=sys.stderr) + logger.info("二维码状态元素不可见,说明二维码有效") result["status_text"] = "" # 空字符串表示正常状态 result["is_expired"] = False break - print(f"✅ 找到状态元素: {selector}", file=sys.stderr) + logger.success(f"✅ 找到状态元素: {selector}") # 提取状态文本 status_text_el = await status_el.query_selector('.status-text') if status_text_el: status_text = await status_text_el.inner_text() result["status_text"] = status_text.strip() - print(f"状态文本: {result['status_text']}", file=sys.stderr) + logger.info(f"状态文本: {result['status_text']}") # 提取状态描述 status_desc_el = await status_el.query_selector('.status-desc') if status_desc_el: status_desc = await status_desc_el.inner_text() result["status_desc"] = status_desc.strip() - print(f"状态描述: {result['status_desc']}", file=sys.stderr) + logger.info(f"状态描述: {result['status_desc']}") # 判断是否过期 if "过期" in result["status_text"] or "过期" in result["status_desc"]: result["is_expired"] = True - print("⚠️ 二维码已过期", file=sys.stderr) + logger.warning("⚠️ 二维码已过期") break except Exception as e: @@ -3179,11 +3622,11 @@ class XHSLoginService: result["status_text"] = "" # 空字符串表示正常状态,小程序端不显示覆盖层 result["is_expired"] = False - print(f"✅ 二维码提取完成: 状态={result['status_text']}, 过期={result['is_expired']}, 登录成功={result['login_success']}", file=sys.stderr) + logger.success(f"✅ 二维码提取完成: 状态={result['status_text']}, 过期={result['is_expired']}, 登录成功={result['login_success']}") return result except Exception as e: - print(f"提取二维码状态失败: {str(e)}", file=sys.stderr) + logger.error(f"提取二维码状态失败: {str(e)}") return { "success": False, "error": str(e) diff --git a/go_backend/controller/employee_controller.go b/go_backend/controller/employee_controller.go index f3ff740..1d4079c 100644 --- a/go_backend/controller/employee_controller.go +++ b/go_backend/controller/employee_controller.go @@ -42,13 +42,20 @@ func (ctrl *EmployeeController) SendXHSCode(c *gin.Context) { // 获取当前登录用户ID employeeID := c.GetInt("employee_id") - err := ctrl.service.SendXHSCode(req.XHSPhone, employeeID) + data, err := ctrl.service.SendXHSCode(req.XHSPhone, employeeID) if err != nil { common.Error(c, common.CodeInternalError, err.Error()) return } - common.SuccessWithMessage(c, "验证码已发送,请在小红书APP中查看", nil) + // 检查是否需要扫码验证 + if needCaptcha, ok := data["need_captcha"].(bool); ok && needCaptcha { + // 发送验证码时触发风控,返回二维码 + common.SuccessWithMessage(c, "需要扫码验证", data) + return + } + + common.SuccessWithMessage(c, "验证码已发送,请在小红书APP中查看", data) } // GetProfile 获取员工个人信息 diff --git a/go_backend/controller/xhs_controller.go b/go_backend/controller/xhs_controller.go index e7d9ea3..09f8880 100644 --- a/go_backend/controller/xhs_controller.go +++ b/go_backend/controller/xhs_controller.go @@ -36,6 +36,13 @@ func (ctrl *XHSController) SendCode(c *gin.Context) { return } + // 检查是否需要扫码验证 + if needCaptcha, ok := result.Data["need_captcha"].(bool); ok && needCaptcha { + // 发送验证码时触发风控,返回二维码 + common.SuccessWithMessage(c, result.Message, result.Data) + return + } + // 判断Python服务返回的结果 if result.Code != 0 { common.Error(c, result.Code, result.Message) diff --git a/go_backend/service/employee_service.go b/go_backend/service/employee_service.go index 1753fac..8363bb6 100644 --- a/go_backend/service/employee_service.go +++ b/go_backend/service/employee_service.go @@ -31,7 +31,7 @@ type XHSCookieVerifyResult struct { } // SendXHSCode 发送小红书验证码(调用Python HTTP服务,增加限流控制) -func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { +func (s *EmployeeService) SendXHSCode(phone string, employeeID int) (map[string]interface{}, error) { ctx := context.Background() // 预检查:验证该手机号是否已被其他用户绑定 @@ -45,11 +45,11 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { // 找到了其他用户的绑定记录 log.Printf("发送验证码 - 用户%d - 失败: 手机号%s已被用户%d绑定", employeeID, phone, conflictAuthor.CreatedUserID) - return errors.New("该手机号已被其他用户绑定") + return nil, errors.New("该手机号已被其他用户绑定") } else if err != gorm.ErrRecordNotFound { // 数据库查询异常 log.Printf("发送验证码 - 用户%d - 检查手机号失败: %v", employeeID, err) - return fmt.Errorf("检查手机号失败: %w", err) + return nil, fmt.Errorf("检查手机号失败: %w", err) } // err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续 @@ -57,7 +57,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { rateLimitKey := fmt.Sprintf("rate:sms:%s", phone) exists, err := utils.ExistsCache(ctx, rateLimitKey) if err == nil && exists { - return errors.New("验证码发送过于频繁,请稍后再试") + return nil, errors.New("验证码发送过于频繁,请稍后再试") } // 从配置获取Python服务地址 @@ -76,7 +76,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { jsonData, err := json.Marshal(requestData) if err != nil { log.Printf("[发送验证码] 序列化请求数据失败: %v", err) - return errors.New("网络错误,请稍后重试") + return nil, errors.New("网络错误,请稍后重试") } log.Printf("[发送验证码] 调用Python HTTP服务: %s, 请求参数: phone=%s", url, phone) @@ -90,7 +90,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { log.Printf("[发送验证码] 创建请求失败: %v", err) - return errors.New("网络错误,请稍后重试") + return nil, errors.New("网络错误,请稍后重试") } req.Header.Set("Content-Type", "application/json") @@ -99,9 +99,9 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { log.Printf("[发送验证码] 调用Python服务失败: %v", err) // 判断是否是超时错误 if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") { - return errors.New("请求超时,请稍后重试") + return nil, errors.New("请求超时,请稍后重试") } - return errors.New("网络错误,请稍后重试") + return nil, errors.New("网络错误,请稍后重试") } defer resp.Body.Close() @@ -109,7 +109,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("[发送验证码] 读取响应失败: %v", err) - return errors.New("网络错误,请稍后重试") + return nil, errors.New("网络错误,请稍后重试") } log.Printf("[发送验证码] Python服务响应: 状态码=%d, 耗时=%.2fs", resp.StatusCode, time.Since(startTime).Seconds()) @@ -123,25 +123,27 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { if err := json.Unmarshal(body, &apiResponse); err != nil { log.Printf("[发送验证码] 解析Python响应失败: %v, body: %s", err, string(body)) - return errors.New("网络错误,请稍后重试") + return nil, errors.New("网络错误,请稍后重试") } - log.Printf("[Python响应] code=%d, message=%s", apiResponse.Code, apiResponse.Message) + log.Printf("[Python响应] code=%d, message=%s, data=%v", apiResponse.Code, apiResponse.Message, apiResponse.Data) // 检查响应code(FastAPI返回code=0为成功) if apiResponse.Code != 0 { log.Printf("[发送验证码] 失败: %s", apiResponse.Message) // 根据错误信息返回用户友好的提示 - return s.getFriendlyErrorMessage(apiResponse.Message) + return nil, s.getFriendlyErrorMessage(apiResponse.Message) } + // 返回完整的data,包括need_captcha、qrcode_image、session_id + log.Printf("[发送验证码] 成功, 返回数据: %v", apiResponse.Data) + // 2. 发送成功后设置限流标记(1分钟) if err := utils.SetCache(ctx, rateLimitKey, "1", 1*time.Minute); err != nil { log.Printf("设置限流缓存失败: %v", err) } - log.Printf("[发送验证码] 验证码发送成功") - return nil + return apiResponse.Data, nil } // getFriendlyErrorMessage 将技术错误信息转换为用户友好提示 diff --git a/miniprogram/miniprogram/pages/login/phone-login.ts b/miniprogram/miniprogram/pages/login/phone-login.ts index 97dd70c..7bb6094 100644 --- a/miniprogram/miniprogram/pages/login/phone-login.ts +++ b/miniprogram/miniprogram/pages/login/phone-login.ts @@ -254,11 +254,38 @@ Page({ return; } - if (!this.data.canLogin) { + const { phone, code, password, loginType, countdown } = this.data; + + // 检查手机号 + if (phone.length !== 11) { + wx.showToast({ + title: '请输入正确的手机号', + icon: 'none', + duration: 2000 + }); return; } - - const { phone, code, password, loginType } = this.data; + + // 检查验证码或密码 + if (loginType === 'code') { + if (!code || code.length < 4) { + wx.showToast({ + title: '请输入验证码', + icon: 'none', + duration: 2000 + }); + return; + } + } else { + if (!password || password.length < 6) { + wx.showToast({ + title: '请输入密码(至少6位)', + icon: 'none', + duration: 2000 + }); + return; + } + } // 显示加载提示 wx.showLoading({ diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts index ee07832..ea62a0f 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts @@ -17,6 +17,7 @@ Page({ countryCodeIndex: 0, pollTimer: null as any, // 轮询定时器 pollCount: 0, // 轮询次数 + socketTask: null as any, // WebSocket连接 // 验证码相关 needCaptcha: false, // 是否需要验证码 captchaType: '', // 验证码类型 @@ -38,6 +39,15 @@ Page({ onLoad() { console.log('小红书绑定页面加载'); + + // 页面加载时就生成session_id并建立WebSocket连接 + const sessionId = `xhs_login_${Date.now().toString(36)}${Math.random().toString(36).substr(2, 9)}`; + console.log('[页面加载] 生成session_id:', sessionId); + this.setData({ sessionId }); + + // 建立WebSocket连接 + console.log('[页面加载] 建立WebSocket连接...'); + this.connectWebSocket(sessionId); }, onUnload() { @@ -48,6 +58,10 @@ Page({ if (this.data.pollTimer) { clearInterval(this.data.pollTimer); } + // 关闭WebSocket连接 + if (this.data.socketTask) { + this.data.socketTask.close(); + } }, // 区号选择 @@ -77,7 +91,7 @@ Page({ return; } - const { phone, countryCodes, countryCodeIndex } = this.data; + const { phone, countryCodes, countryCodeIndex, sessionId, socketTask } = this.data; if (phone.length !== 11) { wx.showToast({ title: '请输入正确的手机号', @@ -87,43 +101,52 @@ Page({ return; } - // 显示加载 - this.setData({ - showLoading: true - }); - try { - // 调用后端API发送验证码 const countryCode = countryCodes[countryCodeIndex]; - console.log('发送验证码到:', phone, '区号:', countryCode); + console.log('[发送验证码] 开始,手机号:', phone, '区号:', countryCode); + console.log('[发送验证码] 使用现有session_id:', sessionId); - const result = await EmployeeService.sendXHSCode(phone); - - // 保存session_id用于后续复用浏览器 - if (result.data && result.data.session_id) { - this.setData({ - sessionId: result.data.session_id - }); - console.log('已保存session_id:', result.data.session_id); + // 检查WebSocket连接 + if (!socketTask) { + console.error('[发送验证码] WebSocket连接不存在,重新建立...'); + this.connectWebSocket(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); } - this.setData({ - showLoading: false + // 通过WebSocket发送send_code消息 + console.log('[发送验证码] 通过WebSocket发送请求...'); + const currentSocketTask = this.data.socketTask; + if (!currentSocketTask) { + throw new Error('WebSocket连接未建立'); + } + + currentSocketTask.send({ + data: JSON.stringify({ + type: 'send_code', + phone: phone, + country_code: countryCode, + login_page: 'home' // 使用小红书首页登录 + }), + success: () => { + console.log('[发送验证码] WebSocket消息发送成功'); + wx.showToast({ + title: '正在发送验证码...', + icon: 'loading', + duration: 20000 + }); + }, + fail: (err: any) => { + console.error('[发送验证码] WebSocket消息发送失败:', err); + wx.showToast({ + title: '发送失败,请重试', + icon: 'none', + duration: 2000 + }); + } }); - - wx.showToast({ - title: '验证码已发送,请在小红书APP中查看', - icon: 'none', - duration: 2000 - }); - - // 开始倒计时 - this.startCountdown(); + } catch (error: any) { - this.setData({ - showLoading: false - }); - + console.error('[发送验证码] 异常:', error); wx.showToast({ title: error.message || '发送失败,请重试', icon: 'none', @@ -164,7 +187,12 @@ Page({ // 绑定账号 async bindAccount() { - const { phone, code, sessionId } = this.data; + const { phone, code, sessionId, countryCodes, countryCodeIndex } = this.data; + + console.log('[绑定账号] 开始绑定'); + console.log('[绑定账号] phone:', phone); + console.log('[绑定账号] code:', code); + console.log('[绑定账号] sessionId:', sessionId); // 验证手机号 if (!phone || phone.length !== 11) { @@ -196,42 +224,50 @@ Page({ return; } - // 显示加载 - this.setData({ - showLoading: true, - loadingText: '正在验证...', - pollCount: 0 - }); - try { - // 调用后端API进行绑定(异步处理) - console.log('绑定小红书账号:', { phone, code, sessionId }); + console.log('[绑定账号] 通过WebSocket发送验证码验证请求...'); - const result = await EmployeeService.bindXHS(phone, code, sessionId); + const socketTask = this.data.socketTask; + if (!socketTask) { + throw new Error('WebSocket连接已断开,请重新发送验证码'); + } - // 后端立即返回,开始轮询绑定状态 - this.setData({ - loadingText: '正在登录小红书...' + const countryCode = countryCodes[countryCodeIndex]; + + wx.showToast({ + title: '正在验证...', + icon: 'loading', + duration: 60000 }); - // 开始轮询绑定状态 - this.startPollingBindStatus(); + socketTask.send({ + data: JSON.stringify({ + type: 'verify_code', + phone: phone, + code: code, + country_code: countryCode, + login_page: 'home' // 使用小红书首页登录 + }), + success: () => { + console.log('[绑定账号] WebSocket消息发送成功,等待后端响应...'); + }, + fail: (err: any) => { + console.error('[绑定账号] WebSocket消息发送失败:', err); + wx.showToast({ + title: '发送失败,请重试', + icon: 'none', + duration: 2000 + }); + } + }); } catch (error: any) { - this.setData({ - showLoading: false + console.error('[绑定账号] 异常:', error); + wx.showToast({ + title: error.message || '绑定失败,请重试', + icon: 'none', + duration: 2000 }); - - // 绑定失败 - this.setData({ - showFail: true - }); - - setTimeout(() => { - this.setData({ - showFail: false - }); - }, 2000); } }, @@ -242,6 +278,11 @@ Page({ clearInterval(this.data.pollTimer); } + // 初始化轮询计数 + this.setData({ + pollCount: 0 + }); + // 立即查询一次 this.checkBindStatus(); @@ -264,17 +305,12 @@ Page({ // 超过60次轮询(60秒),停止轮询 if (pollCount > 60) { this.stopPolling(); - this.setData({ - showLoading: false, - showFail: true + wx.hideToast(); + wx.showToast({ + title: '登录超时,请重试', + icon: 'none', + duration: 2000 }); - - setTimeout(() => { - this.setData({ - showFail: false - }); - }, 2000); - return; } @@ -293,10 +329,12 @@ Page({ if (status.status === 'success') { // 绑定成功 this.stopPolling(); + wx.hideToast(); - this.setData({ - showLoading: false, - showSuccess: true + wx.showToast({ + title: '绑定成功', + icon: 'success', + duration: 2000 }); // 更新本地绑定状态缓存 @@ -312,10 +350,6 @@ Page({ console.log('[手机号登录] 绑定成功,已更新本地缓存'); setTimeout(() => { - this.setData({ - showSuccess: false - }); - // 绑定成功后返回个人中心 wx.navigateBack(); }, 2000); @@ -323,43 +357,36 @@ Page({ } else if (status.status === 'failed') { // 绑定失败 this.stopPolling(); + wx.hideToast(); - this.setData({ - showLoading: false, - showFail: true - }); - wx.showToast({ title: status.error || '绑定失败', icon: 'none', duration: 3000 }); - - setTimeout(() => { - this.setData({ - showFail: false - }); - }, 2000); } else if (status.status === 'need_captcha') { // 需要验证码验证 this.stopPolling(); + wx.hideToast(); console.log('需要验证码验证:', status.captcha_type); + wx.showToast({ + title: status.message || '需要验证码验证', + icon: 'none', + duration: 3000 + }); + this.setData({ - showLoading: false, needCaptcha: true, captchaType: status.captcha_type || 'unknown', - qrcodeImage: status.qrcode_image || '', - loadingText: status.message || '需要验证码验证' + qrcodeImage: status.qrcode_image || '' }); } else if (status.status === 'processing') { // 仍在处理中,继续轮询 - this.setData({ - loadingText: status.message || '正在登录小红书...' - }); + console.log('登录处理中:', status.message); } } catch (error: any) { @@ -380,9 +407,22 @@ Page({ // 切换到手机号登录 switchToPhone() { + console.log('[切换登录] 切换到手机号登录'); + // 停止二维码轮询 this.stopQRCodePolling(); + // 关闭二维码弹窗(如果正在显示) + if (this.data.needCaptcha) { + console.log('[切换登录] 检测到风控弹窗,关闭...'); + this.setData({ + needCaptcha: false + }); + + // 关闭WebSocket连接 + this.closeWebSocket(); + } + // 清空所有扫码登录相关数据 this.setData({ loginType: 'phone', @@ -401,6 +441,8 @@ Page({ countdown: 0, isCounting: false }); + + console.log('[切换登录] 切换完成'); }, // 切换到扫码登录 @@ -848,5 +890,536 @@ Page({ this.stopQRCodePolling(); this.switchToPhone(); } + }, + + // 建立WebSocket连接 + connectWebSocket(sessionId: string) { + // 关闭旧连接 + if (this.data.socketTask) { + try { + this.data.socketTask.close(); + } catch (e) { + console.log('[WebSocket] 关闭旧连接失败(可能已关闭):', e); + } + } + + // 获取Python服务地址(WebSocket端点在Python后端) + const pythonURL = API.pythonURL || API.baseURL; + // 将http/https转为ws/wss + const wsURL = pythonURL.replace('http://', 'ws://').replace('https://', 'wss://'); + const url = `${wsURL}/ws/login/${sessionId}`; + + console.log('[WebSocket] 开始连接:', url); + console.log('[WebSocket] Python服务地址:', pythonURL); + + // 小程序环境检查 + if (url.includes('localhost') || url.includes('127.0.0.1')) { + console.warn('[WebSocket] 检测到localhost地址,请确保开发工具中已勾选"不校验合法域名"'); + console.warn('[WebSocket] 另外需要安装websockets库: pip install websockets'); + } + + let socketConnected = false; // 标记连接是否成功建立 + + const socketTask = wx.connectSocket({ + url: url, + success: () => { + console.log('[WebSocket] 连接请求发送成功'); + }, + fail: (err) => { + console.error('[WebSocket] 连接请求失败:', err); + socketConnected = false; + + // 检查是否是localhost问题 + if (url.includes('localhost') || url.includes('127.0.0.1')) { + wx.showToast({ + title: '请在开发工具中勾选"不校验合法域名"并确保Python服务已安装websockets库', + icon: 'none', + duration: 5000 + }); + } + } + }); + + // 监听连接打开 + socketTask.onOpen(() => { + console.log('[WebSocket] 连接已打开'); + socketConnected = true; + + // 重置重连计数 + this.setData({ reconnectCount: 0 } as any); + + // 启动ping/pong保持连接 + const pingTimer = setInterval(() => { + if (socketTask && socketConnected) { + socketTask.send({ + data: 'ping', + success: () => console.log('[WebSocket] Ping发送成功'), + fail: (err) => console.error('[WebSocket] Ping发送失败:', err) + }); + } + }, 30000); // 每30秒ping一次 + + this.setData({ pingTimer } as any); + }); + + // 监听消息 + socketTask.onMessage((res) => { + console.log('[WebSocket] 收到消息:', res.data); + + // 过滤pong消息(服务端的心跳响应) + if (res.data === 'pong') { + console.log('[WebSocket] 收到pong响应'); + return; + } + + try { + const data = JSON.parse(res.data as string); + + // 处理扫码成功消息(发送验证码阶段的风控) + if (data.type === 'qrcode_scan_success') { + console.log('✅ 扫码验证完成!', data.message); + + // 关闭验证码弹窗 + this.setData({ + needCaptcha: false + }); + + // 显示提示:扫码成功,请重新发送验证码 + wx.showToast({ + title: data.message || '扫码成功,请重新发送验证码', + icon: 'success', + duration: 3000 + }); + + console.log('[WebSocket] 扫码验证完成,提示用户重新发送验证码'); + console.log('[WebSocket] 保持连接,等待后续操作'); + + // 不关闭WebSocket,保持连接用于后续登录流程 + } + // 处理二维码失效消息 + else if (data.type === 'qrcode_expired') { + console.log('⚠️ 二维码已失效!', data.message); + + // 关闭验证码弹窗 + this.setData({ + needCaptcha: false + }); + + // 显示提示:二维码已失效 + wx.showToast({ + title: data.message || '二维码已失效,请重新发送验证码', + icon: 'none', + duration: 3000 + }); + + console.log('[WebSocket] 二维码已失效,关闭弹窗'); + console.log('[WebSocket] 保持连接,等待用户重新操作'); + + // 不关闭WebSocket,保持连接用于重新发送验证码 + } + // 处理登录成功消息(点击登录按钮阶段的风控) + else if (data.type === 'login_success') { + // 判断是扫码验证成功还是真正的登录成功 + if (data.storage_state) { + // 真正的登录成功,包含 storage_state + console.log('✅ 登录成功!', data); + + wx.hideToast(); + + // 关闭验证码弹窗 + this.setData({ + needCaptcha: false + }); + + // 关闭WebSocket + this.closeWebSocket(); + + // 显示绑定成功动画 + this.setData({ + showSuccess: true + }); + + setTimeout(() => { + this.setData({ + showSuccess: false + }); + + // 跳转回上一页 + wx.navigateBack({ + delta: 1, + success: () => { + console.log('✅ 跳转回上一页'); + }, + fail: (err) => { + console.error('⚠️ 跳转失败:', err); + } + }); + }, 2000); + } else { + // 扫码验证成功,但还需要继续登录 + console.log('✅ 扫码验证成功!', data.user_info); + + // 关闭验证码弹窗 + this.setData({ + needCaptcha: false + }); + + // 显示提示:扫码成功,请重新发送验证码 + wx.showToast({ + title: '扫码成功,请重新发送验证码', + icon: 'success', + duration: 3000 + }); + + console.log('[WebSocket] 扫码验证完成,提示用户重新发送验证码'); + console.log('[WebSocket] 保持连接,等待后续登录操作'); + + // 不关闭WebSocket,保持连接用于后续登录流程 + } + } + // 处理need_captcha消息(发送验证码或登录时触发风控) + else if (data.type === 'need_captcha') { + console.log('⚠️ 需要扫码验证:', data.captcha_type); + + // 显示二维码弹窗 + this.setData({ + needCaptcha: true, + captchaType: data.captcha_type || 'unknown', + qrcodeImage: data.qrcode_image || '' + }); + + wx.hideToast(); + wx.showToast({ + title: data.message || '需要扫码验证', + icon: 'none', + duration: 3000 + }); + + console.log('[WebSocket] 已显示风控二维码'); + } + // 处理code_sent消息(验证码发送结果) + else if (data.type === 'code_sent') { + console.log('[WebSocket] 验证码发送结果:', data); + + wx.hideToast(); + + if (data.success) { + wx.showToast({ + title: data.message || '验证码已发送', + icon: 'success', + duration: 2000 + }); + + // 开始倒计时 + this.startCountdown(); + } else { + wx.showToast({ + title: data.message || '发送失败', + icon: 'none', + duration: 2000 + }); + } + } + // 处理login_result消息(登录结果) + else if (data.type === 'login_result') { + console.log('[WebSocket] 登录结果:', data); + + wx.hideToast(); + + if (!data.success) { + wx.showToast({ + title: data.message || '登录失败', + icon: 'none', + duration: 2000 + }); + } + } + } catch (e) { + console.error('[WebSocket] 解析消息失败:', e, '原始数据:', res.data); + } + }); + + // 监听错误 + socketTask.onError((err) => { + console.error('[WebSocket] 连接错误:', err); + socketConnected = false; + + // 记录错误,准备重连 + console.log('[WebSocket] 将在关闭后尝试重连'); + }); + + // 监听关闭 + socketTask.onClose(() => { + console.log('[WebSocket] 连接关闭'); + socketConnected = false; + + // 清理ping定时器 + if ((this.data as any).pingTimer) { + clearInterval((this.data as any).pingTimer); + } + + // 检查是否需要重连(只有弹窗还在时才重连) + if (this.data.needCaptcha && sessionId) { + // 增加重连计数 + const reconnectCount = (this.data as any).reconnectCount || 0; + + // 最多重连3次 + if (reconnectCount < 3) { + const delay = Math.min(1000 * Math.pow(2, reconnectCount), 5000); // 指数退避: 1s, 2s, 4s + console.log(`[WebSocket] 将在${delay}ms后进行第${reconnectCount + 1}次重连`); + + setTimeout(() => { + if (this.data.needCaptcha) { // 再次检查弹窗是否还在 + console.log('[WebSocket] 开始重连...'); + this.setData({ reconnectCount: reconnectCount + 1 } as any); + this.connectWebSocket(sessionId); + } + }, delay); + } else { + console.log('[WebSocket] 已达到最大重连次数,停止重连'); + wx.showToast({ + title: 'WebSocket连接失败,请重新发送验证码', + icon: 'none', + duration: 3000 + }); + } + } + }); + + this.setData({ socketTask }); + }, + + // 关闭WebSocket连接 + closeWebSocket() { + console.log('[WebSocket] 开始关闭连接'); + + // 重置重连计数(主动关闭不需要重连) + this.setData({ reconnectCount: 999 } as any); // 设置为很大的数阻止重连 + + // 清理ping定时器 + if ((this.data as any).pingTimer) { + clearInterval((this.data as any).pingTimer); + console.log('[WebSocket] 已清理ping定时器'); + } + + // 关闭WebSocket连接 + const socketTask = this.data.socketTask; + if (socketTask) { + try { + // 检查WebSocket连接状态 + // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + const readyState = (socketTask as any).readyState; + + if (readyState === 0 || readyState === 1) { + // 只有连接中或已打开的连接才关闭 + console.log(`[WebSocket] 连接状态: ${readyState}, 执行关闭`); + socketTask.close({ + success: () => { + console.log('[WebSocket] 连接关闭成功'); + }, + fail: (err: any) => { + // 忽略关闭失败错误,可能已经关闭 + console.log('[WebSocket] 连接关闭失败(忽略):', err.errMsg); + } + }); + } else { + console.log(`[WebSocket] 连接已关闭或正在关闭, readyState: ${readyState}`); + } + + // 清空socketTask引用 + this.setData({ socketTask: null }); + } catch (e) { + console.log('[WebSocket] 关闭连接异常(忽略):', e); + // 仍然清空socketTask引用 + this.setData({ socketTask: null }); + } + } else { + console.log('[WebSocket] 没有活跃的WebSocket连接'); + } + }, + + // 保存二维码 + saveQRCode() { + if (!this.data.qrcodeImage) { + wx.showToast({ + title: '二维码不存在', + icon: 'none', + duration: 2000 + }); + return; + } + + console.log('[保存二维码] 开始保存'); + + // base64转为临时文件 + const base64Data = this.data.qrcodeImage.replace(/^data:image\/\w+;base64,/, ''); + const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`; + + // 写入临时文件 + const fs = wx.getFileSystemManager(); + fs.writeFile({ + filePath: filePath, + data: base64Data, + encoding: 'base64', + success: () => { + console.log('[保存二维码] 临时文件写入成功:', filePath); + + // 保存到相册 + wx.saveImageToPhotosAlbum({ + filePath: filePath, + success: () => { + console.log('[保存二维码] 保存成功'); + wx.showToast({ + title: '二维码已保存到相册', + icon: 'success', + duration: 2000 + }); + }, + fail: (err) => { + console.error('[保存二维码] 保存到相册失败:', err); + + // 检查是否是权限问题 + if (err.errMsg.includes('auth')) { + wx.showModal({ + title: '需要授权', + content: '请允许保存图片到相册', + success: (res) => { + if (res.confirm) { + wx.openSetting({ + success: (settingRes) => { + if (settingRes.authSetting['scope.writePhotosAlbum']) { + // 获得授权后重试 + this.saveQRCode(); + } + } + }); + } + } + }); + } else { + wx.showToast({ + title: '保存失败,请重试', + icon: 'none', + duration: 2000 + }); + } + } + }); + }, + fail: (err) => { + console.error('[保存二维码] 临时文件写入失败:', err); + wx.showToast({ + title: '保存失败,请重试', + icon: 'none', + duration: 2000 + }); + } + }); + }, + + // 测试WebSocket连接 + testWebSocket() { + console.log('[测试] 开始测试WebSocket...'); + + // 生成测试session_id + const testSessionId = `test_${Date.now()}`; + console.log('[测试] 测试session_id:', testSessionId); + + // 关闭旧连接 + if (this.data.socketTask) { + try { + this.data.socketTask.close(); + } catch (e) { + console.log('[测试] 关闭旧连接失败:', e); + } + } + + // 获取WebSocket地址 + const pythonURL = API.pythonURL || API.baseURL; + const wsURL = pythonURL.replace('http://', 'ws://').replace('https://', 'wss://'); + const url = `${wsURL}/ws/login/${testSessionId}`; + + console.log('[测试] WebSocket地址:', url); + + const socketTask = wx.connectSocket({ + url: url, + success: () => { + console.log('[测试] 连接请求成功'); + }, + fail: (err) => { + console.error('[测试] 连接请求失败:', err); + wx.showToast({ + title: '连接失败', + icon: 'none' + }); + } + }); + + // 监听连接打开 + socketTask.onOpen(() => { + console.log('[测试] 连接已打开'); + wx.showToast({ + title: 'WebSocket连接成功', + icon: 'success' + }); + + // 3秒后发送测试消息 + setTimeout(() => { + console.log('[测试] 发送测试消息...'); + socketTask.send({ + data: JSON.stringify({ + type: 'test', + message: 'This is a test message from frontend' + }), + success: () => { + console.log('[测试] 测试消息发送成功'); + }, + fail: (err: any) => { + console.error('[测试] 测试消息发送失败:', err); + } + }); + }, 3000); + }); + + // 监听消息 + socketTask.onMessage((res) => { + console.log('[测试] 收到消息:', res.data); + + // 过滤pong消息 + if (res.data === 'pong') { + console.log('[测试] 收到pong响应'); + return; + } + + try { + const data = JSON.parse(res.data as string); + console.log('[测试] 解析后的消息:', data); + + // 显示消息 + wx.showModal({ + title: '收到WebSocket消息', + content: `类型: ${data.type}\n内容: ${data.message || JSON.stringify(data)}`, + showCancel: false + }); + } catch (e) { + console.error('[测试] 解析消息失败:', e); + } + }); + + // 监听错误 + socketTask.onError((err) => { + console.error('[测试] 连接错误:', err); + wx.showToast({ + title: '连接错误', + icon: 'none' + }); + }); + + // 监听关闭 + socketTask.onClose(() => { + console.log('[测试] 连接关闭'); + }); + + this.setData({ socketTask }); } }); diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml index 7eb8e3c..f2e3a23 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml @@ -1,5 +1,10 @@ + + + + + @@ -69,11 +74,18 @@ - - - - {{loadingText}} - - - diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss index d85984a..93dc03b 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss @@ -248,6 +248,33 @@ page { color: #999; text-align: center; line-height: 1.6; + margin-bottom: 24rpx; +} + +/* 保存二维码按钮 */ +.save-qrcode-btn { + width: 100%; + height: 80rpx; + background: linear-gradient(135deg, #FF2442 0%, #FF4F6A 100%); + border-radius: 12rpx; + font-size: 30rpx; + font-weight: 500; + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 16rpx rgba(255, 36, 66, 0.3); + transition: all 0.3s; +} + +.save-qrcode-btn::after { + border: none; +} + +.save-qrcode-btn:active { + opacity: 0.8; + transform: scale(0.98); } .bind-form { @@ -465,6 +492,17 @@ page { color: #666; } +/* 内联加载提示(手机号登录区域) */ +.inline-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24rpx; + padding: 80rpx 0; + min-height: 300rpx; +} + /* 二维码错误提示 */ .qrcode-error { position: absolute; diff --git a/miniprogram/miniprogram/services/employee.ts b/miniprogram/miniprogram/services/employee.ts index 1b8cb4e..e1ddf74 100644 --- a/miniprogram/miniprogram/services/employee.ts +++ b/miniprogram/miniprogram/services/employee.ts @@ -74,9 +74,10 @@ export class EmployeeService { * 发送小红书验证码 * 返回 session_id 用于后续复用浏览器 */ - static async sendXHSCode(xhsPhone: string, showLoading = true) { + static async sendXHSCode(xhsPhone: string, showLoading = true, sessionId?: string) { return post<{ sent_at: string; session_id: string }>(API.xhs.sendCode, { - xhs_phone: xhsPhone + xhs_phone: xhsPhone, + session_id: sessionId // 传递session_id给后端 }, showLoading); } diff --git a/验证.txt b/验证.txt new file mode 100644 index 0000000..c6e79f2 --- /dev/null +++ b/验证.txt @@ -0,0 +1 @@ +https://www.xiaohongshu.com/website-login/captcha?redirectPath=https%3A%2F%2Fwww.xiaohongshu.com%2Fexplore%3FexSource%3D&verifyUuid=e3d62847-8664-4bd0-8c7c-9a9032d6eaff&verifyType=124&verifyBiz=461 \ No newline at end of file