first commit
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
刮刮乐远程控制模块
|
||||
通过 WebSocket 实时传输页面截图到前端,并接收用户操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from loguru import logger
|
||||
from playwright.async_api import Page
|
||||
|
||||
|
||||
class CaptchaRemoteController:
|
||||
"""刮刮乐远程控制器"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
self.websocket_connections: Dict[str, Any] = {}
|
||||
|
||||
async def create_session(self, session_id: str, page: Page) -> Dict[str, str]:
|
||||
"""
|
||||
创建远程控制会话
|
||||
|
||||
Args:
|
||||
session_id: 会话ID(通常是用户ID)
|
||||
page: Playwright Page 对象
|
||||
|
||||
Returns:
|
||||
包含会话信息的字典
|
||||
"""
|
||||
# 获取滑块元素位置
|
||||
captcha_info = await self._get_captcha_info(page)
|
||||
|
||||
# 只截取滑块区域,不截取整个页面(性能优化)
|
||||
screenshot_bytes = await self._screenshot_captcha_area(page, captcha_info)
|
||||
screenshot_base64 = base64.b64encode(screenshot_bytes).decode('utf-8')
|
||||
|
||||
# 获取视口大小
|
||||
try:
|
||||
viewport = page.viewport_size
|
||||
if viewport is None:
|
||||
# 如果没有设置viewport,使用默认值或通过JS获取
|
||||
viewport = await page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")
|
||||
except:
|
||||
viewport = {'width': 1280, 'height': 720} # 默认值
|
||||
|
||||
# 存储会话
|
||||
self.active_sessions[session_id] = {
|
||||
'page': page,
|
||||
'screenshot': screenshot_base64,
|
||||
'captcha_info': captcha_info,
|
||||
'completed': False,
|
||||
'viewport': viewport
|
||||
}
|
||||
|
||||
logger.info(f"✅ 创建远程控制会话: {session_id}")
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'screenshot': screenshot_base64,
|
||||
'captcha_info': captcha_info,
|
||||
'viewport': self.active_sessions[session_id]['viewport']
|
||||
}
|
||||
|
||||
async def _screenshot_captcha_area(self, page: Page, captcha_info: Dict[str, Any]) -> bytes:
|
||||
"""截取整个验证码容器区域"""
|
||||
try:
|
||||
if captcha_info and 'x' in captcha_info:
|
||||
# 直接截取整个容器,稍微留一点边距
|
||||
x = max(0, captcha_info['x'] - 10)
|
||||
y = max(0, captcha_info['y'] - 10)
|
||||
width = captcha_info['width'] + 20
|
||||
height = captcha_info['height'] + 20
|
||||
|
||||
# 截取整个验证码容器
|
||||
screenshot_bytes = await page.screenshot(
|
||||
type='jpeg',
|
||||
quality=80, # 验证码区域用高质量
|
||||
clip={
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height
|
||||
}
|
||||
)
|
||||
logger.info(f"✅ 截取验证码容器: {width}x{height} (包含完整验证码)")
|
||||
return screenshot_bytes
|
||||
else:
|
||||
# 如果没有找到滑块,截取整个页面
|
||||
logger.warning("未找到滑块位置,截取整个页面")
|
||||
return await page.screenshot(type='jpeg', quality=75, full_page=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"截取滑块区域失败,使用全页面: {e}")
|
||||
return await page.screenshot(type='jpeg', quality=75, full_page=False)
|
||||
|
||||
async def _get_captcha_info(self, page: Page) -> Dict[str, Any]:
|
||||
"""获取滑块验证码信息(查找整个容器)"""
|
||||
try:
|
||||
# 优先查找整个验证码容器(不是按钮)
|
||||
container_selectors = [
|
||||
'#nocaptcha', # 完整的验证码容器
|
||||
'.scratch-captcha-container',
|
||||
'[id*="captcha"]',
|
||||
'.nc-container'
|
||||
]
|
||||
|
||||
# 先在主页面查找
|
||||
for selector in container_selectors:
|
||||
try:
|
||||
element = await page.query_selector(selector)
|
||||
if element:
|
||||
box = await element.bounding_box()
|
||||
if box and box['width'] > 100 and box['height'] > 100: # 确保找到的是容器
|
||||
logger.info(f"✅ 在主页面找到验证码容器: {selector}, 大小: {box['width']}x{box['height']}")
|
||||
return {
|
||||
'selector': selector,
|
||||
'x': box['x'],
|
||||
'y': box['y'],
|
||||
'width': box['width'],
|
||||
'height': box['height'],
|
||||
'in_iframe': False
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"检查选择器 {selector} 失败: {e}")
|
||||
continue
|
||||
|
||||
# 在 iframe 中查找
|
||||
frames = page.frames
|
||||
for frame in frames:
|
||||
if frame != page.main_frame:
|
||||
for selector in container_selectors:
|
||||
try:
|
||||
element = await frame.query_selector(selector)
|
||||
if element:
|
||||
box = await element.bounding_box()
|
||||
if box and box['width'] > 100 and box['height'] > 100:
|
||||
logger.info(f"✅ 在iframe找到验证码容器: {selector}, 大小: {box['width']}x{box['height']}")
|
||||
return {
|
||||
'selector': selector,
|
||||
'x': box['x'],
|
||||
'y': box['y'],
|
||||
'width': box['width'],
|
||||
'height': box['height'],
|
||||
'in_iframe': True
|
||||
# 注意:不保存 frame 对象,因为不能被 JSON 序列化
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"iframe检查选择器 {selector} 失败: {e}")
|
||||
continue
|
||||
|
||||
logger.warning("⚠️ 未找到验证码容器")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取滑块信息失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_screenshot(self, session_id: str, quality: int = 75) -> Optional[str]:
|
||||
"""更新会话的截图(截取整个验证码容器)"""
|
||||
if session_id not in self.active_sessions:
|
||||
return None
|
||||
|
||||
try:
|
||||
page = self.active_sessions[session_id]['page']
|
||||
captcha_info = self.active_sessions[session_id].get('captcha_info')
|
||||
|
||||
# 截取整个验证码容器
|
||||
if captcha_info and 'x' in captcha_info:
|
||||
x = max(0, captcha_info['x'] - 10)
|
||||
y = max(0, captcha_info['y'] - 10)
|
||||
width = captcha_info['width'] + 20
|
||||
height = captcha_info['height'] + 20
|
||||
|
||||
screenshot_bytes = await page.screenshot(
|
||||
type='jpeg',
|
||||
quality=quality,
|
||||
clip={'x': x, 'y': y, 'width': width, 'height': height}
|
||||
)
|
||||
else:
|
||||
# 降级方案:截取整个页面
|
||||
screenshot_bytes = await page.screenshot(
|
||||
type='jpeg',
|
||||
quality=quality,
|
||||
full_page=False
|
||||
)
|
||||
|
||||
screenshot_base64 = base64.b64encode(screenshot_bytes).decode('utf-8')
|
||||
self.active_sessions[session_id]['screenshot'] = screenshot_base64
|
||||
return screenshot_base64
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新截图失败: {e}")
|
||||
return None
|
||||
|
||||
async def handle_mouse_event(self, session_id: str, event_type: str, x: int, y: int) -> bool:
|
||||
"""
|
||||
处理鼠标事件
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
event_type: 事件类型 (down/move/up)
|
||||
x: X坐标
|
||||
y: Y坐标
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if session_id not in self.active_sessions:
|
||||
logger.warning(f"会话不存在: {session_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
page = self.active_sessions[session_id]['page']
|
||||
|
||||
if event_type == 'down':
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down()
|
||||
logger.debug(f"鼠标按下: ({x}, {y})")
|
||||
|
||||
elif event_type == 'move':
|
||||
await page.mouse.move(x, y)
|
||||
logger.debug(f"鼠标移动: ({x}, {y})")
|
||||
|
||||
elif event_type == 'up':
|
||||
await page.mouse.up()
|
||||
logger.debug(f"鼠标释放: ({x}, {y})")
|
||||
|
||||
else:
|
||||
logger.warning(f"未知事件类型: {event_type}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理鼠标事件失败: {e}")
|
||||
return False
|
||||
|
||||
async def check_completion(self, session_id: str) -> bool:
|
||||
"""检查验证是否完成(更严格的判断)"""
|
||||
if session_id not in self.active_sessions:
|
||||
return False
|
||||
|
||||
try:
|
||||
page = self.active_sessions[session_id]['page']
|
||||
|
||||
# 多个选择器检查,确保更准确
|
||||
captcha_selectors = [
|
||||
'#nocaptcha',
|
||||
'#scratch-captcha-btn',
|
||||
'.scratch-captcha-container',
|
||||
'.scratch-captcha-slider'
|
||||
]
|
||||
|
||||
found_visible_captcha = False
|
||||
|
||||
# 检查主页面
|
||||
for selector in captcha_selectors:
|
||||
try:
|
||||
element = await page.query_selector(selector)
|
||||
if element:
|
||||
is_visible = await element.is_visible()
|
||||
if is_visible:
|
||||
logger.debug(f"主页面发现可见滑块: {selector}")
|
||||
found_visible_captcha = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if found_visible_captcha:
|
||||
return False
|
||||
|
||||
# 检查所有 iframe
|
||||
frames = page.frames
|
||||
for frame in frames:
|
||||
if frame != page.main_frame:
|
||||
for selector in captcha_selectors:
|
||||
try:
|
||||
element = await frame.query_selector(selector)
|
||||
if element:
|
||||
is_visible = await element.is_visible()
|
||||
if is_visible:
|
||||
logger.debug(f"iframe中发现可见滑块: {selector}")
|
||||
found_visible_captcha = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
if found_visible_captcha:
|
||||
break
|
||||
|
||||
if found_visible_captcha:
|
||||
return False
|
||||
|
||||
# 额外检查:看页面内容是否还包含滑块相关文字
|
||||
try:
|
||||
page_content = await page.content()
|
||||
captcha_keywords = ['scratch-captcha', 'nocaptcha', 'slider-btn']
|
||||
|
||||
# 如果页面中仍然有大量滑块相关内容,可能还未完成
|
||||
keyword_count = sum(1 for kw in captcha_keywords if kw in page_content)
|
||||
if keyword_count >= 2:
|
||||
logger.debug(f"页面中仍有 {keyword_count} 个滑块关键词")
|
||||
return False
|
||||
except:
|
||||
pass
|
||||
|
||||
# 所有检查都通过,认为验证完成
|
||||
logger.success(f"✅ 验证完成(所有滑块元素已消失): {session_id}")
|
||||
self.active_sessions[session_id]['completed'] = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查完成状态失败: {e}")
|
||||
# 出错时返回 False,不要误判为成功
|
||||
return False
|
||||
|
||||
def is_completed(self, session_id: str) -> bool:
|
||||
"""检查会话是否已完成"""
|
||||
if session_id not in self.active_sessions:
|
||||
return False
|
||||
return self.active_sessions[session_id].get('completed', False)
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
"""检查会话是否存在"""
|
||||
return session_id in self.active_sessions
|
||||
|
||||
async def close_session(self, session_id: str):
|
||||
"""关闭会话"""
|
||||
if session_id in self.active_sessions:
|
||||
del self.active_sessions[session_id]
|
||||
logger.info(f"🔒 关闭远程控制会话: {session_id}")
|
||||
|
||||
async def auto_refresh_screenshot(self, session_id: str, interval: float = 1.0):
|
||||
"""自动刷新截图(优化版:按需更新)"""
|
||||
last_update_time = asyncio.get_event_loop().time()
|
||||
|
||||
while session_id in self.active_sessions and not self.is_completed(session_id):
|
||||
try:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
|
||||
# 使用自适应刷新:空闲时降低频率
|
||||
if current_time - last_update_time >= interval:
|
||||
screenshot = await self.update_screenshot(session_id, quality=55) # 降低质量提升性能
|
||||
|
||||
if screenshot and session_id in self.websocket_connections:
|
||||
try:
|
||||
ws = self.websocket_connections[session_id]
|
||||
await ws.send_json({
|
||||
'type': 'screenshot_update',
|
||||
'screenshot': screenshot
|
||||
})
|
||||
last_update_time = current_time
|
||||
except:
|
||||
# WebSocket 可能已断开
|
||||
break
|
||||
|
||||
# 降低检查频率,减少 CPU 使用
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"自动刷新截图失败: {e}")
|
||||
await asyncio.sleep(1) # 出错时等待更长时间
|
||||
|
||||
|
||||
# 全局实例
|
||||
captcha_controller = CaptchaRemoteController()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
图片上传器 - 负责将图片上传到闲鱼CDN
|
||||
"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Any
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
|
||||
class ImageUploader:
|
||||
"""图片上传器 - 上传图片到闲鱼CDN"""
|
||||
|
||||
def __init__(self, cookies_str: str):
|
||||
self.cookies_str = cookies_str
|
||||
self.upload_url = "https://stream-upload.goofish.com/api/upload.api?floderId=0&appkey=xy_chat&_input_charset=utf-8"
|
||||
self.session = None
|
||||
self.last_error_type = None
|
||||
self.last_error_message = None
|
||||
self.last_http_status = None
|
||||
|
||||
def _set_last_error(self, error_type: Optional[str], message: Optional[str] = None, status: Optional[int] = None):
|
||||
self.last_error_type = error_type
|
||||
self.last_error_message = message
|
||||
self.last_http_status = status
|
||||
|
||||
async def create_session(self):
|
||||
"""创建HTTP会话"""
|
||||
if not self.session:
|
||||
connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
self.session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout,
|
||||
headers={
|
||||
'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'
|
||||
}
|
||||
)
|
||||
|
||||
async def close_session(self):
|
||||
"""关闭HTTP会话"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
|
||||
def _compress_image(self, image_path: str, max_size: int = 5 * 1024 * 1024, quality: int = 85) -> Optional[str]:
|
||||
"""压缩图片"""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
# 转换为RGB模式(如果是RGBA或其他模式)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
# 创建白色背景
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 获取原始尺寸
|
||||
original_width, original_height = img.size
|
||||
|
||||
# 如果图片太大,调整尺寸
|
||||
max_dimension = 1920
|
||||
if original_width > max_dimension or original_height > max_dimension:
|
||||
if original_width > original_height:
|
||||
new_width = max_dimension
|
||||
new_height = int((original_height * max_dimension) / original_width)
|
||||
else:
|
||||
new_height = max_dimension
|
||||
new_width = int((original_width * max_dimension) / original_height)
|
||||
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
logger.info(f"图片尺寸调整: {original_width}x{original_height} -> {new_width}x{new_height}")
|
||||
|
||||
# 创建临时文件
|
||||
temp_fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
os.close(temp_fd)
|
||||
|
||||
# 保存压缩后的图片
|
||||
img.save(temp_path, 'JPEG', quality=quality, optimize=True)
|
||||
|
||||
# 检查文件大小
|
||||
file_size = os.path.getsize(temp_path)
|
||||
if file_size > max_size:
|
||||
# 如果还是太大,降低质量
|
||||
quality = max(30, quality - 20)
|
||||
img.save(temp_path, 'JPEG', quality=quality, optimize=True)
|
||||
file_size = os.path.getsize(temp_path)
|
||||
logger.info(f"图片质量调整为 {quality}%,文件大小: {file_size / 1024:.1f}KB")
|
||||
|
||||
logger.info(f"图片压缩完成: {file_size / 1024:.1f}KB")
|
||||
return temp_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图片压缩失败: {e}")
|
||||
return None
|
||||
|
||||
async def upload_image(self, image_path: str) -> Optional[str]:
|
||||
"""上传图片到闲鱼CDN"""
|
||||
temp_path = None
|
||||
self._set_last_error(None)
|
||||
try:
|
||||
if not self.session:
|
||||
await self.create_session()
|
||||
|
||||
# 压缩图片
|
||||
temp_path = self._compress_image(image_path)
|
||||
if not temp_path:
|
||||
logger.error("图片压缩失败")
|
||||
return None
|
||||
|
||||
# 读取压缩后的图片数据
|
||||
with open(temp_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
# 构造文件名
|
||||
filename = os.path.basename(image_path)
|
||||
if not filename.lower().endswith(('.jpg', '.jpeg')):
|
||||
filename = os.path.splitext(filename)[0] + '.jpg'
|
||||
|
||||
# 构造请求头
|
||||
headers = {
|
||||
'cookie': self.cookies_str,
|
||||
'Referer': 'https://www.goofish.com/',
|
||||
'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',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-site'
|
||||
}
|
||||
|
||||
# 构造multipart/form-data
|
||||
data = aiohttp.FormData()
|
||||
data.add_field('file', image_data, filename=filename, content_type='image/jpeg')
|
||||
|
||||
# 发送上传请求
|
||||
logger.info(f"开始上传图片到闲鱼CDN: {filename}")
|
||||
async with self.session.post(self.upload_url, data=data, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
response_text = await response.text()
|
||||
logger.debug(f"上传响应: {response_text}")
|
||||
|
||||
# 解析响应获取图片URL
|
||||
image_url = self._parse_upload_response(response_text)
|
||||
if image_url:
|
||||
self._set_last_error(None)
|
||||
logger.info(f"图片上传成功: {image_url}")
|
||||
return image_url
|
||||
else:
|
||||
logger.error("解析上传响应失败")
|
||||
return None
|
||||
else:
|
||||
error_type = 'auth' if response.status in (401, 403) else 'http'
|
||||
self._set_last_error(error_type, f"HTTP {response.status}", response.status)
|
||||
logger.error(f"图片上传失败: HTTP {response.status}")
|
||||
return None
|
||||
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
||||
self._set_last_error('network', str(e))
|
||||
logger.error(f"图片上传异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self._set_last_error('unknown', str(e))
|
||||
logger.error(f"图片上传异常: {e}")
|
||||
return None
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _parse_upload_response(self, response_text: str) -> Optional[str]:
|
||||
"""解析上传响应获取图片URL"""
|
||||
try:
|
||||
# 检查是否返回了登录页面(Cookie失效的标志)
|
||||
if '<!DOCTYPE html>' in response_text or '<html>' in response_text:
|
||||
if '闲鱼' in response_text and ('login' in response_text.lower() or 'mini-login' in response_text):
|
||||
self._set_last_error('auth', '返回登录页面')
|
||||
logger.error("❌ 图片上传失败:Cookie已失效,返回了登录页面!请重新登录获取有效的Cookie")
|
||||
logger.error("💡 解决方法:")
|
||||
logger.error(" 1. 打开浏览器访问 https://www.goofish.com/")
|
||||
logger.error(" 2. 登录您的闲鱼账号")
|
||||
logger.error(" 3. 按F12打开开发者工具,在控制台输入: document.cookie")
|
||||
logger.error(" 4. 复制完整的Cookie字符串,更新配置文件中的Cookie")
|
||||
return None
|
||||
else:
|
||||
self._set_last_error('html_response', '返回HTML页面')
|
||||
logger.error(f"收到HTML响应而非JSON,可能是Cookie失效: {response_text[:500]}")
|
||||
return None
|
||||
|
||||
# 尝试解析JSON响应
|
||||
response_data = json.loads(response_text)
|
||||
|
||||
# 方式1: 标准响应格式
|
||||
if 'data' in response_data and 'url' in response_data['data']:
|
||||
self._set_last_error(None)
|
||||
return response_data['data']['url']
|
||||
|
||||
# 方式2: 在object字段中(闲鱼CDN的响应格式)
|
||||
if 'object' in response_data and isinstance(response_data['object'], dict):
|
||||
obj = response_data['object']
|
||||
if 'url' in obj:
|
||||
self._set_last_error(None)
|
||||
logger.info(f"从object.url提取到图片URL: {obj['url']}")
|
||||
return obj['url']
|
||||
|
||||
# 方式3: 直接在根级别
|
||||
if 'url' in response_data:
|
||||
self._set_last_error(None)
|
||||
return response_data['url']
|
||||
|
||||
# 方式4: 在result中
|
||||
if 'result' in response_data and 'url' in response_data['result']:
|
||||
self._set_last_error(None)
|
||||
return response_data['result']['url']
|
||||
|
||||
# 方式5: 检查是否有文件信息
|
||||
if 'data' in response_data and isinstance(response_data['data'], dict):
|
||||
data = response_data['data']
|
||||
if 'fileUrl' in data:
|
||||
self._set_last_error(None)
|
||||
return data['fileUrl']
|
||||
if 'file_url' in data:
|
||||
self._set_last_error(None)
|
||||
return data['file_url']
|
||||
|
||||
self._set_last_error('response_parse', '无法从响应中提取图片URL')
|
||||
logger.error(f"无法从响应中提取图片URL: {response_data}")
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON格式,尝试其他解析方式
|
||||
self._set_last_error('response_parse', '响应不是有效JSON格式')
|
||||
logger.error(f"响应不是有效的JSON格式,可能是Cookie失效: {response_text[:200]}...")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"解析上传响应异常: {e}")
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.create_session()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close_session()
|
||||
@@ -0,0 +1,256 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
from PIL import Image
|
||||
from typing import Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
class ImageManager:
|
||||
"""图片管理器,负责图片的保存、压缩和访问"""
|
||||
|
||||
def __init__(self, upload_dir: str = "static/uploads/images"):
|
||||
"""初始化图片管理器
|
||||
|
||||
Args:
|
||||
upload_dir: 图片上传目录
|
||||
"""
|
||||
self.upload_dir = upload_dir
|
||||
self.max_size = 5 * 1024 * 1024 # 5MB
|
||||
self.max_width = 1920
|
||||
self.max_height = 1080
|
||||
self.allowed_formats = {'JPEG', 'PNG', 'GIF', 'WEBP'}
|
||||
|
||||
# 确保上传目录存在
|
||||
self._ensure_upload_dir()
|
||||
|
||||
def _ensure_upload_dir(self):
|
||||
"""确保上传目录存在"""
|
||||
try:
|
||||
os.makedirs(self.upload_dir, exist_ok=True)
|
||||
logger.info(f"图片上传目录已准备: {self.upload_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"创建图片上传目录失败: {e}")
|
||||
raise
|
||||
|
||||
def save_image(self, image_data: bytes, original_filename: str = None) -> Optional[str]:
|
||||
"""保存图片文件
|
||||
|
||||
Args:
|
||||
image_data: 图片二进制数据
|
||||
original_filename: 原始文件名(可选)
|
||||
|
||||
Returns:
|
||||
保存成功返回相对路径,失败返回None
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始保存图片,数据大小: {len(image_data)} bytes")
|
||||
|
||||
# 验证图片数据
|
||||
if not self._validate_image_data(image_data):
|
||||
logger.error("图片数据验证失败")
|
||||
return None
|
||||
|
||||
# 生成唯一文件名
|
||||
file_hash = hashlib.md5(image_data).hexdigest()
|
||||
file_extension = self._get_image_extension(image_data)
|
||||
filename = f"{file_hash}_{uuid.uuid4().hex[:8]}.{file_extension}"
|
||||
|
||||
# 完整文件路径
|
||||
file_path = os.path.join(self.upload_dir, filename)
|
||||
|
||||
# 检查文件是否已存在
|
||||
if os.path.exists(file_path):
|
||||
logger.info(f"图片文件已存在,跳过保存: {filename}")
|
||||
return self._get_relative_path(file_path)
|
||||
|
||||
# 处理和保存图片
|
||||
processed_image_data = self._process_image(image_data)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(processed_image_data)
|
||||
|
||||
logger.info(f"图片保存成功: {filename}")
|
||||
return self._get_relative_path(file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存图片失败: {e}")
|
||||
return None
|
||||
|
||||
def _validate_image_data(self, image_data: bytes) -> bool:
|
||||
"""验证图片数据"""
|
||||
try:
|
||||
# 检查文件大小
|
||||
if len(image_data) > self.max_size:
|
||||
logger.warning(f"图片文件过大: {len(image_data)} bytes > {self.max_size} bytes")
|
||||
return False
|
||||
|
||||
# 尝试打开图片验证格式
|
||||
from io import BytesIO
|
||||
with Image.open(BytesIO(image_data)) as img:
|
||||
if img.format not in self.allowed_formats:
|
||||
logger.warning(f"不支持的图片格式: {img.format}")
|
||||
return False
|
||||
|
||||
# 检查图片尺寸(允许更大的尺寸,特别是手机长截图)
|
||||
width, height = img.size
|
||||
max_dimension = 4096 # 最大边长4096像素
|
||||
if width > max_dimension or height > max_dimension:
|
||||
logger.warning(f"图片尺寸过大: {width}x{height},最大允许: {max_dimension}x{max_dimension}")
|
||||
return False
|
||||
|
||||
# 检查图片像素总数(防止过大的图片占用太多内存)
|
||||
total_pixels = width * height
|
||||
max_pixels = 8 * 1024 * 1024 # 8M像素
|
||||
if total_pixels > max_pixels:
|
||||
logger.warning(f"图片像素总数过大: {total_pixels},最大允许: {max_pixels}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图片验证失败: {e}")
|
||||
return False
|
||||
|
||||
def _get_image_extension(self, image_data: bytes) -> str:
|
||||
"""获取图片扩展名"""
|
||||
try:
|
||||
from io import BytesIO
|
||||
with Image.open(BytesIO(image_data)) as img:
|
||||
format_to_ext = {
|
||||
'JPEG': 'jpg',
|
||||
'PNG': 'png',
|
||||
'GIF': 'gif',
|
||||
'WEBP': 'webp'
|
||||
}
|
||||
return format_to_ext.get(img.format, 'jpg')
|
||||
except:
|
||||
return 'jpg'
|
||||
|
||||
def _process_image(self, image_data: bytes) -> bytes:
|
||||
"""处理图片(压缩、调整尺寸等)"""
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
with Image.open(BytesIO(image_data)) as img:
|
||||
# 转换为RGB模式(如果需要)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
# 创建白色背景
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 调整尺寸(如果需要)- 允许更大的尺寸
|
||||
width, height = img.size
|
||||
max_output_dimension = 2048 # 输出最大边长2048像素
|
||||
|
||||
if width > max_output_dimension or height > max_output_dimension:
|
||||
# 计算缩放比例,保持宽高比
|
||||
ratio = min(max_output_dimension / width, max_output_dimension / height)
|
||||
new_width = int(width * ratio)
|
||||
new_height = int(height * ratio)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
logger.info(f"图片已调整尺寸: {width}x{height} -> {new_width}x{new_height}")
|
||||
else:
|
||||
logger.info(f"图片尺寸合适,无需调整: {width}x{height}")
|
||||
|
||||
# 保存为JPEG格式,适度压缩
|
||||
output = BytesIO()
|
||||
img.save(output, format='JPEG', quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图片处理失败: {e}")
|
||||
# 如果处理失败,返回原始数据
|
||||
return image_data
|
||||
|
||||
def _get_relative_path(self, file_path: str) -> str:
|
||||
"""获取相对于项目根目录的路径"""
|
||||
# 将绝对路径转换为相对路径
|
||||
rel_path = os.path.relpath(file_path)
|
||||
# 统一使用正斜杠
|
||||
return rel_path.replace('\\', '/')
|
||||
|
||||
def delete_image(self, image_path: str) -> bool:
|
||||
"""删除图片文件
|
||||
|
||||
Args:
|
||||
image_path: 图片相对路径
|
||||
|
||||
Returns:
|
||||
删除成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
# 构建完整路径
|
||||
if not image_path.startswith(self.upload_dir):
|
||||
full_path = os.path.join(os.getcwd(), image_path)
|
||||
else:
|
||||
full_path = image_path
|
||||
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
logger.info(f"图片删除成功: {image_path}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"图片文件不存在: {image_path}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除图片失败: {e}")
|
||||
return False
|
||||
|
||||
def get_image_info(self, image_path: str) -> Optional[dict]:
|
||||
"""获取图片信息
|
||||
|
||||
Args:
|
||||
image_path: 图片相对路径
|
||||
|
||||
Returns:
|
||||
图片信息字典或None
|
||||
"""
|
||||
try:
|
||||
# 构建完整路径
|
||||
if not image_path.startswith(self.upload_dir):
|
||||
full_path = os.path.join(os.getcwd(), image_path)
|
||||
else:
|
||||
full_path = image_path
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return None
|
||||
|
||||
with Image.open(full_path) as img:
|
||||
return {
|
||||
'width': img.width,
|
||||
'height': img.height,
|
||||
'format': img.format,
|
||||
'mode': img.mode,
|
||||
'size': os.path.getsize(full_path)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取图片信息失败: {e}")
|
||||
return None
|
||||
|
||||
def get_image_size(self, image_path: str) -> tuple:
|
||||
"""获取图片尺寸
|
||||
|
||||
Args:
|
||||
image_path: 图片相对路径
|
||||
|
||||
Returns:
|
||||
(width, height) 或 (None, None)
|
||||
"""
|
||||
try:
|
||||
info = self.get_image_info(image_path)
|
||||
if info:
|
||||
return info['width'], info['height']
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"获取图片尺寸失败: {e}")
|
||||
return None, None
|
||||
|
||||
# 创建全局图片管理器实例
|
||||
image_manager = ImageManager()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
||||
SUPPORTED_NOTIFICATION_TEMPLATE_TYPES = (
|
||||
'message',
|
||||
'token_refresh',
|
||||
'delivery',
|
||||
'slider_success',
|
||||
'face_verify',
|
||||
'password_login_success',
|
||||
'cookie_refresh_success',
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
'message': '''🚨 接收消息通知
|
||||
|
||||
账号: {account_id}
|
||||
买家: {buyer_name} (ID: {buyer_id})
|
||||
商品ID: {item_id}
|
||||
聊天ID: {chat_id}
|
||||
消息内容: {message}
|
||||
|
||||
时间: {time}''',
|
||||
'token_refresh': '''Token刷新异常
|
||||
|
||||
账号ID: {account_id}
|
||||
异常时间: {time}
|
||||
异常信息: {error_message}
|
||||
|
||||
请检查账号Cookie是否过期,如有需要请及时更新Cookie配置。''',
|
||||
'delivery': '''🚨 自动发货通知
|
||||
|
||||
账号: {account_id}
|
||||
买家: {buyer_name} (ID: {buyer_id})
|
||||
商品ID: {item_id}
|
||||
聊天ID: {chat_id}
|
||||
结果: {result}
|
||||
时间: {time}
|
||||
|
||||
请及时处理!''',
|
||||
'slider_success': '''✅ 滑块验证成功,{status_text}
|
||||
|
||||
账号: {account_id}
|
||||
时间: {time}''',
|
||||
'face_verify': '''⚠️ 需要{verification_type} 🚫
|
||||
在验证期间,发货及自动回复暂时无法使用。
|
||||
|
||||
{verification_action}
|
||||
{verification_url}
|
||||
|
||||
账号: {account_id}
|
||||
时间: {time}''',
|
||||
'password_login_success': '''✅ 密码登录成功
|
||||
|
||||
账号: {account_id}
|
||||
时间: {time}
|
||||
Cookie数量: {cookie_count}
|
||||
|
||||
账号Cookie已更新,正在重启服务...''',
|
||||
'cookie_refresh_success': '''✅ 刷新Cookie成功
|
||||
|
||||
账号: {account_id}
|
||||
时间: {time}
|
||||
Cookie数量: {cookie_count}
|
||||
|
||||
账号已可正常使用。''',
|
||||
}
|
||||
|
||||
|
||||
VERIFICATION_TYPE_LABELS = {
|
||||
'face_verify': '人脸验证',
|
||||
'sms_verify': '短信验证',
|
||||
'qr_verify': '二维码验证',
|
||||
'unknown': '身份验证',
|
||||
}
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str:
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return repr(value)
|
||||
|
||||
|
||||
def normalize_channel_type(channel_type: Any) -> str:
|
||||
normalized = str(channel_type or '').strip().lower()
|
||||
mapping = {
|
||||
'ding_talk': 'dingtalk',
|
||||
'dingtalk': 'dingtalk',
|
||||
'dingding': 'dingtalk',
|
||||
'feishu': 'feishu',
|
||||
'lark': 'feishu',
|
||||
'qq': 'qq',
|
||||
'email': 'email',
|
||||
'webhook': 'webhook',
|
||||
'wechat': 'wechat',
|
||||
'telegram': 'telegram',
|
||||
'tg': 'telegram',
|
||||
'bark': 'bark',
|
||||
}
|
||||
return mapping.get(normalized, normalized)
|
||||
|
||||
|
||||
def parse_notification_config(config: Any) -> Dict[str, Any]:
|
||||
if isinstance(config, dict):
|
||||
return dict(config)
|
||||
|
||||
try:
|
||||
if isinstance(config, str):
|
||||
return json.loads(config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {'config': config}
|
||||
|
||||
|
||||
def get_notification_template_text(template_type: str) -> str:
|
||||
from db_manager import db_manager
|
||||
|
||||
try:
|
||||
template_data = db_manager.get_notification_template(template_type)
|
||||
if template_data and template_data.get('template'):
|
||||
return template_data['template']
|
||||
except Exception as exc:
|
||||
logger.warning(f"获取通知模板失败: {_safe_str(exc)}")
|
||||
|
||||
return DEFAULT_NOTIFICATION_TEMPLATES.get(template_type, '')
|
||||
|
||||
|
||||
def format_notification_template(template: str, **kwargs: Any) -> str:
|
||||
rendered = template or ''
|
||||
try:
|
||||
for key, value in kwargs.items():
|
||||
rendered = rendered.replace(f'{{{key}}}', str(value) if value is not None else '未知')
|
||||
return rendered
|
||||
except Exception as exc:
|
||||
logger.error(f"格式化模板失败: {_safe_str(exc)}")
|
||||
return rendered
|
||||
|
||||
|
||||
def render_notification_template(template_type: str, **kwargs: Any) -> str:
|
||||
template = get_notification_template_text(template_type)
|
||||
return format_notification_template(template, **kwargs)
|
||||
|
||||
|
||||
def guess_verification_type(error_message: str = '', verification_url: str = '') -> str:
|
||||
text = f"{error_message or ''} {verification_url or ''}"
|
||||
if '人脸' in text:
|
||||
return '人脸验证'
|
||||
if '短信' in text:
|
||||
return '短信验证'
|
||||
if '二维码' in text or '扫码' in text:
|
||||
return '二维码验证'
|
||||
return '身份验证'
|
||||
|
||||
|
||||
def resolve_verification_type_label(
|
||||
verification_type: str = '',
|
||||
error_message: str = '',
|
||||
verification_url: str = '',
|
||||
) -> str:
|
||||
normalized = str(verification_type or '').strip()
|
||||
if normalized in VERIFICATION_TYPE_LABELS:
|
||||
return VERIFICATION_TYPE_LABELS[normalized]
|
||||
if normalized in VERIFICATION_TYPE_LABELS.values():
|
||||
return normalized
|
||||
return guess_verification_type(error_message, verification_url)
|
||||
|
||||
|
||||
def build_face_verify_notification(
|
||||
account_id: str,
|
||||
time_text: str,
|
||||
*,
|
||||
verification_type: str = '',
|
||||
verification_url: str = '',
|
||||
error_message: str = '',
|
||||
has_screenshot: bool = False,
|
||||
) -> str:
|
||||
verification_type_label = resolve_verification_type_label(
|
||||
verification_type,
|
||||
error_message,
|
||||
verification_url,
|
||||
)
|
||||
|
||||
if has_screenshot:
|
||||
verification_action = '请在自动化网站的账号管理弹窗中扫描二维码完成验证:'
|
||||
verification_target = '自动化网站账号管理弹窗中的验证二维码'
|
||||
else:
|
||||
verification_action = '请点击验证链接完成验证:'
|
||||
verification_target = verification_url or '无'
|
||||
|
||||
return render_notification_template(
|
||||
'face_verify',
|
||||
account_id=account_id,
|
||||
time=time_text,
|
||||
verification_action=verification_action,
|
||||
verification_url=verification_target,
|
||||
verification_type=verification_type_label,
|
||||
)
|
||||
|
||||
|
||||
async def _send_qq_notification(config_data: Dict[str, Any], message: str, *, account_id: str = '') -> bool:
|
||||
qq_number = (config_data.get('qq_number') or config_data.get('config', '') or '').strip()
|
||||
if not qq_number:
|
||||
logger.warning(f"【{account_id}】QQ通知配置为空")
|
||||
return False
|
||||
|
||||
api_url = 'http://36.111.68.231:3000/sendPrivateMsg'
|
||||
params = {'qq': qq_number, 'msg': message}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(api_url, params=params, timeout=10) as response:
|
||||
if response.status in (200, 502):
|
||||
logger.info(f"【{account_id}】QQ通知发送成功")
|
||||
return True
|
||||
logger.warning(f"【{account_id}】QQ通知发送失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_dingtalk_notification(config_data: Dict[str, Any], message: str, *, title: str, account_id: str = '') -> bool:
|
||||
webhook_url = (config_data.get('webhook_url') or config_data.get('config', '') or '').strip()
|
||||
secret = config_data.get('secret', '')
|
||||
if not webhook_url:
|
||||
logger.warning(f"【{account_id}】钉钉通知配置为空")
|
||||
return False
|
||||
|
||||
if secret:
|
||||
timestamp = str(round(time.time() * 1000))
|
||||
secret_enc = secret.encode('utf-8')
|
||||
string_to_sign = f'{timestamp}\n{secret}'.encode('utf-8')
|
||||
sign = base64.b64encode(hmac.new(secret_enc, string_to_sign, digestmod=hashlib.sha256).digest()).decode('utf-8')
|
||||
webhook_url += f'×tamp={timestamp}&sign={sign}'
|
||||
|
||||
data = {
|
||||
'msgtype': 'markdown',
|
||||
'markdown': {
|
||||
'title': title,
|
||||
'text': message,
|
||||
},
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"【{account_id}】钉钉通知发送成功")
|
||||
return True
|
||||
logger.warning(f"【{account_id}】钉钉通知发送失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_feishu_notification(config_data: Dict[str, Any], message: str, *, account_id: str = '') -> bool:
|
||||
webhook_url = config_data.get('webhook_url', '')
|
||||
secret = config_data.get('secret', '')
|
||||
if not webhook_url:
|
||||
logger.warning(f"【{account_id}】飞书通知未配置webhook")
|
||||
return False
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
data = {
|
||||
'msg_type': 'text',
|
||||
'content': {'text': message},
|
||||
'timestamp': timestamp,
|
||||
}
|
||||
if secret:
|
||||
string_to_sign = f'{timestamp}\n{secret}'
|
||||
hmac_code = hmac.new(string_to_sign.encode('utf-8'), ''.encode('utf-8'), digestmod=hashlib.sha256).digest()
|
||||
data['sign'] = base64.b64encode(hmac_code).decode('utf-8')
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||||
response_text = await response.text()
|
||||
if response.status != 200:
|
||||
logger.warning(f"【{account_id}】飞书通知发送失败: HTTP {response.status}, 响应: {response_text}")
|
||||
return False
|
||||
try:
|
||||
response_json = json.loads(response_text)
|
||||
if response_json.get('code') not in (None, 0):
|
||||
logger.warning(f"【{account_id}】飞书通知发送失败: {response_json.get('msg', '未知错误')}")
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.info(f"【{account_id}】飞书通知发送成功")
|
||||
return True
|
||||
|
||||
|
||||
async def _send_bark_notification(config_data: Dict[str, Any], message: str, *, title: str, account_id: str = '') -> bool:
|
||||
server_url = str(config_data.get('server_url', 'https://api.day.app') or 'https://api.day.app').rstrip('/')
|
||||
device_key = config_data.get('device_key', '')
|
||||
if not device_key:
|
||||
logger.warning(f"【{account_id}】Bark通知未配置设备密钥")
|
||||
return False
|
||||
|
||||
data = {
|
||||
'device_key': device_key,
|
||||
'title': config_data.get('title') or title,
|
||||
'body': message,
|
||||
'sound': config_data.get('sound', 'default'),
|
||||
'group': config_data.get('group', 'xianyu'),
|
||||
}
|
||||
if config_data.get('icon'):
|
||||
data['icon'] = config_data['icon']
|
||||
if config_data.get('url'):
|
||||
data['url'] = config_data['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(f'{server_url}/push', json=data, timeout=10) as response:
|
||||
response_text = await response.text()
|
||||
if response.status != 200:
|
||||
logger.warning(f"【{account_id}】Bark通知发送失败: HTTP {response.status}, 响应: {response_text}")
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(response_text)
|
||||
if payload.get('code') != 200:
|
||||
logger.warning(f"【{account_id}】Bark通知发送失败: {payload.get('message', '未知错误')}")
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
if 'success' not in response_text.lower() and 'ok' not in response_text.lower():
|
||||
logger.warning(f"【{account_id}】Bark通知响应格式异常: {response_text}")
|
||||
return False
|
||||
logger.info(f"【{account_id}】Bark通知发送成功")
|
||||
return True
|
||||
|
||||
|
||||
async def _send_email_notification(config_data: Dict[str, Any], message: str, *, title: str, attachment_path: Optional[str] = None, account_id: str = '') -> bool:
|
||||
smtp_server = config_data.get('smtp_server', '')
|
||||
smtp_port = int(config_data.get('smtp_port', 587))
|
||||
email_user = config_data.get('email_user', '')
|
||||
email_password = config_data.get('email_password', '')
|
||||
recipient_email = config_data.get('recipient_email', '')
|
||||
smtp_from = config_data.get('smtp_from', email_user)
|
||||
smtp_use_tls = config_data.get('smtp_use_tls', smtp_port == 587)
|
||||
|
||||
if not all([smtp_server, email_user, email_password, recipient_email]):
|
||||
logger.warning(f"【{account_id}】邮件通知配置不完整")
|
||||
return False
|
||||
|
||||
def send_email_sync() -> bool:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = smtp_from
|
||||
msg['To'] = recipient_email
|
||||
msg['Subject'] = title
|
||||
msg.attach(MIMEText(message, 'plain', 'utf-8'))
|
||||
|
||||
if attachment_path and os.path.exists(attachment_path):
|
||||
with open(attachment_path, 'rb') as handle:
|
||||
attachment_data = handle.read()
|
||||
filename = os.path.basename(attachment_path)
|
||||
if attachment_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
|
||||
attachment = MIMEImage(attachment_data)
|
||||
else:
|
||||
attachment = MIMEApplication(attachment_data)
|
||||
attachment.add_header('Content-Disposition', 'attachment', filename=filename)
|
||||
msg.attach(attachment)
|
||||
|
||||
server = None
|
||||
try:
|
||||
if smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
|
||||
if smtp_use_tls:
|
||||
server.starttls()
|
||||
server.login(email_user, email_password)
|
||||
server.send_message(msg)
|
||||
return True
|
||||
finally:
|
||||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
try:
|
||||
server.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, send_email_sync)
|
||||
if result:
|
||||
logger.info(f"【{account_id}】邮件通知发送成功")
|
||||
return result
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
logger.error(f"【{account_id}】邮件SMTP认证失败: {_safe_str(exc)}")
|
||||
return False
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error(f"【{account_id}】SMTP协议错误: {_safe_str(exc)}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(f"【{account_id}】发送邮件通知异常: {_safe_str(exc)}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_webhook_notification(config_data: Dict[str, Any], message: str, *, title: str, notification_type: str, account_id: str = '') -> bool:
|
||||
webhook_url = config_data.get('webhook_url') or config_data.get('url') or config_data.get('config', '')
|
||||
if not webhook_url:
|
||||
logger.warning(f"【{account_id}】Webhook通知配置为空")
|
||||
return False
|
||||
|
||||
http_method = str(config_data.get('http_method', 'POST')).upper()
|
||||
headers_str = config_data.get('headers', '{}')
|
||||
try:
|
||||
custom_headers = json.loads(headers_str) if isinstance(headers_str, str) else dict(headers_str or {})
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
custom_headers = {}
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
headers.update(custom_headers)
|
||||
data = {
|
||||
'title': title,
|
||||
'message': message,
|
||||
'content': message,
|
||||
'type': notification_type,
|
||||
'notification_type': notification_type,
|
||||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'source': 'xianyu-auto-reply',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request = session.post if http_method == 'POST' else session.put if http_method == 'PUT' else None
|
||||
if request is None:
|
||||
logger.warning(f"【{account_id}】不支持的Webhook方法: {http_method}")
|
||||
return False
|
||||
async with request(webhook_url, json=data, headers=headers, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"【{account_id}】Webhook通知发送成功")
|
||||
return True
|
||||
logger.warning(f"【{account_id}】Webhook通知发送失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_wechat_notification(config_data: Dict[str, Any], message: str, *, account_id: str = '') -> bool:
|
||||
webhook_url = config_data.get('webhook_url', '')
|
||||
if not webhook_url:
|
||||
logger.warning(f"【{account_id}】微信通知配置为空")
|
||||
return False
|
||||
|
||||
data = {'msgtype': 'text', 'text': {'content': message}}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"【{account_id}】微信通知发送成功")
|
||||
return True
|
||||
logger.warning(f"【{account_id}】微信通知发送失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_telegram_notification(config_data: Dict[str, Any], message: str, *, account_id: str = '') -> bool:
|
||||
bot_token = config_data.get('bot_token', '')
|
||||
chat_id = config_data.get('chat_id', '')
|
||||
if not all([bot_token, chat_id]):
|
||||
logger.warning(f"【{account_id}】Telegram通知配置不完整")
|
||||
return False
|
||||
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/sendMessage'
|
||||
data = {'chat_id': chat_id, 'text': message, 'parse_mode': 'HTML'}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(api_url, json=data, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"【{account_id}】Telegram通知发送成功")
|
||||
return True
|
||||
logger.warning(f"【{account_id}】Telegram通知发送失败: HTTP {response.status}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_channel_notification(channel_type: Any, config_data: Dict[str, Any], message: str, *, title: str = '闲鱼管理系统通知', notification_type: str = 'info', attachment_path: Optional[str] = None, account_id: str = '') -> bool:
|
||||
normalized_type = normalize_channel_type(channel_type)
|
||||
if normalized_type == 'qq':
|
||||
return await _send_qq_notification(config_data, message, account_id=account_id)
|
||||
if normalized_type == 'dingtalk':
|
||||
return await _send_dingtalk_notification(config_data, message, title=title, account_id=account_id)
|
||||
if normalized_type == 'feishu':
|
||||
return await _send_feishu_notification(config_data, message, account_id=account_id)
|
||||
if normalized_type == 'bark':
|
||||
return await _send_bark_notification(config_data, message, title=title, account_id=account_id)
|
||||
if normalized_type == 'email':
|
||||
return await _send_email_notification(config_data, message, title=title, attachment_path=attachment_path, account_id=account_id)
|
||||
if normalized_type == 'webhook':
|
||||
return await _send_webhook_notification(config_data, message, title=title, notification_type=notification_type, account_id=account_id)
|
||||
if normalized_type == 'wechat':
|
||||
return await _send_wechat_notification(config_data, message, account_id=account_id)
|
||||
if normalized_type == 'telegram':
|
||||
return await _send_telegram_notification(config_data, message, account_id=account_id)
|
||||
|
||||
logger.warning(f"【{account_id}】不支持的通知渠道类型: {channel_type}")
|
||||
return False
|
||||
|
||||
|
||||
async def dispatch_notifications(notifications: Iterable[Dict[str, Any]], message: str, *, title: str = '闲鱼管理系统通知', notification_type: str = 'info', attachment_path: Optional[str] = None, account_id: str = '') -> bool:
|
||||
notification_sent = False
|
||||
|
||||
for notification in notifications or []:
|
||||
if not notification.get('enabled', True):
|
||||
continue
|
||||
|
||||
channel_type = notification.get('channel_type') or notification.get('type')
|
||||
channel_name = notification.get('channel_name') or notification.get('name') or str(channel_type or 'unknown')
|
||||
channel_config = notification.get('channel_config') if 'channel_config' in notification else notification.get('config')
|
||||
try:
|
||||
config_data = parse_notification_config(channel_config)
|
||||
channel_sent = await send_channel_notification(
|
||||
channel_type,
|
||||
config_data,
|
||||
message,
|
||||
title=title,
|
||||
notification_type=notification_type,
|
||||
attachment_path=attachment_path,
|
||||
account_id=account_id,
|
||||
)
|
||||
if channel_sent:
|
||||
notification_sent = True
|
||||
except Exception as exc:
|
||||
logger.error(f"【{account_id}】发送通知失败 ({channel_name}): {_safe_str(exc)}")
|
||||
|
||||
return notification_sent
|
||||
|
||||
|
||||
async def dispatch_account_notifications(account_id: str, message: str, *, title: str = '闲鱼管理系统通知', notification_type: str = 'info', attachment_path: Optional[str] = None) -> bool:
|
||||
from db_manager import db_manager
|
||||
|
||||
try:
|
||||
notifications = db_manager.get_account_notifications(account_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"【{account_id}】获取通知配置失败: {_safe_str(exc)}")
|
||||
return False
|
||||
|
||||
if not notifications:
|
||||
logger.warning(f"【{account_id}】未配置消息通知,跳过发送")
|
||||
return False
|
||||
|
||||
return await dispatch_notifications(
|
||||
notifications,
|
||||
message,
|
||||
title=title,
|
||||
notification_type=notification_type,
|
||||
attachment_path=attachment_path,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
|
||||
def dispatch_account_notifications_sync(account_id: str, message: str, *, title: str = '闲鱼管理系统通知', notification_type: str = 'info', attachment_path: Optional[str] = None) -> bool:
|
||||
result: Dict[str, bool] = {'sent': False}
|
||||
|
||||
async def runner() -> None:
|
||||
result['sent'] = await dispatch_account_notifications(
|
||||
account_id,
|
||||
message,
|
||||
title=title,
|
||||
notification_type=notification_type,
|
||||
attachment_path=attachment_path,
|
||||
)
|
||||
|
||||
def thread_main() -> None:
|
||||
try:
|
||||
result['sent'] = asyncio.run(runner())
|
||||
except Exception as exc:
|
||||
logger.error(f"【{account_id}】同步发送通知失败: {_safe_str(exc)}")
|
||||
result['sent'] = False
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
asyncio.run(runner())
|
||||
return result['sent']
|
||||
|
||||
thread = threading.Thread(target=thread_main, daemon=True)
|
||||
thread.start()
|
||||
thread.join()
|
||||
return result['sent']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from utils.order_detail_fetcher import OrderDetailFetcher
|
||||
from utils.time_utils import parse_db_timestamp, parse_local_datetime_text_to_db_utc
|
||||
from utils.xianyu_utils import generate_sign, trans_cookies
|
||||
|
||||
|
||||
ORDER_LIST_API_URL = 'https://h5api.m.goofish.com/h5/mtop.taobao.idle.trade.merchant.sold.get/1.0/'
|
||||
ORDER_LIST_API_NAME = 'mtop.taobao.idle.trade.merchant.sold.get'
|
||||
ORDER_LIST_REFERER = 'https://seller.goofish.com/?site=COMMONPRO#/seller-trade/order-manage'
|
||||
ORDER_LIST_QUERY_CODE = 'ALL'
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
ORDER_HISTORY_ANCHOR_FIELDS = (
|
||||
'platform_paid_at',
|
||||
'platform_created_at',
|
||||
'platform_completed_at',
|
||||
)
|
||||
|
||||
ORDER_STATUS_ALIASES = {
|
||||
'processing': 'processing',
|
||||
'pending_payment': 'pending_payment',
|
||||
'pending_ship': 'pending_ship',
|
||||
'partial_success': 'partial_success',
|
||||
'partial_pending_finalize': 'partial_pending_finalize',
|
||||
'shipped': 'shipped',
|
||||
'completed': 'completed',
|
||||
'refunding': 'refunding',
|
||||
'refund_cancelled': 'refund_cancelled',
|
||||
'cancelled': 'cancelled',
|
||||
'unknown': 'unknown',
|
||||
'处理中': 'processing',
|
||||
'待付款': 'pending_payment',
|
||||
'待发货': 'pending_ship',
|
||||
'部分发货': 'partial_success',
|
||||
'部分待收尾': 'partial_pending_finalize',
|
||||
'已发货': 'shipped',
|
||||
'交易成功': 'completed',
|
||||
'已完成': 'completed',
|
||||
'退款中': 'refunding',
|
||||
'退款撤销': 'refund_cancelled',
|
||||
'交易关闭': 'cancelled',
|
||||
'已关闭': 'cancelled',
|
||||
}
|
||||
|
||||
|
||||
def normalize_order_history_status(value: Any) -> Optional[str]:
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
normalized = ORDER_STATUS_ALIASES.get(text)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
return ORDER_STATUS_ALIASES.get(text.lower())
|
||||
|
||||
|
||||
def normalize_history_amount(value: Any) -> Optional[str]:
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
cleaned = text.replace('¥', '').replace('¥', '').replace(',', '').strip()
|
||||
try:
|
||||
return f"{float(cleaned):.2f}"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_order_history_anchor_time(candidate: Dict[str, Any]) -> Optional[str]:
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
|
||||
for field_name in ORDER_HISTORY_ANCHOR_FIELDS:
|
||||
value = str(candidate.get(field_name) or '').strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def classify_order_history_range(
|
||||
anchor_time: Optional[str],
|
||||
utc_start: Optional[str] = None,
|
||||
utc_end_exclusive: Optional[str] = None,
|
||||
) -> str:
|
||||
if not utc_start or not utc_end_exclusive:
|
||||
return 'in_range'
|
||||
|
||||
anchor_dt = parse_db_timestamp(anchor_time) if anchor_time else None
|
||||
start_dt = parse_db_timestamp(utc_start)
|
||||
end_dt = parse_db_timestamp(utc_end_exclusive)
|
||||
if not anchor_dt or not start_dt or not end_dt:
|
||||
return 'unknown'
|
||||
if anchor_dt < start_dt:
|
||||
return 'before'
|
||||
if anchor_dt >= end_dt:
|
||||
return 'after'
|
||||
return 'in_range'
|
||||
|
||||
|
||||
def _cookie_dict_to_string(cookies_dict: Dict[str, str]) -> str:
|
||||
return '; '.join(
|
||||
f'{name}={value}'
|
||||
for name, value in cookies_dict.items()
|
||||
if str(name).strip() and value is not None
|
||||
)
|
||||
|
||||
|
||||
def _extract_set_cookie_updates(response_headers) -> Dict[str, str]:
|
||||
try:
|
||||
set_cookie_values = response_headers.getall('Set-Cookie', [])
|
||||
except Exception:
|
||||
raw_value = response_headers.get('Set-Cookie')
|
||||
if isinstance(raw_value, list):
|
||||
set_cookie_values = raw_value
|
||||
elif raw_value:
|
||||
set_cookie_values = [raw_value]
|
||||
else:
|
||||
set_cookie_values = []
|
||||
|
||||
updates: Dict[str, str] = {}
|
||||
for cookie in set_cookie_values:
|
||||
if '=' not in cookie:
|
||||
continue
|
||||
try:
|
||||
name, value = cookie.split(';', 1)[0].split('=', 1)
|
||||
except ValueError:
|
||||
continue
|
||||
updates[name.strip()] = value.strip()
|
||||
return updates
|
||||
|
||||
|
||||
class OrderHistoryPageFetcher:
|
||||
def __init__(self, cookie_string: str, cookie_id_for_log: str = 'unknown', headless: bool = True):
|
||||
self.cookie_id_for_log = cookie_id_for_log or 'unknown'
|
||||
self.headless = headless
|
||||
self.cookie_string = str(cookie_string or '').strip()
|
||||
self.cookies: Dict[str, str] = trans_cookies(self.cookie_string) if self.cookie_string else {}
|
||||
self.fetcher = OrderDetailFetcher(self.cookie_string, headless=headless, cookie_id_for_log=self.cookie_id_for_log)
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
def _is_auth_failure_ret(self, ret_value: Any) -> bool:
|
||||
if isinstance(ret_value, str):
|
||||
ret_text = ret_value
|
||||
elif isinstance(ret_value, (list, tuple)):
|
||||
ret_text = ' | '.join([str(item) for item in ret_value])
|
||||
else:
|
||||
ret_text = str(ret_value or '')
|
||||
|
||||
auth_keywords = (
|
||||
'令牌过期',
|
||||
'session过期',
|
||||
'FAIL_SYS_USER_VALIDATE',
|
||||
'FAIL_SYS_TOKEN_EXPIRED',
|
||||
'FAIL_SYS_TOKEN_EXOIRED',
|
||||
'FAIL_SYS_SESSION_EXPIRED',
|
||||
'passport.goofish.com',
|
||||
'mini_login',
|
||||
'login',
|
||||
)
|
||||
ret_text_lower = ret_text.lower()
|
||||
return any(keyword.lower() in ret_text_lower for keyword in auth_keywords)
|
||||
|
||||
def _set_runtime_cookie_state(self, cookies_dict: Dict[str, str]) -> bool:
|
||||
normalized = {str(name): str(value) for name, value in cookies_dict.items() if str(name).strip()}
|
||||
new_cookie_string = _cookie_dict_to_string(normalized)
|
||||
if new_cookie_string == self.cookie_string:
|
||||
return False
|
||||
|
||||
self.cookies = normalized
|
||||
self.cookie_string = new_cookie_string
|
||||
self.fetcher.cookie = new_cookie_string
|
||||
return True
|
||||
|
||||
async def _persist_cookie_update(self) -> None:
|
||||
if not self.cookie_string or self.cookie_id_for_log == 'unknown':
|
||||
return
|
||||
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
|
||||
db_manager.update_cookie_account_info(self.cookie_id_for_log, cookie_value=self.cookie_string)
|
||||
except Exception as exc:
|
||||
logger.warning(f"【{self.cookie_id_for_log}】保存刷新后的 Cookie 失败: {exc}")
|
||||
|
||||
async def _apply_response_cookie_updates(self, response_headers) -> bool:
|
||||
updates = _extract_set_cookie_updates(response_headers)
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
merged_cookies = dict(self.cookies)
|
||||
merged_cookies.update(updates)
|
||||
changed = self._set_runtime_cookie_state(merged_cookies)
|
||||
if changed:
|
||||
await self._persist_cookie_update()
|
||||
return changed
|
||||
|
||||
async def _ensure_session(self) -> None:
|
||||
if self.session and not self.session.closed:
|
||||
return
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
self.session = aiohttp.ClientSession(timeout=timeout)
|
||||
|
||||
def _build_request_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'cookie': self.cookie_string,
|
||||
'idle_site_biz_code': 'COMMONPRO',
|
||||
'idle_user_group_member_id': '',
|
||||
'origin': 'https://seller.goofish.com',
|
||||
'referer': ORDER_LIST_REFERER,
|
||||
'user-agent': (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/138.0.0.0 Safari/537.36'
|
||||
),
|
||||
}
|
||||
|
||||
def _build_request_params(self, data_val: str) -> Dict[str, str]:
|
||||
token = self.cookies.get('_m_h5_tk', '').split('_')[0]
|
||||
if not token:
|
||||
raise ValueError(f'【{self.cookie_id_for_log}】Cookie 缺少 _m_h5_tk,无法请求历史订单列表')
|
||||
|
||||
params = {
|
||||
'jsv': '2.7.2',
|
||||
'appKey': '34839810',
|
||||
't': str(int(time.time() * 1000)),
|
||||
'sign': '',
|
||||
'v': '1.0',
|
||||
'type': 'json',
|
||||
'accountSite': 'xianyu',
|
||||
'dataType': 'json',
|
||||
'timeout': '20000',
|
||||
'api': ORDER_LIST_API_NAME,
|
||||
'valueType': 'string',
|
||||
'sessionOption': 'AutoLoginOnly',
|
||||
'spm_cnt': 'a21107h.42831410.0.0',
|
||||
}
|
||||
params['sign'] = generate_sign(params['t'], token, data_val)
|
||||
return params
|
||||
|
||||
async def _request_order_page(self, page_number: int, allow_retry: bool = True) -> Dict[str, Any]:
|
||||
await self._ensure_session()
|
||||
assert self.session is not None
|
||||
|
||||
payload = {
|
||||
'pageNumber': page_number,
|
||||
'rowsPerPage': DEFAULT_PAGE_SIZE,
|
||||
'orderIds': '',
|
||||
'queryCode': ORDER_LIST_QUERY_CODE,
|
||||
'orderSearchParam': '{}',
|
||||
}
|
||||
data_val = json.dumps(payload, separators=(',', ':'))
|
||||
|
||||
try:
|
||||
params = self._build_request_params(data_val)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
async with self.session.post(
|
||||
ORDER_LIST_API_URL,
|
||||
params=params,
|
||||
data={'data': data_val},
|
||||
headers=self._build_request_headers(),
|
||||
) as response:
|
||||
try:
|
||||
res_json = await response.json(content_type=None)
|
||||
except Exception as exc:
|
||||
response_text = await response.text()
|
||||
raise RuntimeError(
|
||||
f'【{self.cookie_id_for_log}】历史订单列表返回非 JSON: status={response.status}, body={response_text[:300]}'
|
||||
) from exc
|
||||
|
||||
cookies_updated = await self._apply_response_cookie_updates(response.headers)
|
||||
|
||||
ret_value = res_json.get('ret', [])
|
||||
if any('SUCCESS::调用成功' in str(ret) for ret in ret_value):
|
||||
return res_json
|
||||
|
||||
if allow_retry and cookies_updated and self._is_auth_failure_ret(ret_value):
|
||||
logger.warning(f"【{self.cookie_id_for_log}】历史订单列表鉴权失败,Cookie 更新后重试第 {page_number} 页")
|
||||
return await self._request_order_page(page_number, allow_retry=False)
|
||||
|
||||
raise RuntimeError(f"【{self.cookie_id_for_log}】历史订单列表 API 调用失败: {ret_value or res_json}")
|
||||
|
||||
def _normalize_order_candidate(self, raw: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
common_data = raw.get('commonData') if isinstance(raw.get('commonData'), dict) else {}
|
||||
buyer_info = raw.get('buyerInfoVO') if isinstance(raw.get('buyerInfoVO'), dict) else {}
|
||||
price_info = raw.get('priceVO') if isinstance(raw.get('priceVO'), dict) else {}
|
||||
|
||||
order_id = str(common_data.get('orderId') or '').strip()
|
||||
if not order_id:
|
||||
return None
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'item_id': str(common_data.get('itemId') or '').strip() or None,
|
||||
'sid': None,
|
||||
'buyer_id': str(buyer_info.get('buyerId') or '').strip() or None,
|
||||
'buyer_nick': str(buyer_info.get('userNick') or '').strip() or None,
|
||||
'order_status': normalize_order_history_status(common_data.get('orderStatus')) or str(common_data.get('orderStatus') or '').strip() or None,
|
||||
'amount': (
|
||||
normalize_history_amount(price_info.get('totalPrice')) or
|
||||
normalize_history_amount(price_info.get('confirmFee')) or
|
||||
normalize_history_amount(price_info.get('auctionPrice'))
|
||||
),
|
||||
'platform_created_at': parse_local_datetime_text_to_db_utc(common_data.get('createTime')),
|
||||
'platform_paid_at': parse_local_datetime_text_to_db_utc(common_data.get('paySuccessTime')),
|
||||
'platform_completed_at': parse_local_datetime_text_to_db_utc(common_data.get('finishTime')),
|
||||
'raw_source': raw,
|
||||
}
|
||||
|
||||
async def open(self) -> bool:
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
await self.fetcher.close()
|
||||
|
||||
async def fetch_recent_orders(
|
||||
self,
|
||||
max_orders: int = 100,
|
||||
max_scroll_rounds: int = 12,
|
||||
utc_start: Optional[str] = None,
|
||||
utc_end_exclusive: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
del max_scroll_rounds
|
||||
|
||||
if max_orders <= 0:
|
||||
return {
|
||||
'orders': [],
|
||||
'scanned_count': 0,
|
||||
'matched_count': 0,
|
||||
'out_of_range_count': 0,
|
||||
'pages_scanned': 0,
|
||||
'stopped_by_range': False,
|
||||
}
|
||||
|
||||
await self.open()
|
||||
|
||||
captured_orders: List[Dict[str, Any]] = []
|
||||
seen_order_ids = set()
|
||||
page_number = 1
|
||||
scanned_count = 0
|
||||
out_of_range_count = 0
|
||||
pages_scanned = 0
|
||||
stopped_by_range = False
|
||||
|
||||
while len(captured_orders) < max_orders:
|
||||
response_json = await self._request_order_page(page_number)
|
||||
pages_scanned += 1
|
||||
module = ((response_json.get('data') or {}).get('module') or {})
|
||||
items = module.get('items') or []
|
||||
next_page = str(module.get('nextPage') or '').lower() == 'true'
|
||||
total_count = str(module.get('totalCount') or '').strip() or 'unknown'
|
||||
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
|
||||
page_scanned_count = 0
|
||||
page_in_range_count = 0
|
||||
page_before_count = 0
|
||||
page_after_count = 0
|
||||
page_unknown_count = 0
|
||||
|
||||
for raw_item in items:
|
||||
candidate = self._normalize_order_candidate(raw_item)
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
order_id = candidate.get('order_id')
|
||||
if not order_id or order_id in seen_order_ids:
|
||||
continue
|
||||
|
||||
seen_order_ids.add(order_id)
|
||||
scanned_count += 1
|
||||
page_scanned_count += 1
|
||||
|
||||
anchor_time = resolve_order_history_anchor_time(candidate)
|
||||
range_status = classify_order_history_range(anchor_time, utc_start=utc_start, utc_end_exclusive=utc_end_exclusive)
|
||||
if range_status == 'in_range':
|
||||
captured_orders.append(candidate)
|
||||
page_in_range_count += 1
|
||||
else:
|
||||
out_of_range_count += 1
|
||||
if range_status == 'before':
|
||||
page_before_count += 1
|
||||
elif range_status == 'after':
|
||||
page_after_count += 1
|
||||
else:
|
||||
page_unknown_count += 1
|
||||
|
||||
if len(captured_orders) >= max_orders:
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"【{self.cookie_id_for_log}】历史订单列表第 {page_number} 页抓取完成: "
|
||||
f"page_items={len(items)}, scanned={page_scanned_count}, in_range={page_in_range_count}, "
|
||||
f"before_range={page_before_count}, after_range={page_after_count}, unknown_anchor={page_unknown_count}, "
|
||||
f"captured={len(captured_orders)}, totalCount={total_count}, nextPage={next_page}"
|
||||
)
|
||||
|
||||
if len(captured_orders) >= max_orders or not next_page or not items:
|
||||
break
|
||||
|
||||
if (
|
||||
utc_start and utc_end_exclusive
|
||||
and page_scanned_count > 0
|
||||
and page_in_range_count == 0
|
||||
and page_unknown_count == 0
|
||||
and page_before_count == page_scanned_count
|
||||
):
|
||||
stopped_by_range = True
|
||||
logger.info(
|
||||
f"【{self.cookie_id_for_log}】历史订单列表在第 {page_number} 页已全部早于开始时间,停止继续翻页"
|
||||
)
|
||||
break
|
||||
|
||||
page_number += 1
|
||||
|
||||
matched_orders = captured_orders[:max_orders]
|
||||
return {
|
||||
'orders': matched_orders,
|
||||
'scanned_count': scanned_count,
|
||||
'matched_count': len(matched_orders),
|
||||
'out_of_range_count': out_of_range_count,
|
||||
'pages_scanned': pages_scanned,
|
||||
'stopped_by_range': stopped_by_range,
|
||||
}
|
||||
|
||||
async def fetch_order_detail(self, order_id: str, force_refresh: bool = True) -> Optional[Dict[str, Any]]:
|
||||
return await self.fetcher.fetch_order_detail(order_id, force_refresh=force_refresh)
|
||||
@@ -0,0 +1,755 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
闲鱼扫码登录工具
|
||||
基于API接口实现二维码生成和Cookie获取(参照myfish-main项目)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import re
|
||||
from random import random
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
import qrcode
|
||||
import qrcode.constants
|
||||
from loguru import logger
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from utils.image_utils import image_manager
|
||||
|
||||
|
||||
def generate_headers():
|
||||
"""生成请求头"""
|
||||
return {
|
||||
'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',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Referer': 'https://passport.goofish.com/',
|
||||
'Origin': 'https://passport.goofish.com',
|
||||
}
|
||||
|
||||
|
||||
class GetLoginParamsError(Exception):
|
||||
"""获取登录参数错误"""
|
||||
|
||||
|
||||
class GetLoginQRCodeError(Exception):
|
||||
"""获取登录二维码失败"""
|
||||
|
||||
|
||||
class NotLoginError(Exception):
|
||||
"""未登录错误"""
|
||||
|
||||
|
||||
class QRLoginSession:
|
||||
"""二维码登录会话"""
|
||||
|
||||
def __init__(self, session_id: str):
|
||||
self.session_id = session_id
|
||||
self.status = 'waiting' # waiting, scanned, success, expired, cancelled, verification_required
|
||||
self.qr_code_url = None
|
||||
self.qr_content = None
|
||||
self.cookies = {}
|
||||
self.unb = None
|
||||
self.created_time = time.time()
|
||||
self.expire_time = 300 # 5分钟过期
|
||||
self.params = {} # 存储登录参数
|
||||
self.verification_url = None # 风控验证URL
|
||||
self.screenshot_path = None # 风控验证截图
|
||||
self.verification_task = None # 风控验证页面保持任务
|
||||
self.success_source = None # 登录成功来源: api/browser
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""检查是否过期"""
|
||||
return time.time() - self.created_time > self.expire_time
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'status': self.status,
|
||||
'qr_code_url': self.qr_code_url,
|
||||
'created_time': self.created_time,
|
||||
'is_expired': self.is_expired()
|
||||
}
|
||||
|
||||
|
||||
class QRLoginManager:
|
||||
"""二维码登录管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.sessions: Dict[str, QRLoginSession] = {}
|
||||
self.headers = generate_headers()
|
||||
self.host = "https://passport.goofish.com"
|
||||
self.api_mini_login = f"{self.host}/mini_login.htm"
|
||||
self.api_generate_qr = f"{self.host}/newlogin/qrcode/generate.do"
|
||||
self.api_scan_status = f"{self.host}/newlogin/qrcode/query.do"
|
||||
self.api_h5_tk = "https://h5api.m.goofish.com/h5/mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get/1.0/"
|
||||
|
||||
# 配置代理(如果需要的话,取消注释并修改代理地址)
|
||||
# self.proxy = "http://127.0.0.1:7890"
|
||||
self.proxy = None
|
||||
|
||||
# 配置超时时间
|
||||
self.timeout = httpx.Timeout(connect=30.0, read=60.0, write=30.0, pool=60.0)
|
||||
|
||||
def _cookie_marshal(self, cookies: dict) -> str:
|
||||
"""将Cookie字典转换为字符串"""
|
||||
return "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
def _create_async_client(self, **kwargs) -> httpx.AsyncClient:
|
||||
"""创建兼容不同 httpx 版本代理参数的 AsyncClient。"""
|
||||
client_kwargs = dict(kwargs)
|
||||
if self.proxy:
|
||||
try:
|
||||
return httpx.AsyncClient(proxy=self.proxy, **client_kwargs)
|
||||
except TypeError as exc:
|
||||
if "unexpected keyword argument 'proxy'" in str(exc):
|
||||
return httpx.AsyncClient(proxies=self.proxy, **client_kwargs)
|
||||
raise
|
||||
return httpx.AsyncClient(**client_kwargs)
|
||||
|
||||
def _build_browser_cookies(self, target_url: str, cookies: Dict[str, str]) -> list[Dict[str, Any]]:
|
||||
"""将API会话中的Cookie转换为Playwright可用格式"""
|
||||
browser_cookies = []
|
||||
parsed = urlparse(target_url or self.host)
|
||||
target_origin = f"{parsed.scheme or 'https'}://{parsed.netloc or 'passport.goofish.com'}"
|
||||
|
||||
for name, value in (cookies or {}).items():
|
||||
if not name or value is None:
|
||||
continue
|
||||
browser_cookies.append({
|
||||
'name': name,
|
||||
'value': str(value),
|
||||
'url': target_origin,
|
||||
'path': '/',
|
||||
})
|
||||
|
||||
return browser_cookies
|
||||
|
||||
def _normalize_cookie_dict(self, cookies: Any) -> Dict[str, str]:
|
||||
"""将不同形式的Cookie数据统一转换为字典"""
|
||||
if isinstance(cookies, dict) or hasattr(cookies, 'items'):
|
||||
return {
|
||||
str(name): str(value)
|
||||
for name, value in cookies.items()
|
||||
if name and value is not None
|
||||
}
|
||||
|
||||
normalized = {}
|
||||
for cookie in cookies or []:
|
||||
if not isinstance(cookie, dict):
|
||||
continue
|
||||
name = cookie.get('name')
|
||||
value = cookie.get('value')
|
||||
if name and value is not None:
|
||||
normalized[str(name)] = str(value)
|
||||
return normalized
|
||||
|
||||
def _merge_session_cookies(self, session: QRLoginSession, cookies: Any):
|
||||
"""合并Cookie到会话中"""
|
||||
cookie_dict = self._normalize_cookie_dict(cookies)
|
||||
if not cookie_dict:
|
||||
return
|
||||
|
||||
session.cookies.update(cookie_dict)
|
||||
if cookie_dict.get('unb'):
|
||||
session.unb = cookie_dict['unb']
|
||||
|
||||
def _has_completed_login_cookies(self, cookie_dict: Dict[str, str]) -> bool:
|
||||
"""基于关键Cookie判断是否已经完成登录"""
|
||||
if not cookie_dict.get('unb'):
|
||||
return False
|
||||
|
||||
companion_keys = ('cookie2', 'havana_lgc2_77', '_tb_token_', 'sgcookie')
|
||||
return any(cookie_dict.get(key) for key in companion_keys)
|
||||
|
||||
def _is_logged_in_url(self, url: str) -> bool:
|
||||
"""判断URL是否已经跳转到登录后的页面"""
|
||||
current_url = str(url or '')
|
||||
if not current_url:
|
||||
return False
|
||||
|
||||
if 'www.goofish.com/im' in current_url:
|
||||
return True
|
||||
|
||||
return (
|
||||
'goofish.com' in current_url and
|
||||
'passport.goofish.com' not in current_url and
|
||||
'mini_login' not in current_url and
|
||||
'/iv/' not in current_url
|
||||
)
|
||||
|
||||
def _mark_session_success(
|
||||
self,
|
||||
session: QRLoginSession,
|
||||
cookies: Any,
|
||||
source: str,
|
||||
require_complete_cookies: bool = False
|
||||
) -> bool:
|
||||
"""统一的会话成功收口,避免多条链路重复覆盖状态"""
|
||||
if not session:
|
||||
return False
|
||||
|
||||
self._merge_session_cookies(session, cookies)
|
||||
|
||||
has_success_cookie = bool(session.cookies.get('unb'))
|
||||
has_complete_cookies = self._has_completed_login_cookies(session.cookies)
|
||||
if not has_success_cookie:
|
||||
return False
|
||||
if require_complete_cookies and not has_complete_cookies:
|
||||
return False
|
||||
|
||||
was_success = session.status == 'success'
|
||||
session.status = 'success'
|
||||
session.success_source = session.success_source or source
|
||||
|
||||
if not was_success:
|
||||
logger.info(
|
||||
f"扫码登录成功(来源: {source}): {session.session_id}, "
|
||||
f"UNB: {session.unb}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def _context_cookie_dict(self, context) -> Dict[str, str]:
|
||||
"""提取浏览器上下文中的Cookie字典"""
|
||||
cookies = await context.cookies()
|
||||
return self._normalize_cookie_dict(cookies)
|
||||
|
||||
async def _probe_browser_login_success(self, session: QRLoginSession, page, context) -> bool:
|
||||
"""在浏览器侧兜底判断验证是否已经完成"""
|
||||
current_url = page.url
|
||||
cookie_dict = await self._context_cookie_dict(context)
|
||||
cookies_ready = self._has_completed_login_cookies(cookie_dict)
|
||||
url_ready = self._is_logged_in_url(current_url)
|
||||
|
||||
if cookies_ready and url_ready:
|
||||
logger.info(
|
||||
f"扫码登录浏览器侧检测成功(当前页): {session.session_id}, URL: {current_url}"
|
||||
)
|
||||
return self._mark_session_success(session, cookie_dict, 'browser', require_complete_cookies=True)
|
||||
|
||||
if not cookies_ready:
|
||||
return False
|
||||
|
||||
probe_page = None
|
||||
try:
|
||||
probe_page = await context.new_page()
|
||||
await probe_page.goto('https://www.goofish.com/im', wait_until='domcontentloaded', timeout=30000)
|
||||
await probe_page.wait_for_timeout(1500)
|
||||
|
||||
probe_url = probe_page.url
|
||||
probe_cookie_dict = await self._context_cookie_dict(context)
|
||||
im_root = await probe_page.query_selector('.rc-virtual-list-holder-inner')
|
||||
has_im_root = im_root is not None
|
||||
|
||||
if self._is_logged_in_url(probe_url):
|
||||
logger.info(
|
||||
f"扫码登录浏览器侧探测成功: {session.session_id}, "
|
||||
f"probe_url: {probe_url}, has_im_root: {has_im_root}"
|
||||
)
|
||||
return self._mark_session_success(session, probe_cookie_dict, 'browser', require_complete_cookies=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"扫码登录浏览器侧探测未确认成功: {session.session_id}, 错误: {e}")
|
||||
finally:
|
||||
if probe_page:
|
||||
try:
|
||||
await probe_page.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
async def _launch_verification_page(self, session_id: str):
|
||||
"""在服务端打开验证页面并截取二维码,保持原始会话存活"""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session or not session.verification_url:
|
||||
return
|
||||
|
||||
playwright = None
|
||||
browser = None
|
||||
context = None
|
||||
page = None
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
logger.info(f"开始打开扫码登录验证页面: {session_id}")
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--lang=zh-CN',
|
||||
]
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 540, 'height': 960},
|
||||
locale='zh-CN',
|
||||
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',
|
||||
ignore_https_errors=True,
|
||||
extra_http_headers={
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||
}
|
||||
)
|
||||
|
||||
browser_cookies = self._build_browser_cookies(session.verification_url, session.cookies)
|
||||
if browser_cookies:
|
||||
await context.add_cookies(browser_cookies)
|
||||
|
||||
page = await context.new_page()
|
||||
await page.goto(session.verification_url, wait_until='domcontentloaded', timeout=60000)
|
||||
await page.wait_for_timeout(2500)
|
||||
|
||||
screenshot_bytes = await page.screenshot(full_page=True)
|
||||
if screenshot_bytes:
|
||||
screenshot_path = image_manager.save_image(screenshot_bytes)
|
||||
if screenshot_path:
|
||||
if session.screenshot_path and session.screenshot_path != screenshot_path:
|
||||
image_manager.delete_image(session.screenshot_path)
|
||||
session.screenshot_path = screenshot_path
|
||||
logger.info(f"扫码登录验证截图已保存: {session_id}, 路径: {screenshot_path}")
|
||||
else:
|
||||
logger.warning(f"扫码登录验证截图保存失败: {session_id}")
|
||||
else:
|
||||
logger.warning(f"扫码登录验证截图为空: {session_id}")
|
||||
|
||||
while True:
|
||||
current_session = self.sessions.get(session_id)
|
||||
if not current_session:
|
||||
break
|
||||
if current_session.status == 'success':
|
||||
logger.info(f"扫码登录验证页检测到会话已成功: {session_id}")
|
||||
break
|
||||
if current_session.status not in {'verification_required', 'scanned', 'waiting', 'processing'}:
|
||||
break
|
||||
|
||||
if await self._probe_browser_login_success(current_session, page, context):
|
||||
break
|
||||
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"扫码登录验证页面任务已取消: {session_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"打开扫码登录验证页面失败: {session_id}, 错误: {e}")
|
||||
finally:
|
||||
try:
|
||||
if page:
|
||||
await page.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if context:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if browser:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if playwright:
|
||||
await playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
latest_session = self.sessions.get(session_id)
|
||||
if latest_session:
|
||||
latest_session.verification_task = None
|
||||
|
||||
logger.info(f"扫码登录验证页面已关闭: {session_id}")
|
||||
|
||||
def _ensure_verification_task(self, session: QRLoginSession):
|
||||
"""确保风控验证页面任务只启动一次"""
|
||||
task = session.verification_task
|
||||
if task and not task.done():
|
||||
return
|
||||
session.verification_task = asyncio.create_task(self._launch_verification_page(session.session_id))
|
||||
|
||||
def _cleanup_session_assets(self, session: QRLoginSession):
|
||||
"""清理会话关联的截图和后台任务"""
|
||||
task = session.verification_task
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
session.verification_task = None
|
||||
|
||||
if session.screenshot_path:
|
||||
image_manager.delete_image(session.screenshot_path)
|
||||
session.screenshot_path = None
|
||||
|
||||
async def _get_mh5tk(self, session: QRLoginSession) -> dict:
|
||||
"""获取m_h5_tk和m_h5_tk_enc"""
|
||||
data = {"bizScene": "home"}
|
||||
data_str = json.dumps(data, separators=(',', ':'))
|
||||
t = str(int(time.time() * 1000))
|
||||
app_key = "34839810"
|
||||
|
||||
# 先发一次 GET 请求,获取 cookie 中的 m_h5_tk
|
||||
async with self._create_async_client(timeout=self.timeout, follow_redirects=True) as client:
|
||||
try:
|
||||
resp = await client.get(self.api_h5_tk, headers=self.headers)
|
||||
cookies = {k: v for k, v in resp.cookies.items()}
|
||||
session.cookies.update(cookies)
|
||||
|
||||
m_h5_tk = cookies.get("m_h5_tk", "")
|
||||
token = m_h5_tk.split("_")[0] if "_" in m_h5_tk else ""
|
||||
|
||||
# 生成签名
|
||||
sign_input = f"{token}&{t}&{app_key}&{data_str}"
|
||||
sign = hashlib.md5(sign_input.encode()).hexdigest()
|
||||
|
||||
# 构造最终请求参数
|
||||
params = {
|
||||
"jsv": "2.7.2",
|
||||
"appKey": app_key,
|
||||
"t": t,
|
||||
"sign": sign,
|
||||
"v": "1.0",
|
||||
"type": "originaljson",
|
||||
"dataType": "json",
|
||||
"timeout": 20000,
|
||||
"api": "mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get",
|
||||
"data": data_str,
|
||||
}
|
||||
|
||||
# 发请求正式获取数据,确保 token 有效
|
||||
await client.post(self.api_h5_tk, params=params, headers=self.headers, cookies=session.cookies)
|
||||
|
||||
return cookies
|
||||
except httpx.ConnectTimeout:
|
||||
logger.error("获取m_h5_tk时连接超时")
|
||||
raise
|
||||
except httpx.ReadTimeout:
|
||||
logger.error("获取m_h5_tk时读取超时")
|
||||
raise
|
||||
except httpx.ConnectError:
|
||||
logger.error("获取m_h5_tk时连接错误")
|
||||
raise
|
||||
|
||||
async def _get_login_params(self, session: QRLoginSession) -> dict:
|
||||
"""获取二维码登录时需要的表单参数"""
|
||||
params = {
|
||||
"lang": "zh_cn",
|
||||
"appName": "xianyu",
|
||||
"appEntrance": "web",
|
||||
"styleType": "vertical",
|
||||
"bizParams": "",
|
||||
"notLoadSsoView": False,
|
||||
"notKeepLogin": False,
|
||||
"isMobile": False,
|
||||
"qrCodeFirst": False,
|
||||
"stie": 77,
|
||||
"rnd": random(),
|
||||
}
|
||||
|
||||
async with self._create_async_client(follow_redirects=True, timeout=self.timeout) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
self.api_mini_login,
|
||||
params=params,
|
||||
cookies=session.cookies,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
# 正则匹配需要的json数据
|
||||
pattern = r"window\.viewData\s*=\s*(\{.*?\});"
|
||||
match = re.search(pattern, resp.text)
|
||||
if match:
|
||||
json_string = match.group(1)
|
||||
view_data = json.loads(json_string)
|
||||
data = view_data.get("loginFormData")
|
||||
if data:
|
||||
data["umidTag"] = "SERVER"
|
||||
session.params.update(data)
|
||||
return data
|
||||
else:
|
||||
raise GetLoginParamsError("未找到loginFormData")
|
||||
else:
|
||||
raise GetLoginParamsError("获取登录参数失败")
|
||||
except httpx.ConnectTimeout:
|
||||
logger.error("获取登录参数时连接超时")
|
||||
raise
|
||||
except httpx.ReadTimeout:
|
||||
logger.error("获取登录参数时读取超时")
|
||||
raise
|
||||
except httpx.ConnectError:
|
||||
logger.error("获取登录参数时连接错误")
|
||||
raise
|
||||
|
||||
async def generate_qr_code(self) -> Dict[str, Any]:
|
||||
"""生成二维码"""
|
||||
try:
|
||||
# 创建新的会话
|
||||
session_id = str(uuid.uuid4())
|
||||
session = QRLoginSession(session_id)
|
||||
|
||||
# 1. 获取m_h5_tk
|
||||
await self._get_mh5tk(session)
|
||||
logger.info(f"获取m_h5_tk成功: {session_id}")
|
||||
|
||||
# 2. 获取登录参数
|
||||
login_params = await self._get_login_params(session)
|
||||
logger.info(f"获取登录参数成功: {session_id}")
|
||||
|
||||
# 3. 生成二维码
|
||||
async with self._create_async_client(follow_redirects=True, timeout=self.timeout) as client:
|
||||
resp = await client.get(
|
||||
self.api_generate_qr,
|
||||
params=login_params,
|
||||
headers=self.headers
|
||||
)
|
||||
logger.debug(f"[调试] 获取二维码接口原始响应: {resp.text}")
|
||||
|
||||
try:
|
||||
results = resp.json()
|
||||
logger.debug(f"[调试] 获取二维码接口解析后: {json.dumps(results, ensure_ascii=False)}")
|
||||
except Exception as e:
|
||||
logger.exception("二维码接口返回不是JSON")
|
||||
raise GetLoginQRCodeError(f"二维码接口返回异常: {resp.text}")
|
||||
|
||||
if results.get("content", {}).get("success") == True:
|
||||
# 更新会话参数
|
||||
session.params.update({
|
||||
"t": results["content"]["data"]["t"],
|
||||
"ck": results["content"]["data"]["ck"],
|
||||
})
|
||||
|
||||
# 获取二维码内容
|
||||
qr_content = results["content"]["data"]["codeContent"]
|
||||
session.qr_content = qr_content
|
||||
|
||||
# 生成二维码图片(base64格式)
|
||||
qr = qrcode.QRCode(
|
||||
version=5,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(qr_content)
|
||||
qr.make()
|
||||
|
||||
# 将二维码转换为base64
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
qr_img = qr.make_image()
|
||||
buffer = BytesIO()
|
||||
qr_img.save(buffer, format='PNG')
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
qr_data_url = f"data:image/png;base64,{qr_base64}"
|
||||
|
||||
session.qr_code_url = qr_data_url
|
||||
session.status = 'waiting'
|
||||
|
||||
# 保存会话
|
||||
self.sessions[session_id] = session
|
||||
|
||||
# 启动状态检查任务
|
||||
asyncio.create_task(self._monitor_qr_status(session_id))
|
||||
|
||||
logger.info(f"二维码生成成功: {session_id}")
|
||||
return {
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'qr_code_url': qr_data_url
|
||||
}
|
||||
else:
|
||||
raise GetLoginQRCodeError("获取登录二维码失败")
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.error(f"连接超时: {e}")
|
||||
return {'success': False, 'message': f'连接超时,请检查网络或尝试使用代理'}
|
||||
except httpx.ReadTimeout as e:
|
||||
logger.error(f"读取超时: {e}")
|
||||
return {'success': False, 'message': f'读取超时,服务器响应过慢'}
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"连接错误: {e}")
|
||||
return {'success': False, 'message': f'连接错误,请检查网络或代理设置'}
|
||||
except Exception as e:
|
||||
logger.exception("二维码生成过程中发生异常")
|
||||
return {'success': False, 'message': f'生成二维码失败: {str(e)}'}
|
||||
|
||||
async def _poll_qrcode_status(self, session: QRLoginSession) -> httpx.Response:
|
||||
"""获取二维码扫描状态"""
|
||||
async with self._create_async_client(follow_redirects=True, timeout=self.timeout) as client:
|
||||
resp = await client.post(
|
||||
self.api_scan_status,
|
||||
data=session.params,
|
||||
cookies=session.cookies,
|
||||
headers=self.headers,
|
||||
)
|
||||
return resp
|
||||
|
||||
async def _monitor_qr_status(self, session_id: str):
|
||||
"""监控二维码状态"""
|
||||
try:
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
logger.info(f"开始监控二维码状态: {session_id}")
|
||||
|
||||
# 监控登录状态
|
||||
max_wait_time = 300 # 5分钟
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
try:
|
||||
# 检查会话是否还存在
|
||||
if session_id not in self.sessions:
|
||||
break
|
||||
if session.status == 'success':
|
||||
logger.info(f"扫码登录API轮询检测到会话已成功: {session_id}")
|
||||
break
|
||||
|
||||
# 轮询二维码状态
|
||||
resp = await self._poll_qrcode_status(session)
|
||||
if session.status == 'success':
|
||||
logger.info(f"扫码登录API轮询响应返回前,会话已由其他链路成功: {session_id}")
|
||||
break
|
||||
qrcode_status = (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("qrCodeStatus")
|
||||
)
|
||||
|
||||
if qrcode_status == "CONFIRMED":
|
||||
# 登录确认
|
||||
if (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("iframeRedirect")
|
||||
is True
|
||||
):
|
||||
# 账号被风控,需要手机验证
|
||||
session.status = 'verification_required'
|
||||
iframe_url = (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("iframeRedirectUrl")
|
||||
)
|
||||
session.verification_url = iframe_url
|
||||
session.expire_time = max(session.expire_time, 600)
|
||||
self._merge_session_cookies(session, resp.cookies)
|
||||
self._ensure_verification_task(session)
|
||||
logger.warning(f"账号被风控,需要手机验证: {session_id}, URL: {iframe_url}")
|
||||
await asyncio.sleep(0.8)
|
||||
continue
|
||||
else:
|
||||
# 登录成功
|
||||
if self._mark_session_success(session, resp.cookies, 'api'):
|
||||
break
|
||||
logger.warning(f"扫码登录API返回成功状态,但关键Cookie不足: {session_id}")
|
||||
|
||||
elif qrcode_status == "NEW":
|
||||
# 二维码未被扫描,继续轮询
|
||||
continue
|
||||
|
||||
elif qrcode_status == "EXPIRED":
|
||||
# 二维码已过期
|
||||
if session.status == 'verification_required':
|
||||
logger.info(f"二维码已过期,但会话已进入验证流程,继续等待: {session_id}")
|
||||
else:
|
||||
session.status = 'expired'
|
||||
logger.info(f"二维码已过期: {session_id}")
|
||||
break
|
||||
|
||||
elif qrcode_status == "SCANED":
|
||||
# 二维码已被扫描,等待确认
|
||||
if session.status == 'waiting':
|
||||
session.status = 'scanned'
|
||||
logger.info(f"二维码已扫描,等待确认: {session_id}")
|
||||
else:
|
||||
# 用户取消确认
|
||||
if session.status == 'verification_required':
|
||||
logger.info(f"扫码状态 {qrcode_status},但验证流程仍在进行,继续等待: {session_id}")
|
||||
else:
|
||||
session.status = 'cancelled'
|
||||
logger.info(f"用户取消登录: {session_id}")
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.8) # 每0.8秒检查一次
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"监控二维码状态异常: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 超时处理
|
||||
if session.status not in ['success', 'expired', 'cancelled', 'verification_required']:
|
||||
session.status = 'expired'
|
||||
logger.info(f"二维码监控超时,标记为过期: {session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"监控二维码状态失败: {e}")
|
||||
if session_id in self.sessions:
|
||||
self.sessions[session_id].status = 'expired'
|
||||
|
||||
def get_session_status(self, session_id: str) -> Dict[str, Any]:
|
||||
"""获取会话状态"""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
return {'status': 'not_found'}
|
||||
|
||||
if session.is_expired() and session.status != 'success':
|
||||
session.status = 'expired'
|
||||
|
||||
result = {
|
||||
'status': session.status,
|
||||
'session_id': session_id
|
||||
}
|
||||
logger.info(f"获取会话状态: {result}")
|
||||
# 如果需要验证,返回验证URL
|
||||
if session.status == 'verification_required':
|
||||
result['verification_url'] = session.verification_url
|
||||
result['screenshot_path'] = session.screenshot_path
|
||||
result['message'] = '账号被风控,需要扫码验证' if session.screenshot_path else '账号被风控,正在准备验证二维码'
|
||||
|
||||
# 如果登录成功,返回Cookie信息
|
||||
if session.status == 'success' and session.cookies and session.unb:
|
||||
result['cookies'] = self._cookie_marshal(session.cookies)
|
||||
result['unb'] = session.unb
|
||||
|
||||
return result
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""清理过期会话"""
|
||||
expired_sessions = []
|
||||
for session_id, session in self.sessions.items():
|
||||
if session.is_expired():
|
||||
expired_sessions.append(session_id)
|
||||
|
||||
for session_id in expired_sessions:
|
||||
self._cleanup_session_assets(self.sessions[session_id])
|
||||
del self.sessions[session_id]
|
||||
logger.info(f"清理过期会话: {session_id}")
|
||||
|
||||
def get_session_cookies(self, session_id: str) -> Optional[Dict[str, str]]:
|
||||
"""获取会话Cookie"""
|
||||
session = self.sessions.get(session_id)
|
||||
if session and session.status == 'success':
|
||||
return {
|
||||
'cookies': self._cookie_marshal(session.cookies),
|
||||
'unb': session.unb
|
||||
}
|
||||
return None
|
||||
|
||||
# 全局二维码登录管理器实例
|
||||
qr_login_manager = QRLoginManager()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
DB_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
LOCAL_DATE_FORMAT = "%Y-%m-%d"
|
||||
UTC = timezone.utc
|
||||
LOCAL_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def get_local_now() -> datetime:
|
||||
"""返回当前北京时间。"""
|
||||
return datetime.now(LOCAL_TIMEZONE)
|
||||
|
||||
|
||||
def parse_db_timestamp(value: str) -> Optional[datetime]:
|
||||
"""将数据库时间字符串按 UTC 解析为 datetime。"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
normalized = text.replace("Z", "+00:00") if text.endswith("Z") else text
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = datetime.strptime(text, DB_DATETIME_FORMAT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def to_db_utc_string(value: datetime) -> str:
|
||||
"""将 datetime 转成数据库使用的 UTC 时间字符串。"""
|
||||
if value.tzinfo is None:
|
||||
aware_value = value.replace(tzinfo=LOCAL_TIMEZONE)
|
||||
else:
|
||||
aware_value = value
|
||||
return aware_value.astimezone(UTC).strftime(DB_DATETIME_FORMAT)
|
||||
|
||||
|
||||
def parse_local_datetime_text_to_db_utc(value: str) -> Optional[str]:
|
||||
"""将中文/本地时间文本解析为数据库使用的 UTC 时间字符串。"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
normalized = re.sub(r"\s+", " ", text.replace("\u3000", " ")).strip()
|
||||
match = re.search(
|
||||
r"(?P<year>\d{4})\s*(?:年|[-/.])\s*(?P<month>\d{1,2})\s*(?:月|[-/.])\s*(?P<day>\d{1,2})"
|
||||
r"\s*(?:日)?\s*(?:T|\s+)\s*(?P<hour>\d{1,2})\s*:\s*(?P<minute>\d{1,2})"
|
||||
r"(?:\s*:\s*(?P<second>\d{1,2}))?",
|
||||
normalized,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
try:
|
||||
local_datetime = datetime(
|
||||
int(match.group("year")),
|
||||
int(match.group("month")),
|
||||
int(match.group("day")),
|
||||
int(match.group("hour")),
|
||||
int(match.group("minute")),
|
||||
int(match.group("second") or 0),
|
||||
tzinfo=LOCAL_TIMEZONE,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return to_db_utc_string(local_datetime)
|
||||
|
||||
|
||||
def local_date_to_utc_start(date_str: str) -> Optional[str]:
|
||||
"""将北京时间日期转成 UTC 起始时间字符串。"""
|
||||
text = str(date_str or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
local_start = datetime.strptime(text, LOCAL_DATE_FORMAT).replace(tzinfo=LOCAL_TIMEZONE)
|
||||
except ValueError:
|
||||
return None
|
||||
return to_db_utc_string(local_start)
|
||||
|
||||
|
||||
def local_date_to_utc_end_exclusive(date_str: str) -> Optional[str]:
|
||||
"""将北京时间日期转成次日零点的 UTC 时间字符串。"""
|
||||
text = str(date_str or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
local_start = datetime.strptime(text, LOCAL_DATE_FORMAT).replace(tzinfo=LOCAL_TIMEZONE)
|
||||
except ValueError:
|
||||
return None
|
||||
return to_db_utc_string(local_start + timedelta(days=1))
|
||||
|
||||
|
||||
def utc_timestamp_to_local_date_string(value: str) -> Optional[str]:
|
||||
"""将 UTC 时间字符串转换为北京时间日期字符串。"""
|
||||
parsed = parse_db_timestamp(value)
|
||||
if not parsed:
|
||||
return None
|
||||
return parsed.astimezone(LOCAL_TIMEZONE).strftime(LOCAL_DATE_FORMAT)
|
||||
|
||||
|
||||
def utc_timestamp_to_local_datetime(value: str) -> Optional[datetime]:
|
||||
"""将 UTC 时间字符串转换为北京时间 datetime。"""
|
||||
parsed = parse_db_timestamp(value)
|
||||
if not parsed:
|
||||
return None
|
||||
return parsed.astimezone(LOCAL_TIMEZONE)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
from functools import partial
|
||||
import time
|
||||
import hashlib
|
||||
import struct
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import blackboxprotobuf
|
||||
from loguru import logger
|
||||
|
||||
subprocess.Popen = partial(subprocess.Popen, encoding="utf-8")
|
||||
import execjs
|
||||
|
||||
def get_js_path():
|
||||
"""获取JavaScript文件的路径"""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(current_dir)
|
||||
js_path = os.path.join(root_dir, 'static', 'xianyu_js_version_2.js')
|
||||
return js_path
|
||||
|
||||
try:
|
||||
# 检查JavaScript运行时是否可用
|
||||
available_runtimes = execjs.runtime_names
|
||||
logger.info(f"可用的JavaScript运行时: {available_runtimes}")
|
||||
|
||||
# 尝试获取默认运行时
|
||||
current_runtime = execjs.get()
|
||||
logger.info(f"当前JavaScript运行时: {current_runtime.name}")
|
||||
|
||||
xianyu_js = execjs.compile(open(get_js_path(), 'r', encoding='utf-8').read())
|
||||
logger.info("JavaScript文件加载成功")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"JavaScript运行时错误: {error_msg}")
|
||||
|
||||
if "Could not find an available JavaScript runtime" in error_msg:
|
||||
logger.error("解决方案:")
|
||||
logger.error("1. 确保已安装Node.js: apt-get install nodejs")
|
||||
logger.error("2. 或安装其他JS运行时: apt-get install nodejs npm")
|
||||
logger.error("3. 检查PATH环境变量是否包含Node.js路径")
|
||||
|
||||
# 尝试检测系统中的JavaScript运行时
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['node', '--version'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"检测到Node.js版本: {result.stdout.strip()}")
|
||||
else:
|
||||
logger.error("Node.js未正确安装或不在PATH中")
|
||||
except FileNotFoundError:
|
||||
logger.error("未找到Node.js可执行文件")
|
||||
|
||||
raise RuntimeError(f"无法加载JavaScript文件: {error_msg}")
|
||||
|
||||
def trans_cookies(cookies_str: str) -> dict:
|
||||
"""将cookies字符串转换为字典"""
|
||||
if not cookies_str:
|
||||
raise ValueError("cookies不能为空")
|
||||
|
||||
cookies = {}
|
||||
for cookie in cookies_str.split("; "):
|
||||
if "=" in cookie:
|
||||
key, value = cookie.split("=", 1)
|
||||
cookies[key] = value
|
||||
return cookies
|
||||
|
||||
|
||||
def generate_mid() -> str:
|
||||
"""生成mid"""
|
||||
import random
|
||||
random_part = int(1000 * random.random())
|
||||
timestamp = int(time.time() * 1000)
|
||||
return f"{random_part}{timestamp} 0"
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""生成uuid"""
|
||||
timestamp = int(time.time() * 1000)
|
||||
return f"-{timestamp}1"
|
||||
|
||||
|
||||
def generate_device_id(user_id: str) -> str:
|
||||
"""生成设备ID"""
|
||||
import random
|
||||
|
||||
# 字符集
|
||||
chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
result = []
|
||||
|
||||
for i in range(36):
|
||||
if i in [8, 13, 18, 23]:
|
||||
result.append("-")
|
||||
elif i == 14:
|
||||
result.append("4")
|
||||
else:
|
||||
if i == 19:
|
||||
# 对于位置19,需要特殊处理
|
||||
rand_val = int(16 * random.random())
|
||||
result.append(chars[(rand_val & 0x3) | 0x8])
|
||||
else:
|
||||
rand_val = int(16 * random.random())
|
||||
result.append(chars[rand_val])
|
||||
|
||||
return ''.join(result) + "-" + user_id
|
||||
|
||||
|
||||
def generate_sign(t: str, token: str, data: str) -> str:
|
||||
"""生成签名"""
|
||||
app_key = "34839810"
|
||||
msg = f"{token}&{t}&{app_key}&{data}"
|
||||
|
||||
# 使用MD5生成签名
|
||||
md5_hash = hashlib.md5()
|
||||
md5_hash.update(msg.encode('utf-8'))
|
||||
return md5_hash.hexdigest()
|
||||
|
||||
|
||||
class MessagePackDecoder:
|
||||
"""MessagePack解码器的纯Python实现"""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
self.length = len(data)
|
||||
|
||||
def read_byte(self) -> int:
|
||||
if self.pos >= self.length:
|
||||
raise ValueError("Unexpected end of data")
|
||||
byte = self.data[self.pos]
|
||||
self.pos += 1
|
||||
return byte
|
||||
|
||||
def read_bytes(self, count: int) -> bytes:
|
||||
if self.pos + count > self.length:
|
||||
raise ValueError("Unexpected end of data")
|
||||
result = self.data[self.pos:self.pos + count]
|
||||
self.pos += count
|
||||
return result
|
||||
|
||||
def read_uint8(self) -> int:
|
||||
return self.read_byte()
|
||||
|
||||
def read_uint16(self) -> int:
|
||||
return struct.unpack('>H', self.read_bytes(2))[0]
|
||||
|
||||
def read_uint32(self) -> int:
|
||||
return struct.unpack('>I', self.read_bytes(4))[0]
|
||||
|
||||
def read_uint64(self) -> int:
|
||||
return struct.unpack('>Q', self.read_bytes(8))[0]
|
||||
|
||||
def read_int8(self) -> int:
|
||||
return struct.unpack('>b', self.read_bytes(1))[0]
|
||||
|
||||
def read_int16(self) -> int:
|
||||
return struct.unpack('>h', self.read_bytes(2))[0]
|
||||
|
||||
def read_int32(self) -> int:
|
||||
return struct.unpack('>i', self.read_bytes(4))[0]
|
||||
|
||||
def read_int64(self) -> int:
|
||||
return struct.unpack('>q', self.read_bytes(8))[0]
|
||||
|
||||
def read_float32(self) -> float:
|
||||
return struct.unpack('>f', self.read_bytes(4))[0]
|
||||
|
||||
def read_float64(self) -> float:
|
||||
return struct.unpack('>d', self.read_bytes(8))[0]
|
||||
|
||||
def read_string(self, length: int) -> str:
|
||||
return self.read_bytes(length).decode('utf-8')
|
||||
|
||||
def decode_value(self) -> Any:
|
||||
"""解码单个MessagePack值"""
|
||||
if self.pos >= self.length:
|
||||
raise ValueError("Unexpected end of data")
|
||||
|
||||
format_byte = self.read_byte()
|
||||
|
||||
# Positive fixint (0xxxxxxx)
|
||||
if format_byte <= 0x7f:
|
||||
return format_byte
|
||||
|
||||
# Fixmap (1000xxxx)
|
||||
elif 0x80 <= format_byte <= 0x8f:
|
||||
size = format_byte & 0x0f
|
||||
return self.decode_map(size)
|
||||
|
||||
# Fixarray (1001xxxx)
|
||||
elif 0x90 <= format_byte <= 0x9f:
|
||||
size = format_byte & 0x0f
|
||||
return self.decode_array(size)
|
||||
|
||||
# Fixstr (101xxxxx)
|
||||
elif 0xa0 <= format_byte <= 0xbf:
|
||||
size = format_byte & 0x1f
|
||||
return self.read_string(size)
|
||||
|
||||
# nil
|
||||
elif format_byte == 0xc0:
|
||||
return None
|
||||
|
||||
# false
|
||||
elif format_byte == 0xc2:
|
||||
return False
|
||||
|
||||
# true
|
||||
elif format_byte == 0xc3:
|
||||
return True
|
||||
|
||||
# bin 8
|
||||
elif format_byte == 0xc4:
|
||||
size = self.read_uint8()
|
||||
return self.read_bytes(size)
|
||||
|
||||
# bin 16
|
||||
elif format_byte == 0xc5:
|
||||
size = self.read_uint16()
|
||||
return self.read_bytes(size)
|
||||
|
||||
# bin 32
|
||||
elif format_byte == 0xc6:
|
||||
size = self.read_uint32()
|
||||
return self.read_bytes(size)
|
||||
|
||||
# float 32
|
||||
elif format_byte == 0xca:
|
||||
return self.read_float32()
|
||||
|
||||
# float 64
|
||||
elif format_byte == 0xcb:
|
||||
return self.read_float64()
|
||||
|
||||
# uint 8
|
||||
elif format_byte == 0xcc:
|
||||
return self.read_uint8()
|
||||
|
||||
# uint 16
|
||||
elif format_byte == 0xcd:
|
||||
return self.read_uint16()
|
||||
|
||||
# uint 32
|
||||
elif format_byte == 0xce:
|
||||
return self.read_uint32()
|
||||
|
||||
# uint 64
|
||||
elif format_byte == 0xcf:
|
||||
return self.read_uint64()
|
||||
|
||||
# int 8
|
||||
elif format_byte == 0xd0:
|
||||
return self.read_int8()
|
||||
|
||||
# int 16
|
||||
elif format_byte == 0xd1:
|
||||
return self.read_int16()
|
||||
|
||||
# int 32
|
||||
elif format_byte == 0xd2:
|
||||
return self.read_int32()
|
||||
|
||||
# int 64
|
||||
elif format_byte == 0xd3:
|
||||
return self.read_int64()
|
||||
|
||||
# str 8
|
||||
elif format_byte == 0xd9:
|
||||
size = self.read_uint8()
|
||||
return self.read_string(size)
|
||||
|
||||
# str 16
|
||||
elif format_byte == 0xda:
|
||||
size = self.read_uint16()
|
||||
return self.read_string(size)
|
||||
|
||||
# str 32
|
||||
elif format_byte == 0xdb:
|
||||
size = self.read_uint32()
|
||||
return self.read_string(size)
|
||||
|
||||
# array 16
|
||||
elif format_byte == 0xdc:
|
||||
size = self.read_uint16()
|
||||
return self.decode_array(size)
|
||||
|
||||
# array 32
|
||||
elif format_byte == 0xdd:
|
||||
size = self.read_uint32()
|
||||
return self.decode_array(size)
|
||||
|
||||
# map 16
|
||||
elif format_byte == 0xde:
|
||||
size = self.read_uint16()
|
||||
return self.decode_map(size)
|
||||
|
||||
# map 32
|
||||
elif format_byte == 0xdf:
|
||||
size = self.read_uint32()
|
||||
return self.decode_map(size)
|
||||
|
||||
# Negative fixint (111xxxxx)
|
||||
elif format_byte >= 0xe0:
|
||||
return format_byte - 0x100
|
||||
|
||||
raise ValueError(f"Unknown format byte: {format_byte:02x}")
|
||||
|
||||
def decode_array(self, size: int) -> List[Any]:
|
||||
"""解码数组"""
|
||||
return [self.decode_value() for _ in range(size)]
|
||||
|
||||
def decode_map(self, size: int) -> Dict[Any, Any]:
|
||||
"""解码字典"""
|
||||
result = {}
|
||||
for _ in range(size):
|
||||
key = self.decode_value()
|
||||
value = self.decode_value()
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def decode(self) -> Any:
|
||||
"""解码整个MessagePack数据"""
|
||||
return self.decode_value()
|
||||
|
||||
|
||||
def decrypt(data: str) -> str:
|
||||
"""解密消息数据"""
|
||||
import json as json_module # 使用别名避免作用域冲突
|
||||
|
||||
try:
|
||||
# 确保输入数据是字符串类型
|
||||
if not isinstance(data, str):
|
||||
data = str(data)
|
||||
|
||||
# 清理数据,移除可能的非ASCII字符
|
||||
try:
|
||||
# 尝试编码为ASCII,如果失败则使用UTF-8编码后再解码
|
||||
data.encode('ascii')
|
||||
except UnicodeEncodeError:
|
||||
# 如果包含非ASCII字符,先编码为UTF-8字节,再解码为ASCII兼容的字符串
|
||||
data = data.encode('utf-8', errors='ignore').decode('ascii', errors='ignore')
|
||||
|
||||
# Base64解码
|
||||
try:
|
||||
decoded_data = base64.b64decode(data)
|
||||
except Exception as decode_error:
|
||||
# 如果base64解码失败,尝试添加填充
|
||||
missing_padding = len(data) % 4
|
||||
if missing_padding:
|
||||
data += '=' * (4 - missing_padding)
|
||||
decoded_data = base64.b64decode(data)
|
||||
|
||||
# 使用MessagePack解码器解码数据
|
||||
decoder = MessagePackDecoder(decoded_data)
|
||||
decoded_value = decoder.decode()
|
||||
|
||||
# 如果解码后的值是字典,转换为JSON字符串
|
||||
if isinstance(decoded_value, dict):
|
||||
def json_serializer(obj):
|
||||
if isinstance(obj, bytes):
|
||||
return obj.decode('utf-8', errors='ignore')
|
||||
raise TypeError(f"Type {type(obj)} not serializable")
|
||||
|
||||
return json_module.dumps(decoded_value, default=json_serializer, ensure_ascii=False)
|
||||
|
||||
# 如果是其他类型,尝试转换为字符串
|
||||
return str(decoded_value)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"解密失败: {str(e)}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
msg = "ggGLAYEBsjMxNDk2MzcwNjNAZ29vZmlzaAKzNDc5ODMzODkwOTZAZ29vZmlzaAOxMzQxNjU2NTI3NDU0Mi5QTk0EAAXPAAABlbKji20GggFlA4UBoAK6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl0DoAQaBdoEKnsiY29udGVudFR5cGUiOjI2LCJkeENhcmQiOnsiaXRlbSI6eyJtYWluIjp7ImNsaWNrUGFyYW0iOnsiYXJnMSI6Ik1zZ0NhcmQiLCJhcmdzIjp7InNvdXJjZSI6ImltIiwidGFza19pZCI6IjNleFFKSE9UbVBVMSIsIm1zZ19pZCI6ImNjOGJjMmRmN2M5MzRkZjA4NmUwNTY3Y2I2OWYxNTczIn19LCJleENvbnRlbnQiOnsiYmdDb2xvciI6IiNGRkZGRkYiLCJidXR0b24iOnsiYmdDb2xvciI6IiNGRkU2MEYiLCJib3JkZXJDb2xvciI6IiNGRkU2MEYiLCJjbGlja1BhcmFtIjp7ImFyZzEiOiJNc2dDYXJkQWN0aW9uIiwiYXJncyI6eyJzb3VyY2UiOiJpbSIsInRhc2tfaWQiOiIzZXhRSkhPVG1QVTEiLCJtc2dfaWQiOiJjYzhiYzJkZjdjOTM0ZGYwODZlMDU2N2NiNjlmMTU3MyJ9fSwiZm9udENvbG9yIjoiIzMzMzMzMyIsInRhcmdldFVybCI6ImZsZWFtYXJrZXQ6Ly9hZGp1c3RfcHJpY2U/Zmx1dHRlcj10cnVlJmJpek9yZGVySWQ9MjUwMzY4ODEyNjM1NjYzNjM3MCIsInRleHQiOiLkv67mlLnku7fmoLwifSwiZGVzYyI6Iuivt+WPjOaWueayn+mAmuWPiuaXtuehruiupOS7t+agvCIsImRlc2NDb2xvciI6IiNBM0EzQTMiLCJ0aXRsZSI6IuaIkeW3suaLjeS4i++8jOW+heS7mOasviIsInVwZ3JhZGUiOnsidGFyZ2V0VXJsIjoiaHR0cHM6Ly9oNS5tLmdvb2Zpc2guY29tL2FwcC9pZGxlRmlzaC1GMmUvZm0tZG93bmxhb2QvaG9tZS5odG1sP25vUmVkcmllY3Q9dHJ1ZSZjYW5CYWNrPXRydWUmY2hlY2tWZXJzaW9uPXRydWUiLCJ2ZXJzaW9uIjoiNy43LjkwIn19LCJ0YXJnZXRVcmwiOiJmbGVhbWFya2V0Oi8vb3JkZXJfZGV0YWlsP2lkPTI1MDM2ODgxMjYzNTY2MzYzNzAmcm9sZT1zZWxsZXIifX0sInRlbXBsYXRlIjp7Im5hbWUiOiJpZGxlZmlzaF9tZXNzYWdlX3RyYWRlX2NoYXRfY2FyZCIsInVybCI6Imh0dHBzOi8vZGluYW1pY3guYWxpYmFiYXVzZXJjb250ZW50LmNvbS9wdWIvaWRsZWZpc2hfbWVzc2FnZV90cmFkZV9jaGF0X2NhcmQvMTY2NzIyMjA1Mjc2Ny9pZGxlZmlzaF9tZXNzYWdlX3RyYWRlX2NoYXRfY2FyZC56aXAiLCJ2ZXJzaW9uIjoiMTY2NzIyMjA1Mjc2NyJ9fX0HAQgBCQAK3gAQpmJpelRhZ9oAe3sic291cmNlSWQiOiJDMkM6M2V4UUpIT1RtUFUxIiwidGFza05hbWUiOiLlt7Lmi43kuItf5pyq5LuY5qy+X+WNluWutiIsIm1hdGVyaWFsSWQiOiIzZXhRSkhPVG1QVTEiLCJ0YXNrSWQiOiIzZXhRSkhPVG1QVTEifbFjbG9zZVB1c2hSZWNlaXZlcqVmYWxzZbFjbG9zZVVucmVhZE51bWJlcqVmYWxzZaxkZXRhaWxOb3RpY2W6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl2nZXh0SnNvbtoBr3sibXNnQXJncyI6eyJ0YXNrX2lkIjoiM2V4UUpIT1RtUFUxIiwic291cmNlIjoiaW0iLCJtc2dfaWQiOiJjYzhiYzJkZjdjOTM0ZGYwODZlMDU2N2NiNjlmMTU3MyJ9LCJxdWlja1JlcGx5IjoiMSIsIm1zZ0FyZzEiOiJNc2dDYXJkIiwidXBkYXRlS2V5IjoiNDc5ODMzODkwOTY6MjUwMzY4ODEyNjM1NjYzNjM3MDoxX25vdF9wYXlfc2VsbGVyIiwibWVzc2FnZUlkIjoiY2M4YmMyZGY3YzkzNGRmMDg2ZTA1NjdjYjY5ZjE1NzMiLCJtdWx0aUNoYW5uZWwiOnsiaHVhd2VpIjoiRVhQUkVTUyIsInhpYW9taSI6IjEwODAwMCIsIm9wcG8iOiJFWFBSRVNTIiwiaG9ub3IiOiJOT1JNQUwiLCJhZ29vIjoicHJvZHVjdCIsInZpdm8iOiJPUkRFUiJ9LCJjb250ZW50VHlwZSI6IjI2IiwiY29ycmVsYXRpb25Hcm91cElkIjoiM2V4UUpIT1RtUFUxX0ZGcjRHT1NuOE9RbyJ9qHJlY2VpdmVyrTIyMDI2NDA5MTgwNzmrcmVkUmVtaW5kZXKy562J5b6F5Lmw5a625LuY5qy+sHJlZFJlbWluZGVyU3R5bGWhMa9yZW1pbmRlckNvbnRlbnS6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl2ucmVtaW5kZXJOb3RpY2W75Lmw5a625bey5ouN5LiL77yM5b6F5LuY5qy+rXJlbWluZGVyVGl0bGW75Lmw5a625bey5ouN5LiL77yM5b6F5LuY5qy+q3JlbWluZGVyVXJs2gCaZmxlYW1hcmtldDovL21lc3NhZ2VfY2hhdD9pdGVtSWQ9OTAwMDUyNjQ0Mjc3JnBlZXJVc2VySWQ9MzE0OTYzNzA2MyZwZWVyVXNlck5pY2s955S3KioqeSZzaWQ9NDc5ODMzODkwOTYmbWVzc2FnZUlkPWNjOGJjMmRmN2M5MzRkZjA4NmUwNTY3Y2I2OWYxNTczJmFkdj1ub6xzZW5kZXJVc2VySWSqMzE0OTYzNzA2M65zZW5kZXJVc2VyVHlwZaEwq3Nlc3Npb25UeXBloTGqdXBkYXRlSGVhZKR0cnVlDAEDgahuZWVkUHVzaKR0cnVl"
|
||||
msg = "ggGLAYEBsjMxNDk2MzcwNjNAZ29vZmlzaAKzNDc5ODMzODkwOTZAZ29vZmlzaAOxMzQxNjU2NTI3NDU0Mi5QTk0EAAXPAAABlbKji20GggFlA4UBoAK6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl0DoAQaBdoEKnsiY29udGVudFR5cGUiOjI2LCJkeENhcmQiOnsiaXRlbSI6eyJtYWluIjp7ImNsaWNrUGFyYW0iOnsiYXJnMSI6Ik1zZ0NhcmQiLCJhcmdzIjp7InNvdXJjZSI6ImltIiwidGFza19pZCI6IjNleFFKSE9UbVBVMSIsIm1zZ19pZCI6ImNjOGJjMmRmN2M5MzRkZjA4NmUwNTY3Y2I2OWYxNTczIn19LCJleENvbnRlbnQiOnsiYmdDb2xvciI6IiNGRkZGRkYiLCJidXR0b24iOnsiYmdDb2xvciI6IiNGRkU2MEYiLCJib3JkZXJDb2xvciI6IiNGRkU2MEYiLCJjbGlja1BhcmFtIjp7ImFyZzEiOiJNc2dDYXJkQWN0aW9uIiwiYXJncyI6eyJzb3VyY2UiOiJpbSIsInRhc2tfaWQiOiIzZXhRSkhPVG1QVTEiLCJtc2dfaWQiOiJjYzhiYzJkZjdjOTM0ZGYwODZlMDU2N2NiNjlmMTU3MyJ9fSwiZm9udENvbG9yIjoiIzMzMzMzMyIsInRhcmdldFVybCI6ImZsZWFtYXJrZXQ6Ly9hZGp1c3RfcHJpY2U/Zmx1dHRlcj10cnVlJmJpek9yZGVySWQ9MjUwMzY4ODEyNjM1NjYzNjM3MCIsInRleHQiOiLkv67mlLnku7fmoLwifSwiZGVzYyI6Iuivt+WPjOaWueayn+mAmuWPiuaXtuehruiupOS7t+agvCIsImRlc2NDb2xvciI6IiNBM0EzQTMiLCJ0aXRsZSI6IuaIkeW3suaLjeS4i++8jOW+heS7mOasviIsInVwZ3JhZGUiOnsidGFyZ2V0VXJsIjoiaHR0cHM6Ly9oNS5tLmdvb2Zpc2guY29tL2FwcC9pZGxlRmlzaC1GMmUvZm0tZG93bmxhb2QvaG9tZS5odG1sP25vUmVkcmllY3Q9dHJ1ZSZjYW5CYWNrPXRydWUmY2hlY2tWZXJzaW9uPXRydWUiLCJ2ZXJzaW9uIjoiNy43LjkwIn19LCJ0YXJnZXRVcmwiOiJmbGVhbWFya2V0Oi8vb3JkZXJfZGV0YWlsP2lkPTI1MDM2ODgxMjYzNTY2MzYzNzAmcm9sZT1zZWxsZXIifX0sInRlbXBsYXRlIjp7Im5hbWUiOiJpZGxlZmlzaF9tZXNzYWdlX3RyYWRlX2NoYXRfY2FyZCIsInVybCI6Imh0dHBzOi8vZGluYW1pY3guYWxpYmFiYXVzZXJjb250ZW50LmNvbS9wdWIvaWRsZWZpc2hfbWVzc2FnZV90cmFkZV9jaGF0X2NhcmQvMTY2NzIyMjA1Mjc2Ny9pZGxlZmlzaF9tZXNzYWdlX3RyYWRlX2NoYXRfY2FyZC56aXAiLCJ2ZXJzaW9uIjoiMTY2NzIyMjA1Mjc2NyJ9fX0HAQgBCQAK3gAQpmJpelRhZ9oAe3sic291cmNlSWQiOiJDMkM6M2V4UUpIT1RtUFUxIiwidGFza05hbWUiOiLlt7Lmi43kuItf5pyq5LuY5qy+X+WNluWutiIsIm1hdGVyaWFsSWQiOiIzZXhRSkhPVG1QVTEiLCJ0YXNrSWQiOiIzZXhRSkhPVG1QVTEifbFjbG9zZVB1c2hSZWNlaXZlcqVmYWxzZbFjbG9zZVVucmVhZE51bWJlcqVmYWxzZaxkZXRhaWxOb3RpY2W6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl2nZXh0SnNvbtoBr3sibXNnQXJncyI6eyJ0YXNrX2lkIjoiM2V4UUpIT1RtUFUxIiwic291cmNlIjoiaW0iLCJtc2dfaWQiOiJjYzhiYzJkZjdjOTM0ZGYwODZlMDU2N2NiNjlmMTU3MyJ9LCJxdWlja1JlcGx5IjoiMSIsIm1zZ0FyZzEiOiJNc2dDYXJkIiwidXBkYXRlS2V5IjoiNDc5ODMzODkwOTY6MjUwMzY4ODEyNjM1NjYzNjM3MDoxX25vdF9wYXlfc2VsbGVyIiwibWVzc2FnZUlkIjoiY2M4YmMyZGY3YzkzNGRmMDg2ZTA1NjdjYjY5ZjE1NzMiLCJtdWx0aUNoYW5uZWwiOnsiaHVhd2VpIjoiRVhQUkVTUyIsInhpYW9taSI6IjEwODAwMCIsIm9wcG8iOiJFWFBSRVNTIiwiaG9ub3IiOiJOT1JNQUwiLCJhZ29vIjoicHJvZHVjdCIsInZpdm8iOiJPUkRFUiJ9LCJjb250ZW50VHlwZSI6IjI2IiwiY29ycmVsYXRpb25Hcm91cElkIjoiM2V4UUpIT1RtUFUxX0ZGcjRHT1NuOE9RbyJ9qHJlY2VpdmVyrTIyMDI2NDA5MTgwNzmrcmVkUmVtaW5kZXKy562J5b6F5Lmw5a625LuY5qy+sHJlZFJlbWluZGVyU3R5bGWhMa9yZW1pbmRlckNvbnRlbnS6W+aIkeW3suaLjeS4i++8jOW+heS7mOasvl2ucmVtaW5kZXJOb3RpY2W75Lmw5a625bey5ouN5LiL77yM5b6F5LuY5qy+rXJlbWluZGVyVGl0bGW75Lmw5a625bey5ouN5LiL77yM5b6F5LuY5qy+q3JlbWluZGVyVXJs2gCaZmxlYW1hcmtldDovL21lc3NhZ2VfY2hhdD9pdGVtSWQ9OTAwMDUyNjQ0Mjc3JnBlZXJVc2VySWQ9MzE0OTYzNzA2MyZwZWVyVXNlck5pY2s955S3KioqeSZzaWQ9NDc5ODMzODkwOTYmbWVzc2FnZUlkPWNjOGJjMmRmN2M5MzRkZjA4NmUwNTY3Y2I2OWYxNTczJmFkdj1ub6xzZW5kZXJVc2VySWSqMzE0OTYzNzA2M65zZW5kZXJVc2VyVHlwZaEwq3Nlc3Npb25UeXBloTGqdXBkYXRlSGVhZKR0cnVlDAEDgahuZWVkUHVzaKR0cnVl"
|
||||
|
||||
res = decrypt(msg)
|
||||
print(res)
|
||||
Reference in New Issue
Block a user