347 lines
13 KiB
Python
347 lines
13 KiB
Python
from playwright.sync_api import sync_playwright
|
||
from pathlib import Path
|
||
import random
|
||
import time
|
||
import cv2
|
||
|
||
def get_gap_offset(bg_path, fullbg_path, debug=False):
|
||
"""比较有缺口背景图和完整背景图,找到缺口 x 坐标(取前两个最大轮廓,选x更大者)"""
|
||
# 1. 灰度读取
|
||
bg_img = cv2.imread(bg_path, 0)
|
||
fullbg_img = cv2.imread(fullbg_path, 0)
|
||
if debug:
|
||
cv2.imwrite("debug_01_bg_gray.png", bg_img)
|
||
cv2.imwrite("debug_02_fullbg_gray.png", fullbg_img)
|
||
|
||
# 2. 计算绝对差
|
||
diff = cv2.absdiff(fullbg_img, bg_img)
|
||
if debug:
|
||
cv2.imwrite("debug_03_diff.png", diff)
|
||
|
||
# 3. 二值化
|
||
_, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
|
||
if debug:
|
||
cv2.imwrite("debug_04_thresh.png", thresh)
|
||
|
||
# 4. 找轮廓
|
||
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||
|
||
if len(contours) < 2:
|
||
raise ValueError("[ERROR] 找到的轮廓少于两个,可能识别失败")
|
||
|
||
# 5. 按面积排序,取前两个最大的
|
||
contours_sorted = sorted(contours, key=cv2.contourArea, reverse=True)[:2]
|
||
|
||
# 6. 获取它们的 bounding box
|
||
rects = [cv2.boundingRect(c) for c in contours_sorted]
|
||
|
||
# 7. 选择 x 坐标更大的那个
|
||
target_rect = max(rects, key=lambda r: r[0])
|
||
x, y, w, h = target_rect
|
||
|
||
# 8. 调试输出
|
||
if debug:
|
||
debug_img = cv2.cvtColor(bg_img, cv2.COLOR_GRAY2BGR)
|
||
for rect in rects:
|
||
cx, cy, cw, ch = rect
|
||
cv2.rectangle(debug_img, (cx, cy), (cx+cw, cy+ch), (0,255,0), 2)
|
||
cv2.rectangle(debug_img, (x, y), (x+w, y+h), (0,0,255), 2)
|
||
cv2.imwrite("debug_05_with_box.png", debug_img)
|
||
|
||
return x
|
||
|
||
# ========= 配置 =========
|
||
domain = "www.natfrp.com"
|
||
target_url = f"https://{domain}/user/"
|
||
ACCOUNT_FILE = Path("account.txt") # 第一行用户名,第二行密码
|
||
STATE_FILE = "state.json"
|
||
MAX_RETRY = 5 # 0 表示无限重试,>0 表示最大重试次数
|
||
|
||
# --- 新增/修改 ---
|
||
ALREADY_SIGNED_TEXT = "今天已经签到过啦" # 用于判定成功的文案(包含即可)
|
||
SIGNED_ANCESTOR_LEVELS = 3 # 向上取第 3 层父级
|
||
SIGNED_ANCESTOR_SCREENSHOT = "checkin.png" # 成功时保存截图的文件名
|
||
# ========================
|
||
|
||
# ---------------- 读取账号密码 ----------------
|
||
def load_username_password(path: Path):
|
||
if not path.exists():
|
||
raise FileNotFoundError(f"{path} 文件不存在")
|
||
lines = [l.strip() for l in path.read_text(encoding="utf-8").strip().splitlines() if l.strip()]
|
||
if len(lines) < 2:
|
||
raise ValueError("account.txt 必须至少有两行:用户名、密码")
|
||
return lines[0], lines[1]
|
||
|
||
# ---------------- 检测函数 ----------------
|
||
def is_cookie_expired(page, timeout=6000):
|
||
try:
|
||
# 页面出现“Nyatwork 登录”说明需要重新登录
|
||
page.wait_for_selector("text=Nyatwork 登录", timeout=timeout)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def confirm_age_if_needed(page, timeout=6000):
|
||
try:
|
||
page.wait_for_selector('text=是,我已满18岁', timeout=timeout)
|
||
page.click('text=是,我已满18岁')
|
||
print("[INFO] 已点击满18岁确认按钮")
|
||
except:
|
||
print("[INFO] 无需进行年龄确认")
|
||
|
||
def is_logged_in(page, timeout=6000):
|
||
try:
|
||
page.wait_for_selector("text=账号信息", timeout=timeout)
|
||
print("[INFO] 检测到已登录状态")
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def is_sign_button_visible(page, timeout=6000):
|
||
try:
|
||
page.wait_for_selector("text=点击这里签到", timeout=timeout)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def is_captcha_visible(page, timeout=6000):
|
||
try:
|
||
page.wait_for_selector(".geetest_slider_button", timeout=timeout)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def wait_captcha_disappear(page, timeout=10000):
|
||
try:
|
||
page.wait_for_selector("text=签到成功", timeout=timeout)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
# --- 新增/修改:查找“今天已经签到过啦”的元素(包含匹配),并取其第 N 层父级 ---
|
||
def find_signed_text_locator(page, timeout=6000):
|
||
"""
|
||
返回包含“今天已经签到过啦”的元素 locator(取第一个可见)。
|
||
使用 normalize-space 规避前后空格影响,并做“包含”匹配。
|
||
"""
|
||
selector = "text=%s" % ALREADY_SIGNED_TEXT
|
||
try:
|
||
page.wait_for_selector(selector, timeout=timeout)
|
||
return page.locator(selector).first
|
||
except Exception as e:
|
||
return None
|
||
|
||
def screenshot_signed_text_ancestor(page, levels: int = SIGNED_ANCESTOR_LEVELS,
|
||
path: str = SIGNED_ANCESTOR_SCREENSHOT,
|
||
timeout: int = 6000) -> bool:
|
||
"""
|
||
截图“包含『今天已经签到过啦』的元素”的第 levels 层父级。
|
||
若未找到该元素或父级,兜底整页截图。返回 True 表示截到了祖先元素,False 表示做了兜底。
|
||
"""
|
||
base = find_signed_text_locator(page, timeout=timeout)
|
||
if base is None:
|
||
# 没找到文本元素,整页兜底
|
||
try:
|
||
page.screenshot(path=path, full_page=True)
|
||
print(f"[WARN] 未找到包含『{ALREADY_SIGNED_TEXT}』的元素,已兜底整页截图:{path}")
|
||
except Exception as e:
|
||
print(f"[ERROR] 兜底整页截图失败:{e}")
|
||
return False
|
||
return False
|
||
|
||
try:
|
||
# 取第 N 层父级
|
||
ancestor = base.locator(f"xpath=ancestor::*[{levels}]").first
|
||
# 有些页面结构不够深,尝试逐级回退
|
||
for lvl in range(levels, 0, -1):
|
||
candidate = base.locator(f"xpath=ancestor::*[{lvl}]").first
|
||
try:
|
||
candidate.wait_for(state="visible", timeout=1200)
|
||
try:
|
||
candidate.scroll_into_view_if_needed()
|
||
except:
|
||
pass
|
||
candidate.screenshot(path=path)
|
||
print(f"[INFO] 已保存签到截图:{path}")
|
||
return True
|
||
except:
|
||
continue
|
||
|
||
# 如果所有层级都没成功,整页兜底
|
||
page.screenshot(path=path, full_page=True)
|
||
print(f"[WARN] 祖先节点截图未成功,已兜底整页截图:{path}")
|
||
return False
|
||
except Exception as e:
|
||
print(f"[ERROR] 祖先截图异常:{e}")
|
||
try:
|
||
page.screenshot(path=path, full_page=True)
|
||
print(f"[WARN] 异常时已兜底整页截图:{path}")
|
||
except:
|
||
return False
|
||
return False
|
||
|
||
# ---------------- 操作函数 ----------------
|
||
def login_with_user_pass(page, username, password):
|
||
page.fill("#username", username)
|
||
page.fill("#password", password)
|
||
page.click("button[id=login]")
|
||
print("[INFO] 已提交登录表单")
|
||
|
||
def click_sign_in_button(page):
|
||
page.click("text=点击这里签到")
|
||
print("[INFO] 已点击签到按钮")
|
||
|
||
def drag_slider_fixed_steps(page, slider_element, distance, button_type="middle", debug_pause=False):
|
||
box = slider_element.bounding_box()
|
||
start_x = int(box['x'])
|
||
start_y = int(box['y'] + box['height'] / 2)
|
||
|
||
print(f"[INFO] 滑块初始坐标: ({start_x}, {start_y})")
|
||
print(f"[INFO] 目标滑动距离: {distance} 像素")
|
||
|
||
page.mouse.move(start_x, start_y)
|
||
page.mouse.down(button=button_type)
|
||
page.wait_for_timeout(300)
|
||
|
||
page.mouse.move(start_x + distance + random.randint(2, 8), start_y)
|
||
page.wait_for_timeout(500)
|
||
|
||
page.mouse.move(start_x + distance - 2, start_y)
|
||
page.mouse.move(start_x + distance - 6, start_y)
|
||
page.wait_for_timeout(400)
|
||
page.mouse.move(start_x + distance - 8, start_y)
|
||
|
||
page.mouse.up(button=button_type)
|
||
print("[INFO] 滑动完成")
|
||
|
||
if debug_pause:
|
||
page.pause()
|
||
|
||
def solve_geetest_puzzle(page):
|
||
bg_path = "bg.png"
|
||
fullbg_path = "fullbg.png"
|
||
page.query_selector('.geetest_canvas_bg').screenshot(path=bg_path)
|
||
page.evaluate("""document.querySelector('.geetest_canvas_fullbg').style.display = 'block';""")
|
||
page.query_selector('.geetest_canvas_fullbg').screenshot(path=fullbg_path)
|
||
page.evaluate("""document.querySelector('.geetest_canvas_fullbg').style.display = 'none';""")
|
||
|
||
gap_x = get_gap_offset(bg_path, fullbg_path, debug=True)
|
||
print(f"[INFO] 缺口坐标: {gap_x}")
|
||
|
||
slider = page.query_selector('.geetest_slider_button')
|
||
distance = gap_x
|
||
|
||
drag_slider_fixed_steps(page, slider, distance, button_type="left", debug_pause=False)
|
||
print("[INFO] 验证码滑动完成")
|
||
|
||
# --- 新增/修改:以“今天已经签到过啦”判定成功 ---
|
||
def wait_signed_text_and_shoot(page, timeout=8000) -> bool:
|
||
"""
|
||
等待出现包含『今天已经签到过啦』的元素;出现则截图其第 3 层父级并返回 True。
|
||
若未出现则返回 False。
|
||
"""
|
||
loc = find_signed_text_locator(page, timeout=timeout)
|
||
if loc is None:
|
||
return False
|
||
# 找到了就截图祖先
|
||
screenshot_signed_text_ancestor(page, SIGNED_ANCESTOR_LEVELS, SIGNED_ANCESTOR_SCREENSHOT)
|
||
return True
|
||
|
||
# ---------------- 主逻辑 ----------------
|
||
def main() -> bool:
|
||
"""
|
||
主流程
|
||
:return: True 表示成功,False 表示失败需要重试
|
||
"""
|
||
username, password = load_username_password(ACCOUNT_FILE)
|
||
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True, slow_mo=200)
|
||
|
||
# 先尝试从 state.json 加载状态(cookie/session)
|
||
try:
|
||
context = browser.new_context(storage_state=STATE_FILE)
|
||
print("[INFO] 已加载保存的状态文件")
|
||
except:
|
||
print("[WARN] 未找到状态文件,将新建上下文")
|
||
context = browser.new_context()
|
||
|
||
page = context.new_page()
|
||
page.goto(target_url, timeout=8000)
|
||
|
||
# 登录后再检查 18 岁确认(若页面已处于登录态也会直接检测)
|
||
if is_logged_in(page):
|
||
confirm_age_if_needed(page)
|
||
else:
|
||
# 检查状态文件里的 cookie 是否失效
|
||
if is_cookie_expired(page):
|
||
print("[WARN] Cookie 已过期,使用账号密码登录")
|
||
login_with_user_pass(page, username, password)
|
||
if is_logged_in(page):
|
||
print("[INFO] 登录成功,更新状态文件")
|
||
context.storage_state(path=STATE_FILE)
|
||
# 登录成功后再检查一次 18 岁确认
|
||
confirm_age_if_needed(page)
|
||
else:
|
||
print("[ERROR] 登录失败")
|
||
browser.close()
|
||
return False
|
||
|
||
# --- 新增/修改:页面若已出现“今天已经签到过啦”,直接判定成功并截图祖先 ---
|
||
if wait_signed_text_and_shoot(page, timeout=2000):
|
||
print("[INFO] 已经签到过啦(页面已有提示)")
|
||
browser.close()
|
||
return True
|
||
|
||
# 正常签到流程
|
||
if is_sign_button_visible(page):
|
||
click_sign_in_button(page)
|
||
if is_captcha_visible(page):
|
||
solve_geetest_puzzle(page)
|
||
# 成功判定:等待出现“今天已经签到过啦”
|
||
if wait_signed_text_and_shoot(page, timeout=10000):
|
||
print("[INFO] 签到成功(出现『今天已经签到过啦』)")
|
||
browser.close()
|
||
return True
|
||
else:
|
||
print("[ERROR] 签到失败:未出现『今天已经签到过啦』提示")
|
||
browser.close()
|
||
return False
|
||
else:
|
||
# 无验证码路径:同样等待出现成功提示
|
||
if wait_signed_text_and_shoot(page, timeout=8000):
|
||
print("[INFO] 签到成功(无需验证码)")
|
||
browser.close()
|
||
return True
|
||
else:
|
||
print("[ERROR] 签到失败:未出现『今天已经签到过啦』提示(无需验证码路径)")
|
||
browser.close()
|
||
return False
|
||
else:
|
||
# 不再以“找不到按钮”作为成功判据;此分支仅作为提示与失败返回
|
||
print("[ERROR] 未发现签到按钮,且未检测到『今天已经签到过啦』提示")
|
||
browser.close()
|
||
return False
|
||
|
||
# ---------------- 带重试启动 ----------------
|
||
if __name__ == "__main__":
|
||
attempt = 0
|
||
while True:
|
||
attempt += 1
|
||
print(f"[INFO] 第 {attempt} 次执行签到")
|
||
try:
|
||
success = main()
|
||
except Exception as e:
|
||
print(f"[ERROR] 程序异常: {e}")
|
||
success = False
|
||
|
||
if success:
|
||
print("[INFO] 本次执行成功,退出程序")
|
||
break
|
||
else:
|
||
if MAX_RETRY > 0 and attempt >= MAX_RETRY:
|
||
print("[ERROR] 超过最大重试次数,退出程序")
|
||
break
|
||
print("[WARN] 本次失败,10秒后重试")
|
||
time.sleep(10)
|