first commit

This commit is contained in:
2026-04-15 22:56:44 +08:00
commit 40a78f0032
77 changed files with 102842 additions and 0 deletions
+368
View File
@@ -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()
+257
View File
@@ -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()
+256
View File
@@ -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()
+1635
View File
File diff suppressed because it is too large Load Diff
+585
View File
@@ -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'&timestamp={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
+444
View File
@@ -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)
+755
View File
@@ -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
+118
View File
@@ -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
+379
View File
@@ -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)