mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
refactor: v2
backend & frontend
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from typing import Dict, Any
|
||||
|
||||
from backend.config import settings
|
||||
from backend.workers.email_notifier import send_success_notification, send_failure_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chrome 配置路径 - 从设置中读取
|
||||
CHROME_BINARY_PATH = settings.CHROME_BINARY_PATH
|
||||
CHROMEDRIVER_PATH = settings.CHROMEDRIVER_PATH
|
||||
|
||||
|
||||
def get_live_x_api_payload(auth_token: str) -> str:
|
||||
"""
|
||||
启动一个临时的无头浏览器会话,获取新鲜的 x-api-request-payload
|
||||
|
||||
Args:
|
||||
auth_token: 用户的 Authorization Token
|
||||
|
||||
Returns:
|
||||
x-api-request-payload 值,失败返回 None
|
||||
"""
|
||||
logger.info("正在启动临时浏览器会话以监听网络日志...")
|
||||
|
||||
# 根据配置创建 Service
|
||||
if CHROMEDRIVER_PATH:
|
||||
service = Service(executable_path=CHROMEDRIVER_PATH)
|
||||
else:
|
||||
service = Service() # 使用 Selenium Manager 自动管理
|
||||
|
||||
chrome_options = Options()
|
||||
|
||||
# 如果配置了 Chrome 路径,则使用配置的路径
|
||||
if CHROME_BINARY_PATH:
|
||||
chrome_options.binary_location = CHROME_BINARY_PATH
|
||||
|
||||
# 开启性能日志记录功能
|
||||
logging_prefs = {'performance': 'ALL'}
|
||||
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
payload_signature = None
|
||||
try:
|
||||
# 导航到同源空白页,用于设置 Cookie
|
||||
driver.get("https://i.jielong.com/my-class")
|
||||
|
||||
# 注入长期 Token
|
||||
driver.add_cookie({
|
||||
'name': 'token',
|
||||
'value': auth_token,
|
||||
'domain': '.jielong.com'
|
||||
})
|
||||
|
||||
# 导航到触发 API 的页面
|
||||
driver.get("https://i.jielong.com/my-form")
|
||||
|
||||
# 等待并捕获 x-api-request-payload
|
||||
max_wait_time = 20 # 最多等待20秒
|
||||
start_time = time.time()
|
||||
found = False
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
logs = driver.get_log('performance')
|
||||
for entry in logs:
|
||||
log = json.loads(entry['message'])['message']
|
||||
if log['method'] == 'Network.requestWillBeSent':
|
||||
headers = log.get('params', {}).get('request', {}).get('headers', {})
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
if 'x-api-request-payload' in headers_lower:
|
||||
payload_signature = headers_lower['x-api-request-payload']
|
||||
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload!")
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if not payload_signature:
|
||||
raise Exception(f"在 {max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
|
||||
debug_screenshot = os.path.join(settings.BASE_DIR, 'payload_debug.png')
|
||||
driver.save_screenshot(debug_screenshot)
|
||||
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
return payload_signature
|
||||
|
||||
|
||||
def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
执行打卡任务
|
||||
|
||||
Args:
|
||||
task: CheckInTask 对象,包含打卡任务配置
|
||||
user_token: 用户的 Authorization Token(从 task.user.authorization 获取)
|
||||
|
||||
Returns:
|
||||
打卡结果字典:
|
||||
- success: 是否成功
|
||||
- status: 状态 (success/failure)
|
||||
- response_text: 响应文本
|
||||
- error_message: 错误信息
|
||||
"""
|
||||
# 从 payload_config 中提取 Signature 用于日志
|
||||
try:
|
||||
payload_dict = json.loads(task.payload_config) if task.payload_config else {}
|
||||
signature = payload_dict.get('Signature', 'Unknown')
|
||||
except:
|
||||
signature = 'Unknown'
|
||||
|
||||
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
|
||||
|
||||
if not user_token:
|
||||
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 的 Token 为空,跳过。"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
# 获取 x-api-request-payload
|
||||
payload_signature = get_live_x_api_payload(user_token)
|
||||
if not payload_signature:
|
||||
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 未能获取到现场签名,打卡中止。"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用任务的 payload_config(从模板生成的完整配置,包含 ThreadId)
|
||||
payload = json.loads(task.payload_config) if task.payload_config else {}
|
||||
|
||||
if not payload.get('ThreadId'):
|
||||
error_msg = f"任务 ID: {task.id} 的 payload_config 缺少 ThreadId"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
|
||||
'Accept-Encoding': "gzip",
|
||||
'Content-Type': "application/json",
|
||||
'authorization': f"Bearer {user_token}",
|
||||
'x-api-request-referer': "https://appservice.qq.com/1110276759",
|
||||
'x-api-request-payload': payload_signature,
|
||||
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
|
||||
'platform': "qq",
|
||||
'x-api-request-mode': "cors",
|
||||
}
|
||||
|
||||
url = "https://api.jielong.com/api/CheckIn/EditRecord"
|
||||
|
||||
# 打印请求详情用于调试
|
||||
payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
logger.info(f"📤 打卡请求详情 - 任务 ID: {task.id} (Signature: {signature})")
|
||||
logger.info(f"📍 URL: {url}")
|
||||
logger.info(f"📦 Payload: {payload_json}")
|
||||
logger.info(f"🔑 x-api-request-payload: {payload_signature[:50]}...")
|
||||
|
||||
response = requests.post(url, data=payload_json, headers=headers)
|
||||
response.raise_for_status()
|
||||
response_text = response.text
|
||||
|
||||
logger.info(f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}")
|
||||
|
||||
# 判断响应内容(参考 V1 实现逻辑)
|
||||
# 使用用户账户的邮箱,而不是任务的邮箱
|
||||
email = task.user.email if task.user else None
|
||||
|
||||
# 情况1: 明确包含"打卡成功" → 成功
|
||||
if "打卡成功" in response_text:
|
||||
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
|
||||
if email:
|
||||
send_success_notification(email)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
# 情况2: 不在打卡时间范围 → 标记为时间范围外
|
||||
# 支持多种匹配方式:直接文本匹配、JSON Data 字段、Description 字段
|
||||
elif ("不在打卡时间范围" in response_text or
|
||||
"不在打卡时间" in response_text or
|
||||
'"Data":"不在打卡时间范围"' in response_text or
|
||||
'"Description":"不在打卡时间范围"' in response_text):
|
||||
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "out_of_time",
|
||||
"response_text": response_text,
|
||||
"error_message": "不在打卡时间范围内"
|
||||
}
|
||||
|
||||
# 情况3: Token 失效的特征标识 → 失败
|
||||
elif ("登录" in response_text):
|
||||
logger.warning(f"⚠️ 检测到登录失败关键字,Token 可能已失效")
|
||||
if email:
|
||||
send_failure_notification(email)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": response_text,
|
||||
"error_message": "Token 已失效,需要重新授权"
|
||||
}
|
||||
|
||||
# 情况4: 其他响应 → 需要人工确认(标记为异常)
|
||||
else:
|
||||
logger.warning(f"⚠️ 未识别的响应内容,请检查: {response_text[:200]}...")
|
||||
# 标记为未知状态,记录完整响应供后续分析
|
||||
return {
|
||||
"success": False,
|
||||
"status": "unknown",
|
||||
"response_text": response_text,
|
||||
"error_message": "未识别的响应,请人工确认"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时请求失败: {e}"
|
||||
logger.error(error_msg)
|
||||
|
||||
response_text = ""
|
||||
if e.response is not None:
|
||||
response_text = e.response.text
|
||||
logger.error(f"响应状态码: {e.response.status_code}, 响应内容: {response_text}")
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": response_text,
|
||||
"error_message": str(e)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时发生未知错误: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": str(e)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import time
|
||||
import logging
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 邮件模板 ---
|
||||
|
||||
EXPIRATION_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Token 到期通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>注意!</h1>
|
||||
<div class="message">
|
||||
<p>{name},请注意!</p>
|
||||
<p>您的 <span class="important">token</span> 已经到期,请尽快重新刷新您的 token,否则您的自动打卡功能将会失效。</p>
|
||||
<p><strong>到期时间:</strong> {exp_time}</p>
|
||||
</div>
|
||||
<p class="footer">邮件发送时间: {send_time}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SUCCESS_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡成功通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #5cb85c; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #5cb85c; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>打卡成功!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统已于 <span class="important">{send_time}</span> 成功为您完成自动打卡。</p>
|
||||
<p>您无需进行任何操作,此邮件仅作通知。</p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
FAILURE_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡失败通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>通知:自动打卡失败!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败。</p>
|
||||
<p><strong>失败原因:</strong> 服务器返回 "需要登录",这通常意味着您的 <span class="important">Token 已失效</span>。</p>
|
||||
<p><strong>请您立即刷新您的 Token,以确保后续打卡能够成功。</strong></p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def get_email_settings():
|
||||
"""
|
||||
从 config.ini 读取邮件配置
|
||||
|
||||
Returns:
|
||||
dict: 邮件配置,如果配置文件不存在则返回 None
|
||||
"""
|
||||
if not settings.EMAIL_CONFIG_FILE.exists():
|
||||
logger.warning("找不到 config.ini,无法发送邮件")
|
||||
return None
|
||||
|
||||
try:
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(settings.EMAIL_CONFIG_FILE, encoding='utf-8')
|
||||
|
||||
if 'Email' not in config_parser:
|
||||
logger.warning("config.ini 中缺少 [Email] 配置段")
|
||||
return None
|
||||
|
||||
return config_parser['Email']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取邮件配置失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _send_email(to_email: str, subject: str, html_content: str, email_settings: dict) -> bool:
|
||||
"""
|
||||
发送邮件
|
||||
|
||||
Args:
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_content: HTML 邮件内容
|
||||
email_settings: 邮件配置
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = email_settings['senderemail']
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP_SSL(email_settings['smtpserver'], int(email_settings['smtpport'])) as server:
|
||||
server.login(email_settings['senderemail'], email_settings['senderpassword'])
|
||||
server.sendmail(msg["From"], msg["To"], msg.as_string())
|
||||
|
||||
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向 {to_email} 发送邮件时失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_expiration_notification(email: str, jwt_exp: str) -> bool:
|
||||
"""
|
||||
发送 Token 到期提醒邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
jwt_exp: Token 过期时间戳
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
exp_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime(float(jwt_exp)))
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = EXPIRATION_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
exp_time=exp_time,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "接龙管家Token到期通知", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送过期通知邮件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_success_notification(email: str) -> bool:
|
||||
"""
|
||||
发送打卡成功通知邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = SUCCESS_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "自动打卡成功通知", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送成功通知邮件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_failure_notification(email: str) -> bool:
|
||||
"""
|
||||
发送打卡失败通知邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = FAILURE_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "打卡失败 - 需要刷新Token", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送失败通知邮件失败: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,262 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
from filelock import FileLock
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chrome 配置路径
|
||||
BASE_DIR = settings.BASE_DIR
|
||||
|
||||
# 调试文件路径
|
||||
DEBUG_SCREENSHOT_PATH = os.path.join(BASE_DIR, "debug_screenshot.png")
|
||||
DEBUG_PAGE_SOURCE_PATH = os.path.join(BASE_DIR, "debug_page_source.html")
|
||||
|
||||
|
||||
def get_chrome_config():
|
||||
"""获取 Chrome 配置(从 settings 读取)"""
|
||||
return {
|
||||
"chrome_binary": settings.CHROME_BINARY_PATH,
|
||||
"chromedriver": settings.CHROMEDRIVER_PATH
|
||||
}
|
||||
|
||||
|
||||
|
||||
def update_session_file(session_id: str, data: dict) -> None:
|
||||
"""线程安全地写入会话文件"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"写入会话文件 {filepath} 失败: {e}")
|
||||
|
||||
|
||||
def get_session_status(session_id: str) -> str:
|
||||
"""安全地读取会话文件的状态"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
data = json.loads(content)
|
||||
return data.get('status')
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_session_data(session_id: str) -> dict:
|
||||
"""读取完整的会话数据"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
return json.loads(content)
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None, client_ip: str = "") -> None:
|
||||
"""
|
||||
使用 Selenium 获取 QQ 扫码登录的 Token
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
jwt_sub: QQ 用户标识(老用户刷新 Token 时提供,新用户为 None)
|
||||
alias: 用户别名(用于新用户注册)
|
||||
client_ip: 客户端 IP 地址
|
||||
"""
|
||||
driver = None
|
||||
current_step = "初始化"
|
||||
|
||||
try:
|
||||
# 获取 Chrome 配置
|
||||
chrome_config = get_chrome_config()
|
||||
chrome_binary_path = chrome_config["chrome_binary"]
|
||||
chromedriver_path = chrome_config["chromedriver"]
|
||||
|
||||
# 配置 Chrome 选项
|
||||
current_step = "配置 ChromeDriver"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
|
||||
chrome_options = Options()
|
||||
|
||||
# 如果指定了自定义 Chrome 路径,则使用
|
||||
if chrome_binary_path:
|
||||
chrome_options.binary_location = chrome_binary_path
|
||||
logger.info(f"Selenium ({session_id}): 使用自定义 Chrome 路径: {chrome_binary_path}")
|
||||
else:
|
||||
logger.info(f"Selenium ({session_id}): 使用系统默认 Chrome")
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
# 启动浏览器
|
||||
current_step = "启动 Chrome 浏览器"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
|
||||
# 如果指定了 ChromeDriver 路径,则使用 Service;否则让 Selenium 自动管理
|
||||
if chromedriver_path:
|
||||
service = Service(executable_path=chromedriver_path)
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
logger.info(f"Selenium ({session_id}): 使用自定义 ChromeDriver: {chromedriver_path}")
|
||||
else:
|
||||
driver = webdriver.Chrome(options=chrome_options)
|
||||
logger.info(f"Selenium ({session_id}): 使用 Selenium Manager 自动管理 ChromeDriver")
|
||||
|
||||
logger.info(f"Selenium ({session_id}): Chrome 浏览器启动成功")
|
||||
current_step = "导航到登录页面"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
driver.get("https://i.jielong.com/login?redirectTo=https%3A%2F%2Fi.jielong.com%2F")
|
||||
|
||||
wait = WebDriverWait(driver, 60)
|
||||
|
||||
# --- 步骤 1: 点击切换到 QQ 登录 ---
|
||||
current_step = "查找并点击切换按钮"
|
||||
toggle_button_selector = "div.login-wrap .toggle"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({toggle_button_selector})...")
|
||||
toggle_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector)))
|
||||
toggle_button.click()
|
||||
|
||||
# --- 步骤 2: 勾选同意服务协议 ---
|
||||
current_step = "勾选同意服务协议"
|
||||
checkbox_selector = "input.ant-checkbox-input[type='checkbox']"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({checkbox_selector})...")
|
||||
checkbox = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, checkbox_selector)))
|
||||
if not checkbox.is_selected():
|
||||
checkbox.click()
|
||||
logger.info(f"Selenium ({session_id}): 已勾选服务协议")
|
||||
|
||||
# --- 步骤 3: 点击"立即登录"按钮 ---
|
||||
current_step = "点击立即登录按钮"
|
||||
login_button_selector = "button.css-1wli0ry.ant-btn.ant-btn-default.login-btn"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({login_button_selector})...")
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, login_button_selector)))
|
||||
login_button.click()
|
||||
|
||||
# --- 步骤 4: 等待二维码加载 ---
|
||||
import time
|
||||
time.sleep(3) # 等待几秒让二维码刷新出来
|
||||
|
||||
current_step = "等待QQ二维码图片加载"
|
||||
qq_qr_image_selector = "#login_container img"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({qq_qr_image_selector})...")
|
||||
qr_element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector)))
|
||||
|
||||
logger.info(f"Selenium ({session_id}): 成功找到QQ二维码元素,正在截图...")
|
||||
qr_base64 = qr_element.screenshot_as_base64
|
||||
update_session_file(session_id, {
|
||||
'status': 'waiting_scan',
|
||||
'qr_image_data': qr_base64,
|
||||
'jwt_sub': jwt_sub,
|
||||
'alias': alias, # 新增:保存 alias
|
||||
'client_ip': client_ip # 新增:保存 IP
|
||||
})
|
||||
|
||||
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
|
||||
cookie_name_to_find = "token"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
WebDriverWait(driver, 120, 1).until(lambda d: d.get_cookie(cookie_name_to_find) is not None) # 改为 120 秒(2分钟)
|
||||
|
||||
cookie = driver.get_cookie(cookie_name_to_find)
|
||||
if cookie:
|
||||
logger.info(f"Selenium ({session_id}): 成功在Cookie中捕获到Token!")
|
||||
update_session_file(session_id, {
|
||||
'status': 'success',
|
||||
'token': cookie['value'],
|
||||
'alias': alias, # 保存 alias
|
||||
'client_ip': client_ip # 保存 IP
|
||||
})
|
||||
else:
|
||||
raise Exception("等待Cookie成功但获取失败")
|
||||
|
||||
except TimeoutException:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。")
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"超时释放用户名预占: {alias}")
|
||||
|
||||
error_message = f"操作超时!卡在了步骤: '{current_step}'。请检查CSS选择器或网络。"
|
||||
logger.error(f"Selenium ({session_id}): {error_message}")
|
||||
|
||||
# 保存调试信息(仅当 driver 已创建时)
|
||||
if driver:
|
||||
try:
|
||||
driver.save_screenshot(DEBUG_SCREENSHOT_PATH)
|
||||
with open(DEBUG_PAGE_SOURCE_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(driver.page_source)
|
||||
logger.error(f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}")
|
||||
except Exception as debug_error:
|
||||
logger.error(f"Selenium ({session_id}): 保存调试信息失败: {debug_error}")
|
||||
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': error_message,
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。")
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"异常释放用户名预占: {alias}")
|
||||
|
||||
logger.error(f"Selenium ({session_id}): 发生未知错误: {e}", exc_info=True)
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
|
||||
finally:
|
||||
if driver:
|
||||
try:
|
||||
driver.quit()
|
||||
logger.info(f"Selenium ({session_id}): 浏览器已关闭")
|
||||
except Exception as quit_error:
|
||||
logger.error(f"Selenium ({session_id}): 关闭浏览器失败: {quit_error}")
|
||||
Reference in New Issue
Block a user