Files
CheckInApp/apps/backend/workers/check_in_worker.py
T
8a12744 d811c20932 feat(backend): replace Selenium with Playwright
BREAKING CHANGE: backend now requires Python 3.12 or newer.
2026-05-04 21:20:30 +08:00

315 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import logging
import requests
import time
from typing import Dict, Any
from playwright.sync_api import sync_playwright
from backend.config import settings
from backend.workers.browser_automation import (
PlaywrightLaunchConfig,
extract_payload_header,
save_page_debug_artifacts,
)
logger = logging.getLogger(__name__)
BASE_DIR = settings.BASE_DIR
DEBUG_SCREENSHOT_PATH = BASE_DIR / "payload_debug.png"
DEBUG_PAGE_SOURCE_PATH = BASE_DIR / "payload_debug_page_source.html"
def get_browser_config() -> PlaywrightLaunchConfig:
"""获取 Playwright 浏览器配置(从 settings 读取)"""
return PlaywrightLaunchConfig(executable_path=settings.BROWSER_EXECUTABLE_PATH)
def get_live_x_api_payload(auth_token: str) -> str | None:
"""
启动一个临时的无头浏览器会话,获取新鲜的 x-api-request-payload
Args:
auth_token: 用户的 Authorization Token
Returns:
x-api-request-payload 值,失败返回 None
"""
logger.info("正在启动临时 Playwright 会话以监听网络请求...")
browser = None
context = None
page = None
payload_signature = None
playwright = None
try:
browser_config = get_browser_config()
playwright = sync_playwright().start()
browser = playwright.chromium.launch(**browser_config.to_launch_kwargs())
context = browser.new_context(**browser_config.to_context_kwargs())
page = context.new_page()
def on_request(request) -> None:
nonlocal payload_signature
if payload_signature:
return
payload = extract_payload_header(request.headers)
if payload:
payload_signature = payload
logger.info("成功通过 Playwright 捕获到现场的 x-api-request-payload")
page.on("request", on_request)
page.goto("https://i.jielong.com/my-class", wait_until="domcontentloaded", timeout=60000)
context.add_cookies(
[
{
"name": "token",
"value": auth_token,
"domain": ".jielong.com",
"path": "/",
}
]
)
page.goto("https://i.jielong.com/my-form", wait_until="domcontentloaded", timeout=60000)
max_wait_time = 20
start_time = time.time()
while time.time() - start_time < max_wait_time:
if payload_signature:
break
page.wait_for_timeout(500)
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}")
try:
if page:
save_page_debug_artifacts(page, DEBUG_SCREENSHOT_PATH, DEBUG_PAGE_SOURCE_PATH)
except Exception as screenshot_error:
logger.warning(f"保存调试截图失败: {screenshot_error}")
finally:
if page:
try:
page.close()
except Exception:
pass
if context:
try:
context.close()
except Exception:
pass
if browser:
try:
browser.close()
except Exception as e:
logger.warning(f"关闭 Playwright 浏览器时出现警告: {e}")
if playwright:
try:
playwright.stop()
except Exception as e:
logger.warning(f"关闭 Playwright runtime 时出现警告: {e}")
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: 错误信息
"""
from backend.utils.json_helpers import safe_parse_payload, extract_signature
payload_dict = safe_parse_payload(task.payload_config)
signature = extract_signature(task.payload_config) or "Unknown"
logger.info(f"Playwright打卡: 正在为任务 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,
}
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:
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
payload = safe_parse_payload(task.payload_config)
thread_id = extract_thread_id(task.payload_config)
if not thread_id:
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}"
)
if "打卡成功" in response_text:
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
if task.user and task.user.email:
try:
from backend.services.email_service import EmailService
task_info = {
"thread_id": payload.get("ThreadId", "未知"),
"name": getattr(task, "name", "打卡任务"),
}
EmailService.notify_check_in_result(task.user, task_info, True, "打卡成功")
except Exception as e:
logger.error(f"发送打卡成功邮件失败: {e}")
return {
"success": True,
"status": "success",
"response_text": response_text,
"error_message": "",
}
elif (
"已被提交" in response_text
or "已经打卡" in response_text
or "重复提交" in response_text
):
logger.info(f"✅ 检测到'已被提交',本次打卡已完成(重复提交,不发送邮件)")
return {
"success": True,
"status": "success",
"response_text": response_text,
"error_message": "",
}
elif "不在打卡时间范围" in response_text or "不在打卡时间" in response_text:
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
return {
"success": False,
"status": "out_of_time",
"response_text": response_text,
"error_message": "不在打卡时间范围内",
}
elif (
"登录" in response_text
or "授权" in response_text
or "未登录" in response_text
or "token" in response_text.lower()
or "Unauthorized" in response_text
or response.status_code == 401
):
logger.warning(f"⚠️ 检测到Token失效特征,Token 可能已失效")
if task.user and task.user.email:
try:
from backend.services.email_service import EmailService
from backend.utils.json_helpers import build_task_info
task_info = build_task_info(task)
EmailService.notify_check_in_result(
task.user, task_info, False, "Token 已失效,需要重新授权"
)
except Exception as e:
logger.error(f"发送打卡失败邮件失败: {e}")
return {
"success": False,
"status": "token_expired",
"response_text": response_text,
"error_message": "Token 已失效,需要重新授权",
}
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)}