359 lines
13 KiB
Python
359 lines
13 KiB
Python
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
import random
|
||
import shutil
|
||
|
||
import cv2
|
||
import numpy as np
|
||
|
||
from playwright.sync_api import sync_playwright, Page
|
||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||
from playwright.sync_api import Error as PlaywrightError
|
||
|
||
# 将 SakuraFrpQiandao 加入搜索路径,复用其模块
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "SakuraFrpQiandao"))
|
||
|
||
from config import Config
|
||
from captcha_handler import CaptchaHandler, ModelApiError
|
||
from human_simulator import HumanSimulator
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.StreamHandler(),
|
||
logging.FileHandler('checkin.log', encoding='utf-8'),
|
||
],
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def get_gap_offset(bg_path: str, fullbg_path: str) -> int:
|
||
"""OpenCV 滑块缺口检测"""
|
||
bg = cv2.imread(bg_path, 0)
|
||
fullbg = cv2.imread(fullbg_path, 0)
|
||
|
||
diff = cv2.absdiff(fullbg, bg)
|
||
_, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
|
||
|
||
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||
if len(contours) < 2:
|
||
raise ValueError("轮廓数量不足,滑块识别失败")
|
||
|
||
sorted_contours = sorted(contours, key=cv2.contourArea, reverse=True)[:2]
|
||
rects = [cv2.boundingRect(c) for c in sorted_contours]
|
||
target = max(rects, key=lambda r: r[0])
|
||
x, y, w, h = target
|
||
logger.info(f"滑块缺口坐标: x={x}, y={y}, w={w}, h={h}")
|
||
return x
|
||
|
||
|
||
def drag_slider(page: Page, slider, canvas_bg, gap_x: int):
|
||
"""模拟人类拖动滑块,根据 canvas 缩放比例校正距离"""
|
||
# 计算 canvas 显示宽度与实际截图宽度的比例
|
||
canvas_box = canvas_bg.bounding_box()
|
||
bg_img = cv2.imread("bg.png", 0)
|
||
img_w = bg_img.shape[1] # 截图宽度(像素)
|
||
display_w = canvas_box['width'] # CSS 显示宽度
|
||
scale = display_w / img_w if img_w > 0 else 1.0
|
||
distance = int(gap_x * scale)
|
||
logger.info(f"Canvas 缩放比例: {scale:.3f} (img={img_w}px, display={display_w:.1f}px), 校正距离: {distance}px")
|
||
|
||
slider_box = slider.bounding_box()
|
||
start_x = slider_box['x'] + slider_box['width'] / 2
|
||
start_y = slider_box['y'] + slider_box['height'] / 2
|
||
|
||
logger.info(f"滑块起点: ({start_x:.0f}, {start_y:.0f})")
|
||
|
||
page.mouse.move(start_x, start_y)
|
||
page.mouse.down()
|
||
page.wait_for_timeout(random.randint(200, 400))
|
||
|
||
# 分段拖动到目标位置过一点,再回退微调(模拟人类)
|
||
page.mouse.move(start_x + distance + random.randint(2, 8), start_y + random.uniform(-1, 1))
|
||
page.wait_for_timeout(random.randint(300, 600))
|
||
page.mouse.move(start_x + distance - random.randint(2, 5), start_y)
|
||
page.wait_for_timeout(random.randint(200, 400))
|
||
page.mouse.move(start_x + distance - random.randint(5, 8), start_y)
|
||
page.mouse.up()
|
||
|
||
logger.info("滑块拖动完成")
|
||
|
||
|
||
def solve_slider_captcha(page: Page) -> bool:
|
||
"""处理滑块验证码(OpenCV 方案)"""
|
||
logger.info("检测到滑块验证码,使用 OpenCV 方案处理...")
|
||
try:
|
||
# 截图含缺口的背景
|
||
bg = page.query_selector('.geetest_canvas_bg')
|
||
if not bg:
|
||
logger.error("未找到滑块背景 canvas")
|
||
return False
|
||
bg.screenshot(path="bg.png")
|
||
|
||
# 显示完整背景并截图
|
||
page.evaluate(
|
||
"document.querySelector('.geetest_canvas_fullbg').style.display = 'block';"
|
||
)
|
||
fullbg = page.query_selector('.geetest_canvas_fullbg')
|
||
fullbg.screenshot(path="fullbg.png")
|
||
page.evaluate(
|
||
"document.querySelector('.geetest_canvas_fullbg').style.display = 'none';"
|
||
)
|
||
|
||
gap_x = get_gap_offset("bg.png", "fullbg.png")
|
||
|
||
slider = page.query_selector('.geetest_slider_button')
|
||
if not slider:
|
||
logger.error("未找到滑块按钮")
|
||
return False
|
||
|
||
drag_slider(page, slider, bg, gap_x)
|
||
time.sleep(2)
|
||
|
||
# 检查成功文案或 slider 消失
|
||
try:
|
||
page.wait_for_selector('.geetest_slider_button', state='detached', timeout=3000)
|
||
logger.info("滑块已消失,验证通过")
|
||
return True
|
||
except PlaywrightTimeoutError:
|
||
logger.warning("滑块未消失,可能未通过")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"滑块验证处理出错: {e}", exc_info=True)
|
||
return False
|
||
|
||
|
||
def detect_captcha_type(page: Page) -> str:
|
||
"""检测验证码类型: 'slider' | 'grid' | 'none'"""
|
||
try:
|
||
slider = page.query_selector('.geetest_slider_button')
|
||
if slider and slider.is_visible():
|
||
return 'slider'
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
grid = page.query_selector('.geetest_tip_img')
|
||
if grid and grid.is_visible():
|
||
return 'grid'
|
||
except Exception:
|
||
pass
|
||
|
||
return 'none'
|
||
|
||
|
||
class CheckInBot:
|
||
def __init__(self, config: Config):
|
||
self.config = config
|
||
self.captcha_handler = CaptchaHandler(config)
|
||
self.simulator = HumanSimulator()
|
||
self.max_retries = config.max_retries
|
||
|
||
def run(self):
|
||
headless = os.getenv('CI') == 'true' or os.getenv('HEADLESS', 'false').lower() == 'true'
|
||
|
||
with sync_playwright() as playwright:
|
||
browser = None
|
||
try:
|
||
launch_opts = {
|
||
"headless": headless,
|
||
"args": [
|
||
"--window-size=1280,800",
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--no-proxy-server",
|
||
"--lang=zh-CN",
|
||
"--disable-gpu",
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
],
|
||
}
|
||
chrome_path = self._resolve_chrome()
|
||
if chrome_path:
|
||
launch_opts["executable_path"] = chrome_path
|
||
|
||
browser = playwright.chromium.launch(**launch_opts)
|
||
context = browser.new_context(
|
||
viewport={"width": 1280, "height": 800},
|
||
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"
|
||
),
|
||
)
|
||
context.add_init_script(
|
||
"Object.defineProperty(navigator, 'webdriver', { get: () => undefined });"
|
||
)
|
||
page = context.new_page()
|
||
page.set_default_timeout(20000)
|
||
|
||
self._login(page)
|
||
self._navigate_dashboard(page)
|
||
self._do_checkin(page)
|
||
logger.info("签到流程完成")
|
||
|
||
except ModelApiError:
|
||
logger.error("模型 API 调用失败,终止")
|
||
except Exception as e:
|
||
logger.error(f"执行出错: {e}", exc_info=True)
|
||
finally:
|
||
if browser and headless:
|
||
browser.close()
|
||
|
||
def _resolve_chrome(self):
|
||
if self.config.chrome_binary_path and os.path.exists(self.config.chrome_binary_path):
|
||
return self.config.chrome_binary_path
|
||
for exe in ("google-chrome", "google-chrome-stable", "chrome", "chromium"):
|
||
found = shutil.which(exe)
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
def _login(self, page: Page):
|
||
url = "https://www.natfrp.com/user/"
|
||
logger.info(f"导航到登录页: {url}")
|
||
page.goto(url, wait_until="domcontentloaded")
|
||
|
||
user_input = page.locator("#username")
|
||
pass_input = page.locator("#password")
|
||
user_input.wait_for(state="visible")
|
||
pass_input.wait_for(state="visible")
|
||
|
||
logger.info("输入登录凭据...")
|
||
user_input.fill("")
|
||
self.simulator.type_text(user_input, self.config.sakurafrp_user)
|
||
pass_input.fill("")
|
||
self.simulator.type_text(pass_input, self.config.sakurafrp_pass)
|
||
|
||
login_btn = page.locator("#login")
|
||
login_btn.wait_for(state="visible")
|
||
login_btn.click()
|
||
logger.info("登录成功")
|
||
|
||
def _navigate_dashboard(self, page: Page):
|
||
self.simulator.random_sleep(3, 5)
|
||
self._dismiss_age_check(page)
|
||
|
||
def _dismiss_age_check(self, page: Page) -> bool:
|
||
"""尝试多种方式解除年龄确认弹窗"""
|
||
# 方式1: 检测 adult-check 弹窗,点击"是"按钮
|
||
try:
|
||
adult_check = page.locator(".adult-check")
|
||
if adult_check.count() > 0 and adult_check.first.is_visible():
|
||
logger.info("检测到年龄确认弹窗...")
|
||
yes_btn = page.locator(".adult-check button").first
|
||
yes_btn.wait_for(state="visible", timeout=3000)
|
||
yes_btn.click(force=True)
|
||
logger.info("已点击年龄确认按钮(force click)")
|
||
time.sleep(1)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
# 方式2: 匹配文本"是,我已满18岁"
|
||
try:
|
||
age = page.locator("div.yes a", has_text="是,我已满18岁")
|
||
age.wait_for(state="visible", timeout=3000)
|
||
age.click()
|
||
logger.info("已处理年龄确认(文本匹配)")
|
||
time.sleep(1)
|
||
return True
|
||
except PlaywrightTimeoutError:
|
||
pass
|
||
|
||
logger.info("无需年龄确认")
|
||
return False
|
||
|
||
def _already_signed(self, page: Page) -> bool:
|
||
try:
|
||
page.wait_for_selector("text=今天已经签到过啦", timeout=3000)
|
||
return True
|
||
except PlaywrightTimeoutError:
|
||
return False
|
||
|
||
def _do_checkin(self, page: Page):
|
||
for attempt in range(1, self.max_retries + 1):
|
||
logger.info(f"签到尝试 {attempt}/{self.max_retries}")
|
||
|
||
# 先检查是否已签到
|
||
if self._already_signed(page):
|
||
logger.info("今日已签到")
|
||
return
|
||
|
||
btn = None
|
||
try:
|
||
btn = page.locator("button", has_text="点击这里签到")
|
||
btn.wait_for(state="visible", timeout=5000)
|
||
except PlaywrightTimeoutError:
|
||
# 等久一点再检查一次
|
||
time.sleep(2)
|
||
if self._already_signed(page):
|
||
logger.info("今日已签到(延迟检测)")
|
||
return
|
||
logger.error("未找到签到按钮,也未检测到已签到标识")
|
||
return
|
||
|
||
logger.info("点击签到按钮...")
|
||
self._dismiss_age_check(page) # 二次确认,防止弹窗阻挡点击
|
||
btn.click(force=True)
|
||
self.simulator.random_sleep(2, 4)
|
||
|
||
captcha_type = detect_captcha_type(page)
|
||
logger.info(f"验证码类型: {captcha_type}")
|
||
|
||
if captcha_type == 'slider':
|
||
if solve_slider_captcha(page):
|
||
page.reload(wait_until="domcontentloaded")
|
||
time.sleep(3)
|
||
if self._already_signed(page):
|
||
logger.info("滑块验证 + 签到成功")
|
||
return
|
||
else:
|
||
page.reload(wait_until="domcontentloaded")
|
||
time.sleep(3)
|
||
continue
|
||
|
||
elif captcha_type == 'grid':
|
||
try:
|
||
self.captcha_handler.handle_geetest_captcha(page)
|
||
page.reload(wait_until="domcontentloaded")
|
||
time.sleep(3)
|
||
if self._already_signed(page):
|
||
logger.info("九宫格验证 + 签到成功")
|
||
return
|
||
except ModelApiError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"九宫格处理出错: {e}")
|
||
|
||
elif captcha_type == 'none':
|
||
time.sleep(3)
|
||
if self._already_signed(page):
|
||
logger.info("签到成功(无验证码)")
|
||
return
|
||
|
||
page.reload(wait_until="domcontentloaded")
|
||
time.sleep(3)
|
||
|
||
logger.warning("已达最大重试次数")
|
||
|
||
|
||
def main():
|
||
try:
|
||
config = Config.from_env()
|
||
logger.info(f"使用账户: {config.sakurafrp_user}")
|
||
bot = CheckInBot(config)
|
||
bot.run()
|
||
except ValueError as e:
|
||
logger.error(f"配置错误: {e}")
|
||
except Exception as e:
|
||
logger.error(f"程序执行失败: {e}", exc_info=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|