SakuraFrp 自动签到(main + sakura_frp_auto_check + SakuraFrpQiandao),已排除 .env 与日志
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import logging
|
||||
import time
|
||||
import random
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
|
||||
from openai import OpenAI
|
||||
from playwright.sync_api import Page
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelApiError(Exception):
|
||||
"""模型 API 错误,属于不可重试错误。"""
|
||||
|
||||
|
||||
class CaptchaHandler:
|
||||
"""验证码处理器"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.client = OpenAI(
|
||||
base_url=config.base_url,
|
||||
api_key=config.api_key
|
||||
)
|
||||
|
||||
def get_img(self, page: Page):
|
||||
try:
|
||||
# 获取验证码图片
|
||||
captcha_img_element = page.locator(".geetest_tip_img")
|
||||
captcha_img_element.wait_for(state="visible", timeout=10000)
|
||||
|
||||
# 获取 CSS 属性
|
||||
bg_style = captcha_img_element.evaluate(
|
||||
"el => window.getComputedStyle(el).backgroundImage"
|
||||
)
|
||||
|
||||
# 正则匹配
|
||||
match = re.search(r'url\(["\']?(.*?)["\']?\)', bg_style)
|
||||
if match:
|
||||
img_url = match.group(1)
|
||||
logger.info(f"成功获取验证码图片 URL: {img_url}")
|
||||
return img_url
|
||||
else:
|
||||
logger.error("无法提取验证码图片 URL")
|
||||
return ""
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("未检测到 GeeTest 验证码窗口")
|
||||
return False
|
||||
|
||||
def handle_geetest_captcha(self, page: Page) -> bool:
|
||||
"""处理 GeeTest 九宫格验证码(带重试机制)"""
|
||||
logger.info("开始处理 GeeTest 验证码...")
|
||||
|
||||
try:
|
||||
# 获取验证码图片
|
||||
img_url = self.get_img(page)
|
||||
if not img_url:
|
||||
logger.error("图片获取失败,刷新网页重试...")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
# 调用视觉模型识别,imagebase64 由配置控制
|
||||
recognition_result = self._recognize_captcha(img_url, imagebase64=self.config.image_as_base64)
|
||||
if not recognition_result:
|
||||
logger.warning("识别失败,刷新网页重试...")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
logger.info(f"验证码识别结果: {recognition_result}")
|
||||
|
||||
# 根据识别结果点击相应的九宫格
|
||||
if not self._click_captcha_items(page, recognition_result):
|
||||
logger.warning("点击失败,刷新网页重试...")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
logger.warning("验证码流程完成,刷新网页验证是否成功...")
|
||||
time.sleep(2)
|
||||
return True
|
||||
except ModelApiError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"处理验证码时发生错误: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _recognize_captcha(self, img_url: str, imagebase64: bool = False) -> Optional[Dict]:
|
||||
"""使用视觉模型识别验证码,支持 imagebase64 方式"""
|
||||
try:
|
||||
prompt = (
|
||||
'这是一个九宫格验证码,请按从左到右、从上到下的顺序识别每个格子里的物品名称,'
|
||||
'最后识别左下角的参考图。输出格式为JSON:{"1":"名称", "2":"名称", ..., "10":"参考图名称"}。'
|
||||
'名称要简洁,参考图名称必须是九宫格里已有的名称。若有类似物品(如气球与热气球),请统一名称。'
|
||||
)
|
||||
image_url_value = img_url
|
||||
if imagebase64:
|
||||
import requests, base64
|
||||
try:
|
||||
resp = requests.get(img_url)
|
||||
resp.raise_for_status()
|
||||
img_bytes = resp.content
|
||||
img_b64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
image_url_value = f"data:image/jpeg;base64,{img_b64}"
|
||||
except Exception as e:
|
||||
logger.error(f"图片转base64失败: {e}")
|
||||
raise ModelApiError("图片转base64失败") from e
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.config.model,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': prompt},
|
||||
{'type': 'image_url', 'image_url': {'url': image_url_value}}
|
||||
]
|
||||
}],
|
||||
stream=False
|
||||
)
|
||||
|
||||
response_data = response.model_dump() if hasattr(response, "model_dump") else {}
|
||||
logger.debug(f"模型完整响应: {response_data}")
|
||||
|
||||
for error_key in ("error", "errors"):
|
||||
if response_data.get(error_key):
|
||||
logger.error(f"模型 API 返回错误: {response_data[error_key]}")
|
||||
raise ModelApiError("模型 API 返回错误")
|
||||
|
||||
choices = response_data.get("choices") or []
|
||||
if not choices:
|
||||
logger.error(f"模型 API 返回非标准成功响应,缺少 choices: {response_data}")
|
||||
raise ModelApiError("模型 API 返回非标准成功响应")
|
||||
|
||||
result_content = choices[0].get("message", {}).get("content")
|
||||
if not result_content:
|
||||
logger.error(f"模型 API 响应缺少 message.content: {response_data}")
|
||||
raise ModelApiError("模型 API 响应缺少 message.content")
|
||||
|
||||
logger.info(f"模型原始输出: {result_content}")
|
||||
json_text = self._extract_json(result_content)
|
||||
if not json_text:
|
||||
logger.error("无法从模型输出中提取有效 JSON")
|
||||
raise ModelApiError("模型输出不包含有效 JSON")
|
||||
|
||||
cleaned_str = json_text.replace("'", '"').strip()
|
||||
|
||||
result_dict = json.loads(cleaned_str)
|
||||
if isinstance(result_dict, dict) and result_dict.get("error"):
|
||||
logger.error(f"模型 API 返回错误: {result_dict['error']}")
|
||||
raise ModelApiError("模型 API 返回错误")
|
||||
|
||||
return result_dict
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON 解析失败: {e}")
|
||||
raise ModelApiError("模型输出 JSON 解析失败") from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ModelApiError):
|
||||
raise
|
||||
status_code = getattr(e, "status_code", None)
|
||||
if status_code:
|
||||
logger.error(f"模型 API 调用失败,HTTP {status_code}: {e}")
|
||||
else:
|
||||
logger.error(f"模型 API 调用失败: {e}", exc_info=True)
|
||||
raise ModelApiError("模型 API 调用失败") from e
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[str]:
|
||||
"""从模型输出中提取 JSON,兼容代码块、Qwen box 标记和普通说明文本。"""
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
|
||||
box_match = re.search(r'<\|begin_of_box\|>(.*?)<\|end_of_box\|>', text, flags=re.DOTALL)
|
||||
if box_match:
|
||||
return box_match.group(1).strip()
|
||||
|
||||
code_block_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, flags=re.DOTALL)
|
||||
if code_block_match:
|
||||
return code_block_match.group(1).strip()
|
||||
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
stack = 0
|
||||
for i in range(start, len(text)):
|
||||
if text[i] == '{':
|
||||
stack += 1
|
||||
elif text[i] == '}':
|
||||
stack -= 1
|
||||
if stack == 0:
|
||||
return text[start:i + 1].strip()
|
||||
|
||||
return None
|
||||
|
||||
def _click_captcha_items(self, page: Page, recognition_result: Dict) -> bool:
|
||||
"""
|
||||
根据识别结果点击九宫格中匹配的格子
|
||||
|
||||
九宫格布局(索引从1开始):
|
||||
1 2 3
|
||||
4 5 6
|
||||
7 8 9
|
||||
|
||||
第10个是参考图(左下角)
|
||||
"""
|
||||
try:
|
||||
# 获取参考图名称(第10个元素)
|
||||
target_name = recognition_result.get("10", "").strip()
|
||||
if not target_name:
|
||||
logger.error("未能从识别结果中获取参考图名称")
|
||||
return False
|
||||
|
||||
logger.info(f"目标物品: {target_name}")
|
||||
|
||||
# 获取所有九宫格元素(前9个)
|
||||
grid_items = page.locator(".geetest_item")
|
||||
item_count = grid_items.count()
|
||||
|
||||
# 排除最后一个(参考图),只处理前9个
|
||||
if item_count < 9:
|
||||
logger.error(f"九宫格元素数量不足,只找到 {item_count} 个")
|
||||
return False
|
||||
|
||||
# 遍历前9个格子,找到匹配的物品并点击
|
||||
clicked_count = 0
|
||||
for i in range(9):
|
||||
position = i + 1 # 位置索引从1开始
|
||||
item_name = recognition_result.get(str(position), "").strip()
|
||||
|
||||
logger.info(f"位置 {position}: {item_name}")
|
||||
|
||||
# 如果当前格子的物品名称匹配参考图
|
||||
if item_name and item_name == target_name:
|
||||
logger.info(f"找到匹配项!位置 {position} - {item_name}")
|
||||
|
||||
# 点击该格子
|
||||
try:
|
||||
grid_items.nth(i).click()
|
||||
clicked_count += 1
|
||||
logger.info(f"已点击位置 {position}")
|
||||
|
||||
# 点击后短暂等待,模拟人类操作
|
||||
time.sleep(random.uniform(0.3, 0.6))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"点击位置 {position} 时出错: {e}")
|
||||
|
||||
if clicked_count == 0:
|
||||
logger.warning(f"未找到匹配 '{target_name}' 的格子")
|
||||
return False
|
||||
|
||||
logger.info(f"共点击了 {clicked_count} 个匹配的格子")
|
||||
|
||||
# 点击完成后,查找并点击确认按钮
|
||||
try:
|
||||
# 等待确认按钮变为可用状态(移除 geetest_disable 类)
|
||||
confirm_button = page.locator(".geetest_commit")
|
||||
confirm_button.wait_for(state="attached", timeout=5000)
|
||||
|
||||
# 检查按钮是否可用(没有 geetest_disable 类)
|
||||
button_classes = confirm_button.get_attribute("class") or ""
|
||||
logger.info(f"确认按钮状态: {button_classes}")
|
||||
|
||||
# 等待按钮变为可点击状态(最多等待3秒)
|
||||
max_wait = 3
|
||||
start = time.time()
|
||||
while "geetest_disable" in (confirm_button.get_attribute("class") or ""):
|
||||
if time.time() - start > max_wait:
|
||||
logger.warning("确认按钮未激活,但仍尝试点击")
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
logger.info("找到确认按钮,准备点击...")
|
||||
confirm_button.click(force=True)
|
||||
logger.info("已点击确认按钮")
|
||||
time.sleep(1)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("未找到确认按钮,可能自动提交")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"点击验证码格子时发生错误: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _refresh_captcha(self, page: Page) -> bool:
|
||||
"""刷新验证码"""
|
||||
try:
|
||||
logger.info("正在刷新验证码...")
|
||||
page.locator(".geetest_refresh").click()
|
||||
logger.info("已点击刷新按钮")
|
||||
time.sleep(1.5) # 等待新验证码加载
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"刷新验证码失败: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user