diff --git a/backend/XHS_CLI_README.md b/backend/XHS_CLI_README.md deleted file mode 100644 index 766a84e..0000000 --- a/backend/XHS_CLI_README.md +++ /dev/null @@ -1,123 +0,0 @@ -# 小红书登录 CLI 工具 - -## 概述 - -这是一个可以被 Go 服务直接调用的 Python CLI 工具,用于小红书登录功能。 -使用此工具后,不再需要单独启动 Python Web 服务。 - -## 使用方式 - -### 1. 发送验证码 - -```bash -python xhs_cli.py send_code <手机号> [国家区号] -``` - -示例: -```bash -python xhs_cli.py send_code 13800138000 +86 -``` - -返回 JSON 格式: -```json -{ - "success": true, - "message": "验证码发送成功" -} -``` - -### 2. 登录 - -```bash -python xhs_cli.py login <手机号> <验证码> [国家区号] -``` - -示例: -```bash -python xhs_cli.py login 13800138000 123456 +86 -``` - -返回 JSON 格式: -```json -{ - "success": true, - "user_info": {...}, - "cookies": {...}, - "url": "https://www.xiaohongshu.com/" -} -``` - -### 3. 注入 Cookie (验证登录状态) - -```bash -python xhs_cli.py inject_cookies '' -``` - -示例: -```bash -python xhs_cli.py inject_cookies '[{"name":"web_session","value":"xxx","domain":".xiaohongshu.com"}]' -``` - -返回 JSON 格式: -```json -{ - "success": true, - "logged_in": true, - "cookies": {...}, - "user_info": {...} -} -``` - -## Go 服务集成 - -Go 服务已经修改为直接调用 Python CLI 脚本,无需启动 Python Web 服务。 - -### 修改的文件 - -1. **backend/xhs_cli.py** (新增) - - 命令行接口工具 - -2. **go_backend/service/xhs_service.go** (修改) - - 使用 `exec.Command` 调用 Python 脚本 - - 不再通过 HTTP 调用 Python 服务 - -3. **go_backend/service/employee_service.go** (修改) - - 使用 `exec.Command` 调用 Python 脚本 - -### 优点 - -- ✅ 只需启动一个 Go 服务 -- ✅ 部署更简单,不需要管理多个服务进程 -- ✅ 减少网络开销 -- ✅ 更容易调试和维护 - -## 依赖要求 - -确保已安装 Python 依赖: -```bash -cd backend -pip install -r requirements.txt -``` - -主要依赖: -- playwright -- asyncio - -## 注意事项 - -1. Python 命令需要在系统 PATH 中可用 -2. 确保 `xhs_login.py` 和 `xhs_cli.py` 在同一目录 -3. Go 服务会在相对路径 `../backend` 下查找 Python 脚本 -4. 所有输出均为 JSON 格式,便于 Go 服务解析 - -## 错误处理 - -如果执行失败,会返回包含错误信息的 JSON: -```json -{ - "success": false, - "error": "错误描述信息" -} -``` - -Go 服务会捕获 stderr 输出并作为错误信息的一部分返回。 diff --git a/backend/XHS_PUBLISH_README.md b/backend/XHS_PUBLISH_README.md deleted file mode 100644 index 6c442fa..0000000 --- a/backend/XHS_PUBLISH_README.md +++ /dev/null @@ -1,313 +0,0 @@ -# 小红书笔记发布脚本使用说明 - -## 功能介绍 - -`xhs_publish.py` 是一个用于自动发布小红书笔记的 Python 脚本,支持通过 Cookie 认证,自动完成图文笔记发布。 - -## 环境准备 - -### 1. 安装依赖 - -```bash -cd backend -pip install -r requirements.txt -``` - -主要依赖: -- playwright (浏览器自动化) -- asyncio (异步处理) - -### 2. 安装浏览器驱动 - -```bash -playwright install chromium -``` - -## 使用方式 - -### 方式一:使用配置文件(推荐) - -#### 1. 准备配置文件 - -复制 `publish_config_example.json` 并修改为实际参数: - -```json -{ - "cookies": [ - { - "name": "a1", - "value": "your_cookie_value_here", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": -1, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - } - ], - "title": "笔记标题", - "content": "笔记内容", - "images": [ - "D:/path/to/image1.jpg", - "D:/path/to/image2.jpg" - ], - "tags": [ - "标签1", - "标签2" - ] -} -``` - -#### 2. 执行发布 - -```bash -python xhs_publish.py --config publish_config.json -``` - -### 方式二:命令行参数 - -```bash -python xhs_publish.py \ - --cookies '[{"name":"a1","value":"xxx","domain":".xiaohongshu.com"}]' \ - --title "笔记标题" \ - --content "笔记内容" \ - --images '["D:/image1.jpg","D:/image2.jpg"]' \ - --tags '["标签1","标签2"]' -``` - -## 参数说明 - -### cookies (必需) - -Cookie 数组,每个 Cookie 对象包含以下字段: - -- `name`: Cookie 名称 -- `value`: Cookie 值 -- `domain`: 域名(通常为 `.xiaohongshu.com`) -- `path`: 路径(通常为 `/`) -- `expires`: 过期时间(-1 表示会话 Cookie) -- `httpOnly`: 是否仅 HTTP -- `secure`: 是否安全 -- `sameSite`: 同站策略(Lax/Strict/None) - -**重要 Cookie(必需):** -- `a1`: 用户身份认证 -- `webId`: 设备标识 -- `web_session`: 会话信息 - -### title (必需) - -笔记标题,字符串类型。 - -**示例:** -``` -"💧夏日必备!2元一杯的柠檬水竟然这么好喝?" -``` - -### content (必需) - -笔记正文内容,字符串类型,支持换行符 `\n`。 - -**示例:** -``` -"今天给大家分享一个超级实惠的夏日饮品!\n\n蜜雪冰城的柠檬水只要2元一杯,性价比真的太高了!" -``` - -### images (可选) - -图片文件路径数组,支持本地绝对路径。 - -**要求:** -- 图片必须是本地文件 -- 支持 jpg、png、gif 等格式 -- 最多上传 9 张图片 -- 建议尺寸:800x600 或更高 - -**示例:** -```json -[ - "D:/project/Work/ai_xhs/backend/temp_uploads/image1.jpg", - "D:/project/Work/ai_xhs/backend/temp_uploads/image2.jpg" -] -``` - -### tags (可选) - -标签数组,会自动添加 `#` 前缀。 - -**示例:** -```json -["夏日清爽", "饮品", "柠檬水"] -``` - -## 获取 Cookie 的方法 - -### 方法一:使用登录脚本 - -```bash -python xhs_cli.py login <手机号> <验证码> -``` - -登录成功后会自动保存 Cookie 到 `cookies.json` 文件。 - -### 方法二:浏览器手动获取 - -1. 在浏览器中登录小红书网页版 -2. 打开开发者工具(F12) -3. 切换到 Network(网络)标签 -4. 刷新页面 -5. 找到任意请求,查看 Request Headers -6. 复制 Cookie 字段内容 -7. 使用在线工具或脚本转换为 JSON 格式 - -### 方法三:使用 Cookie 注入验证 - -```bash -python xhs_cli.py inject_cookies '' -``` - -## 返回结果 - -### 成功示例 - -```json -{ - "success": true, - "message": "笔记发布成功", - "url": "https://www.xiaohongshu.com/explore/xxxx" -} -``` - -### 失败示例 - -```json -{ - "success": false, - "error": "Cookie已失效或未登录" -} -``` - -## 注意事项 - -### 1. Cookie 有效期 - -- Cookie 会在一段时间后失效 -- 需要定期重新登录获取新 Cookie -- 建议使用 Cookie 注入验证接口检查状态 - -### 2. 图片上传 - -- 确保图片文件存在且可访问 -- 图片路径使用绝对路径 -- Windows 系统路径使用 `/` 或 `\\` 分隔符 - -### 3. 发布限制 - -- 小红书可能有发布频率限制 -- 建议控制发布间隔,避免被限流 -- 内容需符合小红书社区规范 - -### 4. 错误处理 - -常见错误及解决方法: - -- **"Cookie已失效"**: 重新登录获取新 Cookie -- **"图片文件不存在"**: 检查图片路径是否正确 -- **"未找到发布按钮"**: 小红书页面结构可能变化,需要更新选择器 -- **"输入内容失败"**: 等待时间不足,增加延迟时间 - -## 与 Go 后端集成 - -在 Go 后端中调用此脚本: - -```go -import ( - "os/exec" - "encoding/json" -) - -// 发布笔记 -func PublishNote(cookies []Cookie, title, content string, images, tags []string) error { - // 构造配置文件 - config := map[string]interface{}{ - "cookies": cookies, - "title": title, - "content": content, - "images": images, - "tags": tags, - } - - // 保存到临时文件 - configFile := "temp_publish_config.json" - data, _ := json.Marshal(config) - ioutil.WriteFile(configFile, data, 0644) - - // 调用 Python 脚本 - cmd := exec.Command("python", "backend/xhs_publish.py", "--config", configFile) - output, err := cmd.CombinedOutput() - if err != nil { - return err - } - - // 解析结果 - var result map[string]interface{} - json.Unmarshal(output, &result) - - if !result["success"].(bool) { - return errors.New(result["error"].(string)) - } - - return nil -} -``` - -## 开发调试 - -### 启用浏览器可视模式 - -修改 `xhs_login.py` 中的 `headless` 参数: - -```python -self.browser = await self.playwright.chromium.launch( - headless=False, # 改为 False 可以看到浏览器操作过程 - args=['--disable-blink-features=AutomationControlled'] -) -``` - -### 查看详细日志 - -脚本会在控制台输出详细的执行日志,包括: -- 浏览器初始化 -- 登录状态验证 -- 图片上传进度 -- 内容输入状态 -- 发布结果 - -## 常见问题 - -### Q: 为什么上传图片后没有显示? - -A: 可能是图片上传时间较长,脚本已经增加了等待时间。如果仍有问题,可以调整 `xhs_login.py` 中的等待时间。 - -### Q: 如何批量发布多条笔记? - -A: 准备多个配置文件,使用循环调用脚本: - -```bash -for config in publish_config_*.json; do - python xhs_publish.py --config "$config" - sleep 60 # 间隔60秒 -done -``` - -### Q: Cookie 多久失效? - -A: 小红书 Cookie 通常在 7-30 天后失效,具体取决于 Cookie 的过期时间设置。 - -## 技术支持 - -如有问题,请查看: -1. 脚本执行日志 -2. 小红书页面结构是否变化 -3. Cookie 是否有效 -4. 图片文件是否存在 diff --git a/backend/browser_pool.py b/backend/browser_pool.py index 4c4da54..5f6b3a0 100644 --- a/backend/browser_pool.py +++ b/backend/browser_pool.py @@ -12,13 +12,13 @@ import sys class BrowserPool: """浏览器池管理器(单例模式)""" - def __init__(self, idle_timeout: int = 1800, max_instances: int = 5, headless: bool = True): + def __init__(self, idle_timeout: int = 1800, max_instances: int = 20, headless: bool = True): """ 初始化浏览器池 Args: idle_timeout: 空闲超时时间(秒),默认30分钟(已禁用,保持常驻) - max_instances: 最大浏览器实例数,默认5个 + max_instances: 最大浏览器实例数,默认20个(支持更多并发) headless: 是否使用无头模式,False为有头模式(方便调试) """ self.playwright = None @@ -37,20 +37,29 @@ class BrowserPool: self.temp_browsers: Dict[str, Dict] = {} # {session_id: {browser, context, page, created_at}} self.temp_lock = asyncio.Lock() + # 请求队列:当超过max_instances时排队等待 + self.waiting_queue: asyncio.Queue = asyncio.Queue() + self.queue_processing = False + + # 扫码登录专用:页面隔离池(共享浏览器和context,但每个用户独立page) + self.qrcode_pages: Dict[str, Dict] = {} # {session_id: {page, created_at}} + self.qrcode_lock = asyncio.Lock() + print(f"[浏览器池] 已创建,常驻模式(不自动清理),最大实例数: {max_instances}", file=sys.stderr) - async def get_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + async def get_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None, session_id: Optional[str] = None, - headless: Optional[bool] = None) -> tuple[Browser, BrowserContext, Page]: + headless: Optional[bool] = None, force_new: bool = False) -> tuple[Browser, BrowserContext, Page]: """ 获取浏览器实例(复用或新建) Args: cookies: 可选的Cookie列表 - proxy: 可选的代理地址 + proxy: 可选的代理配置,格式: {"server": "...", "username": "...", "password": "..."} user_agent: 可选的自定义User-Agent session_id: 会话 ID,用于区分不同的并发请求 headless: 可选的headless模式,为None时使用默认配置 + force_new: 是否强制创建全新浏览器(即使session_id已存在) Returns: (browser, context, page) 三元组 @@ -83,16 +92,37 @@ class BrowserPool: else: async with self.temp_lock: # 首先检查是否已存在该session_id的临时浏览器 - if session_id in self.temp_browsers: + if session_id in self.temp_browsers and not force_new: print(f"[浏览器池] 复用会话 {session_id} 的临时浏览器", file=sys.stderr) browser_info = self.temp_browsers[session_id] return browser_info["browser"], browser_info["context"], browser_info["page"] + # 强制创建全新浏览器:先释放旧的 + if force_new and session_id in self.temp_browsers: + print(f"[浏览器池] force_new=True,释放旧的会话 {session_id}", file=sys.stderr) + old_browser_info = self.temp_browsers[session_id] + try: + await old_browser_info["page"].close() + await old_browser_info["context"].close() + await old_browser_info["browser"].close() + except Exception as e: + print(f"[浏览器池] 释放旧浏览器失败: {str(e)}", file=sys.stderr) + finally: + del self.temp_browsers[session_id] + # 检查是否超过最大实例数 if len(self.temp_browsers) >= self.max_instances - 1: # -1 留给主浏览器 print(f"[浏览器池] ⚠️ 已达最大实例数 ({self.max_instances}),等待释放...", file=sys.stderr) - # TODO: 可以实现等待队列,这里直接报错 - raise Exception(f"浏览器实例数已满,请稍后再试") + + # 等待最多30秒,每秒1秒检查一次 + for i in range(30): + await asyncio.sleep(1) + if len(self.temp_browsers) < self.max_instances - 1: + print(f"[浏览器池] 检测到空闲实例,继续创建", file=sys.stderr) + break + else: + # 超时30秒仍满,返回错误 + raise Exception(f"浏览器实例数已满,请稍后再试") print(f"[浏览器池] 为会话 {session_id} 创建临时浏览器 ({len(self.temp_browsers)+1}/{self.max_instances-1})", file=sys.stderr) @@ -131,9 +161,9 @@ class BrowserPool: await self.close() return False - async def _init_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + async def _init_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None): - """初始化新浏览器实例""" + """初始化新浏览器实例。proxy为dict格式: {"server": "...", "username": "...", "password": "..."}""" try: # 启动Playwright if not self.playwright: @@ -202,7 +232,7 @@ class BrowserPool: ], } if proxy: - launch_kwargs["proxy"] = {"server": proxy} + launch_kwargs["proxy"] = proxy # proxy已经是dict格式,直接使用 self.browser = await self.playwright.chromium.launch(**launch_kwargs) print("[浏览器池] Chromium浏览器启动成功", file=sys.stderr) @@ -215,9 +245,9 @@ class BrowserPool: await self.close() raise - async def _create_new_context(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + async def _create_new_context(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None): - """创建新的浏览器上下文""" + """创建新的浏览器上下文。proxy为dict格式: {"server": "...", "username": "...", "password": "..."}""" try: # 关闭旧上下文 if self.context: @@ -231,6 +261,62 @@ class BrowserPool: } self.context = await self.browser.new_context(**context_kwargs) + # 注入反检测脚本(关键) + await self.context.add_init_script(""" + // 移除webdriver标记 + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined + }); + + // 隐藏chrome自动化特征 + window.chrome = { + runtime: {} + }; + + // 模拟plugins + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { + 0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format"}, + description: "Portable Document Format", + filename: "internal-pdf-viewer", + length: 1, + name: "Chrome PDF Plugin" + }, + { + 0: {type: "application/pdf", suffixes: "pdf", description: ""}, + description: "", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + length: 1, + name: "Chrome PDF Viewer" + } + ], + }); + + // 模拟permissions API + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: Notification.permission }) : + originalQuery(parameters) + ); + + // 阻止检测自动化的网络请求 + const originalFetch = window.fetch; + window.fetch = function(...args) { + const url = args[0]; + if (typeof url === 'string' && ( + url.includes('127.0.0.1:9222') || + url.includes('localhost:9222') || + url.includes('chrome-extension://invalid') + )) { + return Promise.reject(new Error('blocked')); + } + return originalFetch.apply(this, args); + }; + """) + print("[浏览器池] 已注入反检测脚本", file=sys.stderr) + # 注入Cookie if cookies: await self.context.add_cookies(cookies) @@ -402,13 +488,13 @@ class BrowserPool: except: pass - async def _create_temp_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + async def _create_temp_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None, headless: bool = True) -> tuple[Browser, BrowserContext, Page]: """创建临时浏览器实例(用于并发请求) Args: cookies: Cookie列表 - proxy: 代理地址 + proxy: 代理配置,格式: {"server": "...", "username": "...", "password": "..."} user_agent: 自定义User-Agent headless: 是否使用无头模式 """ @@ -425,14 +511,14 @@ class BrowserPool: # 启动浏览器(临时实例,性能优先配置) launch_kwargs = { - "headless": headless, # 使用传入的headless参数 + "headless": headless, "args": [ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', - # 性能优化 + # 性能优化 - 减少资源占用 '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--disable-site-isolation-trials', @@ -442,20 +528,19 @@ class BrowserPool: '--disable-renderer-backgrounding', '--disable-background-networking', - # 缓存优化 - '--disk-cache-size=268435456', - '--media-cache-size=134217728', + # 缓存优化 - 减小缓存以节省内存 + '--disk-cache-size=67108864', # 64MB(原256MB) + '--media-cache-size=33554432', # 32MB(原128MB) - # 渲染优化 - '--enable-gpu-rasterization', - '--enable-zero-copy', - '--ignore-gpu-blocklist', - '--enable-accelerated-2d-canvas', + # 渲染优化 - 禁用GPU以减少资源占用 + '--disable-gpu', + '--disable-accelerated-2d-canvas', + '--disable-accelerated-video-decode', # 网络优化 '--enable-quic', '--enable-tcp-fast-open', - '--max-connections-per-host=10', + '--max-connections-per-host=6', # 减少连接数(原10) # 减少不必要的功能 '--disable-extensions', @@ -466,6 +551,9 @@ class BrowserPool: '--disable-prompt-on-repost', '--disable-domain-reliability', '--disable-component-update', + '--disable-plugins', + '--disable-sync', + '--disable-translate', # 界面优化 '--hide-scrollbars', @@ -473,21 +561,82 @@ class BrowserPool: '--no-first-run', '--no-default-browser-check', '--metrics-recording-only', - '--force-color-profile=srgb', + + # 内存优化 + '--js-flags=--max-old-space-size=512', # 限制JS堆内存 ], } if proxy: - launch_kwargs["proxy"] = {"server": proxy} + launch_kwargs["proxy"] = proxy # proxy已经是dict格式,直接使用 browser = await self.playwright.chromium.launch(**launch_kwargs) - # 创建上下文 + # 创建上下文(使用隐身模式,确保无痕迹) context_kwargs = { "viewport": {'width': 1280, 'height': 720}, "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', + "no_viewport": False, + "ignore_https_errors": True, + # 不使用storage_state,确保完全干净 } context = await browser.new_context(**context_kwargs) + # 注入反检测脚本(关键) + await context.add_init_script(""" + // 移除webdriver标记 + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined + }); + + // 隐藏chrome自动化特征 + window.chrome = { + runtime: {} + }; + + // 模拟plugins + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { + 0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format"}, + description: "Portable Document Format", + filename: "internal-pdf-viewer", + length: 1, + name: "Chrome PDF Plugin" + }, + { + 0: {type: "application/pdf", suffixes: "pdf", description: ""}, + description: "", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + length: 1, + name: "Chrome PDF Viewer" + } + ], + }); + + // 模拟permissions API + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: Notification.permission }) : + originalQuery(parameters) + ); + + // 阻止检测自动化的网络请求 + const originalFetch = window.fetch; + window.fetch = function(...args) { + const url = args[0]; + if (typeof url === 'string' && ( + url.includes('127.0.0.1:9222') || + url.includes('localhost:9222') || + url.includes('chrome-extension://invalid') + )) { + return Promise.reject(new Error('blocked')); + } + return originalFetch.apply(this, args); + }; + """) + print("[临时浏览器] 已注入反检测脚本", file=sys.stderr) + # 注入Cookie if cookies: await context.add_cookies(cookies) @@ -516,6 +665,54 @@ class BrowserPool: finally: del self.temp_browsers[session_id] + async def get_qrcode_page(self, session_id: str) -> Page: + """ + 为扫码登录获取页面(页面隔离模式) + 多个用户共享同一个浏览器实例,但每个用户有独立的page + 这样可以大大减少浏览器崩溃风险 + + Args: + session_id: 会话 ID + + Returns: + Page 对象 + """ + async with self.qrcode_lock: + # 复用已有的page + if session_id in self.qrcode_pages: + print(f"[扫码页面池] 复用会话 {session_id} 的页面", file=sys.stderr) + return self.qrcode_pages[session_id]["page"] + + # 确保主浏览器已初始化 + async with self.init_lock: + if not await self._is_browser_alive(): + print("[扫码页面池] 主浏览器未初始化,创建中...", file=sys.stderr) + await self._init_browser() + + # 从主context创建新page + print(f"[扫码页面池] 为会话 {session_id} 创建新页面 ({len(self.qrcode_pages)+1} 个活跃页面)", file=sys.stderr) + page = await self.context.new_page() + + self.qrcode_pages[session_id] = { + "page": page, + "created_at": time.time() + } + + return page + + async def release_qrcode_page(self, session_id: str): + """释放扫码登录页面""" + async with self.qrcode_lock: + if session_id in self.qrcode_pages: + page_info = self.qrcode_pages[session_id] + try: + await page_info["page"].close() + print(f"[扫码页面池] 已释放会话 {session_id} 的页面", file=sys.stderr) + except Exception as e: + print(f"[扫码页面池] 释放页面异常: {str(e)}", file=sys.stderr) + finally: + del self.qrcode_pages[session_id] + def get_stats(self) -> Dict[str, Any]: """获取浏览器池统计信息""" return { @@ -524,6 +721,7 @@ class BrowserPool: "page_alive": self.page is not None, "is_preheated": self.is_preheated, "temp_browsers_count": len(self.temp_browsers), + "qrcode_pages_count": len(self.qrcode_pages), "max_instances": self.max_instances, "last_used_time": self.last_used_time, "idle_seconds": int(time.time() - self.last_used_time) if self.last_used_time > 0 else 0, diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml index f1ea424..7fe7bc0 100644 --- a/backend/config.dev.yaml +++ b/backend/config.dev.yaml @@ -32,7 +32,7 @@ login: # ========== 定时发布调度器配置 ========== scheduler: - enabled: true # 是否启用定时任务 + enabled: false # 是否启用定时任务 cron: "*/5 * * * * *" # Cron表达式(秒 分 时 日 月 周) - 每5秒执行一次(开发环境测试) max_concurrent: 2 # 最大并发发布数 publish_timeout: 300 # 发布超时时间(秒) diff --git a/backend/config.prod.yaml b/backend/config.prod.yaml index 1b741a7..b5e7770 100644 --- a/backend/config.prod.yaml +++ b/backend/config.prod.yaml @@ -28,7 +28,7 @@ browser_pool: # ========== 登录/绑定功能配置 ========== login: headless: false # 登录/绑定时的浏览器模式: false=有头模式(配合Xvfb避免被检测),true=无头模式 - page: "home" # 登录页面类型: creator=创作者中心(creator.xiaohongshu.com/login), home=小红书首页(www.xiaohongshu.com) + page: "creator" # 登录页面类型: creator=创作者中心(creator.xiaohongshu.com/login), home=小红书首页(www.xiaohongshu.com) # ========== 定时发布调度器配置 ========== scheduler: @@ -49,7 +49,7 @@ scheduler: # ========== 代理池配置 ========== proxy_pool: - enabled: false # 默认关闭,按需开启 + enabled: true # 启用代理池,避免IP被风控 api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964" # ========== 阿里云短信配置 ========== diff --git a/backend/test.py b/backend/damai.py similarity index 77% rename from backend/test.py rename to backend/damai.py index b2897ed..6493910 100644 --- a/backend/test.py +++ b/backend/damai.py @@ -9,21 +9,19 @@ import requests -proxy_ip = "36.137.177.131:50001"; +proxy_ip = "210.51.27.194:50001"; # 用户名密码认证(私密代理/独享代理) -username = "qqwvy0" -password = "mun3r7xz" +username = "hb6su3" +password = "acv2ciow" proxies = { "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": username, "pwd": password, "proxy": proxy_ip}, "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": username, "pwd": password, "proxy": proxy_ip} } -print(proxies) - # 要访问的目标网页 -target_url = "https://creator.xiaohongshu.com/login"; +target_url = "https://www.xiaohongshu.com/explore"; # 使用代理IP发送请求 response = requests.get(target_url, proxies=proxies) diff --git a/backend/damai_proxy_config.py b/backend/damai_proxy_config.py index ef69f2f..4589ac9 100644 --- a/backend/damai_proxy_config.py +++ b/backend/damai_proxy_config.py @@ -1,23 +1,62 @@ """ -大麦固定代理IP配置 -用于在无头浏览器中使用固定代理IP +小红书代理IP配置 +用于在无头浏览器中使用代理IP防止风控 """ -# 大麦固定代理IP池 -DAMAI_PROXY_POOL = [ +# 代理IP池配置 +PROXY_POOL = [ { - "name": "大麦代理1", + "name": "代理01", + "server": "http://60.188.239.186:3101", # 如果支持SOCKS5,改为 socks5://... + "username": "46vTEIvZt", + "password": "gM33AFND", + "enabled": False # HTTP代理不支持HTTPS隧道,暂时禁用 + }, + { + "name": "代理02", + "server": "http://222.94.104.232:4201", + "username": "46azrCOcF", + "password": "WKyKYE6P", + "enabled": False # HTTP代理不支持HTTPS隧道,暂时禁用 + }, + { + "name": "代理03", + "server": "http://125.94.108.2:4601", + "username": "46eX9tk99", + "password": "odtvKjpl", + "enabled": False # HTTP代理不支持HTTPS隧道,暂时禁用 + }, + { + "name": "代理04", + "server": "http://113.24.66.191:3601", + "username": "46r74jRaD", + "password": "WjOXiXjq", + "enabled": False # HTTP代理不支持HTTPS隧道,暂时禁用 + }, + { + "name": "代理05", + "server": "http://113.249.158.23:4401", + "username": "46oKu9Ovb", + "password": "4kWUGkNv", + "enabled": False # HTTP代理不支持HTTPS隧道,暂时禁用 + }, { + "name": "天启01", "server": "http://36.137.177.131:50001", "username": "qqwvy0", "password": "mun3r7xz", - "enabled": True - }, - { - "name": "大麦代理2", + "enabled": False + }, { + "name": "天启02", "server": "http://111.132.40.72:50002", "username": "ih3z07", "password": "078bt7o5", - "enabled": True + "enabled": False + }, { + "name": "天启03", + "server": "http://210.51.27.194:50001", + "username": "hb6su3", + "password": "acv2ciow", + "enabled": False } ] @@ -27,18 +66,18 @@ def get_proxy_config(index: int = 0) -> dict: 获取指定索引的代理配置 Args: - index: 代理索引(0或1) + index: 代理索引(0-4) Returns: 代理配置字典,包含server、username、password """ - if index < 0 or index >= len(DAMAI_PROXY_POOL): - raise ValueError(f"代理索引无效: {index},有效范围: 0-{len(DAMAI_PROXY_POOL)-1}") - - proxy = DAMAI_PROXY_POOL[index] + if index < 0 or index >= len(PROXY_POOL): + raise ValueError(f"代理索引无效: {index},有效范围: 0-{len(PROXY_POOL) - 1}") + + proxy = PROXY_POOL[index] if not proxy.get("enabled", True): raise ValueError(f"代理已禁用: {proxy['name']}") - + return { "server": proxy["server"], "username": proxy["username"], @@ -60,7 +99,7 @@ def get_all_enabled_proxies() -> list: "password": p["password"], "name": p["name"] } - for p in DAMAI_PROXY_POOL + for p in PROXY_POOL if p.get("enabled", True) ] @@ -73,11 +112,11 @@ def get_random_proxy() -> dict: 代理配置字典 """ import random - enabled_proxies = [p for p in DAMAI_PROXY_POOL if p.get("enabled", True)] - + enabled_proxies = [p for p in PROXY_POOL if p.get("enabled", True)] + if not enabled_proxies: raise ValueError("没有可用的代理") - + proxy = random.choice(enabled_proxies) return { "server": proxy["server"], @@ -87,12 +126,69 @@ def get_random_proxy() -> dict: } -# 快捷访问 +def format_proxy_url(proxy_config: dict) -> str: + """ + 将代理配置格式化为Playwright可用的代理URL + + Args: + proxy_config: 代理配置字典 + + Returns: + 格式化的代理URL: http://username:password@host:port + """ + server = proxy_config['server'].replace('http://', '').replace('https://', '') + username = proxy_config['username'] + password = proxy_config['password'] + + return f"http://{username}:{password}@{server}" + + +def format_proxy_for_playwright(proxy_config: dict) -> dict: + """ + 将代理配置格式化为Playwright的proxy字典格式 + + Args: + proxy_config: 代理配置字典 + + Returns: + Playwright proxy配置: {"server": "...", "username": "...", "password": "..."} + """ + return { + "server": proxy_config['server'], + "username": proxy_config['username'], + "password": proxy_config['password'] + } + + +# 快捷访问函数(保持向后兼容) def get_proxy_1(): - """获取代理1配置""" + """获取代理01配置""" return get_proxy_config(0) def get_proxy_2(): - """获取代理2配置""" + """获取代理02配置""" return get_proxy_config(1) + + +def get_proxy_3(): + """获取代理03配置""" + return get_proxy_config(2) + + +def get_proxy_4(): + """获取代理04配置""" + return get_proxy_config(3) + + +def get_proxy_5(): + """获取代理05配置""" + return get_proxy_config(4) + +def get_proxy_6(): + """获取代理06配置""" + return get_proxy_config(5) + +def get_proxy_7(): + """获取代理07配置""" + return get_proxy_config(6) diff --git a/backend/error_screenshot.py b/backend/error_screenshot.py index 6f662dc..d5b14f9 100644 --- a/backend/error_screenshot.py +++ b/backend/error_screenshot.py @@ -38,6 +38,20 @@ async def save_error_screenshot( return None try: + # 检查页面状态 + try: + current_url = page.url + print(f"[错误截图] 当前URL: {current_url}", file=sys.stderr) + + # 检查是否是空白页 + if current_url in ['about:blank', '', 'data:,']: + print(f"[错误截图] 警告: 当前页面为空白页,截图可能没有内容", file=sys.stderr) + + # 等待页面稳定 + await page.wait_for_load_state('domcontentloaded', timeout=3000) + except Exception as state_error: + print(f"[错误截图] 检查页面状态失败: {str(state_error)}", file=sys.stderr) + # 生成文件名:年月日时分秒_错误类型.png timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -52,11 +66,21 @@ async def save_error_screenshot( filepath = SCREENSHOT_DIR / filename - # 截图 - await page.screenshot(path=str(filepath), full_page=True) + # 截图(添加超时和全页截图) + await page.screenshot( + path=str(filepath), + full_page=True, + timeout=10000 # 10秒超时 + ) + + # 检查截图文件大小 + file_size = filepath.stat().st_size + print(f"[错误截图] 已保存: {filepath} (大小: {file_size} bytes)", file=sys.stderr) + + # 如果文件太小(小于5KB),可能是空白截图 + if file_size < 5120: + print(f"[错误截图] 警告: 截图文件过小 ({file_size} bytes),可能为空白页面", file=sys.stderr) - # 打印日志 - print(f"[错误截图] 已保存: {filepath}", file=sys.stderr) if error_message: print(f"[错误截图] 错误信息: {error_message}", file=sys.stderr) @@ -115,6 +139,19 @@ async def save_screenshot_with_html( return None, None try: + # 检查页面状态 + try: + current_url = page.url + print(f"[错误截图] 当前URL: {current_url}", file=sys.stderr) + + if current_url in ['about:blank', '', 'data:,']: + print(f"[错误截图] 警告: 当前页面为空白页", file=sys.stderr) + + # 等待页面稳定 + await page.wait_for_load_state('domcontentloaded', timeout=3000) + except Exception as state_error: + print(f"[错误截图] 检查页面状态失败: {str(state_error)}", file=sys.stderr) + # 生成文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_error_type = "".join(c for c in error_type if c.isalnum() or c in ('_', '-')) @@ -126,7 +163,16 @@ async def save_screenshot_with_html( # 保存截图 screenshot_path = SCREENSHOT_DIR / f"{base_filename}.png" - await page.screenshot(path=str(screenshot_path), full_page=True) + await page.screenshot( + path=str(screenshot_path), + full_page=True, + timeout=10000 + ) + + # 检查截图文件大小 + screenshot_size = screenshot_path.stat().st_size + if screenshot_size < 5120: + print(f"[错误截图] 警告: 截图文件过小 ({screenshot_size} bytes)", file=sys.stderr) # 保存HTML html_path = SCREENSHOT_DIR / f"{base_filename}.html" @@ -134,8 +180,9 @@ async def save_screenshot_with_html( with open(html_path, 'w', encoding='utf-8') as f: f.write(html_content) - print(f"[错误截图] 已保存截图: {screenshot_path}", file=sys.stderr) - print(f"[错误截图] 已保存HTML: {html_path}", file=sys.stderr) + html_size = html_path.stat().st_size + print(f"[错误截图] 已保存截图: {screenshot_path} ({screenshot_size} bytes)", file=sys.stderr) + print(f"[错误截图] 已保存HTML: {html_path} ({html_size} bytes)", file=sys.stderr) if error_message: print(f"[错误截图] 错误信息: {error_message}", file=sys.stderr) diff --git a/backend/main.py b/backend/main.py index 34be784..a48e28c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -288,9 +288,10 @@ async def send_code(request: SendCodeRequest): 支持选择从创作者中心或小红书首页登录 并发支持:为每个请求分配独立的浏览器实例 """ - # 使用手机号作为session_id,确保发送验证码和登录验证使用同一个浏览器 - session_id = f"xhs_login_{request.phone}" - print(f"[发送验证码] session_id={session_id}, phone={request.phone}", file=sys.stderr) + # 使用随机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) # 获取配置中的默认login_page,如果API传入了则优先使用API参数 config = get_config() @@ -315,6 +316,14 @@ async def send_code(request: SendCodeRequest): ) if result["success"]: + # 验证浏览器是否已保存到池中 + if browser_pool and session_id in browser_pool.temp_browsers: + print(f"[发送验证码] ✅ 浏览器实例已保存到池中: {session_id}", file=sys.stderr) + print(f"[发送验证码] 当前池中共有 {len(browser_pool.temp_browsers)} 个临时浏览器", file=sys.stderr) + else: + print(f"[发送验证码] ⚠️ 浏览器实例未保存到池中: {session_id}", file=sys.stderr) + print(f"[发送验证码] 池中的session列表: {list(browser_pool.temp_browsers.keys()) if browser_pool else 'None'}", file=sys.stderr) + return BaseResponse( code=0, message="验证码已发送,请在小红书APP中查看", @@ -421,6 +430,346 @@ async def verify_phone_code(request: VerifyCodeRequest): data=None ) +@app.post("/api/xhs/qrcode/start", response_model=BaseResponse) +async def start_qrcode_login(): + """ + 启动小红书扫码登录,返回二维码图片和状态 + 每个用户必须使用独立的浏览器实例,不能共享Context + """ + try: + print("[扫码登录] 启动扫码登录流程", file=sys.stderr) + + # 使用随机UUID创建临时的登录服务实例,完全不复用 + import uuid + session_id = f"qrcode_login_{uuid.uuid4().hex}" + print(f"[扫码登录] 创建全新浏览器实例 session_id={session_id}", file=sys.stderr) + + qrcode_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, + session_id=session_id, + use_page_isolation=False # 小红书不支持页面隔离,必须独立浏览器 + ) + + # 初始化浏览器 + await qrcode_service.init_browser() + + # 启动扫码登录 + result = await qrcode_service.start_qrcode_login() + + if result["success"]: + return BaseResponse( + code=0, + message="二维码获取成功", + data={ + "session_id": session_id, + "qrcode_image": result["qrcode_image"], + "status_text": result.get("status_text", ""), + "status_desc": result.get("status_desc", ""), + "is_expired": result.get("is_expired", False), + # 添加二维码创建信息 + "qr_url": result.get("qr_url", ""), + "qr_id": result.get("qr_id", ""), + "qr_code": result.get("qr_code", ""), + "multi_flag": result.get("multi_flag", 0) + } + ) + else: + # 失败后释放临时浏览器 + if browser_pool and session_id: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[扫码登录] 已释放失败的session: {session_id}", file=sys.stderr) + except Exception as release_error: + print(f"[扫码登录] 释放浏览器失败: {str(release_error)}", file=sys.stderr) + + return BaseResponse( + code=1, + message=result.get("error", "获取二维码失败"), + data=None + ) + + except Exception as e: + print(f"[扫码登录] 异常: {str(e)}", file=sys.stderr) + + # 异常后释放临时浏览器 + if browser_pool and 'session_id' in locals(): + try: + await browser_pool.release_temp_browser(session_id) + print(f"[扫码登录] 已释放异常的session: {session_id}", file=sys.stderr) + except Exception as release_error: + print(f"[扫码登录] 释放浏览器失败: {str(release_error)}", file=sys.stderr) + + return BaseResponse( + code=1, + message=f"启动扫码登录失败: {str(e)}", + data=None + ) + +@app.post("/api/xhs/qrcode/status") +async def get_qrcode_status(request: dict): + """ + 轮询获取扫码状态和最新的二维码图片 + """ + try: + session_id = request.get('session_id') + if not session_id: + return BaseResponse( + code=1, + message="session_id不能为空", + data=None + ) + + # 检查session是否存在于浏览器池中 + if browser_pool and session_id not in browser_pool.temp_browsers: + print(f"[扫码状态] session_id={session_id} 已失效,要求重新创建二维码", file=sys.stderr) + return BaseResponse( + code=2, # 特殊错误码,表示session失效 + message="会话已失效,请刷新二维码重新开始", + data={ + "session_expired": True + } + ) + + # 使用session_id获取浏览器实例 + qrcode_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, + session_id=session_id + ) + + # 初始化浏览器(会复用已有的) + await qrcode_service.init_browser() + + # 提取当前二维码状态 + result = await qrcode_service.extract_qrcode_with_status() + + if result["success"]: + # 如果登录成功,返回登录信息 + if result.get("login_success"): + return BaseResponse( + code=0, + message="扫码登录成功", + data={ + "login_success": True, + "user_info": result.get("user_info"), + "cookies": result.get("cookies"), + "cookies_full": result.get("cookies_full"), + "login_state": result.get("login_state") + } + ) + else: + # 还未登录,返回二维码状态 + return BaseResponse( + code=0, + message="获取状态成功", + data={ + "login_success": False, + "qrcode_image": result["qrcode_image"], + "status_text": result.get("status_text", ""), + "status_desc": result.get("status_desc", ""), + "is_expired": result.get("is_expired", False) + } + ) + else: + return BaseResponse( + code=1, + message=result.get("error", "获取状态失败"), + data=None + ) + + except Exception as e: + print(f"[扫码状态] 异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + return BaseResponse( + code=1, + message=f"获取状态失败: {str(e)}", + data=None + ) + +@app.post("/api/xhs/qrcode/refresh") +async def refresh_qrcode(request: dict): + """ + 刷新过期的二维码 + """ + try: + session_id = request.get('session_id') + if not session_id: + return BaseResponse( + code=1, + message="session_id不能为空", + data=None + ) + + # 使用session_id获取浏览器实例 + qrcode_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, + session_id=session_id + ) + + # 初始化浏览器 + await qrcode_service.init_browser() + + # 刷新二维码 + result = await qrcode_service.refresh_qrcode() + + if result["success"]: + return BaseResponse( + code=0, + message="二维码刷新成功", + data={ + "qrcode_image": result["qrcode_image"], + "status_text": result.get("status_text", ""), + "status_desc": result.get("status_desc", ""), + "is_expired": result.get("is_expired", False), + # 添加二维码创建信息 + "qr_url": result.get("qr_url", ""), + "qr_id": result.get("qr_id", ""), + "qr_code": result.get("qr_code", ""), + "multi_flag": result.get("multi_flag", 0) + } + ) + else: + # 检查是否需要重启 + if result.get("need_restart"): + return BaseResponse( + code=3, # 特殊错误码,表示需要重启 + message="页面已失效,请重新启动扫码登录", + data={ + "need_restart": True + } + ) + return BaseResponse( + code=1, + message=result.get("error", "刷新失败"), + data=None + ) + + except Exception as e: + print(f"[刷新二维码] 异常: {str(e)}", file=sys.stderr) + return BaseResponse( + code=1, + message=f"刷新二维码失败: {str(e)}", + data=None + ) + +@app.post("/api/xhs/save-bind-info") +async def save_bind_info(request: dict): + """ + 保存扫码登录的绑定信息到Go后端 + 与验证码登录不同,扫码登录直接返回了完整数据,需要由Python转发给Go后端保存 + """ + try: + employee_id = request.get('employee_id') + cookies_full = request.get('cookies_full', []) + user_info = request.get('user_info', {}) + login_state = request.get('login_state', {}) + + if not employee_id: + return BaseResponse( + code=1, + message="employee_id不能为空", + data=None + ) + + # 调用Go后端API保存 + config = get_config() + go_backend_url = config.get_str('go_backend.url', 'http://localhost:8080') + + # 构造请求数据,模仏bind-xhs接口的返回格式 + # Go后端期望接收的是验证码登录的结果 + save_data = { + "employee_id": employee_id, + "cookies_full": cookies_full, + "user_info": user_info, + "login_state": login_state + } + + import aiohttp + async with aiohttp.ClientSession() as session: + # 获取小程序传来的token + auth_header = request.get('Authorization', '') + + async with session.post( + f"{go_backend_url}/api/xhs/save-qrcode-login", + json=save_data, + headers={'Authorization': auth_header} if auth_header else {} + ) as resp: + result = await resp.json() + + if resp.status == 200 and result.get('code') == 200: + return BaseResponse( + code=0, + message="保存成功", + data=result.get('data') + ) + else: + return BaseResponse( + code=1, + message=result.get('message', '保存失败'), + data=None + ) + + except Exception as e: + print(f"[保存绑定信息] 异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + return BaseResponse( + code=1, + message=f"保存失败: {str(e)}", + data=None + ) + +@app.post("/api/xhs/qrcode/cancel") +async def cancel_qrcode_login(request: dict): + """ + 取消扫码登录,释放浏览器资源 + 用于用户切换登录方式或关闭页面时 + """ + try: + session_id = request.get('session_id') + if not session_id: + return BaseResponse( + code=1, + message="session_id不能为空", + data=None + ) + + # 释放临时浏览器 + if browser_pool: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[取消扫码] 已释放 session: {session_id}", file=sys.stderr) + return BaseResponse( + code=0, + message="已取消扫码登录", + data=None + ) + except Exception as e: + print(f"[取消扫码] 释放浏览器失败: {str(e)}", file=sys.stderr) + # 即使失败也返回成功,不影响用户体验 + return BaseResponse( + code=0, + message="已取消扫码登录", + data=None + ) + else: + return BaseResponse( + code=0, + message="浏览器池未初始化", + data=None + ) + + except Exception as e: + print(f"[取消扫码] 异常: {str(e)}", file=sys.stderr) + return BaseResponse( + code=0, # 即使异常也返回成功 + message="已取消扫码登录", + data=None + ) + @app.post("/api/xhs/login", response_model=BaseResponse) async def login(request: LoginRequest): """ @@ -429,13 +778,16 @@ async def login(request: LoginRequest): 支持选择从创作者中心或小红书首页登录 并发支持:可复用send-code接口的session_id """ - # 使用手机号作为session_id,复用发送验证码时的浏览器 - # 如果前端传了session_id就使用前端的,否则根据手机号生成 + # 必须使用前端传递的session_id来复用浏览器 + # 如果前端没有传session_id,说明前端实现有问题 if not request.session_id: - session_id = f"xhs_login_{request.phone}" - else: - session_id = request.session_id + return BaseResponse( + code=1, + message="缺少session_id参数,无法复用浏览器实例,请重新发送验证码", + data=None + ) + session_id = request.session_id print(f"[登录验证] session_id={session_id}, phone={request.phone}", file=sys.stderr) # 获取配置中的默认login_page,如果API传入了则优先使用API参数 @@ -448,7 +800,15 @@ async def login(request: LoginRequest): try: # 如果有session_id,复用send-code的浏览器;否则创建新的 if session_id: - print(f"[登录验证] 复用send-code的浏览器: {session_id}", file=sys.stderr) + print(f"[登录验证] 尝试复用send-code的浏览器: {session_id}", file=sys.stderr) + + # 先检查浏览器池中是否存在该session + if browser_pool and session_id in browser_pool.temp_browsers: + print(f"[登录验证] ✅ 在浏览器池中找到session: {session_id}", file=sys.stderr) + else: + print(f"[登录验证] ⚠️ 浏览器池中未找到session: {session_id}", file=sys.stderr) + print(f"[登录验证] 当前池中的session列表: {list(browser_pool.temp_browsers.keys()) if browser_pool else 'None'}", file=sys.stderr) + request_login_service = XHSLoginService( use_pool=True, headless=login_service.headless, # 使用配置文件中的login.headless配置 @@ -456,6 +816,12 @@ async def login(request: LoginRequest): ) # 初始化浏览器,以便从浏览器池获取临时浏览器 await request_login_service.init_browser() + + # 再次验证浏览器是否正常初始化 + if request_login_service.page: + print(f"[登录验证] ✅ 浏览器初始化成功,当前URL: {request_login_service.page.url}", file=sys.stderr) + else: + print(f"[登录验证] ❌ 浏览器初始化失败,page为None", file=sys.stderr) else: # 旧逻辑:不传session_id,使用全局登录服务 print(f"[登录验证] 使用全局登录服务(旧逻辑)", file=sys.stderr) diff --git a/backend/requirements.txt b/backend/requirements.txt index effe14b..5fb2625 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ alibabacloud_dysmsapi20170525==2.0.24 alibabacloud_credentials==0.3.4 alibabacloud_tea_openapi==0.3.9 alibabacloud_tea_util==0.3.13 +loguru==0.7.2 diff --git a/backend/test_api_response.py b/backend/test_api_response.py deleted file mode 100644 index 54e1964..0000000 --- a/backend/test_api_response.py +++ /dev/null @@ -1,188 +0,0 @@ -""" -测试 API 返回格式 -验证登录 API 是否正确返回 cookies 和 cookies_full -""" -import json - - -def test_api_response_format(): - """测试 API 响应格式""" - - # 模拟 API 返回的数据 - mock_response = { - "code": 0, - "message": "登录成功", - "data": { - "user_info": {}, - "cookies": { - "a1": "xxx", - "webId": "yyy", - "web_session": "zzz" - }, - "cookies_full": [ - { - "name": "a1", - "value": "xxx", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066496, - "httpOnly": False, - "secure": False, - "sameSite": "Lax" - }, - { - "name": "webId", - "value": "yyy", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066496, - "httpOnly": False, - "secure": False, - "sameSite": "Lax" - }, - { - "name": "web_session", - "value": "zzz", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066497, - "httpOnly": True, - "secure": True, - "sameSite": "Lax" - } - ], - "login_time": "2025-12-12T23:30:00" - } - } - - print("="*60) - print("API 响应格式测试") - print("="*60) - print() - - # 检查响应结构 - assert "code" in mock_response, "缺少 code 字段" - assert "message" in mock_response, "缺少 message 字段" - assert "data" in mock_response, "缺少 data 字段" - - data = mock_response["data"] - - # 检查 cookies 字段(键值对格式) - print("✅ 检查 cookies 字段(键值对格式):") - assert "cookies" in data, "缺少 cookies 字段" - assert isinstance(data["cookies"], dict), "cookies 应该是字典类型" - print(f" 类型: {type(data['cookies']).__name__}") - print(f" 示例: {json.dumps(data['cookies'], ensure_ascii=False, indent=2)}") - print() - - # 检查 cookies_full 字段(Playwright 完整格式) - print("✅ 检查 cookies_full 字段(Playwright 完整格式):") - assert "cookies_full" in data, "缺少 cookies_full 字段" - assert isinstance(data["cookies_full"], list), "cookies_full 应该是列表类型" - print(f" 类型: {type(data['cookies_full']).__name__}") - print(f" 数量: {len(data['cookies_full'])} 个 Cookie") - print(f" 示例(第一个):") - print(f"{json.dumps(data['cookies_full'][0], ensure_ascii=False, indent=6)}") - print() - - # 检查 cookies_full 的每个元素 - print("✅ 检查 cookies_full 的结构:") - for i, cookie in enumerate(data["cookies_full"]): - assert "name" in cookie, f"Cookie[{i}] 缺少 name 字段" - assert "value" in cookie, f"Cookie[{i}] 缺少 value 字段" - assert "domain" in cookie, f"Cookie[{i}] 缺少 domain 字段" - assert "path" in cookie, f"Cookie[{i}] 缺少 path 字段" - assert "expires" in cookie, f"Cookie[{i}] 缺少 expires 字段" - assert "httpOnly" in cookie, f"Cookie[{i}] 缺少 httpOnly 字段" - assert "secure" in cookie, f"Cookie[{i}] 缺少 secure 字段" - assert "sameSite" in cookie, f"Cookie[{i}] 缺少 sameSite 字段" - print(f" Cookie[{i}] ({cookie['name']}): ✅ 所有字段完整") - - print() - print("="*60) - print("🎉 所有检查通过!API 返回格式正确") - print("="*60) - print() - - # 使用场景说明 - print("📝 使用场景:") - print() - print("1. 前端展示 - 使用 cookies(键值对格式):") - print(" const cookies = response.data.cookies;") - print(" console.log(cookies.a1, cookies.webId);") - print() - - print("2. 数据库存储 - 使用 cookies_full(完整格式):") - print(" const cookiesFull = response.data.cookies_full;") - print(" await db.saveCookies(userId, JSON.stringify(cookiesFull));") - print() - - print("3. Python 脚本使用 - 使用 cookies_full:") - print(" cookies_full = response['data']['cookies_full']") - print(" publisher = XHSPublishService(cookies_full)") - print() - - -def compare_formats(): - """对比两种格式""" - - print("="*60) - print("格式对比分析") - print("="*60) - print() - - # 键值对格式 - cookies_dict = { - "a1": "xxx", - "webId": "yyy", - "web_session": "zzz" - } - - # Playwright 完整格式 - cookies_full = [ - { - "name": "a1", - "value": "xxx", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066496, - "httpOnly": False, - "secure": False, - "sameSite": "Lax" - } - ] - - print("📊 键值对格式:") - dict_str = json.dumps(cookies_dict, ensure_ascii=False, indent=2) - print(dict_str) - print(f" 大小: {len(dict_str)} 字符") - print() - - print("📊 Playwright 完整格式:") - full_str = json.dumps(cookies_full, ensure_ascii=False, indent=2) - print(full_str) - print(f" 大小: {len(full_str)} 字符") - print() - - print("📊 对比结果:") - print(f" 完整格式 vs 键值对格式: {len(full_str)} / {len(dict_str)} = {len(full_str)/len(dict_str):.1f}x") - print(f" 每个 Cookie 完整格式约增加: {(len(full_str) - len(dict_str)) // len(cookies_dict)} 字符") - print() - - print("✅ 结论:") - print(" - 完整格式虽然较大,但包含所有必要属性") - print(" - 对于数据库存储,建议使用完整格式") - print(" - 对于前端展示,可以使用键值对格式") - print() - - -if __name__ == "__main__": - # 测试 API 响应格式 - test_api_response_format() - - # 对比两种格式 - compare_formats() - - print("="*60) - print("✅ 测试完成!") - print("="*60) diff --git a/backend/test_basic_browser.py b/backend/test_basic_browser.py deleted file mode 100644 index 26b1368..0000000 --- a/backend/test_basic_browser.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -基础浏览器测试脚本 -用于测试浏览器是否能正常加载小红书页面 -""" -import asyncio -from playwright.async_api import async_playwright -import sys - - -async def test_basic_browser(proxy_index: int = 0): - """基础浏览器测试""" - print(f"\n{'='*60}") - print(f"🔍 基础浏览器测试") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - - try: - async with async_playwright() as p: - # 配置代理 - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - print(f" 配置的代理对象: {proxy_config_obj}") - - # 启动浏览器 - browser = await p.chromium.launch( - headless=False, # 非无头模式,便于观察 - proxy=proxy_config_obj - ) - - # 创建上下文 - context = await browser.new_context( - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ) - - # 创建页面 - page = await context.new_page() - - print(f"\n🌐 尝试访问百度...") - try: - await page.goto('https://www.baidu.com', wait_until='networkidle', timeout=15000) - await asyncio.sleep(2) - - title = await page.title() - url = page.url - content_len = len(await page.content()) - - print(f" ✅ 百度访问成功") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - except Exception as e: - print(f" ❌ 百度访问失败: {str(e)}") - - print(f"\n🌐 尝试访问小红书登录页...") - try: - await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=15000) - await asyncio.sleep(5) # 等待更长时间 - - title = await page.title() - url = page.url - content = await page.content() - content_len = len(content) - - print(f" 访问结果:") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - - # 检查是否有特定内容 - if content_len == 0: - print(f" ⚠️ 页面内容为空,可能存在加载问题") - elif "验证" in content or "captcha" in content.lower() or "安全" in content: - print(f" ⚠️ 检测到验证或安全提示") - else: - print(f" ✅ 页面加载正常") - - # 查找页面上的所有元素 - print(f"\n🔍 分析页面元素...") - - # 查找所有input元素 - inputs = await page.query_selector_all('input') - print(f" 找到 {len(inputs)} 个input元素") - - # 查找所有表单相关元素 - form_elements = await page.query_selector_all('input, button, select, textarea') - print(f" 找到 {len(form_elements)} 个表单相关元素") - - # 打印前几个元素的信息 - for i, elem in enumerate(form_elements[:5]): - try: - tag = await elem.evaluate('el => el.tagName') - text = await elem.inner_text() - placeholder = await elem.get_attribute('placeholder') - class_name = await elem.get_attribute('class') - id_attr = await elem.get_attribute('id') - - print(f" 元素 {i+1}:") - print(f" - 标签: {tag}") - print(f" - 文本: {text[:50]}...") - print(f" - placeholder: {placeholder}") - print(f" - class: {class_name[:50]}...") - print(f" - id: {id_attr}") - except Exception as e: - print(f" 元素 {i+1}: 获取信息失败 - {str(e)}") - - except Exception as e: - print(f" ❌ 小红书访问失败: {str(e)}") - import traceback - traceback.print_exc() - - print(f"\n⏸️ 浏览器保持打开状态,您可以手动检查页面") - print(f" 按 Enter 键关闭浏览器...") - - # 等待用户输入 - input() - - await browser.close() - print(f"✅ 浏览器已关闭") - - except Exception as e: - print(f"❌ 测试过程异常: {str(e)}") - import traceback - traceback.print_exc() - - -async def main(): - """主函数""" - print("="*60) - print("🔍 基础浏览器测试工具") - print("="*60) - - proxy_choice = input("\n请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - await test_basic_browser(proxy_idx) - - print(f"\n{'='*60}") - print("✅ 测试完成!") - print("="*60) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_browser_pool_fix.py b/backend/test_browser_pool_fix.py deleted file mode 100644 index 86d8153..0000000 --- a/backend/test_browser_pool_fix.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -测试修复后的浏览器池 -验证预热超时问题是否已解决 -""" -import asyncio -import sys -from xhs_login import XHSLoginService - - -async def test_browser_pool_with_proxy(proxy_index: int = 0): - """测试修复后的浏览器池""" - print(f"\n{'='*60}") - print(f"🔧 测试修复后的浏览器池") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - print(f" 代理URL: {proxy_url}") - - # 创建登录服务(使用浏览器池) - login_service = XHSLoginService(use_pool=True) # 使用浏览器池 - - try: - print(f"\n🚀 初始化浏览器(使用代理 + 浏览器池)...") - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) - print("✅ 浏览器初始化成功") - - # 检查浏览器池状态 - browser_pool = login_service.browser_pool - if browser_pool: - stats = browser_pool.get_stats() - print(f"\n📊 浏览器池状态:") - print(f" 主浏览器存活: {stats['browser_alive']}") - print(f" 上下文存活: {stats['context_alive']}") - print(f" 页面存活: {stats['page_alive']}") - print(f" 是否预热: {stats['is_preheated']}") - print(f" 临时浏览器数: {stats['temp_browsers_count']}") - - # 访问小红书登录页面 - print(f"\n🌐 访问小红书创作者平台...") - await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) - await asyncio.sleep(2) - - title = await login_service.page.title() - url = login_service.page.url - content_len = len(await login_service.page.content()) - - print(f"✅ 访问成功") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - - # 检查关键元素 - phone_input = await login_service.page.query_selector('input[placeholder="手机号"]') - if phone_input: - print(f"✅ 找到手机号输入框") - else: - print(f"❌ 未找到手机号输入框") - - # 查找所有input元素 - inputs = await login_service.page.query_selector_all('input') - print(f" 共找到 {len(inputs)} 个input元素") - - if content_len == 0: - print(f"⚠️ 页面内容为空") - else: - print(f"✅ 页面内容正常加载") - - return True - - except Exception as e: - print(f"❌ 测试失败: {str(e)}") - import traceback - traceback.print_exc() - return False - finally: - await login_service.close_browser() - - -async def test_multiple_requests(proxy_index: int = 0): - """测试多个请求复用浏览器池""" - print(f"\n{'='*60}") - print(f"🔄 测试浏览器池复用") - print(f"{'='*60}") - - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - - success_count = 0 - - for i in range(3): - print(f"\n🧪 请求 {i+1}/3") - login_service = XHSLoginService(use_pool=True) - - try: - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) - - # 访问页面 - await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) - await asyncio.sleep(1) - - content_len = len(await login_service.page.content()) - if content_len > 0: - print(f" ✅ 请求 {i+1} 成功,内容长度: {content_len}") - success_count += 1 - else: - print(f" ❌ 请求 {i+1} 失败,内容为空") - - except Exception as e: - print(f" ❌ 请求 {i+1} 异常: {str(e)}") - finally: - await login_service.close_browser() - - # 等待一下避免请求过于频繁 - if i < 2: - await asyncio.sleep(1) - - print(f"\n📈 测试结果: {success_count}/3 请求成功") - return success_count == 3 - - -def explain_fix(): - """解释修复内容""" - print("="*60) - print("🔧 修复内容说明") - print("="*60) - - print("\n修复的两个问题:") - print("1. 增加超时时间: 从30秒增加到45秒") - print("2. 修改等待策略: 从'networkidle'改为'domcontentloaded'") - print(" - 'networkidle': 等待网络空闲(可能等待时间过长)") - print(" - 'domcontentloaded': DOM内容加载完成(更快更稳定)") - - print("\n浏览器池优化效果:") - print("✅ 减少预热超时错误") - print("✅ 提高页面加载成功率") - print("✅ 保持浏览器常驻,提升性能") - - -async def main(): - """主函数""" - explain_fix() - - print(f"\n{'='*60}") - print("🎯 选择测试模式") - print("="*60) - - print("\n1. 单次浏览器池测试") - print("2. 多请求复用测试") - print("3. 全部测试") - - try: - choice = input("\n请选择测试模式 (1-3, 默认为3): ").strip() - - if choice not in ['1', '2', '3']: - choice = '3' - - proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - if choice in ['1', '3']: - print(f"\n{'-'*40}") - print("测试1: 单次浏览器池测试") - success1 = await test_browser_pool_with_proxy(proxy_idx) - - if choice in ['2', '3']: - print(f"\n{'-'*40}") - print("测试2: 多请求复用测试") - success2 = await test_multiple_requests(proxy_idx) - - if choice == '3': - overall_success = success1 and success2 - elif choice == '1': - overall_success = success1 - else: - overall_success = success2 - - print(f"\n{'='*60}") - if overall_success: - print("✅ 所有测试通过!浏览器池预热问题已修复") - else: - print("❌ 部分测试失败,请检查配置") - print("="*60) - - except KeyboardInterrupt: - print("\n\n⚠️ 测试被用户中断") - except Exception as e: - print(f"\n❌ 测试过程中出现错误: {str(e)}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_cookie_file.py b/backend/test_cookie_file.py deleted file mode 100644 index ef349b0..0000000 --- a/backend/test_cookie_file.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -测试 Cookie 文件路径支持 -""" -import subprocess -import sys -import json - - -def test_cookie_file_param(): - """测试 --cookies 参数支持文件路径""" - - print("="*60) - print("测试 Cookie 文件路径参数支持") - print("="*60) - print() - - # 测试命令 - cmd = [ - sys.executable, - "xhs_publish.py", - "--cookies", "test_cookies.json", # 使用文件路径 - "--title", "【测试】Cookie文件路径参数", - "--content", "测试使用 --cookies 参数传递文件路径,而不是 JSON 字符串", - "--images", '["https://picsum.photos/800/600","https://picsum.photos/800/600"]', - "--tags", '["测试","Cookie文件","自动化"]' - ] - - print("执行命令:") - print(" ".join(cmd)) - print() - print("-"*60) - print() - - # 执行命令 - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding='utf-8' - ) - - # 输出结果 - print("标准输出:") - print(result.stdout) - - if result.stderr: - print("\n标准错误:") - print(result.stderr) - - print() - print("-"*60) - - # 解析结果 - try: - # 尝试从输出中提取 JSON 结果 - lines = result.stdout.strip().split('\n') - for i, line in enumerate(lines): - if line.strip().startswith('{'): - json_str = '\n'.join(lines[i:]) - response = json.loads(json_str) - - print("\n解析结果:") - print(json.dumps(response, ensure_ascii=False, indent=2)) - - if response.get('success'): - print("\n✅ 测试成功!Cookie 文件路径参数工作正常") - if 'url' in response: - print(f"📎 笔记链接: {response['url']}") - else: - print(f"\n❌ 测试失败: {response.get('error')}") - break - except json.JSONDecodeError: - print("⚠️ 无法解析 JSON 输出") - - return result.returncode == 0 - - except Exception as e: - print(f"❌ 执行失败: {str(e)}") - return False - - -def test_quick_publish(): - """测试 quick_publish.py 脚本""" - - print("\n") - print("="*60) - print("测试 quick_publish.py 脚本") - print("="*60) - print() - - cmd = [ - sys.executable, - "quick_publish.py", - "【测试】快速发布脚本", - "测试 quick_publish.py 的简化调用方式", - "https://picsum.photos/800/600,https://picsum.photos/800/600", - "测试,快速发布,自动化", - "test_cookies.json" - ] - - print("执行命令:") - print(" ".join(cmd)) - print() - print("-"*60) - print() - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding='utf-8' - ) - - print(result.stdout) - - if result.stderr: - print("\n标准错误:") - print(result.stderr) - - return result.returncode == 0 - - except Exception as e: - print(f"❌ 执行失败: {str(e)}") - return False - - -if __name__ == "__main__": - print() - print("🧪 Cookie 文件路径支持测试") - print() - - # 检查 Cookie 文件是否存在 - import os - if not os.path.exists('test_cookies.json'): - print("❌ 错误: test_cookies.json 文件不存在") - print("请先创建 Cookie 文件") - sys.exit(1) - - print("✅ 找到 Cookie 文件: test_cookies.json") - print() - - # 测试1: xhs_publish.py 使用文件路径 - success1 = test_cookie_file_param() - - # 测试2: quick_publish.py - success2 = test_quick_publish() - - # 总结 - print() - print("="*60) - print("测试总结") - print("="*60) - print(f"xhs_publish.py (Cookie文件): {'✅ 通过' if success1 else '❌ 失败'}") - print(f"quick_publish.py: {'✅ 通过' if success2 else '❌ 失败'}") - print() - - if success1 and success2: - print("🎉 所有测试通过!") - else: - print("⚠️ 部分测试失败,请检查错误信息") diff --git a/backend/test_cookie_format_fix.py b/backend/test_cookie_format_fix.py deleted file mode 100644 index 48c27d5..0000000 --- a/backend/test_cookie_format_fix.py +++ /dev/null @@ -1,313 +0,0 @@ -""" -测试Cookie格式处理修复 -验证scheduler.py中的_format_cookies方法能正确处理各种Cookie格式 -""" -import json -from typing import List, Dict - - -def _format_cookies(cookies) -> List[Dict]: - """ - 格式化Cookie,只处理非标准格式的Cookie - 对于Playwright原生格式的Cookie,直接返回,不做任何修改 - - 这是scheduler.py中_format_cookies方法的副本,用于独立测试 - - Args: - cookies: Cookie数据,支持list[dict]或dict格式 - - Returns: - 格式化后的Cookie列表 - """ - # 如果是字典格式(键值对),转换为列表格式 - if isinstance(cookies, dict): - cookies = [ - { - "name": name, - "value": str(value) if not isinstance(value, str) else value, - "domain": ".xiaohongshu.com", - "path": "/" - } - for name, value in cookies.items() - ] - - # 验证是否为列表 - if not isinstance(cookies, list): - raise ValueError(f"Cookie必须是列表或字典格式,当前类型: {type(cookies).__name__}") - - # 检查是否是Playwright原生格式(包含name和value字段) - if cookies and isinstance(cookies[0], dict) and 'name' in cookies[0] and 'value' in cookies[0]: - # 已经是Playwright格式,直接返回,不做任何修改 - return cookies - - # 其他格式,进行基础验证 - formatted_cookies = [] - for cookie in cookies: - if not isinstance(cookie, dict): - raise ValueError(f"Cookie元素必须是字典格式,当前类型: {type(cookie).__name__}") - - # 确保有基本字段 - if 'domain' not in cookie and 'url' not in cookie: - cookie = cookie.copy() - cookie['domain'] = '.xiaohongshu.com' - if 'path' not in cookie and 'url' not in cookie: - if 'domain' in cookie or 'url' not in cookie: - cookie = cookie.copy() if cookie is cookies[cookies.index(cookie)] else cookie - cookie['path'] = '/' - - formatted_cookies.append(cookie) - - return formatted_cookies - - -def test_format_cookies(): - """测试_format_cookies方法""" - - print("="*60) - print("测试 Cookie 格式处理") - print("="*60) - - # 测试1: 字典格式(键值对) - print("\n测试 1: 字典格式(键值对)") - cookies_dict = { - "a1": "xxx", - "webId": "yyy", - "web_session": "zzz" - } - try: - result = _format_cookies(cookies_dict) - print(f"✅ 成功处理字典格式") - print(f" 输入: {type(cookies_dict).__name__} with {len(cookies_dict)} items") - print(f" 输出: {type(result).__name__} with {len(result)} items") - print(f" 第一个Cookie: {result[0]}") - assert isinstance(result, list) - assert len(result) == 3 - assert all('name' in c and 'value' in c and 'domain' in c for c in result) - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试2: 列表格式(完整格式,已有domain和path) - print("\n测试 2: 列表格式(完整格式)") - cookies_list_full = [ - { - "name": "a1", - "value": "xxx", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": -1, - "httpOnly": False, - "secure": False, - "sameSite": "Lax" - } - ] - try: - result = _format_cookies(cookies_list_full) - print(f"✅ 成功处理完整列表格式") - print(f" 输入: {type(cookies_list_full).__name__} with {len(cookies_list_full)} items") - print(f" 输出: {type(result).__name__} with {len(result)} items") - # 验证Playwright原生格式被完整保留 - print(f" 保留的字段: {list(result[0].keys())}") - assert result == cookies_list_full, "Playwright原生格式应该被完整保留,不做任何修改" - assert 'expires' in result[0], "expires字段应该被保留" - assert result[0]['expires'] == -1, "expires=-1应该被保留" - assert isinstance(result, list) - assert len(result) == 1 - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试3: 非Playwright格式(缺少name字段,需要补充domain和path) - print("\n测试 3: 非Playwright格式(缺少字段,需要补充)") - cookies_list_partial = [ - { - "cookie_name": "a1", # 没有name字段,不是Playwright格式 - "cookie_value": "xxx" - } - ] - try: - result = _format_cookies(cookies_list_partial) - print(f"✅ 成功处理非Playwright格式") - print(f" 输入: {type(cookies_list_partial).__name__} with {len(cookies_list_partial)} items") - print(f" 输出: {type(result).__name__} with {len(result)} items") - print(f" 自动添加的字段: domain={result[0].get('domain')}, path={result[0].get('path')}") - assert isinstance(result, list) - # 应该自动添加domain和path - assert result[0]['domain'] == '.xiaohongshu.com' - assert result[0]['path'] == '/' - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试4: 双重JSON编码(模拟数据库存储场景) - print("\n测试 4: 双重JSON编码字符串") - cookies_dict = {"a1": "xxx", "webId": "yyy"} - # 第一次JSON编码 - cookies_json_1 = json.dumps(cookies_dict) - # 第二次JSON编码 - cookies_json_2 = json.dumps(cookies_json_1) - - print(f" 原始字典: {cookies_dict}") - print(f" 第一次编码: {cookies_json_1}") - print(f" 第二次编码: {cookies_json_2}") - - # 模拟从数据库读取并解析 - try: - # 第一次解析 - cookies_parsed_1 = json.loads(cookies_json_2) - print(f" 第一次解析后类型: {type(cookies_parsed_1).__name__}") - - # 处理双重编码 - if isinstance(cookies_parsed_1, str): - cookies_parsed_2 = json.loads(cookies_parsed_1) - print(f" 第二次解析后类型: {type(cookies_parsed_2).__name__}") - cookies = cookies_parsed_2 - else: - cookies = cookies_parsed_1 - - # 格式化 - result = _format_cookies(cookies) - print(f"✅ 成功处理双重JSON编码") - print(f" 最终输出: {type(result).__name__} with {len(result)} items") - assert isinstance(result, list) - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试5: 错误格式 - 字符串(不是JSON) - print("\n测试 5: 错误格式 - 普通字符串") - try: - result = _format_cookies("invalid_string") - print(f"❌ 应该抛出异常但没有") - except ValueError as e: - print(f"✅ 正确抛出ValueError异常") - print(f" 错误信息: {str(e)}") - except Exception as e: - print(f"❌ 抛出了非预期的异常: {str(e)}") - - # 测试6: 错误格式 - 列表中包含非字典元素 - print("\n测试 6: 错误格式 - 列表中包含非字典元素") - try: - result = _format_cookies(["string_item", 123]) - print(f"❌ 应该抛出异常但没有") - except ValueError as e: - print(f"✅ 正确抛出ValueError异常") - print(f" 错误信息: {str(e)}") - except Exception as e: - print(f"❌ 抛出了非预期的异常: {str(e)}") - - # 测试7: Playwright原生格式中value为对象(保持原样) - print("\n测试 7: Playwright原生格式中value为对象(应保持原样)") - cookies_with_object_value = [ - { - "name": "test_cookie", - "value": {"nested": "object"}, # value是对象 - "domain": ".xiaohongshu.com", - "path": "/" - } - ] - try: - result = _format_cookies(cookies_with_object_value) - print(f"✅ Playwright原生格式被完整保留") - print(f" 输入value类型: {type(cookies_with_object_value[0]['value']).__name__}") - print(f" 输出value类型: {type(result[0]['value']).__name__}") - print(f" 输出value内容: {result[0]['value']}") - # Playwright原生格式不做任何修改,包括uvalue - assert result == cookies_with_object_value, "Playwright原生格式应完整保留" - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试8: 字典格式中value为数字 - print("\n测试 8: 字典格式中value为数字(应自动转换为字符串)") - cookies_dict_with_number = { - "a1": "xxx", - "user_id": 12345, # value是数字 - "is_login": True # value是布尔值 - } - try: - result = _format_cookies(cookies_dict_with_number) - print(f"✅ 成功处理数字/布尔value") - print(f" 输入: {cookies_dict_with_number}") - print(f" user_id value类型: {type(result[1]['value']).__name__}, 值: {result[1]['value']}") - print(f" is_login value类型: {type(result[2]['value']).__name__}, 值: {result[2]['value']}") - # 验证不再包含expires等字段 - print(f" 字段: {list(result[0].keys())}") - assert all(isinstance(c['value'], str) for c in result), "所有value应该都是字符串类型" - assert 'expires' not in result[0], "不应该包含expires字段" - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试9: Playwright原生格式中expires=-1(应被保留) - print("\n测试 9: Playwright原生格式中expires=-1(应被保留)") - cookies_with_invalid_expires = [ - { - "name": "test_cookie", - "value": "test_value", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": -1 # Playwright原生格式 - } - ] - try: - result = _format_cookies(cookies_with_invalid_expires) - print(f"✅ Playwright原生格式被完整保留") - print(f" 原始字段: {list(cookies_with_invalid_expires[0].keys())}") - print(f" 处理后字段: {list(result[0].keys())}") - assert result == cookies_with_invalid_expires, "Playwright原生格式应被完整保留" - assert 'expires' in result[0] and result[0]['expires'] == -1, "expires=-1应该被保留" - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试10: Playwright原生格式中expires为浮点数(应被保留) - print("\n测试 10: Playwright原生格式中expires为浮点数(应被保留)") - cookies_with_float_expires = [ - { - "name": "test_cookie", - "value": "test_value", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066497.112584 # Playwright原生格式常常有浮点数 - } - ] - try: - result = _format_cookies(cookies_with_float_expires) - print(f"✅ Playwright原生格式被完整保留") - print(f" 原始expires: {cookies_with_float_expires[0]['expires']} (类型: {type(cookies_with_float_expires[0]['expires']).__name__})") - print(f" 处理后expires: {result[0]['expires']} (类型: {type(result[0]['expires']).__name__})") - assert result == cookies_with_float_expires, "Playwright原生格式应被完整保留" - assert isinstance(result[0]['expires'], float), "expires浮点数应该被保留" - except Exception as e: - print(f"❌ 失败: {str(e)}") - - # 测试11: Playwright原生格式中sameSite大小写(应被保留) - print("\n测试 11: Playwright原生格式中sameSite(应被完整保留)") - cookies_with_samesite = [ - { - "name": "test_cookie1", - "value": "test_value1", - "domain": ".xiaohongshu.com", - "path": "/", - "sameSite": "Lax" # Playwright原生格式 - }, - { - "name": "test_cookie2", - "value": "test_value2", - "domain": ".xiaohongshu.com", - "path": "/", - "sameSite": "Strict" - } - ] - try: - result = _format_cookies(cookies_with_samesite) - print(f"✅ Playwright原生格式被完整保留") - print(f" cookie1 sameSite: {result[0]['sameSite']}") - print(f" cookie2 sameSite: {result[1]['sameSite']}") - assert result == cookies_with_samesite, "Playwright原生格式应被完整保留" - assert result[0]['sameSite'] == 'Lax' - assert result[1]['sameSite'] == 'Strict' - except Exception as e: - print(f"❌ 失败: {str(e)}") - - print("\n" + "="*60) - print("测试完成") - print("="*60) - - -if __name__ == "__main__": - test_format_cookies() diff --git a/backend/test_cookie_formats.py b/backend/test_cookie_formats.py deleted file mode 100644 index f59d851..0000000 --- a/backend/test_cookie_formats.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -测试两种 Cookie 格式支持 -""" -import asyncio -import json -from xhs_publish import XHSPublishService - - -# 格式1: Playwright 完整格式(从文件读取) -playwright_cookies = [ - { - "name": "a1", - "value": "19b11d16e24t3h3xmlvojbrw1cr55xwamiacluw3c50000231766", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066496, - "httpOnly": False, - "secure": False, - "sameSite": "Lax" - }, - { - "name": "web_session", - "value": "030037ae088f0acf2c81329d432e4a12fcb0ca", - "domain": ".xiaohongshu.com", - "path": "/", - "expires": 1797066497.112584, - "httpOnly": True, - "secure": True, - "sameSite": "Lax" - } -] - -# 格式2: 键值对格式(从数据库读取) -keyvalue_cookies = { - "a1": "19b11d16e24t3h3xmlvojbrw1cr55xwamiacluw3c50000231766", - "abRequestId": "b273b4d0-3ef7-5b8f-bba4-2d19e63ad883", - "acw_tc": "0a4ae09717655304937202738e4b75c08d6eb78f2c8d30d7dc5a465429e1e6", - "gid": "yjDyyfyKiD6DyjDyyfyKd37EJ49qxqC61hlV0qSDFEySFS2822CE01888JqyWKK8Djdi8d2j", - "loadts": "1765530496548", - "sec_poison_id": "a589e333-c364-477c-9d14-53af8a1e7f1c", - "unread": "{%22ub%22:%22648455690000000014025d90%22%2C%22ue%22:%2264b34737000000002f0262f9%22%2C%22uc%22:22}", - "webBuild": "5.0.6", - "webId": "fdf2dccee4bec7534aff5581310c0e26", - "web_session": "030037ae088f0acf2c81329d432e4a12fcb0ca", - "websectiga": "984412fef754c018e472127b8effd174be8a5d51061c991aadd200c69a2801d6", - "xsecappid": "xhs-pc-web" -} - - -async def test_playwright_format(): - """测试 Playwright 格式""" - print("="*60) - print("测试 1: Playwright 格式(完整格式)") - print("="*60) - - try: - publisher = XHSPublishService(playwright_cookies) - print("✅ 初始化成功") - print(f" 转换后的 Cookie 数量: {len(publisher.cookies)}") - return True - except Exception as e: - print(f"❌ 初始化失败: {e}") - return False - - -async def test_keyvalue_format(): - """测试键值对格式""" - print("\n" + "="*60) - print("测试 2: 键值对格式(数据库格式)") - print("="*60) - - try: - publisher = XHSPublishService(keyvalue_cookies) - print("✅ 初始化成功") - print(f" 转换后的 Cookie 数量: {len(publisher.cookies)}") - - # 显示转换后的一个示例 - print("\n转换示例(第一个 Cookie):") - print(json.dumps(publisher.cookies[0], ensure_ascii=False, indent=2)) - - return True - except Exception as e: - print(f"❌ 初始化失败: {e}") - return False - - -async def test_from_file(): - """从文件读取测试""" - print("\n" + "="*60) - print("测试 3: 从 cookies.json 文件读取") - print("="*60) - - try: - with open('cookies.json', 'r', encoding='utf-8') as f: - cookies = json.load(f) - - publisher = XHSPublishService(cookies) - print("✅ 初始化成功") - print(f" Cookie 数量: {len(publisher.cookies)}") - return True - except FileNotFoundError: - print("⚠️ cookies.json 文件不存在,跳过此测试") - return None - except Exception as e: - print(f"❌ 初始化失败: {e}") - return False - - -async def main(): - print("\n🧪 Cookie 格式兼容性测试\n") - - # 测试1: Playwright格式 - result1 = await test_playwright_format() - - # 测试2: 键值对格式 - result2 = await test_keyvalue_format() - - # 测试3: 从文件读取 - result3 = await test_from_file() - - # 总结 - print("\n" + "="*60) - print("测试总结") - print("="*60) - print(f"Playwright 格式: {'✅ 通过' if result1 else '❌ 失败'}") - print(f"键值对格式: {'✅ 通过' if result2 else '❌ 失败'}") - if result3 is not None: - print(f"文件读取: {'✅ 通过' if result3 else '❌ 失败'}") - else: - print(f"文件读取: ⚠️ 跳过") - - if result1 and result2: - print("\n🎉 所有格式测试通过!") - print("\n💡 使用说明:") - print(" - 从 Python 脚本保存的 cookies.json → Playwright 格式") - print(" - 从数据库读取的 Cookie → 键值对格式") - print(" - 两种格式都可以正常使用!") - else: - print("\n⚠️ 部分测试失败") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/test_cookie_inject.bat b/backend/test_cookie_inject.bat deleted file mode 100644 index b83a6bd..0000000 --- a/backend/test_cookie_inject.bat +++ /dev/null @@ -1,31 +0,0 @@ -@echo off -chcp 65001 >nul -echo ======================================== -echo 小红书Cookie注入测试工具 -echo ======================================== -echo. -echo 此工具使用Playwright真实注入Cookie -echo 支持验证Cookie有效性并跳转到指定页面 -echo. -echo ======================================== -echo. - -cd /d %~dp0 - -REM 检查是否有cookies.json文件 -if exist cookies.json ( - echo 检测到 cookies.json 文件 - echo. - python test_cookie_inject.py -) else ( - echo 未找到 cookies.json 文件 - echo 请先准备Cookie文件或在程序中手动输入 - echo. - python test_cookie_inject.py -) - -echo. -echo ======================================== -echo 测试完成 -echo ======================================== -pause diff --git a/backend/test_cookie_inject.py b/backend/test_cookie_inject.py deleted file mode 100644 index b217f7d..0000000 --- a/backend/test_cookie_inject.py +++ /dev/null @@ -1,398 +0,0 @@ -""" -Cookie注入测试脚本 -使用Playwright注入Cookie并验证其有效性 -支持跳转到创作者中心或小红书首页 -""" -import asyncio -import sys -import json -from pathlib import Path -from playwright.async_api import async_playwright -from typing import Optional, List, Dict, Any - - -class CookieInjector: - """Cookie注入器""" - - def __init__(self, headless: bool = False): - """ - 初始化Cookie注入器 - - Args: - headless: 是否使用无头模式,False可以看到浏览器界面 - """ - self.headless = headless - self.playwright = None - self.browser = None - self.context = None - self.page = None - - async def init_browser(self): - """初始化浏览器""" - try: - print("正在启动浏览器...") - - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - try: - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - except Exception as e: - print(f"警告: 设置事件循环策略失败: {str(e)}") - - self.playwright = await async_playwright().start() - - # 启动浏览器 - self.browser = await self.playwright.chromium.launch( - headless=self.headless, - args=['--disable-blink-features=AutomationControlled'] - ) - - # 创建浏览器上下文 - self.context = await self.browser.new_context( - viewport={'width': 1280, 'height': 720}, - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ) - - # 创建新页面 - self.page = await self.context.new_page() - - print("浏览器初始化成功") - - except Exception as e: - print(f"浏览器初始化失败: {str(e)}") - raise - - async def inject_cookies(self, cookies: List[Dict[str, Any]]) -> bool: - """ - 注入Cookie - - Args: - cookies: Cookie列表 - - Returns: - 是否注入成功 - """ - try: - if not self.context: - await self.init_browser() - - print(f"正在注入 {len(cookies)} 个Cookie...") - - # 注入Cookie到浏览器上下文 - await self.context.add_cookies(cookies) - - print("Cookie注入成功") - return True - - except Exception as e: - print(f"Cookie注入失败: {str(e)}") - return False - - async def verify_and_navigate(self, target_page: str = 'creator') -> Dict[str, Any]: - """ - 验证Cookie并跳转到指定页面 - - Args: - target_page: 目标页面类型 ('creator' 或 'home') - - Returns: - 验证结果字典 - """ - try: - if not self.page: - return {"success": False, "error": "浏览器未初始化"} - - # 确定目标URL - urls = { - 'creator': 'https://creator.xiaohongshu.com', - 'home': 'https://www.xiaohongshu.com' - } - target_url = urls.get(target_page, urls['creator']) - page_name = '创作者中心' if target_page == 'creator' else '小红书首页' - - print(f"\n正在访问{page_name}: {target_url}") - - # 访问目标页面 - await self.page.goto(target_url, wait_until='networkidle', timeout=30000) - await asyncio.sleep(2) # 等待页面完全加载 - - # 获取当前URL和标题 - current_url = self.page.url - title = await self.page.title() - - print(f"当前URL: {current_url}") - print(f"页面标题: {title}") - - # 检查是否被重定向到登录页 - is_logged_in = 'login' not in current_url.lower() - - if is_logged_in: - print("Cookie验证成功,已登录状态") - - # 尝试获取用户信息 - try: - # 等待用户相关元素出现(如头像、用户名等) - await self.page.wait_for_selector('[class*="avatar"], [class*="user"]', timeout=5000) - print("检测到用户信息元素,确认登录成功") - except Exception: - print("未检测到明显的用户信息元素,但未跳转到登录页") - - return { - "success": True, - "message": f"Cookie有效,已成功访问{page_name}", - "url": current_url, - "title": title, - "logged_in": True - } - else: - print("Cookie可能已失效,页面跳转到登录页") - return { - "success": False, - "error": "Cookie已失效或无效,页面跳转到登录页", - "url": current_url, - "title": title, - "logged_in": False - } - - except Exception as e: - print(f"验证过程异常: {str(e)}") - import traceback - traceback.print_exc() - return { - "success": False, - "error": f"验证过程异常: {str(e)}" - } - - async def keep_browser_open(self, duration: int = 60): - """ - 保持浏览器打开一段时间,方便观察 - - Args: - duration: 保持打开的秒数,0表示永久打开直到手动关闭 - """ - try: - if duration == 0: - print("\n浏览器将保持打开,按 Ctrl+C 关闭...") - try: - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - print("\n用户中断,准备关闭浏览器...") - else: - print(f"\n浏览器将保持打开 {duration} 秒...") - await asyncio.sleep(duration) - print("时间到,准备关闭浏览器...") - except Exception as e: - print(f"保持浏览器异常: {str(e)}") - - async def close_browser(self): - """关闭浏览器""" - try: - print("\n正在关闭浏览器...") - if self.page: - await self.page.close() - if self.context: - await self.context.close() - if self.browser: - await self.browser.close() - if self.playwright: - await self.playwright.stop() - print("浏览器已关闭") - except Exception as e: - print(f"关闭浏览器异常: {str(e)}") - - -def load_cookies_from_file(file_path: str) -> Optional[List[Dict[str, Any]]]: - """ - 从文件加载Cookie - - Args: - file_path: Cookie文件路径 - - Returns: - Cookie列表,失败返回None - """ - try: - cookie_file = Path(file_path) - if not cookie_file.exists(): - print(f"Cookie文件不存在: {file_path}") - return None - - with open(cookie_file, 'r', encoding='utf-8') as f: - cookies = json.load(f) - - if not isinstance(cookies, list): - print("Cookie格式错误:必须是数组") - return None - - if len(cookies) == 0: - print("Cookie数组为空") - return None - - # 验证每个Cookie必须有name和value - for cookie in cookies: - if not cookie.get('name') or not cookie.get('value'): - print(f"Cookie格式错误:缺少name或value字段") - return None - - print(f"成功加载 {len(cookies)} 个Cookie") - return cookies - - except json.JSONDecodeError as e: - print(f"Cookie文件JSON解析失败: {str(e)}") - return None - except Exception as e: - print(f"加载Cookie文件失败: {str(e)}") - return None - - -async def test_cookie_inject( - cookies_source: str, - target_page: str = 'creator', - headless: bool = False, - keep_open: int = 0 -): - """ - 测试Cookie注入 - - Args: - cookies_source: Cookie来源(文件路径或JSON字符串) - target_page: 目标页面 ('creator' 或 'home') - headless: 是否使用无头模式 - keep_open: 保持浏览器打开的秒数(0表示永久打开) - """ - print("="*60) - print("Cookie注入并验证测试") - print("="*60) - - # 加载Cookie - cookies = None - - # 尝试作为文件路径加载 - if Path(cookies_source).exists(): - print(f"\n从文件加载Cookie: {cookies_source}") - cookies = load_cookies_from_file(cookies_source) - else: - # 尝试作为JSON字符串解析 - try: - print("\n尝试解析Cookie JSON字符串...") - cookies = json.loads(cookies_source) - if isinstance(cookies, list) and len(cookies) > 0: - print(f"成功解析 {len(cookies)} 个Cookie") - except Exception as e: - print(f"Cookie解析失败: {str(e)}") - - if not cookies: - print("\n加载Cookie失败,请检查输入") - return - - # 创建注入器 - injector = CookieInjector(headless=headless) - - try: - # 初始化浏览器 - await injector.init_browser() - - # 注入Cookie - inject_success = await injector.inject_cookies(cookies) - - if not inject_success: - print("\nCookie注入失败") - return - - # 验证并跳转 - result = await injector.verify_and_navigate(target_page) - - print("\n" + "="*60) - print("验证结果") - print("="*60) - - if result.get('success'): - print(f"状态: 成功") - print(f"消息: {result.get('message')}") - print(f"URL: {result.get('url')}") - print(f"标题: {result.get('title')}") - print(f"登录状态: {'已登录' if result.get('logged_in') else '未登录'}") - else: - print(f"状态: 失败") - print(f"错误: {result.get('error')}") - if result.get('url'): - print(f"当前URL: {result.get('url')}") - - # 保持浏览器打开 - if keep_open >= 0: - await injector.keep_browser_open(keep_open) - - except KeyboardInterrupt: - print("\n\n用户中断测试") - except Exception as e: - print(f"\n测试过程异常: {str(e)}") - import traceback - traceback.print_exc() - finally: - await injector.close_browser() - - print("\n" + "="*60) - print("测试完成") - print("="*60) - - -async def main(): - """主函数""" - print("="*60) - print("小红书Cookie注入测试工具") - print("="*60) - - print("\n功能说明:") - print("1. 注入Cookie到浏览器") - print("2. 验证Cookie有效性") - print("3. 跳转到指定页面(创作者中心/小红书首页)") - - print("\n" + "="*60) - - # 输入Cookie来源 - print("\n请输入Cookie来源:") - print("1. 输入Cookie文件路径(如: cookies.json)") - print("2. 直接粘贴JSON格式的Cookie") - - cookies_source = input("\nCookie来源: ").strip() - - if not cookies_source: - print("Cookie来源不能为空") - return - - # 选择目标页面 - print("\n请选择目标页面:") - print("1. 创作者中心(creator.xiaohongshu.com)") - print("2. 小红书首页(www.xiaohongshu.com)") - - page_choice = input("\n选择 (1 或 2, 默认为 1): ").strip() - target_page = 'home' if page_choice == '2' else 'creator' - - # 选择浏览器模式 - headless_choice = input("\n是否使用无头模式?(y/n, 默认为 n): ").strip().lower() - headless = headless_choice == 'y' - - # 选择保持打开时间 - keep_open_input = input("\n保持浏览器打开时间(秒,0表示直到手动关闭,默认60): ").strip() - try: - keep_open = int(keep_open_input) if keep_open_input else 60 - except ValueError: - keep_open = 60 - - # 执行测试 - await test_cookie_inject( - cookies_source=cookies_source, - target_page=target_page, - headless=headless, - keep_open=keep_open - ) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) diff --git a/backend/test_damai_proxy.py b/backend/test_damai_proxy.py deleted file mode 100644 index 8bd56cc..0000000 --- a/backend/test_damai_proxy.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -大麦固定代理IP测试脚本 -测试两个固定代理IP在无头浏览器中的可用性 -""" -import asyncio -import sys -from playwright.async_api import async_playwright - - -# 大麦固定代理IP配置 -DAMAI_PROXIES = [ - { - "name": "大麦代理1", - "server": "http://36.137.177.131:50001", - "username": "qqwvy0", - "password": "mun3r7xz" - }, - { - "name": "大麦代理2", - "server": "http://111.132.40.72:50002", - "username": "ih3z07", - "password": "078bt7o5" - } -] - - -async def test_proxy(proxy_config: dict): - """ - 测试单个代理IP - - Args: - proxy_config: 代理配置字典 - """ - print(f"\n{'='*60}") - print(f"🔍 开始测试: {proxy_config['name']}") - print(f" 代理服务器: {proxy_config['server']}") - print(f" 认证信息: {proxy_config['username']} / {proxy_config['password']}") - print(f"{'='*60}") - - playwright = None - browser = None - - try: - # 启动Playwright - playwright = await async_playwright().start() - print("✅ Playwright启动成功") - - # 配置代理 - proxy_settings = { - "server": proxy_config["server"], - "username": proxy_config["username"], - "password": proxy_config["password"] - } - - # 启动浏览器(带代理) - print(f"🚀 正在启动浏览器(使用代理: {proxy_config['server']})...") - browser = await playwright.chromium.launch( - headless=True, - proxy=proxy_settings, - args=[ - '--disable-blink-features=AutomationControlled', - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-features=IsolateOrigins,site-per-process', - ] - ) - print("✅ 浏览器启动成功") - - # 创建上下文 - context = await browser.new_context( - viewport={'width': 1280, 'height': 720}, - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ) - print("✅ 浏览器上下文创建成功") - - # 创建页面 - page = await context.new_page() - print("✅ 页面创建成功") - - # 测试1: 访问IP检测网站(检查代理IP是否生效) - print("\n📍 测试1: 访问IP检测网站...") - try: - await page.goto("http://httpbin.org/ip", timeout=30000) - await asyncio.sleep(2) - - # 获取页面内容 - content = await page.content() - print("✅ 访问成功,页面内容:") - print(content[:500]) # 只显示前500字符 - - # 尝试提取IP信息 - ip_info = await page.evaluate("() => document.body.innerText") - print(f"\n🌐 当前IP信息:\n{ip_info}") - - except Exception as e: - print(f"❌ 测试1失败: {str(e)}") - - # 测试2: 访问小红书登录页(检查代理在实际场景中是否可用) - print("\n📍 测试2: 访问小红书登录页...") - try: - await page.goto("https://creator.xiaohongshu.com/login", timeout=30000) - await asyncio.sleep(3) - - title = await page.title() - url = page.url - print(f"✅ 访问成功") - print(f" 页面标题: {title}") - print(f" 当前URL: {url}") - - except Exception as e: - print(f"❌ 测试2失败: {str(e)}") - - # 测试3: 访问大麦网(测试目标网站) - print("\n📍 测试3: 访问大麦网...") - try: - await page.goto("https://www.damai.cn/", timeout=30000) - await asyncio.sleep(3) - - title = await page.title() - url = page.url - print(f"✅ 访问成功") - print(f" 页面标题: {title}") - print(f" 当前URL: {url}") - - except Exception as e: - print(f"❌ 测试3失败: {str(e)}") - - print(f"\n✅ {proxy_config['name']} 测试完成") - - except Exception as e: - print(f"\n❌ {proxy_config['name']} 测试失败: {str(e)}") - import traceback - traceback.print_exc() - - finally: - # 清理资源 - try: - if browser: - await browser.close() - print("🧹 浏览器已关闭") - if playwright: - await playwright.stop() - print("🧹 Playwright已停止") - except Exception as e: - print(f"⚠️ 清理资源时出错: {str(e)}") - - -async def test_all_proxies(): - """测试所有代理IP""" - print("\n" + "="*60) - print("🎯 大麦固定代理IP测试") - print("="*60) - print(f"📊 共配置 {len(DAMAI_PROXIES)} 个代理IP") - - # 依次测试每个代理 - for i, proxy_config in enumerate(DAMAI_PROXIES, 1): - print(f"\n\n{'#'*60}") - print(f"# 测试进度: {i}/{len(DAMAI_PROXIES)}") - print(f"{'#'*60}") - - await test_proxy(proxy_config) - - # 测试间隔 - if i < len(DAMAI_PROXIES): - print(f"\n⏳ 等待5秒后测试下一个代理...") - await asyncio.sleep(5) - - print("\n" + "="*60) - print("🎉 所有代理测试完成!") - print("="*60) - - -async def test_single_proxy(index: int = 0): - """ - 测试单个代理IP - - Args: - index: 代理索引(0或1) - """ - if index < 0 or index >= len(DAMAI_PROXIES): - print(f"❌ 无效的代理索引: {index},请使用 0 或 1") - return - - await test_proxy(DAMAI_PROXIES[index]) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 解析命令行参数 - if len(sys.argv) > 1: - try: - proxy_index = int(sys.argv[1]) - print(f"🎯 测试单个代理(索引: {proxy_index})") - asyncio.run(test_single_proxy(proxy_index)) - except ValueError: - print("❌ 参数错误,请使用: python test_damai_proxy.py [0|1]") - print(" 0: 测试代理1") - print(" 1: 测试代理2") - print(" 不带参数: 测试所有代理") - else: - # 测试所有代理 - asyncio.run(test_all_proxies()) diff --git a/backend/test_headless_comparison.py b/backend/test_headless_comparison.py deleted file mode 100644 index e011b46..0000000 --- a/backend/test_headless_comparison.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -对比测试有头模式和无头模式的页面获取情况 -""" -import asyncio -from playwright.async_api import async_playwright -import sys - - -async def test_headless_comparison(proxy_index: int = 0): - """对比测试有头模式和无头模式""" - print(f"\n{'='*60}") - print(f"🔍 对比测试有头模式 vs 无头模式") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - - # 配置代理对象 - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - print(f" 配置的代理对象: {proxy_config_obj}") - - # 测试无头模式 - print(f"\n🧪 测试 1/2: 无头模式 (headless=True)") - await test_single_mode(True, proxy_config_obj) - - print(f"\n🧪 测试 2/2: 有头模式 (headless=False)") - await test_single_mode(False, proxy_config_obj) - - print(f"\n{'='*60}") - print("✅ 对比测试完成!") - print("="*60) - - -async def test_single_mode(headless: bool, proxy_config_obj: dict): - """测试单个模式""" - mode_name = "无头模式" if headless else "有头模式" - print(f" 正在启动浏览器 ({mode_name})...") - - try: - async with async_playwright() as p: - # 启动浏览器 - browser = await p.chromium.launch( - headless=headless, - proxy=proxy_config_obj, - # 添加一些额外参数以提高稳定性 - args=[ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-blink-features=AutomationControlled', - ] - ) - - # 创建上下文 - context = await browser.new_context( - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - viewport={'width': 1280, 'height': 720} - ) - - # 创建页面 - page = await context.new_page() - - # 访问小红书登录页面 - print(f" 访问小红书登录页...") - try: - # 使用不同的wait_until策略 - await page.goto('https://creator.xiaohongshu.com/login', - wait_until='domcontentloaded', - timeout=15000) - - # 等待一段时间让页面内容加载 - await asyncio.sleep(3) - - # 获取页面信息 - title = await page.title() - url = page.url - content = await page.content() - content_len = len(content) - - print(f" ✅ {mode_name} - 访问成功") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - - # 检查关键元素 - phone_input = await page.query_selector('input[placeholder="手机号"]') - if phone_input: - print(f" ✅ 找到手机号输入框") - else: - print(f" ❌ 未找到手机号输入框") - - # 查找所有input元素 - inputs = await page.query_selector_all('input') - print(f" 找到 {len(inputs)} 个input元素") - - if content_len == 0: - print(f" ⚠️ 页面内容为空") - elif "验证" in content or "captcha" in content.lower() or "安全" in content: - print(f" ⚠️ 检测到验证或安全提示") - else: - print(f" ✅ 页面内容正常") - - except Exception as e: - print(f" ❌ {mode_name} - 访问失败: {str(e)}") - - await browser.close() - print(f" 🔄 {mode_name} 浏览器已关闭") - - except Exception as e: - print(f" ❌ {mode_name} - 测试异常: {str(e)}") - - -async def test_with_different_wait_strategies(proxy_index: int = 0): - """测试不同的页面等待策略""" - print(f"\n{'='*60}") - print(f"🔍 测试不同页面等待策略") - print(f"{'='*60}") - - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - wait_strategies = [ - ('domcontentloaded', 'DOM内容加载完成'), - ('load', '页面完全加载'), - ('networkidle', '网络空闲'), - ('commit', '导航提交') - ] - - for wait_strategy, description in wait_strategies: - print(f"\n🧪 测试等待策略: {description} ({wait_strategy})") - - try: - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, # 使用无头模式进行测试 - proxy=proxy_config_obj - ) - - context = await browser.new_context( - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ) - - page = await context.new_page() - - try: - print(f" 访问小红书登录页 (wait_until='{wait_strategy}')...") - await page.goto('https://creator.xiaohongshu.com/login', - wait_until=wait_strategy, - timeout=15000) - - # 额外等待时间 - await asyncio.sleep(2) - - content = await page.content() - content_len = len(content) - - print(f" ✅ 访问成功") - print(f" 内容长度: {content_len} 字符") - - # 检查手机号输入框 - phone_input = await page.query_selector('input[placeholder="手机号"]') - if phone_input: - print(f" ✅ 找到手机号输入框") - else: - print(f" ❌ 未找到手机号输入框") - - except Exception as e: - print(f" ❌ 访问失败: {str(e)}") - - await browser.close() - - except Exception as e: - print(f" ❌ 测试异常: {str(e)}") - - -def explain_page_loading_factors(): - """解释影响页面加载的因素""" - print("="*60) - print("💡 影响页面加载的因素") - print("="*60) - - print("\n1. 浏览器模式差异:") - print(" • 有头模式: 浏览器界面可见,渲染更完整") - print(" • 无头模式: 后台运行,可能加载策略略有不同") - - print("\n2. 页面等待策略:") - print(" • domcontentloaded: DOM构建完成(推荐)") - print(" • load: 所有资源加载完成") - print(" • networkidle: 网络空闲(可能等待较长时间)") - - print("\n3. 反检测措施:") - print(" • 浏览器指纹混淆") - print(" • User-Agent设置") - print(" • 禁用webdriver属性") - - print("\n4. 网络因素:") - print(" • 代理IP质量") - print(" • 网络延迟") - print(" • 目标网站反爬虫机制") - - -async def main(): - """主函数""" - explain_page_loading_factors() - - print(f"\n{'='*60}") - print("🎯 选择测试模式") - print("="*60) - - print("\n1. 有头模式 vs 无头模式对比测试") - print("2. 不同页面等待策略测试") - - try: - choice = input("\n请选择测试模式 (1-2, 默认为1): ").strip() - - if choice not in ['1', '2']: - choice = '1' - - proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - if choice == '1': - await test_headless_comparison(proxy_idx) - elif choice == '2': - await test_with_different_wait_strategies(proxy_idx) - - print(f"\n{'='*60}") - print("✅ 测试完成!") - print("="*60) - - except KeyboardInterrupt: - print("\n\n⚠️ 测试被用户中断") - except Exception as e: - print(f"\n❌ 测试过程中出现错误: {str(e)}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_headless_mode.py b/backend/test_headless_mode.py deleted file mode 100644 index 2861749..0000000 --- a/backend/test_headless_mode.py +++ /dev/null @@ -1,356 +0,0 @@ -""" -使用代理并开启有头模式的示例 -展示如何在使用代理的同时开启浏览器界面 -""" -import asyncio -from playwright.async_api import async_playwright -import sys - - -async def test_proxy_with_headless_false(proxy_index: int = 0): - """使用代理并开启有头模式测试""" - print(f"\n{'='*60}") - print(f"🔍 测试代理 + 有头模式") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - print(f" 有头模式: 开启") - - try: - async with async_playwright() as p: - # 配置代理 - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - print(f" 配置的代理对象: {proxy_config_obj}") - - # 启动浏览器 - 使用有头模式 - browser = await p.chromium.launch( - headless=False, # 有头模式,可以看到浏览器界面 - proxy=proxy_config_obj - ) - - # 创建上下文 - context = await browser.new_context( - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ) - - # 创建页面 - page = await context.new_page() - - print(f"\n🌐 访问百度测试代理连接...") - try: - await page.goto('https://www.baidu.com', wait_until='networkidle', timeout=15000) - await asyncio.sleep(2) - - title = await page.title() - url = page.url - print(f" ✅ 百度访问成功") - print(f" 标题: {title}") - print(f" URL: {url}") - except Exception as e: - print(f" ❌ 百度访问失败: {str(e)}") - - print(f"\n🌐 访问小红书创作者平台...") - try: - await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=15000) - await asyncio.sleep(3) - - title = await page.title() - url = page.url - content_len = len(await page.content()) - - print(f" 访问结果:") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - - if content_len == 0: - print(f" ⚠️ 页面内容为空") - else: - print(f" ✅ 页面加载成功") - - except Exception as e: - print(f" ❌ 小红书访问失败: {str(e)}") - - print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面") - print(f" 代理正在生效,您可以看到浏览器界面") - print(f" 按 Enter 键关闭浏览器...") - - # 等待用户输入 - input() - - await browser.close() - print(f"✅ 浏览器已关闭") - - except Exception as e: - print(f"❌ 测试过程异常: {str(e)}") - import traceback - traceback.print_exc() - - -async def test_xhs_login_with_headless_false(phone: str, proxy_index: int = 0): - """ - 使用有头模式测试小红书登录流程 - - Args: - phone: 手机号 - proxy_index: 代理索引 (0 或 1) - """ - print(f"\n{'='*60}") - print(f"📱 使用有头模式测试小红书登录") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - print(f" 手机号: {phone}") - print(f" 有头模式: 开启") - - # 创建登录服务,使用有头模式 - from xhs_login import XHSLoginService - login_service = XHSLoginService(use_pool=False) # 不使用池,便于调试 - - try: - # 初始化浏览器(使用代理 + 有头模式) - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - - # 注意:XHSLoginService 内部使用了浏览器池模式,我们先看看如何修改它来支持有头模式 - print(" 正在启动浏览器(使用代理 + 有头模式)...") - - # 直接使用Playwright创建有头模式的浏览器 - async with async_playwright() as p: - # 配置代理 - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - # 启动浏览器 - 有头模式 - browser = await p.chromium.launch( - headless=False, # 有头模式 - proxy=proxy_config_obj - ) - - context = await browser.new_context( - user_agent=user_agent, - viewport={'width': 1280, 'height': 720} - ) - - page = await context.new_page() - - print("✅ 浏览器启动成功(有头模式 + 代理)") - - # 访问小红书登录页面 - print(f"\n🌐 访问小红书创作者平台登录页...") - await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=30000) - await asyncio.sleep(2) - - print(f"✅ 进入登录页面") - print(f" 当前URL: {page.url}") - - # 查找手机号输入框 - print(f"\n🔍 查找手机号输入框...") - try: - # 尝试多种选择器 - phone_input_selectors = [ - 'input[placeholder="手机号"]', - 'input[placeholder*="手机"]', - 'input[type="tel"]', - 'input[type="text"]' - ] - - phone_input = None - for selector in phone_input_selectors: - try: - phone_input = await page.wait_for_selector(selector, timeout=3000) - if phone_input: - print(f" ✅ 找到手机号输入框: {selector}") - break - except: - continue - - if phone_input: - # 输入手机号 - await phone_input.fill(phone) - print(f" ✅ 已输入手机号: {phone}") - - # 等待界面更新 - await asyncio.sleep(1) - - # 查找发送验证码按钮 - print(f"\n🔍 查找发送验证码按钮...") - code_button_selectors = [ - 'text="发送验证码"', - 'text="获取验证码"', - 'button:has-text("验证码")', - 'button:has-text("发送")', - 'div:has-text("验证码")' - ] - - code_button = None - for selector in code_button_selectors: - try: - code_button = await page.wait_for_selector(selector, timeout=3000) - if code_button: - print(f" ✅ 找到验证码按钮: {selector}") - break - except: - continue - - if code_button: - print(f"\nℹ️ 已找到手机号输入框和验证码按钮") - print(f" 您可以在浏览器中手动点击发送验证码") - print(f" 验证码将发送到: {phone}") - - print(f"\n⏸️ 浏览器保持打开状态,您可以手动操作") - print(f" 按 Enter 键关闭浏览器...") - input() - else: - print(f" ❌ 未找到发送验证码按钮") - else: - print(f" ❌ 未找到手机号输入框") - print(f"\n📄 页面上可用的输入框:") - inputs = await page.query_selector_all('input') - for i, inp in enumerate(inputs): - try: - placeholder = await inp.get_attribute('placeholder') - input_type = await inp.get_attribute('type') - print(f" 输入框 {i+1}: type={input_type}, placeholder={placeholder}") - except: - continue - - except Exception as e: - print(f" ❌ 操作失败: {str(e)}") - - # 保持浏览器打开供用户观察 - print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面元素") - print(f" 按 Enter 键关闭浏览器...") - input() - - await browser.close() - print(f"✅ 浏览器已关闭") - - except Exception as e: - print(f"❌ 测试过程异常: {str(e)}") - import traceback - traceback.print_exc() - - -def show_headless_comparison(): - """显示有头模式和无头模式的对比""" - print("="*60) - print("💡 有头模式 vs 无头模式对比") - print("="*60) - - print("\n有头模式 (headless=False):") - print(" ✅ 优点:") - print(" • 可以看到浏览器界面,便于调试") - print(" • 可以观察页面加载过程") - print(" • 可以手动与页面交互") - print(" • 有助于识别页面元素选择器") - print("") - print(" ❌ 缺点:") - print(" • 占用屏幕空间") - print(" • 可能影响用户其他操作") - print(" • 资源消耗稍大") - - print("\n无头模式 (headless=True):") - print(" ✅ 优点:") - print(" • 不显示浏览器界面,后台运行") - print(" • 资源消耗较少") - print(" • 适合自动化任务") - print(" • 可以在服务器环境运行") - print("") - print(" ❌ 缺点:") - print(" • 无法直观看到页面") - print(" • 调试相对困难") - - print("\n🎯 使用建议:") - print(" • 开发调试时使用有头模式") - print(" • 生产环境使用无头模式") - print(" • 代理配置在两种模式下都有效") - - -async def main(): - """主函数""" - show_headless_comparison() - - print(f"\n{'='*60}") - print("🎯 选择测试模式") - print("="*60) - - print("\n1. 基础代理 + 有头模式测试") - print("2. 小红书登录 + 有头模式测试") - - try: - choice = input("\n请选择测试模式 (1-2, 默认为1): ").strip() - - if choice not in ['1', '2']: - choice = '1' - - proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - if choice == '1': - await test_proxy_with_headless_false(proxy_idx) - elif choice == '2': - phone = input("请输入手机号: ").strip() - if not phone: - print("❌ 手机号不能为空") - return - await test_xhs_login_with_headless_false(phone, proxy_idx) - - print(f"\n{'='*60}") - print("✅ 测试完成!") - print("="*60) - - except KeyboardInterrupt: - print("\n\n⚠️ 测试被用户中断") - except Exception as e: - print(f"\n❌ 测试过程中出现错误: {str(e)}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_login_flow.py b/backend/test_login_flow.py deleted file mode 100644 index 9623a13..0000000 --- a/backend/test_login_flow.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -小红书验证码登录流程测试脚本 -测试完整的验证码发送和登录流程 -""" -import asyncio -import sys -from xhs_login import XHSLoginService - - -async def test_send_verification_code(phone: str, proxy_index: int = 0): - """ - 测试发送验证码流程 - - Args: - phone: 手机号 - proxy_index: 代理索引 (0 或 1) - """ - print(f"\n{'='*60}") - print(f"📱 测试发送验证码流程") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - print(f" 手机号: {phone}") - - # 创建登录服务 - login_service = XHSLoginService() - - try: - # 初始化浏览器(使用代理) - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) - print("✅ 浏览器初始化成功(已启用代理)") - - # 发送验证码 - print(f"\n📤 正在发送验证码到 {phone}...") - result = await login_service.send_verification_code(phone) - - if result.get('success'): - print(f"✅ 验证码发送成功!") - print(f" 消息: {result.get('message')}") - return login_service # 返回服务实例供后续登录使用 - else: - print(f"❌ 验证码发送失败: {result.get('error')}") - return None - - except Exception as e: - print(f"❌ 发送验证码过程异常: {str(e)}") - import traceback - traceback.print_exc() - return None - - -async def test_login_with_code(login_service: XHSLoginService, phone: str, code: str): - """ - 测试使用验证码登录 - - Args: - login_service: XHSLoginService实例 - phone: 手机号 - code: 验证码 - """ - print(f"\n{'='*60}") - print(f"🔑 测试使用验证码登录") - print(f"{'='*60}") - - print(f" 手机号: {phone}") - print(f" 验证码: {code}") - - try: - # 执行登录 - result = await login_service.login(phone, code) - - if result.get('success'): - print("✅ 登录成功!") - - # 显示获取到的Cookies信息 - cookies = result.get('cookies', {}) - print(f" 获取到 {len(cookies)} 个Cookie") - - # 保存完整Cookies到文件 - cookies_full = result.get('cookies_full', []) - if cookies_full: - import json - with open('cookies.json', 'w', encoding='utf-8') as f: - json.dump(cookies_full, f, ensure_ascii=False, indent=2) - print(" ✅ 已保存完整Cookies到 cookies.json") - - # 显示用户信息 - user_info = result.get('user_info', {}) - if user_info: - print(f" 用户信息: {list(user_info.keys())}") - - return result - else: - print(f"❌ 登录失败: {result.get('error')}") - return result - - except Exception as e: - print(f"❌ 登录过程异常: {str(e)}") - import traceback - traceback.print_exc() - return {"success": False, "error": str(e)} - - -async def test_complete_login_flow(phone: str, code: str = None, proxy_index: int = 0): - """ - 测试完整的登录流程 - - Args: - phone: 手机号 - code: 验证码(如果为None,则只测试发送验证码) - proxy_index: 代理索引 - """ - print("="*60) - print("🔄 测试完整登录流程") - print("="*60) - - # 步骤1: 发送验证码 - print("\n📋 步骤1: 发送验证码") - login_service = await test_send_verification_code(phone, proxy_index) - - if not login_service: - print("❌ 发送验证码失败,终止流程") - return - - # 如果提供了验证码,则执行登录 - if code: - print("\n📋 步骤2: 使用验证码登录") - result = await test_login_with_code(login_service, phone, code) - - if result.get('success'): - print("\n🎉 完整登录流程成功!") - else: - print(f"\n❌ 完整登录流程失败: {result.get('error')}") - else: - print("\n⚠️ 提供了验证码参数才可完成登录步骤") - print(" 请在手机上查看验证码,然后调用登录方法") - - # 清理资源 - await login_service.close_browser() - - -async def test_multiple_proxies_login(phone: str, proxy_indices: list = [0, 1]): - """ - 测试使用多个代理进行登录 - - Args: - phone: 手机号 - proxy_indices: 代理索引列表 - """ - print("="*60) - print("🔄 测试多代理登录") - print("="*60) - - for i, proxy_idx in enumerate(proxy_indices): - print(f"\n🧪 测试代理 {proxy_idx + 1} (第 {i+1} 次尝试)") - - # 由于验证码只能发送一次,这里只测试发送验证码 - login_service = await test_send_verification_code(phone, proxy_idx) - - if login_service: - print(f" ✅ 代理 {proxy_idx + 1} 发送验证码成功") - await login_service.close_browser() - else: - print(f" ❌ 代理 {proxy_idx + 1} 发送验证码失败") - - # 在测试之间添加延迟 - if i < len(proxy_indices) - 1: - print(" ⏳ 等待3秒后测试下一个代理...") - await asyncio.sleep(3) - - -def show_usage_examples(): - """显示使用示例""" - print("="*60) - print("💡 使用示例") - print("="*60) - - print("\n1️⃣ 仅发送验证码:") - print(" # 发送验证码到手机号,使用代理1") - print(" await test_send_verification_code('13800138000', proxy_index=0)") - - print("\n2️⃣ 完整登录流程:") - print(" # 完整流程:发送验证码 + 登录") - print(" await test_complete_login_flow('13800138000', '123456', proxy_index=0)") - - print("\n3️⃣ 多代理测试:") - print(" # 测试多个代理") - print(" await test_multiple_proxies_login('13800138000', [0, 1])") - - -async def main(): - """主函数""" - show_usage_examples() - - print(f"\n{'='*60}") - print("🎯 选择测试模式") - print("="*60) - - print("\n1. 发送验证码测试") - print("2. 完整登录流程测试") - print("3. 多代理测试") - - try: - choice = input("\n请选择测试模式 (1-3, 默认为1): ").strip() - - if choice not in ['1', '2', '3']: - choice = '1' - - phone = input("请输入手机号: ").strip() - - if not phone: - print("❌ 手机号不能为空") - return - - if choice == '1': - proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - await test_send_verification_code(phone, proxy_idx) - - elif choice == '2': - code = input("请输入验证码 (留空则只测试发送): ").strip() - proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - await test_complete_login_flow(phone, code if code else None, proxy_idx) - - elif choice == '3': - await test_multiple_proxies_login(phone) - - print(f"\n{'='*60}") - print("✅ 测试完成!") - print("="*60) - - except KeyboardInterrupt: - print("\n\n⚠️ 测试被用户中断") - except Exception as e: - print(f"\n❌ 测试过程中出现错误: {str(e)}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_login_page_config.py b/backend/test_login_page_config.py deleted file mode 100644 index 4d5c965..0000000 --- a/backend/test_login_page_config.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -测试登录页面配置功能 -验证通过配置文件控制登录页面类型(creator vs home) -""" -import sys -from config import load_config - -def test_config_reading(): - """测试配置读取""" - print("="*60) - print("测试配置文件读取") - print("="*60) - - # 测试dev配置 - print("\n1. 测试开发环境配置 (config.dev.yaml)") - config_dev = load_config('dev') - login_page = config_dev.get_str('login.page', 'creator') - login_headless = config_dev.get_bool('login.headless', False) - - print(f" login.page = {login_page}") - print(f" login.headless = {login_headless}") - - # 根据配置决定预热URL - if login_page == "home": - preheat_url = "https://www.xiaohongshu.com" - else: - preheat_url = "https://creator.xiaohongshu.com/login" - - print(f" 预热URL = {preheat_url}") - - # 测试prod配置 - print("\n2. 测试生产环境配置 (config.prod.yaml)") - config_prod = load_config('prod') - login_page_prod = config_prod.get_str('login.page', 'creator') - login_headless_prod = config_prod.get_bool('login.headless', False) - - print(f" login.page = {login_page_prod}") - print(f" login.headless = {login_headless_prod}") - - if login_page_prod == "home": - preheat_url_prod = "https://www.xiaohongshu.com" - else: - preheat_url_prod = "https://creator.xiaohongshu.com/login" - - print(f" 预热URL = {preheat_url_prod}") - - print("\n" + "="*60) - print("✅ 配置读取测试完成") - print("="*60) - - -def test_api_parameter_override(): - """测试API参数覆盖配置""" - print("\n" + "="*60) - print("测试API参数覆盖配置") - print("="*60) - - config = load_config('dev') - default_login_page = config.get_str('login.page', 'creator') - - # 模拟不同的API参数情况 - test_cases = [ - (None, "应使用配置默认值"), - ("creator", "API指定creator"), - ("home", "API指定home"), - ] - - for api_param, description in test_cases: - login_page = api_param if api_param else default_login_page - print(f"\n场景: {description}") - print(f" 配置默认值 = {default_login_page}") - print(f" API参数 = {api_param}") - print(f" 最终使用 = {login_page}") - - # 决定URL - if login_page == "home": - url = "https://www.xiaohongshu.com" - page_name = "小红书首页" - else: - url = "https://creator.xiaohongshu.com/login" - page_name = "创作者中心" - - print(f" → 将访问: {page_name} ({url})") - - print("\n" + "="*60) - print("✅ API参数覆盖测试完成") - print("="*60) - - -if __name__ == "__main__": - try: - test_config_reading() - test_api_parameter_override() - - print("\n🎉 所有测试通过!") - print("\n使用说明:") - print("1. 在 config.dev.yaml 或 config.prod.yaml 中修改 login.page 配置") - print("2. 可选值: creator (创作者中心) 或 home (小红书首页)") - print("3. API请求中的 login_page 参数可以覆盖配置文件的默认值") - print("4. 如果API请求不传 login_page 参数,将使用配置文件中的默认值") - - except Exception as e: - print(f"\n❌ 测试失败: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/backend/test_network_images.py b/backend/test_network_images.py deleted file mode 100644 index d3a5ae3..0000000 --- a/backend/test_network_images.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -测试网络图片下载功能 -""" -import asyncio -import json -from xhs_publish import XHSPublishService - - -async def test_network_images(): - """测试网络图片功能""" - - print("="*50) - print("网络图片下载功能测试") - print("="*50) - print() - - # 1. 准备测试 Cookie(从 cookies.json 读取) - try: - with open('cookies.json', 'r', encoding='utf-8') as f: - cookies = json.load(f) - print(f"✅ 成功读取 {len(cookies)} 个 Cookie") - except FileNotFoundError: - print("❌ cookies.json 文件不存在") - print("请先运行登录获取 Cookie:") - print(" python xhs_cli.py login <手机号> <验证码>") - return - - # 2. 准备测试数据 - title = "【测试】网络图片发布测试" - content = """测试使用网络 URL 图片发布笔记 📸 - -本次测试使用了: -✅ 网络 URL 图片(picsum.photos) -✅ 自动下载功能 -✅ 临时文件管理 - -如果你看到这条笔记,说明网络图片功能正常!""" - - # 3. 使用网络图片 URL - images = [ - "https://picsum.photos/800/600?random=test1", - "https://picsum.photos/800/600?random=test2", - "https://picsum.photos/800/600?random=test3" - ] - - print(f"\n测试图片 URL:") - for i, url in enumerate(images, 1): - print(f" {i}. {url}") - - tags = ["测试", "网络图片", "自动发布"] - - # 4. 创建发布服务 - print("\n开始测试发布...") - publisher = XHSPublishService(cookies) - - # 5. 执行发布 - result = await publisher.publish( - title=title, - content=content, - images=images, - tags=tags, - cleanup=True # 自动清理临时文件 - ) - - # 6. 显示结果 - print("\n" + "="*50) - print("测试结果:") - print(json.dumps(result, ensure_ascii=False, indent=2)) - print("="*50) - - if result.get('success'): - print("\n✅ 测试成功!网络图片功能正常") - if 'url' in result: - print(f"📎 笔记链接: {result['url']}") - else: - print(f"\n❌ 测试失败: {result.get('error')}") - - -if __name__ == "__main__": - asyncio.run(test_network_images()) diff --git a/backend/test_optimized_browser.py b/backend/test_optimized_browser.py deleted file mode 100644 index f224493..0000000 --- a/backend/test_optimized_browser.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -优化的代理浏览器配置 -解决小红书对代理IP的限制问题 -""" -import asyncio -from playwright.async_api import async_playwright -import sys - - -async def test_optimized_proxy_browser(proxy_index: int = 0): - """测试优化的代理浏览器配置""" - print(f"\n{'='*60}") - print(f"🚀 测试优化的代理浏览器配置") - print(f"{'='*60}") - - # 从代理配置获取代理信息 - from damai_proxy_config import get_proxy_config - proxy_config = get_proxy_config(proxy_index) - proxy_server = proxy_config['server'].replace('http://', '') - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" - - print(f"✅ 使用代理: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - - try: - async with async_playwright() as p: - # 配置代理 - proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') - if len(proxy_parts) == 2: - auth_part = proxy_parts[0] - server_part = proxy_parts[1] - username, password = auth_part.split(':') - - proxy_config_obj = { - "server": f"http://{server_part}", - "username": username, - "password": password - } - else: - proxy_config_obj = {"server": proxy_url} - - print(f" 配置的代理对象: {proxy_config_obj}") - - # 启动浏览器 - 使用优化参数 - browser = await p.chromium.launch( - headless=False, # 使用有头模式,便于观察 - proxy=proxy_config_obj, - args=[ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-blink-features=AutomationControlled', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-background-networking', - '--enable-features=NetworkService,NetworkServiceInProcess', - '--disable-ipc-flooding-protection', - '--disable-web-security', - '--disable-features=IsolateOrigins,site-per-process', - '--disable-site-isolation-trials', - '--disable-extensions', - '--disable-breakpad', - '--disable-component-extensions-with-background-pages', - '--disable-hang-monitor', - '--disable-prompt-on-repost', - '--disable-domain-reliability', - '--disable-component-update', - '--hide-scrollbars', - '--mute-audio', - '--no-first-run', - '--no-default-browser-check', - '--metrics-recording-only', - '--force-color-profile=srgb', - '--disable-default-apps', - '--disable-features=TranslateUI', - '--disable-features=Translate', - '--disable-features=OptimizationHints', - '--disable-features=InterestCohortAPI', - '--disable-features=BlinkGenPropertyTrees', - '--disable-features=ImprovedCookieControls', - '--disable-features=SameSiteDefaultChecksMethodRigorously', - '--disable-features=CookieSameSiteByDefaultWhenReportingEnabled', - '--disable-features=AutofillServerCommunication', - '--disable-features=AutofillUseOptimizedLocalStorage', - '--disable-features=CalculateNativeWinOcclusion', - '--disable-features=VizDisplayCompositor', - '--disable-features=VizHitTestQuery', - ] - ) - - # 创建上下文 - 设置浏览器指纹混淆 - context = await browser.new_context( - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - viewport={'width': 1280, 'height': 720}, - # 隐瞒自动化特征 - bypass_csp=True, - java_script_enabled=True, - ) - - # 创建页面 - page = await context.new_page() - - # 隐瞒自动化特征 - await page.add_init_script(""" - Object.defineProperty(navigator, 'webdriver', { - get: () => undefined, - }); - - Object.defineProperty(navigator, 'plugins', { - get: () => [1, 2, 3, 4, 5], - }); - - Object.defineProperty(navigator, 'languages', { - get: () => ['zh-CN', 'zh', 'en'], - }); - - // 隐瞒代理检测 - delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array; - delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise; - delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol; - """) - - print(f"\n🌐 访问百度测试代理连接...") - try: - await page.goto('https://www.baidu.com', wait_until='domcontentloaded', timeout=15000) - await asyncio.sleep(2) - - title = await page.title() - url = page.url - print(f" ✅ 百度访问成功") - print(f" 标题: {title}") - print(f" URL: {url}") - except Exception as e: - print(f" ❌ 百度访问失败: {str(e)}") - - print(f"\n🌐 访问小红书创作者平台...") - try: - await page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) - await asyncio.sleep(3) # 等待更长时间 - - title = await page.title() - url = page.url - content = await page.content() - content_len = len(content) - - print(f" 访问结果:") - print(f" 标题: {title}") - print(f" URL: {url}") - print(f" 内容长度: {content_len} 字符") - - if content_len == 0: - print(f" ⚠️ 页面内容为空") - elif "验证" in content or "captcha" in content.lower() or "安全" in content: - print(f" ⚠️ 检测到验证或安全提示") - else: - print(f" ✅ 页面加载成功") - - # 查找手机号输入框 - print(f"\n🔍 查找手机号输入框...") - try: - phone_input = await page.wait_for_selector('input[placeholder="手机号"]', timeout=5000) - if phone_input: - print(f" ✅ 找到手机号输入框") - else: - print(f" ❌ 未找到手机号输入框") - except: - print(f" ❌ 未找到手机号输入框") - - # 查找所有input元素 - inputs = await page.query_selector_all('input') - print(f" 找到 {len(inputs)} 个input元素") - - # 查找发送验证码按钮 - print(f"\n🔍 查找发送验证码按钮...") - try: - code_button = await page.wait_for_selector('text="发送验证码"', timeout=5000) - if code_button: - print(f" ✅ 找到发送验证码按钮") - else: - print(f" ❌ 未找到发送验证码按钮") - except: - print(f" ❌ 未找到发送验证码按钮") - - except Exception as e: - print(f" ❌ 小红书访问失败: {str(e)}") - - print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面") - print(f" 按 Enter 键关闭浏览器...") - input() - - await browser.close() - print(f"✅ 浏览器已关闭") - - except Exception as e: - print(f"❌ 测试过程异常: {str(e)}") - import traceback - traceback.print_exc() - - -def explain_optimizations(): - """解释优化措施""" - print("="*60) - print("🔧 优化措施说明") - print("="*60) - - print("\n1. 浏览器启动参数优化:") - print(" • 添加更多反检测参数") - print(" • 禁用可能导致检测的功能") - - print("\n2. 浏览器指纹混淆:") - print(" • 隐瞒webdriver特征") - print(" • 伪造插件列表") - print(" • 设置真实语言") - - print("\n3. 页面加载策略:") - print(" • 使用domcontentloaded而非networkidle") - print(" • 增加超时时间") - - -async def main(): - """主函数""" - explain_optimizations() - - print(f"\n{'='*60}") - print("🎯 选择代理进行测试") - print("="*60) - - proxy_choice = input("\n请选择代理 (0 或 1, 默认为0): ").strip() - if proxy_choice not in ['0', '1']: - proxy_choice = '0' - proxy_idx = int(proxy_choice) - - await test_optimized_proxy_browser(proxy_idx) - - print(f"\n{'='*60}") - print("✅ 测试完成!") - print("="*60) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_oss.py b/backend/test_oss.py deleted file mode 100644 index 03799a7..0000000 --- a/backend/test_oss.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -测试OSS上传功能 -""" -import sys -from oss_utils import OSSUploader - -def test_oss_connection(): - """测试OSS连接""" - print("=" * 60) - print("测试阿里云OSS连接") - print("=" * 60) - - try: - # 创建OSS上传器 - uploader = OSSUploader() - - print(f"\n✅ OSS配置:") - print(f" Bucket: {uploader.bucket_name}") - print(f" Endpoint: {uploader.endpoint}") - print(f" Access Key ID: {uploader.access_key_id[:8]}...") - - # 测试Bucket是否可访问 - try: - # 列出bucket中的对象(最多1个) - result = uploader.bucket.list_objects(prefix=uploader.base_path, max_keys=1) - print(f"\n✅ Bucket访问成功!") - print(f" 基础路径: {uploader.base_path}") - - if result.object_list: - print(f" 示例文件: {result.object_list[0].key}") - - except Exception as e: - print(f"\n❌ Bucket访问失败: {e}") - return False - - print("\n" + "=" * 60) - print("✅ OSS配置测试通过!") - print("=" * 60) - return True - - except Exception as e: - print(f"\n❌ OSS初始化失败: {e}") - print("\n请检查配置:") - print(" 1. Access Key ID和Secret是否正确") - print(" 2. Bucket名称是否正确") - print(" 3. Endpoint地区是否匹配") - return False - -if __name__ == "__main__": - success = test_oss_connection() - sys.exit(0 if success else 1) diff --git a/backend/test_password_hash.py b/backend/test_password_hash.py deleted file mode 100644 index 77826c1..0000000 --- a/backend/test_password_hash.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import hashlib - -passwords = [ - "123456", - "password", - "admin123", -] - -print("=== Python SHA256 密码加密测试 ===") -for pwd in passwords: - hash_result = hashlib.sha256(pwd.encode('utf-8')).hexdigest() - print(f"密码: {pwd}") - print(f"SHA256: {hash_result}\n") diff --git a/backend/test_proxy_connectivity.py b/backend/test_proxy_connectivity.py deleted file mode 100644 index 965dea5..0000000 --- a/backend/test_proxy_connectivity.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -""" -固定代理IP测试脚本 -使用requests请求代理服务器,验证代理是否可用 -""" - -import requests -import json -from damai_proxy_config import get_proxy_config, get_all_enabled_proxies - - -def test_proxy_requests(proxy_info, target_url="http://httpbin.org/ip"): - """ - 使用requests测试代理IP - - Args: - proxy_info: 代理信息字典,包含server, username, password - target_url: 目标测试URL - """ - print(f"\n{'='*60}") - print(f"🔍 测试代理: {proxy_info.get('name', 'Unknown')}") - print(f" 服务器: {proxy_info['server']}") - print(f" 用户名: {proxy_info['username']}") - print(f" 目标URL: {target_url}") - print(f"{'='*60}") - - # 构建代理认证信息 - proxy_server = proxy_info['server'].replace('http://', '') - proxy_url = f"http://{proxy_info['username']}:{proxy_info['password']}@{proxy_server}" - - proxies = { - "http": proxy_url, - "https": proxy_url - } - - try: - # 发送测试请求 - print("🚀 发送测试请求...") - response = requests.get(target_url, proxies=proxies, timeout=5) # 减少超时时间到5秒 - - if response.status_code == 200: - print(f"✅ 代理测试成功!状态码: {response.status_code}") - - # 尝试解析IP信息 - try: - ip_info = response.json() - print(f"🌐 当前IP信息: {json.dumps(ip_info, indent=2, ensure_ascii=False)}") - except: - print(f"🌐 页面内容 (前500字符): {response.text[:500]}") - - return True - else: - print(f"❌ 代理测试失败!状态码: {response.status_code}") - print(f"响应内容: {response.text[:200]}") - return False - - except requests.exceptions.ProxyError: - print("❌ 代理连接错误:无法连接到代理服务器") - return False - except requests.exceptions.ConnectTimeout: - print("❌ 连接超时:代理服务器响应超时") - return False - except requests.exceptions.RequestException as e: - print(f"❌ 请求异常: {str(e)}") - return False - - -def test_all_proxies(): - """测试所有配置的代理""" - print("🎯 开始测试所有代理IP") - - proxies = get_all_enabled_proxies() - - if not proxies: - print("❌ 没有找到可用的代理配置") - return - - print(f"📊 共找到 {len(proxies)} 个代理IP") - - results = [] - for i, proxy in enumerate(proxies, 1): - print(f"\n\n{'#'*60}") - print(f"# 测试进度: {i}/{len(proxies)}") - print(f"{'#'*60}") - - success = test_proxy_requests(proxy) - results.append({ - 'proxy': proxy['name'], - 'server': proxy['server'], - 'success': success - }) - - if i < len(proxies): - print(f"\n⏳ 等待2秒后测试下一个代理...") - import time - time.sleep(2) - - # 输出测试结果汇总 - print(f"\n{'='*60}") - print("📊 测试结果汇总:") - print(f"{'='*60}") - - success_count = 0 - for result in results: - status = "✅ 成功" if result['success'] else "❌ 失败" - print(f" {result['proxy']} ({result['server']}) - {status}") - if result['success']: - success_count += 1 - - print(f"\n📈 总体成功率: {success_count}/{len(results)} ({success_count/len(results)*100:.1f}%)") - - # 如果有成功的代理,显示可用于小红书的代理 - successful_proxies = [r for r in results if r['success']] - if successful_proxies: - print(f"\n🎉 以下代理可用于小红书登录发文:") - for proxy in successful_proxies: - print(f" - {proxy['proxy']}: {proxy['server']}") - - return results - - -def test_xhs_proxy_format(): - """测试适用于小红书的代理格式""" - print(f"\n{'='*60}") - print("🔧 测试适用于Playwright的代理格式") - print(f"{'='*60}") - - proxies = get_all_enabled_proxies() - - for proxy in proxies: - server = proxy['server'].replace('http://', '') # 移除http://前缀 - proxy_url = f"http://{proxy['username']}:{proxy['password']}@{server}" - print(f" {proxy['name']}:") - print(f" 服务器地址: {proxy['server']}") - print(f" Playwright格式: {proxy_url}") - print() - - -if __name__ == "__main__": - print("🚀 开始测试固定代理IP") - - # 测试代理格式 - test_xhs_proxy_format() - - # 测试所有代理 - test_all_proxies() - - print(f"\n{'='*60}") - print("🎉 代理测试完成!") - print(f"{'='*60}") \ No newline at end of file diff --git a/backend/test_proxy_detailed.py b/backend/test_proxy_detailed.py deleted file mode 100644 index e04649c..0000000 --- a/backend/test_proxy_detailed.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -固定代理IP详细测试脚本 -测试代理IP在Playwright中的表现,包含更多调试信息 -""" -import asyncio -import json -import sys -from xhs_login import XHSLoginService -from damai_proxy_config import get_proxy_config - - -async def test_proxy_detailed(proxy_index: int = 0): - """详细测试代理IP""" - print(f"\n{'='*60}") - print(f"🔍 详细测试代理: 代理{proxy_index + 1}") - print(f"{'='*60}") - - # 获取代理配置 - try: - proxy_config = get_proxy_config(proxy_index) - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 - print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - except Exception as e: - print(f"❌ 获取代理配置失败: {str(e)}") - return None - - # 创建登录服务实例 - login_service = XHSLoginService(use_pool=False) # 不使用池,便于调试 - - try: - # 初始化浏览器(使用代理) - print(f"\n🚀 正在启动浏览器(使用代理)...") - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) - print("✅ 浏览器启动成功") - - # 测试访问普通网站 - print(f"\n📍 测试访问普通网站(百度)...") - try: - await login_service.page.goto('https://www.baidu.com', wait_until='networkidle', timeout=10000) - await asyncio.sleep(2) - title = await login_service.page.title() - url = login_service.page.url - print(f"✅ 百度访问成功") - print(f" 页面标题: {title}") - print(f" 当前URL: {url}") - except Exception as e: - print(f"❌ 百度访问失败: {str(e)}") - - # 测试访问IP检测网站 - print(f"\n📍 测试访问IP检测网站...") - try: - await login_service.page.goto('http://httpbin.org/ip', wait_until='networkidle', timeout=10000) - await asyncio.sleep(2) - content = await login_service.page.content() - print(f"✅ IP检测网站访问成功") - print(f" 页面内容: {content[:200]}...") - except Exception as e: - print(f"❌ IP检测网站访问失败: {str(e)}") - - # 测试访问小红书创作者平台 - print(f"\n📍 测试访问小红书创作者平台...") - try: - await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=20000) # 增加超时时间 - await asyncio.sleep(3) # 等待更长时间 - - title = await login_service.page.title() - url = login_service.page.url - print(f"✅ 小红书访问成功") - print(f" 页面标题: '{title}'") - print(f" 当前URL: {url}") - - # 检查页面内容 - content = await login_service.page.content() - if "验证" in content or "captcha" in content.lower() or "block" in content.lower() or "安全验证" in content: - print("⚠️ 检测到可能的验证或拦截") - else: - print("✅ 未检测到验证拦截") - - except Exception as e: - print(f"❌ 小红书访问失败: {str(e)}") - # 尝试访问普通页面看看是否完全被封 - try: - await login_service.page.goto('https://www.google.com', wait_until='networkidle', timeout=10000) - print(" 提示: 代理可以访问其他网站,但可能被小红书限制") - except Exception: - print(" 提示: 代理可能完全被限制") - - print(f"\n✅ 代理{proxy_index + 1} 详细测试完成") - return login_service - - except Exception as e: - print(f"❌ 代理{proxy_index + 1} 详细测试失败: {str(e)}") - import traceback - traceback.print_exc() - return None - finally: - # 关闭浏览器 - await login_service.close_browser() - - -async def main(): - """主测试函数""" - print("\n" + "="*60) - print("🎯 固定代理IP详细测试") - print("="*60) - - # 测试两个代理 - for i in range(2): - await test_proxy_detailed(i) - print(f"\n⏳ 等待3秒后测试下一个代理...") - await asyncio.sleep(3) - - print(f"\n{'='*60}") - print("🎉 详细测试完成!") - print("="*60) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_proxy_xhs.py b/backend/test_proxy_xhs.py deleted file mode 100644 index 320b0ae..0000000 --- a/backend/test_proxy_xhs.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -固定代理IP下小红书登录发文功能测试脚本 -测试使用固定代理IP进行小红书登录和发文功能 -""" -import asyncio -import json -import sys -from xhs_login import XHSLoginService -from xhs_publish import XHSPublishService -from damai_proxy_config import get_proxy_config - - -async def test_login_with_proxy(proxy_index: int = 0): - """使用指定代理测试小红书登录""" - print(f"\n{'='*60}") - print(f"🔍 开始测试代理登录: 代理{proxy_index + 1}") - print(f"{'='*60}") - - # 获取代理配置 - try: - proxy_config = get_proxy_config(proxy_index) - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 - print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - except Exception as e: - print(f"❌ 获取代理配置失败: {str(e)}") - return None - - # 创建登录服务实例 - login_service = XHSLoginService() - - try: - # 初始化浏览器(使用代理) - print(f"\n🚀 正在启动浏览器(使用代理)...") - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) - print("✅ 浏览器启动成功") - - # 访问小红书创作者平台 - print(f"\n📍 访问小红书创作者平台...") - await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=30000) - await asyncio.sleep(2) - - title = await login_service.page.title() - url = login_service.page.url - print(f"✅ 访问成功") - print(f" 页面标题: {title}") - print(f" 当前URL: {url}") - - # 检查是否被代理拦截或出现验证码 - content = await login_service.page.content() - if "验证" in content or "captcha" in content.lower() or "block" in content.lower(): - print("⚠️ 检测到可能的验证或拦截") - - print(f"\n✅ 代理{proxy_index + 1} 连接测试完成") - return login_service - - except Exception as e: - print(f"❌ 代理{proxy_index + 1} 测试失败: {str(e)}") - import traceback - traceback.print_exc() - return None - finally: - # 注意:这里不关闭浏览器,让调用者决定何时关闭 - pass - - -async def test_publish_with_proxy(cookies, proxy_index: int = 0): - """使用指定代理测试小红书发文""" - print(f"\n{'='*60}") - print(f"📝 开始测试代理发文: 代理{proxy_index + 1}") - print(f"{'='*60}") - - # 获取代理配置 - try: - proxy_config = get_proxy_config(proxy_index) - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 - print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") - print(f" 代理服务器: {proxy_config['server']}") - except Exception as e: - print(f"❌ 获取代理配置失败: {str(e)}") - return None - - # 准备测试数据 - title = "【代理测试】固定IP代理发布测试" - content = """这是一条通过固定IP代理发布的测试笔记 📝 - -测试内容: -- 验证代理IP是否正常工作 -- 检查发布功能是否正常 -- 确认网络连接稳定性 - -如果你看到这条笔记,说明代理发布成功了! - -#代理测试 #自动化发布 #网络测试""" - - # 测试图片(可选) - images = [] # 可以添加图片路径进行测试 - - # 标签 - tags = ["代理测试", "自动化发布", "网络测试"] - - try: - # 创建发布服务 - print(f"\n🚀 创建发布服务(使用代理: 代理{proxy_index + 1})...") - publisher = XHSPublishService(cookies, proxy=proxy_url) - - # 执行发布 - print(f"\n📤 开始发布笔记...") - result = await publisher.publish( - title=title, - content=content, - images=images if images else None, - tags=tags - ) - - # 显示结果 - print(f"\n{'='*50}") - print("发布结果:") - print(json.dumps(result, ensure_ascii=False, indent=2)) - print("="*50) - - if result.get('success'): - print(f"\n✅ 代理{proxy_index + 1} 发布测试成功!") - if 'url' in result: - print(f"📎 笔记链接: {result['url']}") - else: - print(f"\n❌ 代理{proxy_index + 1} 发布测试失败: {result.get('error')}") - - return result - - except Exception as e: - print(f"❌ 代理{proxy_index + 1} 发布测试异常: {str(e)}") - import traceback - traceback.print_exc() - return None - - -async def main(): - """主测试函数""" - print("\n" + "="*60) - print("🎯 固定代理IP下小红书登录发文功能测试") - print("="*60) - - # 测试代理连接 - login_service = None - for i in range(2): # 测试两个代理 - login_service = await test_login_with_proxy(i) - if login_service: - print(f"✅ 代理{i+1} 连接测试成功,可以用于后续操作") - break - else: - print(f"⚠️ 代理{i+1} 连接测试失败,尝试下一个代理...") - - if not login_service: - print("\n❌ 所有代理都无法连接,测试终止") - return - - try: - # 验证登录状态(虽然我们没有真正的登录,但可以检查Cookie是否有效) - print(f"\n🔍 验证当前浏览器状态...") - verify_result = await login_service.verify_login_status() - print(f"验证结果: {verify_result.get('message', '未知状态')}") - except Exception as e: - print(f"验证状态时出错: {str(e)}") - - # 如果有cookies.json文件,可以尝试使用已保存的cookies进行发布测试 - cookies = None - try: - with open('cookies.json', 'r', encoding='utf-8') as f: - cookies = json.load(f) - print(f"\n✅ 成功读取 cookies.json,包含 {len(cookies)} 个Cookie") - except FileNotFoundError: - print(f"\n⚠️ cookies.json 文件不存在,跳过发布测试") - print(" 如需测试发布功能,请先登录获取Cookie") - - if cookies: - # 使用第一个有效的代理进行发布测试 - for i in range(2): - proxy_config = get_proxy_config(i) - proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" - - # 测试代理连接 - temp_login = XHSLoginService() - try: - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - await temp_login.init_browser(cookies=cookies, proxy=proxy_url, user_agent=user_agent) - - # 验证登录状态 - verify_result = await temp_login.verify_login_status() - if verify_result.get('logged_in'): - print(f"\n✅ 代理{i+1} + Cookie 组合验证成功,开始发布测试") - await test_publish_with_proxy(cookies, i) - break - else: - print(f"⚠️ 代理{i+1} + Cookie 组合验证失败") - except Exception as e: - print(f"⚠️ 代理{i+1} 连接测试失败: {str(e)}") - finally: - await temp_login.close_browser() - else: - print("\n❌ 所有代理都无法与Cookie配合使用,发布测试终止") - - # 清理资源 - if login_service: - await login_service.close_browser() - - print(f"\n{'='*60}") - print("🎉 测试完成!") - print("="*60) - - -if __name__ == "__main__": - # Windows环境下设置事件循环策略 - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - # 运行测试 - asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_publish.py b/backend/test_publish.py deleted file mode 100644 index 1186a0b..0000000 --- a/backend/test_publish.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -小红书发布功能测试脚本 -快速测试发布功能是否正常工作 -""" -import asyncio -import json -import os -from xhs_publish import XHSPublishService - - -async def test_publish(): - """测试发布功能""" - - # 1. 从 cookies.json 读取 Cookie - try: - with open('cookies.json', 'r', encoding='utf-8') as f: - cookies = json.load(f) - print(f"✅ 成功读取 {len(cookies)} 个 Cookie") - except FileNotFoundError: - print("❌ cookies.json 文件不存在") - print("请先运行登录获取 Cookie:") - print(" python xhs_cli.py login <手机号> <验证码>") - return - except Exception as e: - print(f"❌ 读取 cookies.json 失败: {e}") - return - - # 2. 准备测试数据 - title = "【测试】小红书发布功能测试" - content = """这是一条测试笔记 📝 - -今天测试一下自动发布功能是否正常~ - -如果你看到这条笔记,说明发布成功了! - -#测试 #自动化""" - - # 3. 准备测试图片(可选) - images = [] - test_image_dir = "temp_uploads" - if os.path.exists(test_image_dir): - for file in os.listdir(test_image_dir): - if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')): - img_path = os.path.abspath(os.path.join(test_image_dir, file)) - images.append(img_path) - if len(images) >= 3: # 最多3张测试图片 - break - - if images: - print(f"✅ 找到 {len(images)} 张测试图片") - else: - print("⚠️ 未找到测试图片,将只发布文字") - - # 4. 准备标签 - tags = ["测试", "自动化发布"] - - # 5. 创建发布服务 - print("\n开始发布测试笔记...") - publisher = XHSPublishService(cookies) - - # 6. 执行发布 - result = await publisher.publish( - title=title, - content=content, - images=images if images else None, - tags=tags - ) - - # 7. 显示结果 - print("\n" + "="*50) - print("发布结果:") - print(json.dumps(result, ensure_ascii=False, indent=2)) - print("="*50) - - if result.get('success'): - print("\n✅ 测试成功!笔记已发布") - if 'url' in result: - print(f"📎 笔记链接: {result['url']}") - else: - print(f"\n❌ 测试失败: {result.get('error')}") - - -if __name__ == "__main__": - print("="*50) - print("小红书发布功能测试") - print("="*50) - print() - - # 运行测试 - asyncio.run(test_publish()) diff --git a/backend/xhs_login.py b/backend/xhs_login.py index 70288ae..c65c449 100644 --- a/backend/xhs_login.py +++ b/backend/xhs_login.py @@ -17,6 +17,16 @@ from datetime import datetime from pathlib import Path from browser_pool import get_browser_pool from error_screenshot import save_error_screenshot, save_screenshot_with_html +from loguru import logger +from damai_proxy_config import get_random_proxy, format_proxy_for_playwright + +# 配置loguru日志格式 +logger.remove() # 移除默认handler +logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", + level="INFO" +) async def download_image(url: str) -> str: @@ -65,18 +75,20 @@ async def download_image(url: str) -> str: class XHSLoginService: """小红书登录服务""" - def __init__(self, use_pool: bool = True, headless: bool = True, session_id: Optional[str] = None): + def __init__(self, use_pool: bool = True, headless: bool = True, session_id: Optional[str] = None, use_page_isolation: bool = False): """ 初始化登录服务 - + Args: use_pool: 是否使用浏览器池(默认True,提升性能) headless: 是否使用无头模式,False为有头模式(方便调试) - session_id: 会话ID,用于并发隔离(不同的session_id会创建独立的浏览器实例) + session_id: 会话 ID,用于并发隔离(不同的session_id会创建独立的浏览器实例) + use_page_isolation: 是否使用页面隔离模式(扫码登录专用,减少浏览器实例数) """ self.use_pool = use_pool self.headless = headless self.session_id = session_id # 保存session_id用于并发隔离 + self.use_page_isolation = use_page_isolation # 页面隔离模式 self.browser_pool = get_browser_pool(headless=headless) if use_pool else None self.playwright = None self.browser: Optional[Browser] = None @@ -84,17 +96,26 @@ class XHSLoginService: self.page: Optional[Page] = None self.current_phone = None - async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, user_agent: Optional[str] = None, restore_state: bool = False): + async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None, restore_state: bool = False, use_random_proxy: bool = True): """ 初始化浏览器 Args: cookies: 可选的Cookie列表,用于恢复登录状态 - proxy: 可选的代理地址,例如 http://user:pass@ip:port + proxy: 可选的代理配置,例如 {"server": "http://ip:port", "username": "...", "password": "..."} user_agent: 可选的自定义User-Agent restore_state: 是否从log_state.json文件恢复完整登录状态 + use_random_proxy: 是否自动使用随机代理(默认True) """ try: + # 如果没有指定代理且启用自动代理,则使用随机代理 + if not proxy and use_random_proxy: + try: + proxy_config = get_random_proxy() + proxy = format_proxy_for_playwright(proxy_config) + logger.info(f"[代理] 自动选择代理: {proxy_config['name']} ({proxy_config['server']})") + except Exception as e: + logger.info(f"[代理] 无可用代理,使用直连访问") # 如果要求恢复状态,先加载 login_state.json login_state = None if restore_state and os.path.exists('login_state.json'): @@ -112,12 +133,54 @@ class XHSLoginService: # 使用浏览器池 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) + + # 获取或创建页面 + self.page = await self.browser_pool.get_qrcode_page(self.session_id) + + # 使用浏览器池的主浏览器和context + self.browser = self.browser_pool.browser + self.context = self.browser_pool.context + + print("浏览器初始化成功(页面隔离模式)", file=sys.stderr) + return + + # 普通浏览器池模式 print(f"[浏览器池模式] 从浏览器池获取实例 (session_id={self.session_id}, headless={self.headless})", file=sys.stderr) 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参数 ) + # 保存proxy配置 + if proxy: + self.proxy = proxy + + # 检查page状态,如果是空白页或已关闭,重新创建page + try: + current_url = self.page.url + print(f"当前URL: {current_url}", file=sys.stderr) + + if current_url == 'about:blank' or current_url == '': + print("[浏览器池] 检测到空白页面,重新创建page", file=sys.stderr) + try: + # 关闭旧page + await self.page.close() + except Exception as e: + print(f"[浏览器池] 关闭旧page失败: {str(e)}", file=sys.stderr) + # 创建新page + self.page = await self.context.new_page() + print(f"[浏览器池] 已创建新page, 新URL: {self.page.url}", file=sys.stderr) + + # 更新浏览器池中保存的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) + except Exception as e: + print(f"[浏览器池] 检查page状态异常: {str(e)}", file=sys.stderr) + # 如果有localStorage/sessionStorage,恢复它们 if login_state: await self._restore_storage(login_state) @@ -149,7 +212,8 @@ class XHSLoginService: ], } if proxy: - launch_kwargs["proxy"] = {"server": proxy} + launch_kwargs["proxy"] = proxy # 直接使用proxy字典 + self.proxy = proxy # 保存proxy配置供后续使用 self.browser = await self.playwright.chromium.launch(**launch_kwargs) @@ -390,13 +454,13 @@ class XHSLoginService: except Exception as e: print(f"⚠️ 恢夏storage失败: {str(e)}", file=sys.stderr) - async def init_browser_with_storage_state(self, storage_state_path: str, proxy: Optional[str] = None): + async def init_browser_with_storage_state(self, storage_state_path: str, proxy: Optional[dict] = None): """ 使用Playwright原生storage_state初始化浏览器(最优方案) Args: storage_state_path: storage_state文件路径 - proxy: 可选的代理地址 + proxy: 可选的代理配置 """ try: if not os.path.exists(storage_state_path): @@ -424,7 +488,7 @@ class XHSLoginService: ], } if proxy: - launch_kwargs["proxy"] = {"server": proxy} + launch_kwargs["proxy"] = proxy # 直接使用proxy字典 self.browser = await self.playwright.chromium.launch(**launch_kwargs) @@ -574,6 +638,71 @@ class XHSLoginService: print(f"⚠️ 提取二维码失败: {str(e)}", file=sys.stderr) return None + async def _navigate_with_qrcode_listener(self, url: str, timeout: int = 120): + """ + 带有二维码API监听的页面导航 + 通过监听https://edith.xiaohongshu.com/api/sns/web/v1/login/qrcode/create + 来判断登录框是否已加载完成,而不是等待固定时间 + + Args: + url: 目标URL + timeout: 最大等待时间(秒),默认120秒 + """ + qrcode_api_detected = False + + # 设置路由监听二维码创建API + async def handle_qrcode_create(route): + nonlocal qrcode_api_detected + try: + request = route.request + logger.info(f"[页面导航] 监听到二维码API请求: {request.url}") + qrcode_api_detected = True + + # 继续请求 + await route.continue_() + except Exception as e: + logger.error(f"[页面导航] 处理二维码API请求失败: {str(e)}") + await route.continue_() + + try: + # 注册路由监听 + await self.page.route('**/api/sns/web/v1/login/qrcode/create', handle_qrcode_create) + logger.info(f"[页面导航] 已注册二维码API监听") + + # 开始导航,不等待加载完成 + try: + await self.page.goto(url, wait_until='commit', timeout=timeout * 1000) + logger.info(f"[页面导航] 已开始导航到 {url}") + except Exception as e: + # 即使超时也继续,只要URL匹配 + current_url = self.page.url + logger.warning(f"[页面导航] 导航超时,但尝试继续: {str(e)}") + logger.info(f"[页面导航] 当前URL: {current_url}") + + # 等待二维码API请求(最多等待timeout秒) + wait_count = 0 + max_wait = timeout * 10 # 每次等待0.1秒 + + while not qrcode_api_detected and wait_count < max_wait: + await asyncio.sleep(0.1) + wait_count += 1 + + if qrcode_api_detected: + logger.success(f"[页面导航] 监听到二维码API请求,登录框已加载完成(耗时{wait_count * 0.1:.1f}秒)") + else: + logger.warning(f"[页面导航] {timeout}秒内未监听到二维码API请求,尝试继续") + + # 额外等待500ms确保元素渲染完成 + await asyncio.sleep(0.5) + + finally: + # 移除路由监听 + try: + await self.page.unroute('**/api/sns/web/v1/login/qrcode/create') + logger.info(f"[页面导航] 已移除二维码API监听") + except Exception: + pass + async def send_verification_code(self, phone: str, country_code: str = "+86", login_page: str = "creator") -> Dict[str, Any]: """ 发送验证码 @@ -587,7 +716,10 @@ class XHSLoginService: Dict containing success status and error message if any """ try: + logger.info(f"[发送验证码] 开始 - 手机号: {phone}, 登录页面: {login_page}") + if not self.page: + logger.info(f"[发送验证码] 浏览器未初始化,开始初始化...") await self.init_browser() self.current_phone = phone @@ -608,19 +740,39 @@ class XHSLoginService: else: # 页面变了,重新访问登录页 print(f"[预热] 页面已变更 ({current_url}),重新访问{page_name}登录页...", file=sys.stderr) - await self.page.goto(login_url, wait_until='networkidle', timeout=30000) - await asyncio.sleep(0.5) + await self._navigate_with_qrcode_listener(login_url) else: - # 未预热或不是池模式,正常访问页面 + # 未预热或不是池模式,使用监听机制访问页面 print(f"正在访问{page_name}登录页...", file=sys.stderr) - # 优化:超时时间缩短到30秒,使用networkidle提升加载速度 - try: - await self.page.goto(login_url, wait_until='networkidle', timeout=30000) - print("✅ 页面加载完成", file=sys.stderr) - except Exception as e: - print(f"页面加载超时,尝试继续: {str(e)}", file=sys.stderr) - # 超时后等待500ms,让关键元素加载 - await asyncio.sleep(0.5) + + # 先验证代理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) + + await self._navigate_with_qrcode_listener(login_url) print(f"✅ 已进入{page_name}登录页面", file=sys.stderr) @@ -850,14 +1002,23 @@ class XHSLoginService: ] # 直接查找,不重试 - send_code_btn = None + send_code_selector = None for selector in selectors: send_code_btn = await self.page.query_selector(selector) if send_code_btn: print(f"✅ 找到发送验证码按钮: {selector}", file=sys.stderr) + send_code_selector = selector break - if send_code_btn: + if send_code_selector: + # 重新获取元素句柄以确保其有效性 + send_code_btn = await self.page.query_selector(send_code_selector) + if not send_code_btn: + return { + "success": False, + "error": "按钮元素已失效,请重试" + } + # 获取按钮文本内容 btn_text = await send_code_btn.inner_text() btn_text = btn_text.strip() if btn_text else "" @@ -892,9 +1053,20 @@ class XHSLoginService: } print(f"✅ 按钮已激活: class={class_name}", file=sys.stderr) - # 点击按钮 - await send_code_btn.click() - print("✅ 已点击发送验证码", file=sys.stderr) + # 在点击前再次确保元素有效(页面DOM可能在检查过程中更新) + try: + # 使用 page.click 直接通过选择器点击,避免元素句柄失效问题 + await self.page.click(send_code_selector, timeout=5000) + print("✅ 已点击发送验证码", file=sys.stderr) + except Exception as click_error: + # 如果直接点击失败,尝试重新获取元素点击 + print(f"⚠️ 直接点击失败: {str(click_error)}, 尝试重新获取元素", file=sys.stderr) + send_code_btn = await self.page.query_selector(send_code_selector) + if send_code_btn: + await send_code_btn.click() + print("✅ 重新获取元素后点击成功", file=sys.stderr) + else: + raise Exception("按钮元素已失效,无法点击") # 等待页面响应,检测是否出现验证二维码 await asyncio.sleep(1.5) @@ -924,6 +1096,7 @@ class XHSLoginService: } # 直接返回成功,不再检测滑块 + logger.info(f"[发送验证码] 成功 - 手机号: {phone}") print("\n✅ 验证码发送流程完成,请查看手机短信", file=sys.stderr) print("请在小程序中输入收到的验证码并点击登录\n", file=sys.stderr) print("[响应即将返回] success=True, message=验证码发送成功", file=sys.stderr) @@ -951,6 +1124,7 @@ 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) @@ -2519,3 +2693,649 @@ class XHSLoginService: "success": False, "error": str(e) } + + async def start_qrcode_login(self, login_page: str = "home") -> Dict[str, Any]: + """ + 启动小红书首页的扫码登录流程 + + Args: + login_page: 登录页面类型,默认home(小红书首页) + + Returns: + Dict containing qrcode image and status + """ + try: + if not self.page: + await self.init_browser() + + # 访问小红书首页 + login_url = 'https://www.xiaohongshu.com' + logger.info(f"[扫码登录] 正在访问小红书首页...") + + # 强制访问首页,不管当前在哪个页面 + try: + # 使用domcontentloaded而不是networkidle,避免等待所有资源加载 + await self.page.goto(login_url, wait_until='domcontentloaded', timeout=10000) + current_url = self.page.url + logger.success(f"[扫码登录] 页面加载完成, 当前URL: {current_url}") + + # 检查是否跳转到验证码页面 + if '/website-login/captcha' in current_url or 'verifyUuid=' in current_url: + logger.warning(f"[扫码登录] 检测到风控验证页面,尝试等待或跳过...") + # 等待30秒,看是否会自动跳过 + await asyncio.sleep(30) + current_url = self.page.url + logger.info(f"[扫码登录] 等待30秒后当前URL: {current_url}") + + # 如果还在验证码页面,返回错误 + if '/website-login/captcha' in current_url or 'verifyUuid=' in current_url: + return { + "success": False, + "error": "当前IP被风控,需要验证。请稍后再试或启用代理。" + } + except Exception as e: + # 即使超时也继续,因为页面可能已经跳转到explore + current_url = self.page.url + if 'xiaohongshu.com' in current_url: + logger.warning(f"[扫码登录] 页面加载超时但已到达小红书页面: {current_url}") + else: + logger.error(f"[扫码登录] 页面加载失败: {str(e)}, 当前URL: {current_url}") + raise e + + # 🔥 关键修改: 在explore页面后立即注册路由监听,被动等待二维码创建 + qrcode_create_data = None + + # 设置路由监听二维码创建 API + async def handle_qrcode_create(route): + nonlocal qrcode_create_data + try: + request = route.request + logger.info(f"[扫码登录] API请求: {request.method} {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)}") + + if data.get('code') == 0 and data.get('success') and data.get('data'): + qrcode_create_data = data.get('data') + logger.success(f"[扫码登录] 获取到二维码 qr_id={qrcode_create_data.get('qr_id')}") + 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_() + + # 注册路由 (在explore页面后立即注册) + await self.page.route('**/api/sns/web/v1/login/qrcode/create', handle_qrcode_create) + logger.info("[扫码登录] 已注册 API路由监听,等待页面自动触发二维码创建...") + + # 被动等待二维码创建 API请求完成(最多等待30秒) + for i in range(300): # 300 * 0.1 = 30秒 + if qrcode_create_data: + break + await asyncio.sleep(0.1) + + if not qrcode_create_data: + logger.warning("[扫码登录] 30秒内未捕获到二维码创建 API请求,尝试从页面提取二维码") + + # 提取二维码和状态(但不检测登录成功,因为这是初始化) + qrcode_result = await self.extract_qrcode_with_status(check_login_success=False) + + # 如果获取到二维码创建信息,添加到结果中 + if qrcode_create_data: + qrcode_result["qr_id"] = qrcode_create_data.get('qr_id') + qrcode_result["qr_code"] = qrcode_create_data.get('code') + qrcode_result["qr_url"] = qrcode_create_data.get('url') + qrcode_result["multi_flag"] = qrcode_create_data.get('multi_flag') + + return qrcode_result + + except Exception as e: + print(f"启动扫码登录失败: {str(e)}", file=sys.stderr) + return { + "success": False, + "error": str(e) + } + + async def extract_qrcode_with_status(self, check_login_success: bool = True) -> Dict[str, Any]: + """ + 提取二维码图片和状态信息,并检测是否扫码成功 + + Args: + check_login_success: 是否检测登录成功,默认True。start_qrcode_login时传False + + Returns: + Dict containing qrcode image, status text, login success and user data + """ + try: + if not self.page: + return { + "success": False, + "error": "浏览器未初始化" + } + + result = { + "success": True, + "qrcode_image": "", + "status_text": "", + "status_desc": "", + "is_expired": False, + "login_success": False, # 新增:是否扫码登录成功 + "user_info": None, + "cookies": None, + "cookies_full": None, + "login_state": None + } + + # 只有在轮询检查时才判断登录成功 + if check_login_success: + # 方法1: 监听用户信息API请求(最准确的方式) + user_me_data = None + try: + # 直接请求用户信息API + response = await self.page.evaluate(''' + async () => { + try { + const response = await fetch('https://edith.xiaohongshu.com/api/sns/web/v2/user/me', { + method: 'GET', + credentials: 'include' + }); + const data = await response.json(); + return data; + } catch (error) { + return { error: error.message }; + } + } + ''') + + if response and not response.get('error'): + # 关键修复: 检查是否是游客状态 + if response.get('code') == 0 and response.get('success') and response.get('data'): + data = response.get('data') + is_guest = data.get('guest', False) + + # 只有非游客状态才算登录成功 + if not is_guest and data.get('user_id') and data.get('nickname'): + user_me_data = data + logger.success(f"[扫码登录] 登录成功! user_id={user_me_data.get('user_id')}, nickname={user_me_data.get('nickname')}") + except Exception as e: + logger.error(f"[扫码登录] 请求用户信息 API异常: {str(e)}") + + # 如果获取到用户信息,说明登录成功 + if user_me_data: + result["login_success"] = True + + # 等待页面稳定 + await asyncio.sleep(1) + + # 获取Cookies + try: + cookies = await self.context.cookies() + cookies_dict = {cookie['name']: cookie['value'] for cookie in cookies} + result["cookies"] = cookies_dict + result["cookies_full"] = cookies + except Exception as e: + logger.error(f"[扫码登录] 获取Cookie失败: {str(e)}") + + # 构建用户信息(使用API返回的数据 + localStorage) + try: + # 先从 localStorage 获取基础信息 + storage = await self.page.evaluate('() => JSON.stringify(localStorage)') + storage_dict = json.loads(storage) + + user_info = { + # 从 API 响应中提取的信息(最准确) + 'user_id': user_me_data.get('user_id'), + 'red_id': user_me_data.get('red_id'), + 'nickname': user_me_data.get('nickname'), + 'desc': user_me_data.get('desc'), + 'gender': user_me_data.get('gender'), + 'avatar_small': user_me_data.get('images'), # 小头像 + 'avatar_large': user_me_data.get('imageb'), # 大头像 + 'is_guest': user_me_data.get('guest', False) + } + + # 补充 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] + + result["user_info"] = user_info + except Exception as e: + logger.error(f"[扫码登录] 构建用户信息失败: {str(e)}") + # 即\u4f7f失\u8d25,\u4e5f\u4f7f\u7528API\u8fd4\u56de\u7684\u6570\u636e + result["user_info"] = { + 'user_id': user_me_data.get('user_id'), + 'red_id': user_me_data.get('red_id'), + 'nickname': user_me_data.get('nickname'), + 'desc': user_me_data.get('desc'), + 'gender': user_me_data.get('gender'), + 'avatar_small': user_me_data.get('images'), + 'avatar_large': user_me_data.get('imageb'), + 'is_guest': user_me_data.get('guest', False) + } + + # 获取完整的登录状态 + try: + current_url = self.page.url + localStorage_data = {} + sessionStorage_data = {} + + try: + 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) + + 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) + + result["login_state"] = { + "cookies": result["cookies_full"], + "localStorage": localStorage_data, + "sessionStorage": sessionStorage_data, + "url": current_url, + "timestamp": time.time() + } + print("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001", file=sys.stderr) + except Exception as e: + print(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}", file=sys.stderr) + + return result + + # 如果API请求失败,退而求其次使用页面元素检测 + print("\u26a0\ufe0f API\u68c0\u6d4b\u5931\u8d25,\u4f7f\u7528\u9875\u9762\u5143\u7d20\u68c0\u6d4b", file=sys.stderr) + current_url = self.page.url + print(f"\u5f53\u524dURL: {current_url}", file=sys.stderr) + + # 方法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 + try: + qrcode_img = await self.page.query_selector('.qrcode-img') + if qrcode_img: + qrcode_exists = await qrcode_img.is_visible() + except Exception: + pass + + # 方法3: 检查\u767b\u5f55\u5f39\u7a97\u662f\u5426\u5173\u95ed + login_modal_closed = True + try: + modal_selectors = [ + '.login-container', + '.reds-modal', + '[class*="login-modal"]', + '[class*="LoginModal"]', + ] + for selector in modal_selectors: + modal = await self.page.query_selector(selector) + if modal and await modal.is_visible(): + login_modal_closed = False + break + except Exception: + pass + + # 方法4: 检查\u662f\u5426\u6709\u767b\u5f55\u540e\u7684\u7528\u6237\u4fe1\u606f\u5143\u7d20 + has_user_info = False + try: + user_selectors = [ + '.user-info', + '.avatar', + '[class*="user"]', + ] + for selector in user_selectors: + user_el = await self.page.query_selector(selector) + if user_el and await user_el.is_visible(): + has_user_info = True + break + 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) + + # 综合\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) + result["login_success"] = True + + # 等\u5f85\u9875\u9762\u7a33\u5b9a + await asyncio.sleep(1) + + # 获\u53d6Cookies + try: + cookies = await self.context.cookies() + 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) + except Exception as e: + print(f"\u26a0\ufe0f \u83b7\u53d6Cookie\u5931\u8d25: {str(e)}", file=sys.stderr) + + # 获\u53d6\u7528\u6237\u4fe1\u606f + try: + storage = await self.page.evaluate('() => JSON.stringify(localStorage)') + storage_dict = json.loads(storage) + + user_info = {} + 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] + + result["user_info"] = user_info + print(f"\u2705 \u5df2\u83b7\u53d6\u7528\u6237\u4fe1\u606f: {list(user_info.keys())}", file=sys.stderr) + except Exception as e: + print(f"\u26a0\ufe0f \u83b7\u53d6\u7528\u6237\u4fe1\u606f\u5931\u8d25: {str(e)}", file=sys.stderr) + + # 获\u53d6\u5b8c\u6574\u7684\u767b\u5f55\u72b6\u6001 + try: + localStorage_data = {} + sessionStorage_data = {} + + try: + 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) + + 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) + + result["login_state"] = { + "cookies": result["cookies_full"], + "localStorage": localStorage_data, + "sessionStorage": sessionStorage_data, + "url": current_url, + "timestamp": time.time() + } + print("\u2705 \u5df2\u6784\u5efa\u5b8c\u6574\u767b\u5f55\u72b6\u6001", file=sys.stderr) + except Exception as e: + print(f"\u26a0\ufe0f \u6784\u5efa\u767b\u5f55\u72b6\u6001\u5931\u8d25: {str(e)}", file=sys.stderr) + + return result + + # 还在登录页或不检查登录状态,继续提取二维码和状态 + # 提取二维码图片 + qrcode_selectors = [ + '.qrcode-img', + 'img.qrcode-img', + '.qrcode img', + 'img[src*="data:image"]', + 'img[alt*="二维码"]', + ] + + for selector in qrcode_selectors: + try: + qrcode_img = await self.page.wait_for_selector(selector, timeout=3000) + if qrcode_img: + # 获取src属性 + src = await qrcode_img.get_attribute('src') + if src: + if src.startswith('data:image'): + result["qrcode_image"] = src + else: + # 如果是URL,尝试下载转换 + try: + async with aiohttp.ClientSession() as session: + async with session.get(src, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status == 200: + img_data = await response.read() + import base64 + 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) + except Exception as e: + print(f"⚠️ 下载二维码失败: {str(e)}", file=sys.stderr) + + # 如果还是没有图片,尝试截图 + if not result["qrcode_image"]: + try: + screenshot_bytes = await qrcode_img.screenshot() + if screenshot_bytes: + 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) + except Exception as e: + print(f"⚠️ 截取二维码失败: {str(e)}", file=sys.stderr) + + break + except Exception as e: + continue + + if not result["qrcode_image"]: + return { + "success": False, + "error": "未找到二维码图片" + } + + # 提取状态信息 + print("正在提取二维码状态...", file=sys.stderr) + status_selectors = [ + '.status', + '.qrcode-status', + '[class*="status"]', + ] + + for selector in status_selectors: + try: + status_el = await self.page.query_selector(selector) + if status_el: + # 检查状态是否可见 + is_visible = await status_el.is_visible() + if not is_visible: + print("二维码状态元素不可见,说明二维码有效", file=sys.stderr) + result["status_text"] = "" # 空字符串表示正常状态 + result["is_expired"] = False + break + + print(f"✅ 找到状态元素: {selector}", file=sys.stderr) + + # 提取状态文本 + 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) + + # 提取状态描述 + 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) + + # 判断是否过期 + if "过期" in result["status_text"] or "过期" in result["status_desc"]: + result["is_expired"] = True + print("⚠️ 二维码已过期", file=sys.stderr) + + break + except Exception as e: + continue + + # 如果没有找到状态元素,说明二维码正常(不设置status_text,小程序端自己显示) + if not result["status_text"]: + result["status_text"] = "" # 空字符串表示正常状态,小程序端不显示覆盖层 + result["is_expired"] = False + + print(f"✅ 二维码提取完成: 状态={result['status_text']}, 过期={result['is_expired']}, 登录成功={result['login_success']}", file=sys.stderr) + return result + + except Exception as e: + print(f"提取二维码状态失败: {str(e)}", file=sys.stderr) + return { + "success": False, + "error": str(e) + } + + async def refresh_qrcode(self) -> Dict[str, Any]: + """ + 刷新二维码(当二维码过期时点击刷新) + + Returns: + Dict containing new qrcode and status + """ + try: + if not self.page: + return { + "success": False, + "error": "浏览器未初始化" + } + + # 检查page状态,如果是空白页,需要重新导航到登录页 + try: + current_url = self.page.url + logger.info(f"[刷新二维码] 当前URL: {current_url}") + + if current_url == 'about:blank' or current_url == '': + logger.warning("[刷新二维码] 检测到空白页,重新导航到explore页面") + await self.page.goto('https://www.xiaohongshu.com/explore', wait_until='networkidle') + await asyncio.sleep(1) + except Exception as e: + logger.error(f"[刷新二维码] 检查page状态异常: {str(e)}") + + logger.info("[刷新二维码] 正在刷新...") + + # 🔥 关键修改: 先注册路由监听,然后再打开登录弹窗 + qrcode_create_data = None + + # 设置路由监听二维码创建 API + async def handle_qrcode_create(route): + nonlocal qrcode_create_data + try: + # 记录请求 + request = route.request + logger.info(f"[刷新二维码] API请求: {request.method} {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)}") + + if data.get('code') == 0 and data.get('success') and data.get('data'): + qrcode_create_data = data.get('data') + logger.success(f"[刷新二维码] 获取到新二维码 qr_id={qrcode_create_data.get('qr_id')}") + 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/v1/login/qrcode/create', handle_qrcode_create) + logger.info("[刷新二维码] 已注册 API路由监听") + + # 确保在登录页面或扫码页面 + current_url = self.page.url + if 'login' not in current_url.lower(): + # 如果不在登录页,先打开登录页 + logger.info("[刷新二维码] 不在登录页,先打开登录页") + try: + login_btn = await self.page.wait_for_selector('text="登录"', timeout=3000) + if login_btn: + await login_btn.click() + await asyncio.sleep(1) + except Exception as e: + logger.warning(f"[刷新二维码] 打开登录页失败: {str(e)}") + + # 确保切换到扫码登录选项卡 + qrcode_tab_selectors = [ + 'text="扫码登录"', + 'div:has-text("扫码登录")', + 'text="二维码登录"', + 'div:has-text("二维码登录")', + '.qrcode-tab', + '[data-type="qrcode"]', + ] + + for selector in qrcode_tab_selectors: + try: + qrcode_tab = await self.page.query_selector(selector) + if qrcode_tab: + logger.info("[刷新二维码] 切换到扫码登录模式") + await qrcode_tab.click() + await asyncio.sleep(0.5) + break + except Exception: + continue + + # 查找刷新按钮或刷新文本 + refresh_selectors = [ + '.status-desc.refresh', + 'text="点击刷新"', + '.refresh', + '[class*="refresh"]', + ] + + refresh_clicked = False + for selector in refresh_selectors: + try: + refresh_el = await self.page.query_selector(selector) + if refresh_el: + logger.info(f"[刷新二维码] 找到刷新按钮: {selector}") + await refresh_el.click() + logger.success("[刷新二维码] 已点击刷新") + await asyncio.sleep(1) + refresh_clicked = True + break + except Exception: + continue + + if not refresh_clicked: + return { + "success": False, + "error": "未找到刷新按钮" + } + + # 等待二维码创建 API请求完成(最多等待 3 秒) + for i in range(30): # 30 * 0.1 = 3秒 + if qrcode_create_data: + break + await asyncio.sleep(0.1) + + if not qrcode_create_data: + logger.warning("[刷新二维码] 未捕获到二维码创建 API请求") + + # 重新提取二维码 + qrcode_result = await self.extract_qrcode_with_status(check_login_success=False) + + # 如果获取到二维码创建信息,添加到结果中 + if qrcode_create_data: + qrcode_result["qr_id"] = qrcode_create_data.get('qr_id') + qrcode_result["qr_code"] = qrcode_create_data.get('code') + qrcode_result["qr_url"] = qrcode_create_data.get('url') + qrcode_result["multi_flag"] = qrcode_create_data.get('multi_flag') + logger.success("[刷新二维码] 已将二维码创建信息添加到返回结果") + + return qrcode_result + + except Exception as e: + logger.error(f"[刷新二维码] 失败: {str(e)}") + return { + "success": False, + "error": str(e) + } + diff --git a/go_backend/config/config.go b/go_backend/config/config.go index a6f9ac5..ca603a9 100644 --- a/go_backend/config/config.go +++ b/go_backend/config/config.go @@ -232,3 +232,12 @@ func (c *DatabaseConfig) GetDSN() string { c.Loc, ) } + +// GetPythonServiceURL 获取Python服务地址 +func GetPythonServiceURL() string { + if AppConfig != nil && AppConfig.XHS.PythonServiceURL != "" { + return AppConfig.XHS.PythonServiceURL + } + // 默认返回本地地址 + return "http://localhost:8000" +} diff --git a/go_backend/controller/employee_controller.go b/go_backend/controller/employee_controller.go index 543afb2..f3ff740 100644 --- a/go_backend/controller/employee_controller.go +++ b/go_backend/controller/employee_controller.go @@ -10,6 +10,7 @@ import ( "context" "encoding/base64" "fmt" + "net/http" "strconv" "strings" "time" @@ -218,8 +219,9 @@ func (ctrl *EmployeeController) BindXHS(c *gin.Context) { employeeID := c.GetInt("employee_id") var req struct { - XHSPhone string `json:"xhs_phone" binding:"required"` - Code string `json:"code" binding:"required"` + XHSPhone string `json:"xhs_phone" binding:"required"` + Code string `json:"code" binding:"required"` + SessionID string `json:"session_id"` // 发送验证码时返回的session_id,用于复用浏览器 } if err := c.ShouldBindJSON(&req); err != nil { @@ -227,7 +229,7 @@ func (ctrl *EmployeeController) BindXHS(c *gin.Context) { return } - _, err := ctrl.service.BindXHS(employeeID, req.XHSPhone, req.Code) + _, err := ctrl.service.BindXHS(employeeID, req.XHSPhone, req.Code, req.SessionID) if err != nil { common.Error(c, common.CodeBindXHSFailed, err.Error()) return @@ -746,3 +748,109 @@ func (ctrl *EmployeeController) RevokeUserToken(c *gin.Context) { common.SuccessWithMessage(c, fmt.Sprintf("已禁用用户 %s (手机号: %s),该用户需要重新登录", targetUser.Username, targetUser.Phone), nil) } + +// SaveQRCodeLogin 保存扫码登录的绑定信息 +// 由Python后端调用,不需要认证 +func (ctrl *EmployeeController) SaveQRCodeLogin(c *gin.Context) { + var req struct { + EmployeeID int `json:"employee_id" binding:"required"` + CookiesFull []interface{} `json:"cookies_full"` + UserInfo map[string]interface{} `json:"user_info"` + LoginState map[string]interface{} `json:"login_state"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, common.CodeInvalidParams, "参数错误") + return + } + + // 调用service层直接保存,与验证码登录相同的逻辑 + err := ctrl.service.SaveQRCodeLogin(req.EmployeeID, req.CookiesFull, req.UserInfo, req.LoginState) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + common.SuccessWithMessage(c, "绑定成功", nil) +} + +// StartQRCodeLogin 启动扫码登录,转发到Python服务 +func (ctrl *EmployeeController) StartQRCodeLogin(c *gin.Context) { + employeeID := c.GetInt("employee_id") + + data, err := ctrl.service.StartQRCodeLogin(employeeID) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + // 直接返回Python的响应格式,保持code=0 + c.JSON(http.StatusOK, data) +} + +// GetQRCodeStatus 获取扫码状态,转发到Python服务 +func (ctrl *EmployeeController) GetQRCodeStatus(c *gin.Context) { + employeeID := c.GetInt("employee_id") + + var req struct { + SessionID string `json:"session_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, common.CodeInvalidParams, "参数错误") + return + } + + data, err := ctrl.service.GetQRCodeStatus(employeeID, req.SessionID) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + // 直接返回Python的响应格式,保持code=0或code=2 + c.JSON(http.StatusOK, data) +} + +// RefreshQRCode 刷新二维码,转发到Python服务 +func (ctrl *EmployeeController) RefreshQRCode(c *gin.Context) { + employeeID := c.GetInt("employee_id") + + var req struct { + SessionID string `json:"session_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, common.CodeInvalidParams, "参数错误") + return + } + + data, err := ctrl.service.RefreshQRCode(employeeID, req.SessionID) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + // 直接返回Python的响应格式,保持code=0或code=3 + c.JSON(http.StatusOK, data) +} + +// CancelQRCodeLogin 取消扫码登录,释放浏览器资源 +func (ctrl *EmployeeController) CancelQRCodeLogin(c *gin.Context) { + var req struct { + SessionID string `json:"session_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, common.CodeInvalidParams, "参数错误") + return + } + + data, err := ctrl.service.CancelQRCodeLogin(req.SessionID) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + // 直接返回Python的响应格式 + c.JSON(http.StatusOK, data) +} diff --git a/go_backend/router/router.go b/go_backend/router/router.go index ccfd605..07b4661 100644 --- a/go_backend/router/router.go +++ b/go_backend/router/router.go @@ -115,5 +115,20 @@ func SetupRouter(r *gin.Engine) { employee.GET("/feedback", feedbackCtrl.GetFeedbackList) // 获取反馈列表 employee.GET("/feedback/:id", feedbackCtrl.GetFeedbackDetail) // 获取反馈详情 } + + // 小红书相关接口(不需要认证,由Python后端或小程序调用) + xhs := api.Group("/xhs") + { + xhsCtrl := controller.NewEmployeeController() + + // 扫码登录相关接口(需要token,但不在employee路由下) + xhs.POST("/qrcode/start", middleware.AuthMiddleware(), xhsCtrl.StartQRCodeLogin) // 启动扫码登录 + xhs.POST("/qrcode/status", middleware.AuthMiddleware(), xhsCtrl.GetQRCodeStatus) // 轮询扫码状态 + xhs.POST("/qrcode/refresh", middleware.AuthMiddleware(), xhsCtrl.RefreshQRCode) // 刷新二维码 + xhs.POST("/qrcode/cancel", middleware.AuthMiddleware(), xhsCtrl.CancelQRCodeLogin) // 取消扫码登录 + + // 保存扫码登录的绑定信息 + xhs.POST("/save-qrcode-login", xhsCtrl.SaveQRCodeLogin) + } } } diff --git a/go_backend/service/employee_service.go b/go_backend/service/employee_service.go index 1fc9c8f..1753fac 100644 --- a/go_backend/service/employee_service.go +++ b/go_backend/service/employee_service.go @@ -79,7 +79,8 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { return errors.New("网络错误,请稍后重试") } - log.Printf("[发送验证码] 调用Python HTTP服务: %s", url) + log.Printf("[发送验证码] 调用Python HTTP服务: %s, 请求参数: phone=%s", url, phone) + startTime := time.Now() // 发送HTTP POST请求,增加超时控制(60秒) client := &http.Client{ @@ -111,7 +112,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { return errors.New("网络错误,请稍后重试") } - log.Printf("[发送验证码] Python服务响应状态: %d", resp.StatusCode) + log.Printf("[发送验证码] Python服务响应: 状态码=%d, 耗时=%.2fs", resp.StatusCode, time.Since(startTime).Seconds()) // 解析响应(FastAPI返回格式: {code, message, data}) var apiResponse struct { @@ -276,7 +277,7 @@ func (s *EmployeeService) UpdateProfile(employeeID int, nickname, email, avatar } // BindXHS 绑定小红书账号(异步处理,立即返回) -func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string, error) { +func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code, sessionID string) (string, error) { if code == "" { return "", errors.New("验证码不能为空") } @@ -297,15 +298,15 @@ func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string } // 异步执行绑定流程 - go s.asyncBindXHS(employeeID, xhsPhone, code) + go s.asyncBindXHS(employeeID, xhsPhone, code, sessionID) // 立即返回成功,告知前端正在处理 - log.Printf("绑定小红书 - 用户%d - 异步任务已启动", employeeID) + log.Printf("绑定小红书 - 用户%d - 异步任务已启动 (session_id=%s)", employeeID, sessionID) return "", nil } // asyncBindXHS 异步执行小红书绑定流程 -func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code string) { +func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code, sessionID string) { ctx := context.Background() cacheService := NewCacheService() @@ -340,8 +341,8 @@ func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code string) { } // err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续 - // 调用Python服务进行验证码验证和登录 - loginResult, err := s.callPythonLogin(xhsPhone, code) + // 调用Python服务进行验证码验证和登录,传递session_id + loginResult, err := s.callPythonLogin(xhsPhone, code, sessionID) if err != nil { return fmt.Errorf("小红书登录失败: %w", err) } @@ -608,7 +609,7 @@ func (s *EmployeeService) GetBindXHSStatus(employeeID int) (map[string]interface } // callPythonLogin 调用Python HTTP服务完成小红书登录(优化:使用浏览器池) -func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginResponse, error) { +func (s *EmployeeService) callPythonLogin(phone, code, sessionID string) (*PythonLoginResponse, error) { // 从配置获取Python服务地址 pythonServiceURL := config.AppConfig.XHS.PythonServiceURL if pythonServiceURL == "" { @@ -621,6 +622,7 @@ func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginRespo "phone": phone, "code": code, "country_code": "+86", + "session_id": sessionID, // 关键:传递session_id用于复用浏览器 } jsonData, err := json.Marshal(requestData) @@ -628,7 +630,7 @@ func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginRespo return nil, fmt.Errorf("序列化请求数据失败: %w", err) } - log.Printf("[绑定小红书] 调用Python HTTP服务: %s", url) + log.Printf("[绑定小红书] 调用Python HTTP服务: %s, session_id=%s", url, sessionID) // 发送HTTP POST请求 resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) @@ -2198,3 +2200,388 @@ func (s *EmployeeService) RepublishRecord(employeeID int, recordID int) (string, return publishLink, nil } + +// SaveQRCodeLogin 保存扫码登录的绑定信息 +// 复用BindXHS的保存逻辑,但不需要调用Python后端,直接保存数据 +func (s *EmployeeService) SaveQRCodeLogin(employeeID int, cookiesFull []interface{}, userInfo map[string]interface{}, loginState map[string]interface{}) error { + ctx := context.Background() + + // 查询用户信息 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return fmt.Errorf("获取用户信息失败: %w", err) + } + + // 优先使用 login_state(完整登录状态),如果没有则降级使用cookies + var loginStateJSON string + + if len(loginState) > 0 { + // 新版:使用完整的login_state(包含cookies + localStorage + sessionStorage) + loginStateBytes, err := json.Marshal(loginState) + if err == nil { + loginStateJSON = string(loginStateBytes) + log.Printf("扫码登录 - 用户%d - 完整LoginState长度: %d", employeeID, len(loginStateJSON)) + } else { + log.Printf("扫码登录 - 用户%d - 序列化login_state失败: %v", employeeID, err) + } + } else if len(cookiesFull) > 0 { + // 降级:使用旧版本的 cookies_full + log.Printf("扫码登录 - 用户%d - 警告: 未找到login_state,降级使用cookies", employeeID) + cookiesBytes, err := json.Marshal(cookiesFull) + if err == nil { + loginStateJSON = string(cookiesBytes) + log.Printf("扫码登录 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON)) + } + } + + if loginStateJSON == "" { + log.Printf("扫码登录 - 用户%d - 错误: 未能获取到任何登录数据", employeeID) + return errors.New("登录成功但未能获取到登录数据,请重试") + } + + // 提取小红书账号昵称 + xhsNickname := "小红书用户" + xhsPhone := "" // 扫码登录没有手机号 + + if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" { + xhsNickname = nickname + } else if username, ok := userInfo["username"].(string); ok && username != "" { + xhsNickname = username + } + + // 尝试从 userInfo 提取 red_id 作为 phone + if redID, ok := userInfo["red_id"].(string); ok && redID != "" { + xhsPhone = redID + } + + now := time.Now() + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 创建或更新 ai_authors 表的小红书账号记录 + log.Printf("扫码登录 - 用户%d - 开始创建或更新作者记录", employeeID) + + author := models.Author{ + EnterpriseID: employee.EnterpriseID, + CreatedUserID: employeeID, + Phone: employee.Phone, + AuthorName: xhsNickname, + XHSCookie: loginStateJSON, + XHSPhone: xhsPhone, + XHSAccount: xhsNickname, + BoundAt: &now, + Channel: 1, // 1=小红书 + Status: "active", + } + + // 查询是否已存在记录 + var existingAuthor models.Author + err := database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID).First(&existingAuthor).Error + + if err == gorm.ErrRecordNotFound { + // 创建新记录 + if err := tx.Create(&author).Error; err != nil { + tx.Rollback() + log.Printf("扫码登录 - 用户%d - 创建作者记录失败: %v", employeeID, err) + return fmt.Errorf("创建作者记录失败: %w", err) + } + log.Printf("扫码登录 - 用户%d - 创建作者记录成功", employeeID) + } else { + // 更新现有记录 + if err := tx.Model(&models.Author{}).Where( + "created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID, + ).Updates(map[string]interface{}{ + "author_name": xhsNickname, + "xhs_cookie": loginStateJSON, + "xhs_phone": xhsPhone, + "xhs_account": xhsNickname, + "bound_at": &now, + "status": "active", + "phone": employee.Phone, + }).Error; err != nil { + tx.Rollback() + log.Printf("扫码登录 - 用户%d - 更新作者记录失败: %v", employeeID, err) + return fmt.Errorf("更新作者记录失败: %w", err) + } + log.Printf("扫码登录 - 用户%d - 更新作者记录成功", employeeID) + } + + // 更新 ai_users 表的绑定标识 + if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil { + tx.Rollback() + log.Printf("扫码登录 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err) + return fmt.Errorf("更新用户绑定标识失败: %w", err) + } + + log.Printf("扫码登录 - 用户%d - 数据库更新成功", employeeID) + + // 提交事务 + if err := tx.Commit().Error; err != nil { + log.Printf("扫码登录 - 用户%d - 事务提交失败: %v", employeeID, err) + return fmt.Errorf("提交事务失败: %w", err) + } + + // 清除相关缓存 + cacheService := NewCacheService() + if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil { + log.Printf("清除缓存失败: %v", err) + } + + log.Printf("扫码登录 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname) + return nil +} + +// StartQRCodeLogin 启动扫码登录,转发到Python服务 +func (s *EmployeeService) StartQRCodeLogin(employeeID int) (map[string]interface{}, error) { + log.Printf("[启动扫码登录] 用户ID: %d", employeeID) + + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + url := fmt.Sprintf("%s/api/xhs/qrcode/start", pythonServiceURL) + log.Printf("[启动扫码登录] 调用Python服务: %s", url) + + // 发送HTTP POST请求,启动扫码需要启动浏览器+加载页面+获取二维码,设置90秒超时 + client := &http.Client{ + Timeout: 90 * time.Second, + } + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + log.Printf("[启动扫码登录] 创建请求失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[启动扫码登录] 调用Python服务失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[启动扫码登录] 读取响应失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 解析响应,直接返回完整响应体 + var apiResponse map[string]interface{} + if err := json.Unmarshal(body, &apiResponse); err != nil { + log.Printf("[启动扫码登录] 解析响应失败: %v, body: %s", err, string(body)) + return nil, errors.New("网络错误,请稍后重试") + } + + // 检查Python响应的code字段 + if code, ok := apiResponse["code"].(float64); ok && code != 0 { + if msg, ok := apiResponse["message"].(string); ok { + log.Printf("[启动扫码登录] 失败: %s", msg) + return nil, errors.New(msg) + } + return nil, errors.New("启动失败") + } + + log.Printf("[启动扫码登录] 成功") + // 返回完整的Python响应,保持code=0格式 + return apiResponse, nil +} + +// GetQRCodeStatus 获取扫码状态,转发到Python服务 +func (s *EmployeeService) GetQRCodeStatus(employeeID int, sessionID string) (map[string]interface{}, error) { + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + url := fmt.Sprintf("%s/api/xhs/qrcode/status", pythonServiceURL) + requestData := map[string]string{ + "session_id": sessionID, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + log.Printf("[扫码状态] 序列化请求数据失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 发送HTTP POST请求 + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("[扫码状态] 创建请求失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[扫码状态] 调用Python服务失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[扫码状态] 读取响应失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 解析响应,直接返回完整响应体 + var apiResponse map[string]interface{} + if err := json.Unmarshal(body, &apiResponse); err != nil { + log.Printf("[扫码状态] 解析响应失败: %v, body: %s", err, string(body)) + return nil, errors.New("网络错误,请稍后重试") + } + + // 扫码状态接口可能返回 code=2 表示 session 失效 + // 这种情况不算错误,直接返回给前端处理 + // 直接返回完整的Python响应,让前端自己判断 + return apiResponse, nil +} + +// RefreshQRCode 刷新二维码,转发到Python服务 +func (s *EmployeeService) RefreshQRCode(employeeID int, sessionID string) (map[string]interface{}, error) { + log.Printf("[刷新二维码] 用户ID: %d, SessionID: %s", employeeID, sessionID) + + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + url := fmt.Sprintf("%s/api/xhs/qrcode/refresh", pythonServiceURL) + requestData := map[string]string{ + "session_id": sessionID, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + log.Printf("[刷新二维码] 序列化请求数据失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 发送HTTP POST请求,刷新二维码需要重新加载页面,设置60秒超时 + client := &http.Client{ + Timeout: 60 * time.Second, + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("[刷新二维码] 创建请求失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[刷新二维码] 调用Python服务失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[刷新二维码] 读取响应失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 解析响应,直接返回完整响应体 + var apiResponse map[string]interface{} + if err := json.Unmarshal(body, &apiResponse); err != nil { + log.Printf("[刷新二维码] 解析响应失败: %v, body: %s", err, string(body)) + return nil, errors.New("网络错误,请稍后重试") + } + + // 刷新接口可能返回 code=3 表示需要重启 + // 这种情况不算错误,直接返回给前端处理 + // 直接返回完整的Python响应,让前端自己判断 + log.Printf("[刷新二维码] 成功") + return apiResponse, nil +} + +// CancelQRCodeLogin 取消扫码登录,转发到Python服务 +func (s *EmployeeService) CancelQRCodeLogin(sessionID string) (map[string]interface{}, error) { + log.Printf("[取消扫码] SessionID: %s", sessionID) + + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + url := fmt.Sprintf("%s/api/xhs/qrcode/cancel", pythonServiceURL) + requestData := map[string]string{ + "session_id": sessionID, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + log.Printf("[取消扫码] 序列化请求数据失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + + // 发送HTTP POST请求 + client := &http.Client{ + Timeout: 10 * time.Second, // 短超时,取消操作应该很快 + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("[取消扫码] 创建请求失败: %v", err) + return nil, errors.New("网络错误,请稍后重试") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[取消扫码] 调用Python服务失败: %v", err) + // 取消失败也返回成功,不影响用户体验 + return map[string]interface{}{ + "code": 0, + "message": "已取消扫码登录", + }, nil + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[取消扫码] 读取响应失败: %v", err) + return map[string]interface{}{ + "code": 0, + "message": "已取消扫码登录", + }, nil + } + + // 解析响应,直接返回完整响应体 + var apiResponse map[string]interface{} + if err := json.Unmarshal(body, &apiResponse); err != nil { + log.Printf("[取消扫码] 解析响应失败: %v, body: %s", err, string(body)) + return map[string]interface{}{ + "code": 0, + "message": "已取消扫码登录", + }, nil + } + + log.Printf("[取消扫码] 成功") + return apiResponse, nil +} diff --git a/go_backend/service/xhs_service.go b/go_backend/service/xhs_service.go index 89bb6a6..08313b5 100644 --- a/go_backend/service/xhs_service.go +++ b/go_backend/service/xhs_service.go @@ -1,12 +1,14 @@ package service import ( + "ai_xhs/config" "bytes" "encoding/json" "fmt" + "io" "log" - "os/exec" - "path/filepath" + "net/http" + "time" ) type XHSService struct{} @@ -38,128 +40,93 @@ type LoginResponse struct { Data map[string]interface{} `json:"data"` } -// SendVerificationCode 调用Python脚本发送验证码 +// SendVerificationCode 调用Python HTTP API发送验证码 func (s *XHSService) SendVerificationCode(phone, countryCode string) (*SendCodeResponse, error) { // 如果没有传国家码,默认使用+86 if countryCode == "" { countryCode = "+86" } - // 获取Python脚本路径和venv中的Python解释器 - backendDir := filepath.Join("..", "backend") - pythonScript := filepath.Join(backendDir, "xhs_cli.py") + // 获取Python服务地址 + pythonURL := config.GetPythonServiceURL() + apiURL := fmt.Sprintf("%s/api/xhs/send-code", pythonURL) - // 使用venv中的Python解释器 (跨平台) - pythonCmd := getPythonPath(backendDir) - - // 执行Python脚本 - cmd := exec.Command(pythonCmd, pythonScript, "send_code", phone, countryCode) - - // 设置工作目录为Python脚本所在目录 - cmd.Dir = backendDir - - // 捕获输出 - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - // 执行命令 - err := cmd.Run() - - // 打印Python脚本的日志输出(stderr) - if stderr.Len() > 0 { - log.Printf("[Python日志-发送验证码] %s", stderr.String()) + // 构造请求体 + reqData := map[string]interface{}{ + "phone": phone, + "country_code": countryCode, } - + reqBody, _ := json.Marshal(reqData) + + // 发送HTTP POST请求 + client := &http.Client{ + Timeout: 60 * time.Second, // 60秒超时 + } + resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(reqBody)) if err != nil { - return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String()) + return nil, fmt.Errorf("调用Python服务失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) } - // 获取UTF-8编码的输出 - outputStr := stdout.String() - - // 解析JSON输出 + log.Printf("[Python API-发送验证码] 响应: %s", string(body)) + + // 解析JSON响应 var result SendCodeResponse - if err := json.Unmarshal([]byte(outputStr), &result); err != nil { - return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr) + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body)) } - // 检查Python脚本返回的success字段 - if !result.Data["success"].(bool) { - return &SendCodeResponse{ - Code: 1, - Message: result.Data["error"].(string), - }, nil - } - - return &SendCodeResponse{ - Code: 0, - Message: "验证码已发送", - Data: result.Data, - }, nil + return &result, nil } -// VerifyLogin 调用Python脚本验证登录 +// VerifyLogin 调用Python HTTP API验证登录 func (s *XHSService) VerifyLogin(phone, code, countryCode string) (*LoginResponse, error) { // 如果没有传国家码,默认使用+86 if countryCode == "" { countryCode = "+86" } - // 获取Python脚本路径和venv中的Python解释器 - backendDir := filepath.Join("..", "backend") - pythonScript := filepath.Join(backendDir, "xhs_cli.py") + // 获取Python服务地址 + pythonURL := config.GetPythonServiceURL() + apiURL := fmt.Sprintf("%s/api/xhs/login", pythonURL) - // 使用venv中的Python解释器 (跨平台) - pythonCmd := getPythonPath(backendDir) - - // 执行Python脚本 - cmd := exec.Command(pythonCmd, pythonScript, "login", phone, code, countryCode) - - // 设置工作目录 - cmd.Dir = backendDir - - // 捕获输出 - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - // 执行命令 - err := cmd.Run() - - // 打印Python脚本的日志输出(stderr) - if stderr.Len() > 0 { - log.Printf("[Python日志-登录] %s", stderr.String()) + // 构造请求体 + reqData := map[string]interface{}{ + "phone": phone, + "code": code, + "country_code": countryCode, } - + reqBody, _ := json.Marshal(reqData) + + // 发送HTTP POST请求 + client := &http.Client{ + Timeout: 120 * time.Second, // 120秒超时(登录可能较慢) + } + resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(reqBody)) if err != nil { - return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String()) + return nil, fmt.Errorf("调用Python服务失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) } - // 获取UTF-8编码的输出 - outputStr := stdout.String() - - // 解析JSON输出 + log.Printf("[Python API-验证登录] 响应: %s", string(body)) + + // 解析JSON响应 var result LoginResponse - if err := json.Unmarshal([]byte(outputStr), &result); err != nil { - return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr) + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body)) } - // 检查Python脚本返回的success字段 - if !result.Data["success"].(bool) { - errorMsg := "登录失败" - if errStr, ok := result.Data["error"].(string); ok { - errorMsg = errStr - } - return &LoginResponse{ - Code: 1, - Message: errorMsg, - }, nil - } - - return &LoginResponse{ - Code: 0, - Message: "登录成功", - Data: result.Data, - }, nil + return &result, nil } diff --git a/miniprogram/miniprogram/app.json b/miniprogram/miniprogram/app.json index 5ef3a9e..dbb28ec 100644 --- a/miniprogram/miniprogram/app.json +++ b/miniprogram/miniprogram/app.json @@ -1,24 +1,15 @@ { "pages": [ "pages/home/home", - "pages/article-generate/article-generate", "pages/login/login", "pages/login/phone-login", "pages/articles/articles", "pages/article-detail/article-detail", "pages/profile/profile", - "pages/profile/user-info/user-info", - "pages/profile/social-binding/social-binding", "pages/profile/platform-bind/platform-bind", - "pages/profile/xhs-login/xhs-login", - "pages/profile/published/published", "pages/profile/article-detail/article-detail", - "pages/profile/about/about", - "pages/profile/feedback/feedback", "pages/agreement/user-agreement/user-agreement", - "pages/agreement/privacy-policy/privacy-policy", - "pages/index/index", - "pages/logs/logs" + "pages/agreement/privacy-policy/privacy-policy" ], "window": { "navigationBarTextStyle": "white", diff --git a/miniprogram/miniprogram/pages/article-detail/article-detail.ts b/miniprogram/miniprogram/pages/article-detail/article-detail.ts index dec4445..c81518e 100644 --- a/miniprogram/miniprogram/pages/article-detail/article-detail.ts +++ b/miniprogram/miniprogram/pages/article-detail/article-detail.ts @@ -56,14 +56,6 @@ Page({ this.setData({ showClaimButton: false }); - - // 延迟后跳转到发布页面,传递领取信息 - setTimeout(() => { - const copy = response.data!.copy; - wx.redirectTo({ - url: `/pages/article-generate/article-generate?copyId=${copyId}&claimId=${response.data!.claim_id}&productId=${productId}&productName=${encodeURIComponent(copy.title)}&title=${encodeURIComponent(copy.title)}&content=${encodeURIComponent(copy.content)}` - }); - }, 1500); } } catch (error) { console.error('领取文案失败:', error); diff --git a/miniprogram/miniprogram/pages/index/index.json b/miniprogram/miniprogram/pages/index/index.json deleted file mode 100644 index b55b5a2..0000000 --- a/miniprogram/miniprogram/pages/index/index.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "usingComponents": { - } -} \ No newline at end of file diff --git a/miniprogram/miniprogram/pages/index/index.ts b/miniprogram/miniprogram/pages/index/index.ts deleted file mode 100644 index c7aaf97..0000000 --- a/miniprogram/miniprogram/pages/index/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -// index.ts -// 获取应用实例 -const app = getApp() -const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0' - -Component({ - data: { - motto: 'Hello World', - userInfo: { - avatarUrl: defaultAvatarUrl, - nickName: '', - }, - hasUserInfo: false, - canIUseGetUserProfile: wx.canIUse('getUserProfile'), - canIUseNicknameComp: wx.canIUse('input.type.nickname'), - }, - methods: { - // 事件处理函数 - bindViewTap() { - wx.navigateTo({ - url: '../logs/logs', - }) - }, - onChooseAvatar(e: any) { - const { avatarUrl } = e.detail - const { nickName } = this.data.userInfo - this.setData({ - "userInfo.avatarUrl": avatarUrl, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - onInputChange(e: any) { - const nickName = e.detail.value - const { avatarUrl } = this.data.userInfo - this.setData({ - "userInfo.nickName": nickName, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - getUserProfile() { - // 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗 - wx.getUserProfile({ - desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写 - success: (res) => { - console.log(res) - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true - }) - } - }) - }, - }, -}) diff --git a/miniprogram/miniprogram/pages/index/index.wxml b/miniprogram/miniprogram/pages/index/index.wxml deleted file mode 100644 index 0721ba0..0000000 --- a/miniprogram/miniprogram/pages/index/index.wxml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - 昵称 - - - - - - 请使用2.10.4及以上版本基础库 - - - - {{userInfo.nickName}} - - - - {{motto}} - - - diff --git a/miniprogram/miniprogram/pages/index/index.wxss b/miniprogram/miniprogram/pages/index/index.wxss deleted file mode 100644 index 1ebed4b..0000000 --- a/miniprogram/miniprogram/pages/index/index.wxss +++ /dev/null @@ -1,62 +0,0 @@ -/**index.wxss**/ -page { - height: 100vh; - display: flex; - flex-direction: column; -} -.scrollarea { - flex: 1; - overflow-y: hidden; -} - -.userinfo { - display: flex; - flex-direction: column; - align-items: center; - color: #aaa; - width: 80%; -} - -.userinfo-avatar { - overflow: hidden; - width: 128rpx; - height: 128rpx; - margin: 20rpx; - border-radius: 50%; -} - -.usermotto { - margin-top: 200px; -} - -.avatar-wrapper { - padding: 0; - width: 56px !important; - border-radius: 8px; - margin-top: 40px; - margin-bottom: 40px; -} - -.avatar { - display: block; - width: 56px; - height: 56px; -} - -.nickname-wrapper { - display: flex; - width: 100%; - padding: 16px; - box-sizing: border-box; - border-top: .5px solid rgba(0, 0, 0, 0.1); - border-bottom: .5px solid rgba(0, 0, 0, 0.1); - color: black; -} - -.nickname-label { - width: 105px; -} - -.nickname-input { - flex: 1; -} diff --git a/miniprogram/miniprogram/pages/login/phone-login.ts b/miniprogram/miniprogram/pages/login/phone-login.ts index 17e7663..6330184 100644 --- a/miniprogram/miniprogram/pages/login/phone-login.ts +++ b/miniprogram/miniprogram/pages/login/phone-login.ts @@ -1,5 +1,6 @@ // pages/login/phone-login.ts import { API } from '../../config/api'; +import { EmployeeService } from '../../services/employee'; Page({ data: { @@ -143,7 +144,7 @@ Page({ }, // 获取验证码 - getVerifyCode() { + async getVerifyCode() { if (!this.data.agreed) { wx.showToast({ title: '请先同意用户协议', @@ -167,56 +168,42 @@ Page({ return; } - // 显示加载提示 - wx.showLoading({ - title: '发送中...', - mask: true - }); - - // 调用后端API发送验证码 - wx.request({ - url: `${API.baseURL}/api/xhs/send-verification-code`, - method: 'POST', - data: { - phone: phone - }, - success: (res: any) => { - wx.hideLoading(); - - if (res.statusCode === 200 && res.data.code === 200) { - // 发送成功 - wx.showToast({ - title: '验证码已发送', - icon: 'success', - duration: 2000 - }); - - // 开发环境打印验证码 - if (res.data.data && res.data.data.code) { - console.log('验证码:', res.data.data.code); - } - - // 开始倒计时 - this.startCountdown(); - } else { - // 发送失败 - wx.showToast({ - title: res.data.message || '发送失败,请稍后重试', - icon: 'none', - duration: 2000 - }); - } - }, - fail: (err) => { - wx.hideLoading(); - console.error('发送验证码请求失败:', err); + try { + // 调用封装的Service方法发送验证码(禁用loading,验证码发送应立即响应) + const res = await EmployeeService.sendXHSCode(phone, false); + + // 兼容 code=0 和 code=200 + if (res.code === 200 || res.code === 0) { + // 发送成功 wx.showToast({ - title: '网络错误,请稍后重试', + title: '验证码已发送', + icon: 'success', + duration: 2000 + }); + + // 开发环境打印验证码 + if (res.data && res.data.code) { + console.log('验证码:', res.data.code); + } + + // 开始倒计时 + this.startCountdown(); + } else { + // 发送失败 + wx.showToast({ + title: res.message || '发送失败,请稍后重试', icon: 'none', duration: 2000 }); } - }); + } catch (err) { + console.error('发送验证码失败:', err); + wx.showToast({ + title: '网络错误,请稍后重试', + icon: 'none', + duration: 2000 + }); + } }, // 开始倒计时 diff --git a/miniprogram/miniprogram/pages/profile/about/about.json b/miniprogram/miniprogram/pages/profile/about/about.json deleted file mode 100644 index 9854f02..0000000 --- a/miniprogram/miniprogram/pages/profile/about/about.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "navigationBarTitleText": "关于我们", - "navigationBarBackgroundColor": "#ff2442", - "navigationBarTextStyle": "white" -} diff --git a/miniprogram/miniprogram/pages/profile/about/about.ts b/miniprogram/miniprogram/pages/profile/about/about.ts deleted file mode 100644 index 81e1f6e..0000000 --- a/miniprogram/miniprogram/pages/profile/about/about.ts +++ /dev/null @@ -1,10 +0,0 @@ -// pages/profile/about/about.ts -Page({ - data: { - - }, - - onLoad() { - - } -}); diff --git a/miniprogram/miniprogram/pages/profile/about/about.wxml b/miniprogram/miniprogram/pages/profile/about/about.wxml deleted file mode 100644 index 1e8d101..0000000 --- a/miniprogram/miniprogram/pages/profile/about/about.wxml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - AI文章审核平台 - v1.0.0 - - - - - 产品介绍 - AI文章审核平台是一款智能内容生成与审核管理系统,通过AI技术帮助用户高效管理文章内容,提升内容审核效率。 - - - - 联系我们 - - 客服邮箱: - support@example.com - - - 客服电话: - 400-888-8888 - - - 工作时间: - 周一至周五 9:00-18:00 - - - - - 更新日志 - - v1.0.0 - 2024-12-05 - · 初始版本发布\n· 支持文章管理和审核\n· 优化用户体验 - - - - - - © 2024 AI文章审核平台 - All Rights Reserved - - diff --git a/miniprogram/miniprogram/pages/profile/about/about.wxss b/miniprogram/miniprogram/pages/profile/about/about.wxss deleted file mode 100644 index 6c67ff2..0000000 --- a/miniprogram/miniprogram/pages/profile/about/about.wxss +++ /dev/null @@ -1,144 +0,0 @@ -/* pages/profile/about/about.wxss */ -page { - background: #f8f8f8; - height: 100%; - width: 100%; -} - -.page-container { - min-height: 100vh; - width: 100%; - padding-bottom: 60rpx; - box-sizing: border-box; - overflow-x: hidden; -} - -.logo-section { - width: 100%; - background: #07c160; - padding: 80rpx 30rpx; - display: flex; - flex-direction: column; - align-items: center; - box-sizing: border-box; -} - -.app-logo { - width: 160rpx; - height: 160rpx; - border-radius: 24rpx; - box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15); - margin-bottom: 30rpx; -} - -.app-name { - font-size: 40rpx; - font-weight: bold; - color: white; - margin-bottom: 16rpx; -} - -.app-version { - font-size: 24rpx; - color: rgba(255, 255, 255, 0.8); -} - -.info-section { - width: 100%; - padding: 20rpx 30rpx; - box-sizing: border-box; -} - -.info-card { - width: 100%; - background: white; - border-radius: 16rpx; - padding: 30rpx; - margin-bottom: 20rpx; - box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06); - box-sizing: border-box; - word-wrap: break-word; - word-break: break-all; -} - -.card-title { - font-size: 32rpx; - font-weight: 600; - color: #333; - margin-bottom: 20rpx; - padding-bottom: 16rpx; - border-bottom: 2rpx solid #f0f0f0; -} - -.card-content { - font-size: 28rpx; - color: #666; - line-height: 1.8; - word-wrap: break-word; - word-break: break-all; -} - -.contact-item { - display: flex; - margin-bottom: 16rpx; - word-wrap: break-word; - word-break: break-all; -} - -.contact-item:last-child { - margin-bottom: 0; -} - -.contact-label { - font-size: 28rpx; - color: #999; - min-width: 140rpx; -} - -.contact-value { - font-size: 28rpx; - color: #333; - word-wrap: break-word; - word-break: break-all; - flex: 1; -} - -.log-item { - display: flex; - flex-direction: column; -} - -.log-version { - font-size: 30rpx; - font-weight: 600; - color: #07c160; - margin-bottom: 8rpx; -} - -.log-date { - font-size: 24rpx; - color: #999; - margin-bottom: 16rpx; -} - -.log-content { - font-size: 28rpx; - color: #666; - line-height: 1.8; - white-space: pre-line; -} - -.footer-section { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding: 40rpx 30rpx; - box-sizing: border-box; -} - -.copyright { - font-size: 24rpx; - color: #999; - line-height: 1.6; -} diff --git a/miniprogram/miniprogram/pages/profile/env-switch/env-switch.json b/miniprogram/miniprogram/pages/profile/env-switch/env-switch.json deleted file mode 100644 index 0b3a051..0000000 --- a/miniprogram/miniprogram/pages/profile/env-switch/env-switch.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "navigationBarTitleText": "环境切换", - "navigationBarBackgroundColor": "#667eea", - "navigationBarTextStyle": "white" -} diff --git a/miniprogram/miniprogram/pages/profile/env-switch/env-switch.wxml b/miniprogram/miniprogram/pages/profile/env-switch/env-switch.wxml deleted file mode 100644 index 61c6480..0000000 --- a/miniprogram/miniprogram/pages/profile/env-switch/env-switch.wxml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - 当前环境 - - {{currentEnv === 'dev' ? '开发环境' : currentEnv === 'test' ? '测试环境' : '生产环境'}} - - - - - - - - - - {{item.name}} - - - 当前 - 切换 - - - - - - 主服务: - - {{configs[item.key].baseURL}} - - - - Python: - - {{configs[item.key].pythonURL}} - - - - - - - - - ⚠️ 温馨提示 - • 切换环境后会清除登录状态,需要重新登录 - • 开发环境用于本地开发调试 - • 测试环境用于服务器功能测试 - • 生产环境为正式线上环境 - • 点击地址可以复制 - - - - - - - diff --git a/miniprogram/miniprogram/pages/profile/feedback/feedback.json b/miniprogram/miniprogram/pages/profile/feedback/feedback.json deleted file mode 100644 index 1e4b4f7..0000000 --- a/miniprogram/miniprogram/pages/profile/feedback/feedback.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "navigationBarTitleText": "意见反馈", - "navigationBarBackgroundColor": "#07c160", - "navigationBarTextStyle": "white" -} diff --git a/miniprogram/miniprogram/pages/profile/feedback/feedback.ts b/miniprogram/miniprogram/pages/profile/feedback/feedback.ts deleted file mode 100644 index 92fd904..0000000 --- a/miniprogram/miniprogram/pages/profile/feedback/feedback.ts +++ /dev/null @@ -1,97 +0,0 @@ -// pages/profile/feedback/feedback.ts -import { request } from '../../../utils/request'; - -Page({ - data: { - typeList: ['功能建议', 'Bug反馈', '体验问题', '其他'], - typeIndex: 0, - content: '', - contact: '', - nickname: '' - }, - - onLoad() { - // 获取用户信息 - const employeeInfo = wx.getStorageSync('employeeInfo'); - if (employeeInfo && employeeInfo.name) { - this.setData({ - nickname: employeeInfo.name - }); - } - }, - - onTypeChange(e: any) { - this.setData({ - typeIndex: e.detail.value - }); - }, - - onContentInput(e: any) { - console.log('输入内容:', e.detail.value); - this.setData({ - content: e.detail.value - }); - }, - - onContactInput(e: any) { - this.setData({ - contact: e.detail.value - }); - }, - - async handleSubmit() { - console.log('提交时的内容:', this.data.content); - console.log('内容长度:', this.data.content.length); - console.log('去空格后:', this.data.content.trim()); - - if (!this.data.content.trim()) { - wx.showToast({ - title: '请输入问题描述', - icon: 'none' - }); - return; - } - - wx.showLoading({ - title: '提交中...', - mask: true - }); - - try { - const res = await request({ - url: '/api/employee/feedback', - method: 'POST', - data: { - feedback_type: this.data.typeList[this.data.typeIndex], - description: this.data.content.trim(), - contact_info: this.data.contact.trim(), - nickname: this.data.nickname - } - }); - - wx.hideLoading(); - - if (res.code === 200) { - wx.showToast({ - title: '提交成功', - icon: 'success' - }); - - setTimeout(() => { - wx.navigateBack(); - }, 1500); - } else { - wx.showToast({ - title: res.message || '提交失败', - icon: 'none' - }); - } - } catch (error: any) { - wx.hideLoading(); - wx.showToast({ - title: error.message || '网络错误', - icon: 'none' - }); - } - } -}); diff --git a/miniprogram/miniprogram/pages/profile/feedback/feedback.wxml b/miniprogram/miniprogram/pages/profile/feedback/feedback.wxml deleted file mode 100644 index c5f19b7..0000000 --- a/miniprogram/miniprogram/pages/profile/feedback/feedback.wxml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - 反馈类型 - - - {{typeList[typeIndex]}} - - - - - - - 问题描述 - - {{content.length}}/1000 - - - - - - 🏷️ - 话题标签 - (可选) - - - - - #{{item}}# - - - - - - - 💡 - 发布前请确保已登录小红书账号 - - - - - - - - - {{toastMessage}} - - diff --git a/miniprogram/miniprogram/services/employee.ts b/miniprogram/miniprogram/services/employee.ts index 26760d0..1b8cb4e 100644 --- a/miniprogram/miniprogram/services/employee.ts +++ b/miniprogram/miniprogram/services/employee.ts @@ -72,9 +72,10 @@ export interface PublishRecord { export class EmployeeService { /** * 发送小红书验证码 + * 返回 session_id 用于后续复用浏览器 */ static async sendXHSCode(xhsPhone: string, showLoading = true) { - return post(API.xhs.sendCode, { + return post<{ sent_at: string; session_id: string }>(API.xhs.sendCode, { xhs_phone: xhsPhone }, showLoading); } @@ -88,11 +89,16 @@ export class EmployeeService { /** * 绑定小红书账号 + * @param xhsPhone 小红书手机号 + * @param code 验证码 + * @param sessionId 发送验证码时返回的session_id,用于复用浏览器 + * @param showLoading 是否显示加载动画 */ - static async bindXHS(xhsPhone: string, code: string, showLoading = false) { + static async bindXHS(xhsPhone: string, code: string, sessionId?: string, showLoading = false) { return post<{ xhs_account: string }>(API.employee.bindXHS, { xhs_phone: xhsPhone, - code + code, + session_id: sessionId // 传递session_id }, showLoading); } diff --git a/miniprogram/miniprogram/utils/request.ts b/miniprogram/miniprogram/utils/request.ts index 94a2b33..289745f 100644 --- a/miniprogram/miniprogram/utils/request.ts +++ b/miniprogram/miniprogram/utils/request.ts @@ -51,8 +51,8 @@ export function request(options: RequestOptions): Promise; - // 处理业务状态码 - if (response.code === 200) { + // 处理业务状态码(兼容 code=0 和 code=200) + if (response.code === 200 || response.code === 0) { resolve(response); } else if (response.code === 401) { // Token过期或未登录