commit 2a9f96fb1874e6903113ec95cc93c4bbea8c4951 Author: RRRosmontis Date: Thu Aug 27 21:43:38 2026 +0800 SakuraFrp 自动签到(main + sakura_frp_auto_check + SakuraFrpQiandao),已排除 .env 与日志 diff --git a/SakuraFrpQiandao/.gitignore b/SakuraFrpQiandao/.gitignore new file mode 100644 index 0000000..8bd7485 --- /dev/null +++ b/SakuraFrpQiandao/.gitignore @@ -0,0 +1,13 @@ +checkin.log +chromedriver.exe +captured_captcha.jpg +error_screenshot.png +error_page_source.html +.env +__pycache__/ +*.pyc +.vscode/ +.idea/ +node_modules/ +.venv +test/ diff --git a/SakuraFrpQiandao/Dockerfile b/SakuraFrpQiandao/Dockerfile new file mode 100644 index 0000000..25252c0 --- /dev/null +++ b/SakuraFrpQiandao/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.11-slim + +ARG TARGETARCH +WORKDIR /app + +# Playwright/Chrome runtime deps. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl fonts-liberation gnupg wget \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \ + libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libx11-xcb1 \ + libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 \ + && rm -rf /var/lib/apt/lists/* + +# Install browser: Google Chrome on amd64, Chromium on arm64/others. +RUN set -eux; \ + if [ "${TARGETARCH}" = "amd64" ]; then \ + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-linux.gpg; \ + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends google-chrome-stable; \ + rm -rf /var/lib/apt/lists/*; \ + ln -sf /usr/bin/google-chrome /usr/bin/container-browser; \ + else \ + apt-get update; \ + apt-get install -y --no-install-recommends chromium || apt-get install -y --no-install-recommends chromium-browser; \ + rm -rf /var/lib/apt/lists/*; \ + if command -v chromium >/dev/null 2>&1; then ln -sf "$(command -v chromium)" /usr/bin/container-browser; else ln -sf "$(command -v chromium-browser)" /usr/bin/container-browser; fi; \ + fi + +COPY requirements.txt ./ +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt \ + && python -m playwright install-deps chromium + +COPY . . + +ENV CI=true \ + HEADLESS=true \ + CHROME_BINARY_PATH=/usr/bin/container-browser + +CMD ["python", "main.py"] diff --git a/SakuraFrpQiandao/LICENSE b/SakuraFrpQiandao/LICENSE new file mode 100644 index 0000000..1121860 --- /dev/null +++ b/SakuraFrpQiandao/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yumu_Tudou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SakuraFrpQiandao/README.md b/SakuraFrpQiandao/README.md new file mode 100644 index 0000000..6c399e3 --- /dev/null +++ b/SakuraFrpQiandao/README.md @@ -0,0 +1,171 @@ +# SakuraFrp 自动签到脚本 + +使用 Playwright 驱动浏览器,并通过 AI 视觉识别完成 SakuraFrp 九宫格验证码签到。 + +## 功能特性 + +- 自动登录 SakuraFrp 账户 +- AI 视觉识别九宫格验证码 +- 自动点击匹配的验证码格子 +- 失败自动重试,默认最多 10 次 +- 支持 GitHub Actions 定时执行 +- 支持邮件通知和日志归档 + +## 本地运行 + +### 1. 环境准备 + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +``` + +本项目默认优先使用本机已安装的 Chrome,不需要下载 ChromeDriver,也不强制执行 `playwright install chromium`。 + +如果本机没有 Chrome,或希望使用 Playwright 托管浏览器,可以执行: + +```bash +python -m playwright install chromium +``` + +### 2. 配置环境变量 + +创建 `.env` 文件: + +```env +SAKURAFRP_USER=your_username +SAKURAFRP_PASS=your_password + +BASE_URL=https://api.example.com/v1 +API_KEY=your_api_key +MODEL=your_model_name + +MAX_RETRIES=10 +HEADLESS=false +CHROME_BINARY_PATH= + +# 是否将验证码图片转换为 base64(可选) +# 可接受:true/false, 1/0, yes/no, on/off +# 通过第三方适配器接入的服务可能不支持直接使用图片 URL,此时设为 true。 +IMAGE_AS_BASE64=false +``` + +`CHROME_BINARY_PATH` 可选。留空时会自动查找系统 Chrome;如果你的 Chrome 不在默认位置,可以显式指定: + +```env +CHROME_BINARY_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe +``` + +Playwright 只复用 Chrome 可执行文件,不会读取你日常 Chrome 的用户目录、Cookie、历史记录或扩展。 + +### 3. 运行脚本 + +```bash +python main.py +``` + +## GitHub Actions 部署 + +工作流文件位于 `.github/workflows/sakurafrp_sign.yml`,默认每天北京时间 9:00 执行,也支持手动触发。 + +需要配置以下 Secrets: + +| 名称 | 说明 | +| --- | --- | +| `SAKURAFRP_USER` | SakuraFrp 用户名 | +| `SAKURAFRP_PASS` | SakuraFrp 密码 | +| `BASE_URL` | OpenAI 兼容 API 地址 | +| `API_KEY` | API 密钥 | +| `MODEL` | 多模态模型名称 | + +邮件通知为可选配置: + +| 名称 | 说明 | +| --- | --- | +| `EMAIL_USERNAME` | 发件邮箱 | +| `EMAIL_PASSWORD` | 邮箱授权码或应用密码 | +| `RECEIVER_EMAIL` | 收件邮箱,默认发给自己 | +| `SMTP_SERVER` | SMTP 服务器,默认 `smtp.gmail.com` | +| `SMTP_PORT` | SMTP 端口,默认 `587` | + +Actions 使用 GitHub runner 自带 Chrome,并执行 `python -m playwright install-deps chromium` 安装 Linux 运行依赖,避免每次下载 Playwright Chromium 大包。 + +## 项目结构 + +```text +SakuraFrp-Qiandao/ +├── .github/workflows/sakurafrp_sign.yml +├── automation.py # Playwright 签到主流程 +├── captcha_handler.py # GeeTest 验证码识别和点击 +├── config.py # 环境变量和日志配置 +├── human_simulator.py # 简单人类操作模拟 +├── main.py # 程序入口 +├── send_email.py # 邮件通知 +├── requirements.txt # Python 依赖 +└── README.md +``` + +## 配置说明 + +### 定时任务时间 + +修改 `.github/workflows/sakurafrp_sign.yml` 中的 cron 表达式: + +```yaml +schedule: + - cron: '0 1 * * *' # UTC 1:00 = 北京时间 9:00 +``` + +常用时间对照: +- `0 1 * * *` - 每天 9:00(北京时间) +- `0 13 * * *` - 每天 21:00(北京时间) +- `0 1,13 * * *` - 每天 9:00 和 21:00 + +### AI 模型推荐 + +支持任何兼容 OpenAI API 的多模态模型: + +- OpenAI: `gpt-4o` +- 阿里通义: `qwen-vl-plus`, `qwen-vl-max` +- 智谱 AI: `glm-4v` +- GitHub Copilot: 可通过第三方 OpenAI 兼容适配层接入 +- ModelScope: 各种开源视觉模型 + +## 故障排查 + +### 验证码识别失败 + +尝试更换更强的视觉模型,或增加 `MAX_RETRIES`。模型需要支持图片输入,并兼容 OpenAI Chat Completions API。 + +### 模型返回非标准响应 + +脚本会记录完整响应结构。如果响应中包含 `error` 或 `errors`,会直接终止,因为刷新验证码无法修复模型 API 错误。 + +### Playwright 下载 Chromium 失败 + +如果出现 `timed out`、`ECONNRESET` 或 `Failed to download Chrome for Testing`,通常是网络到 Playwright/CDN 下载源不稳定。推荐直接使用系统 Chrome,并在 `.env` 中按需设置 `CHROME_BINARY_PATH`。 + +### 登录失败 + +检查 `SAKURAFRP_USER` 和 `SAKURAFRP_PASS` 是否正确,必要时关闭无头模式查看页面行为: + +```env +HEADLESS=false +``` + +### 未收到邮件 + +确认邮箱开启 SMTP,并使用授权码或应用专用密码,不要直接使用普通登录密码。 + +## 注意事项 + +请勿频繁触发签到,避免被限流。妥善保管账号密码、API Key 和邮箱授权码。 + +## 相关链接 + +- [SakuraFrp 官网](https://www.natfrp.com/) +- [Playwright 文档](https://playwright.dev/python/) +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [copilot-api](https://github.com/caozhiyuan/copilot-api) +- [LiteLLM](https://github.com/BerriAI/litellm) diff --git a/SakuraFrpQiandao/automation.py b/SakuraFrpQiandao/automation.py new file mode 100644 index 0000000..e7c4205 --- /dev/null +++ b/SakuraFrpQiandao/automation.py @@ -0,0 +1,256 @@ +import logging +import os +import shutil +import time +from playwright.sync_api import Error as PlaywrightError +from playwright.sync_api import Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import sync_playwright +from config import Config +from captcha_handler import ModelApiError + +logger = logging.getLogger(__name__) + + +class CheckInAutomation: + """签到自动化主类""" + def __init__(self, config: Config): + self.config = config + try: + from captcha_handler import CaptchaHandler + except ImportError: + from captcha_handler import CaptchaHandler # 如果模块名不同 + self.captcha_handler = CaptchaHandler(config) + try: + from human_simulator import HumanSimulator + except ImportError: + from human_simulator import HumanSimulator # 如果模块名不同 + self.simulator = HumanSimulator() + self.max_retries = config.max_retries + + def run(self): + """执行签到流程""" + # GitHub Actions 环境自动使用 headless 模式 + headless = os.getenv('CI') == 'true' or os.getenv('HEADLESS', 'false').lower() == 'true' + + with sync_playwright() as playwright: + browser = None + try: + logger.info("正在初始化 Playwright Chromium...") + launch_options = { + "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_path() + if chrome_path: + logger.info(f"使用 Chrome 路径: {chrome_path}") + launch_options["executable_path"] = chrome_path + + browser = playwright.chromium.launch(**launch_options) + 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) + + # 步骤1: 登录 + if not self._login(page): + logger.error("登录失败") + return + + # 步骤2: 跳转到 处理年龄 + if not self._navigate_to_sakurafrp(page): + logger.error("跳转到 SakuraFrp 失败") + return + + # 步骤3: 执行签到 + if not self._perform_checkin(page): + logger.error("签到失败") + page.screenshot(path="error_screenshot.png", full_page=True) + with open("error_page_source.html", "w", encoding="utf-8") as f: + f.write(page.content()) + return + + logger.info("✓ 签到流程完成") + + except ModelApiError: + logger.error("模型 API 调用失败,终止签到流程") + return + except Exception as e: + logger.error(f"执行过程中发生错误: {e}", exc_info=True) + finally: + if browser and headless: + browser.close() + logger.info("脚本执行完毕") + + def _resolve_chrome_path(self): + """优先使用环境变量指定的 Chrome,其次查找系统 Chrome。""" + if self.config.chrome_binary_path and os.path.exists(self.config.chrome_binary_path): + return self.config.chrome_binary_path + + candidate_paths = [ + os.path.join( + os.environ.get("PROGRAMFILES", ""), + "Google", + "Chrome", + "Application", + "chrome.exe", + ), + os.path.join( + os.environ.get("PROGRAMFILES(X86)", ""), + "Google", + "Chrome", + "Application", + "chrome.exe", + ), + os.path.join( + os.environ.get("LOCALAPPDATA", ""), + "Google", + "Chrome", + "Application", + "chrome.exe", + ), + ] + + for executable in ("google-chrome", "google-chrome-stable", "chrome", "chromium"): + found = shutil.which(executable) + if found: + return found + + for path in candidate_paths: + if path and os.path.exists(path): + return path + + return None + + def _login(self, page: Page) -> bool: + """执行登录""" + login_url = "https://www.natfrp.com/user/" + logger.info(f"导航到登录页面: {login_url}") + page.goto(login_url, wait_until="domcontentloaded") + + try: + # 输入用户名和密码 + username_input = page.locator("#username") + password_input = page.locator("#password") + username_input.wait_for(state="visible") + password_input.wait_for(state="visible") + + logger.info("输入登录凭据...") + username_input.fill("") + self.simulator.type_text(username_input, self.config.sakurafrp_user) + password_input.fill("") + self.simulator.type_text(password_input, self.config.sakurafrp_pass) + + # 点击登录按钮 + login_button = page.locator("#login") + login_button.wait_for(state="visible") + logger.info("点击登录按钮...") + login_button.click() + + self.simulator.random_sleep(3, 5) + logger.info("登录成功") + return True + + except PlaywrightTimeoutError: + logger.error("登录页面元素加载超时") + return False + except Exception as e: + logger.error(f"登录过程出错: {e}", exc_info=True) + return False + + def _navigate_to_sakurafrp(self, page: Page) -> bool: + """跳转到 SakuraFrp 仪表板""" + try: + # 点击 SakuraFrp 链接 + # sakura_link = page.locator("div.action-list a", has_text="Sakura Frp") + # logger.info("点击 SakuraFrp 跳转链接...") + # sakura_link.click() + # self.simulator.random_sleep(2, 4) + + # 处理年龄确认弹窗(如果存在) + try: + age_confirm = page.locator("div.yes a", has_text="是,我已满18岁") + age_confirm.wait_for(state="visible", timeout=5000) + logger.info("处理年龄确认弹窗...") + age_confirm.click() + self.simulator.random_sleep(2, 3) + except PlaywrightTimeoutError: + logger.info("未检测到年龄确认弹窗") + + logger.info("成功跳转到 SakuraFrp 仪表板") + return True + + except PlaywrightTimeoutError: + logger.warning("SakuraFrp 跳转链接未找到,可能已在目标页面") + return True + except Exception as e: + logger.error(f"跳转过程出错: {e}", exc_info=True) + return False + + def _perform_checkin(self, page: Page) -> bool: + + """执行签到操作""" + for attempt in range(1, self.max_retries+1): + logger.info(f"验证码尝试 {attempt}/{self.max_retries}") + try: + # 查找签到按钮 + check_in_button = None + try: + check_in_button = page.locator("button", has_text="点击这里签到") + check_in_button.wait_for(state="visible") + logger.info("找到签到按钮") + except PlaywrightTimeoutError: + # 检查是否已签到 + try: + page.locator("p", has_text="今天已经签到过啦").wait_for( + state="visible", + timeout=2000, + ) + logger.info("今日已签到") + return True + except PlaywrightTimeoutError: + logger.error("未找到签到按钮或已签到标识") + return False + + # 点击签到按钮 + if check_in_button: + logger.info("点击签到按钮...") + check_in_button.click() + self.simulator.random_sleep(2, 4) + + # 处理验证码 + self.captcha_handler.handle_geetest_captcha(page) + page.reload(wait_until="domcontentloaded") + time.sleep(5) + continue + + return False + + except ModelApiError: + raise + except PlaywrightError as e: + logger.error(f"签到过程出错: {e}", exc_info=True) + return False + except Exception as e: + logger.error(f"签到过程出错: {e}", exc_info=True) + return False + logger.info("已达到最大重试次数") + return False diff --git a/SakuraFrpQiandao/captcha_handler.py b/SakuraFrpQiandao/captcha_handler.py new file mode 100644 index 0000000..ede2250 --- /dev/null +++ b/SakuraFrpQiandao/captcha_handler.py @@ -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 diff --git a/SakuraFrpQiandao/config.py b/SakuraFrpQiandao/config.py new file mode 100644 index 0000000..c4132f7 --- /dev/null +++ b/SakuraFrpQiandao/config.py @@ -0,0 +1,61 @@ +import logging +import os +from typing import Optional +from dataclasses import dataclass + +# 尝试加载 .env 文件 +try: + from dotenv import load_dotenv + load_dotenv(override=True) +except ImportError: + pass + +# 配置日志 +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__) + + +@dataclass +class Config: + """配置数据类""" + sakurafrp_user: str + sakurafrp_pass: str + base_url: str + api_key: str + model: str + chrome_binary_path: Optional[str] = None + max_retries: int = 10 + image_as_base64: bool = False + + @classmethod + def from_env(cls) -> 'Config': + """从环境变量加载配置""" + def get_env(key: str, required: bool = True) -> str: + value = os.environ.get(key, "").split('\n')[0].strip() + if required and not value: + raise ValueError(f"环境变量 {key} 未设置或为空") + return value + + def get_bool_env(key: str, default: bool = False) -> bool: + raw_value = os.environ.get(key, "").strip().lower() + if not raw_value: + return default + return raw_value in {"1", "true", "yes", "on"} + + return cls( + sakurafrp_user=get_env("SAKURAFRP_USER"), + sakurafrp_pass=get_env("SAKURAFRP_PASS"), + base_url=get_env("BASE_URL"), + api_key=get_env("API_KEY"), + model=get_env("MODEL"), + chrome_binary_path=get_env("CHROME_BINARY_PATH", required=False), + max_retries=int(get_env("MAX_RETRIES", required=False) or 10), + image_as_base64=get_bool_env("IMAGE_AS_BASE64", default=False) + ) diff --git a/SakuraFrpQiandao/human_simulator.py b/SakuraFrpQiandao/human_simulator.py new file mode 100644 index 0000000..6c6b6d5 --- /dev/null +++ b/SakuraFrpQiandao/human_simulator.py @@ -0,0 +1,18 @@ +import time +import random + + +class HumanSimulator: + """模拟人类行为""" + + @staticmethod + def type_text(element, text: str, min_delay: float = 0.05, max_delay: float = 0.2): + """模拟人类打字""" + for char in text: + element.type(char, delay=0) + time.sleep(random.uniform(min_delay, max_delay)) + + @staticmethod + def random_sleep(min_sec: float = 1.0, max_sec: float = 3.0): + """随机等待""" + time.sleep(random.uniform(min_sec, max_sec)) diff --git a/SakuraFrpQiandao/main.py b/SakuraFrpQiandao/main.py new file mode 100644 index 0000000..f650616 --- /dev/null +++ b/SakuraFrpQiandao/main.py @@ -0,0 +1,26 @@ +from config import Config +from automation import CheckInAutomation +import logging + +logger = logging.getLogger(__name__) + + +def main(): + """主函数""" + try: + # 加载配置 + config = Config.from_env() + logger.info(f"使用账户: {config.sakurafrp_user}") + + # 执行自动签到 + automation = CheckInAutomation(config) + automation.run() + + except ValueError as e: + logger.error(f"配置错误: {e}") + except Exception as e: + logger.error(f"程序执行失败: {e}", exc_info=True) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/SakuraFrpQiandao/requirements.txt b/SakuraFrpQiandao/requirements.txt new file mode 100644 index 0000000..4928919 --- /dev/null +++ b/SakuraFrpQiandao/requirements.txt @@ -0,0 +1,14 @@ +# Browser automation +playwright>=1.46.0 + +# API client +openai>=1.0.0 + +# Environment variables +python-dotenv==1.0.0 + +# Packaging +setuptools>=65.0.0 + +# Utilities +requests>=2.31.0 diff --git a/SakuraFrpQiandao/send_email.py b/SakuraFrpQiandao/send_email.py new file mode 100644 index 0000000..492981c --- /dev/null +++ b/SakuraFrpQiandao/send_email.py @@ -0,0 +1,96 @@ +import smtplib +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from datetime import datetime + + +def send_log_email(log_file='checkin.log'): + """ + 发送签到日志邮件 + + Args: + log_file: 日志文件路径 + """ + # 从环境变量读取配置 + smtp_server = os.getenv('SMTP_SERVER', 'smtp.gmail.com') + smtp_port = int(os.getenv('SMTP_PORT', '587')) + sender_email = os.getenv('EMAIL_USERNAME', '') + sender_password = os.getenv('EMAIL_PASSWORD', '') + receiver_email = os.getenv('RECEIVER_EMAIL', sender_email) + + # 检查配置 + if not sender_email or not sender_password: + print("❌ 邮件配置未设置,跳过发送") + return False + + try: + # 读取日志内容 + log_content = "" + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8') as f: + log_content = f.read() + else: + log_content = "日志文件不存在" + + # 判断签到是否成功(根据日志内容) + is_success = "签到流程完成" in log_content or "验证码验证成功" in log_content + status_emoji = "✅" if is_success else "❌" + status_text = "成功" if is_success else "失败" + + # 创建邮件 + msg = MIMEMultipart() + msg['From'] = sender_email + msg['To'] = receiver_email + msg['Subject'] = f"{status_emoji} SakuraFrp 签到{status_text} - {datetime.now().strftime('%Y-%m-%d')}" + + # 邮件正文 + body = f""" +SakuraFrp 自动签到报告 + +状态: {status_emoji} {status_text} +时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + +{'='*50} +日志内容: +{'='*50} + +{log_content[-2000:] if len(log_content) > 2000 else log_content} + +{'='*50} +此邮件由自动签到系统发送 + """ + + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + # 添加日志附件 + if os.path.exists(log_file): + with open(log_file, 'rb') as f: + part = MIMEBase('application', 'octet-stream') + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header('Content-Disposition', f'attachment; filename= {os.path.basename(log_file)}') + msg.attach(part) + + if smtp_port == 465: + server = smtplib.SMTP_SSL(smtp_server, smtp_port) + else: + server = smtplib.SMTP(smtp_server, smtp_port) + server.starttls() + + with server: + server.login(sender_email, sender_password) + server.send_message(msg) + + print(f"✅ 邮件发送成功: {receiver_email}") + return True + + except Exception as e: + print(f"❌ 邮件发送失败: {e}") + return False + + +if __name__ == "__main__": + send_log_email() diff --git a/bg.png b/bg.png new file mode 100644 index 0000000..8fa835a Binary files /dev/null and b/bg.png differ diff --git a/fullbg.png b/fullbg.png new file mode 100644 index 0000000..efaa2b4 Binary files /dev/null and b/fullbg.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..8d514f1 --- /dev/null +++ b/main.py @@ -0,0 +1,358 @@ +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() diff --git a/sakura_frp_auto_check/.gitignore b/sakura_frp_auto_check/.gitignore new file mode 100644 index 0000000..a354c7f --- /dev/null +++ b/sakura_frp_auto_check/.gitignore @@ -0,0 +1,10 @@ +/bg.png +/cookies.txt +/debug_01_bg_gray.png +/debug_02_fullbg_gray.png +/debug_03_diff.png +/debug_04_thresh.png +/debug_05_with_box.png +/fullbg.png +/state.json +/account.txt \ No newline at end of file diff --git a/sakura_frp_auto_check/CLAUDE.md b/sakura_frp_auto_check/CLAUDE.md new file mode 100644 index 0000000..f739e32 --- /dev/null +++ b/sakura_frp_auto_check/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is an automated FRP (NATFRP) check-in bot built with Python and Playwright. The application automates daily sign-in on www.natfrp.com with CAPTCHA solving capabilities. + +## Architecture + +### Core Components + +- **main.py**: Single-file application containing the entire automation logic +- **Computer Vision Module** (`get_gap_offset`): Uses OpenCV to solve sliding CAPTCHA puzzles by detecting gaps in background images +- **Browser Automation**: Uses Playwright to interact with the NATFRP website +- **State Management**: Preserves login sessions using `state.json` to avoid repeated logins +- **Retry Logic**: Built-in retry mechanism with configurable maximum attempts + +### Key Functions + +- `get_gap_offset()`: CAPTCHA solver using image difference detection in main.py:7-51 +- `main()`: Main automation workflow in main.py:251-324 +- `solve_geetest_puzzle()`: Handles GeeTest CAPTCHA solving in main.py:220-235 +- `wait_signed_text_and_shoot()`: Success detection and screenshot capture in main.py:238-248 + +## Running the Application + +### Basic Execution +```bash +python main.py +``` + +### Dependencies +The project requires: +- playwright +- opencv-python (cv2) +- pathlib (built-in) + +Install Playwright browsers after installing the package: +```bash +playwright install chromium +``` + +## Configuration Files + +- **account.txt**: Contains username (line 1) and password (line 2) for NATFRP login +- **state.json**: Browser session state for maintaining login sessions +- **.gitignore**: Excludes sensitive files and debug images + +## Generated Files + +- **bg.png, fullbg.png**: CAPTCHA background images extracted during solving +- **debug_*.png**: Debug images for CAPTCHA solving analysis (when debug=True) +- **checkin.png**: Screenshot saved upon successful sign-in + +## Configuration Variables + +Key settings in main.py:53-64: +- `MAX_RETRY`: Maximum retry attempts (0 for infinite) +- `ALREADY_SIGNED_TEXT`: Success detection text ("今天已经签到过啦") +- `SIGNED_ANCESTOR_LEVELS`: Screenshot capture depth (3 parent levels) +- `SIGNED_ANCESTOR_SCREENSHOT`: Success screenshot filename + +## CAPTCHA Solving Process + +1. Extract background image with gap (`bg.png`) +2. Extract complete background image (`fullbg.png`) +3. Calculate absolute difference between images +4. Apply binary threshold and find contours +5. Select gap location based on largest contours +6. Simulate human-like mouse dragging to solve + +## Browser Configuration + +- Runs in non-headless mode by default (`headless=False`) +- Uses `slow_mo=200` for human-like interaction timing +- Maintains session state between runs via `state.json` \ No newline at end of file diff --git a/sakura_frp_auto_check/CLAUDE_CN.md b/sakura_frp_auto_check/CLAUDE_CN.md new file mode 100644 index 0000000..fefc77a --- /dev/null +++ b/sakura_frp_auto_check/CLAUDE_CN.md @@ -0,0 +1,85 @@ +# CLAUDE_CN.md + +此文件为 Claude Code (claude.ai/code) 在此代码仓库中工作时提供中文指导。 + +## 项目概述 + +这是一个使用 Python 和 Playwright 构建的自动化 FRP (NATFRP) 签到机器人。该应用程序可以自动在 www.natfrp.com 上进行每日签到,具备验证码破解能力。 + +## 架构设计 + +### 核心组件 + +- **main.py**: 包含所有自动化逻辑的单文件应用程序 +- **计算机视觉模块** (`get_gap_offset`): 使用 OpenCV 通过检测背景图像缺口来破解滑动验证码 +- **浏览器自动化**: 使用 Playwright 与 NATFRP 网站交互 +- **状态管理**: 使用 `state.json` 保存登录会话以避免重复登录 +- **重试机制**: 内置重试逻辑,支持配置最大重试次数 + +### 关键函数 + +- `get_gap_offset()`: 验证码破解器,使用图像差异检测 (main.py:7-51) +- `main()`: 主要自动化工作流程 (main.py:251-324) +- `solve_geetest_puzzle()`: 处理极验验证码破解 (main.py:220-235) +- `wait_signed_text_and_shoot()`: 成功检测和截图保存 (main.py:238-248) + +## 运行应用程序 + +### 基本执行 +```bash +python main.py +``` + +### 依赖项 +项目需要以下依赖: +- playwright +- opencv-python (cv2) +- pathlib (内置模块) + +安装 Playwright 浏览器: +```bash +playwright install chromium +``` + +## 配置文件 + +- **account.txt**: 包含 NATFRP 登录用户名(第1行)和密码(第2行) +- **state.json**: 浏览器会话状态,用于保持登录状态 +- **.gitignore**: 排除敏感文件和调试图像 + +## 生成文件 + +- **bg.png, fullbg.png**: 验证码破解过程中提取的背景图像 +- **debug_*.png**: 验证码破解分析的调试图像(当 debug=True 时) +- **checkin.png**: 签到成功时保存的截图 + +## 配置变量 + +main.py:53-64 中的关键设置: +- `MAX_RETRY`: 最大重试次数(0表示无限重试) +- `ALREADY_SIGNED_TEXT`: 成功检测文本("今天已经签到过啦") +- `SIGNED_ANCESTOR_LEVELS`: 截图捕获深度(3层父级元素) +- `SIGNED_ANCESTOR_SCREENSHOT`: 成功截图文件名 + +## 验证码破解流程 + +1. 提取带缺口的背景图像(`bg.png`) +2. 提取完整背景图像(`fullbg.png`) +3. 计算两张图像的绝对差异 +4. 应用二值化阈值并查找轮廓 +5. 根据最大轮廓选择缺口位置 +6. 模拟人类般的鼠标拖拽来破解验证码 + +## 浏览器配置 + +- 默认以非无头模式运行(`headless=False`) +- 使用 `slow_mo=200` 实现类人交互时序 +- 通过 `state.json` 在运行间保持会话状态 + +## 使用说明 + +1. 确保 `account.txt` 文件包含正确的用户名和密码 +2. 首次运行会创建 `state.json` 保存登录状态 +3. 程序会自动检测是否已签到,避免重复操作 +4. 遇到验证码时会自动进行图像识别和破解 +5. 签到成功后会保存截图作为凭证 \ No newline at end of file diff --git a/sakura_frp_auto_check/LICENSE b/sakura_frp_auto_check/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/sakura_frp_auto_check/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sakura_frp_auto_check/README.md b/sakura_frp_auto_check/README.md new file mode 100644 index 0000000..79d1be9 --- /dev/null +++ b/sakura_frp_auto_check/README.md @@ -0,0 +1,116 @@ +# SAKURA FRP 自动签到脚本 + +> 自动访问 `https://www.natfrp.com/user/` 完成每日签到;支持在本地直接运行,也支持在 **QingLong(青龙)** 定时任务中运行。 +> 成功后会自动保存签到结果截图(默认:`checkin.png`),并在必要时自动处理滑块验证。 + +## 一、目录结构 + +``` +. +├─ main.py # 主程序(无需改动即可用) +├─ account.txt # 账号文件(必填:第1行用户名;第2行密码) +├─ state.json # 登录状态缓存文件(自动生成与更新) +└─ checkin.png # 成功时保存的签到区域截图(自动生成) +``` + +> 说明:程序会优先尝试复用 `state.json` 中的登录状态;状态失效时会自动回退为账号密码登录。 + +## 二、准备工作 + +1. **创建账号文件 `account.txt`** + - 第 1 行:用户名 + - 第 2 行:密码 + - 文件编码建议使用 `UTF-8`,不要多余空行或空格。 + 示例: + ``` + your_username + your_password + ``` + +2. **(可选)检查/调整配置** — 打开 `main.py` 顶部“配置”区域,可按需修改: + - `ACCOUNT_FILE`:账号文件路径(默认 `account.txt`) + - `STATE_FILE`:状态缓存文件(默认 `state.json`) + - `MAX_RETRY`:失败重试次数(`0` 表示无限重试) + - `SIGNED_ANCESTOR_SCREENSHOT`:成功截图文件名(默认 `checkin.png`) + +> **无需修改网站域名与地址**,默认已指向 `https://www.natfrp.com/user/`。 + +## 三、环境要求 + +需要安装Playwright及其依赖,推荐使用 Python 3.7 及以上版本。 + +## 四、在 QingLong(青龙)中使用 + +> 适用于青龙面板 v2.XX 及以上版本(路径请以你面板的实际目录为准)。 + +### 1)放置脚本与账号文件 + +方式 A:在面板「文件管理」中将 `main.py` 和 `account.txt` 上传到脚本目录,例如: +``` +/ql/data/scripts/natfrp_checkin/ +``` + +方式 B:通过仓库/订阅拉取(如你有自建仓库),确保最终能在脚本目录看到 `main.py` 与 `account.txt`。 + +> **注意**:`account.txt` 一定要跟 `main.py` 放在同一目录(或同步修改 `ACCOUNT_FILE` 为你的自定义路径)。 + +### 2)创建定时任务 + +在青龙面板 ->「定时任务」->「新建任务」: + +- **名称**:NATFRP 自动签到 +- **命令**(按你的实际路径替换): + ```bash + python3 /ql/data/scripts/natfrp_checkin/main.py + ``` +- **定时规则(CRON)**:建议每日固定时间运行一次,例如每天 8:10: + ``` + 10 8 * * * + ``` + +保存后即可生效。你也可以在任务列表中手动点击「运行」测试是否正常。 + +### 3)查看运行日志与产物 + +- 在任务详情页可查看实时日志; +- 成功后会生成 `checkin.png`(截图)与 `state.json`(状态); +- 若失败,会打印原因并根据 `MAX_RETRY` 自动重试。 + +## 五、常见问题 + +1. **提示账号文件不存在或格式错误** + - 确认 `account.txt` 与 `main.py` 在同一目录; + - 确认文件编码为 `UTF-8`,且只有两行:第一行用户名,第二行密码; + - 不要包含多余空行或空格。 + +2. **一直提示登录状态过期** + - 删除同目录下旧的 `state.json` 后重跑; + - 确认账号密码正确; + - 网络/地区问题可能导致访问异常,可稍后再试。 + +3. **出现滑块但始终无法通过** + - 目标站点的风控策略可能调整; + - 可适当增加重试次数(`MAX_RETRY`),或更换执行时间段; + - 若长时间失败,建议手动登录一次,让站点信任度恢复。 + +4. **成功了但没有截图** + - 程序以出现“今天已经签到过啦”的提示为成功判据; + - 如果站点文案发生变化,可修改 `ALREADY_SIGNED_TEXT`; + - 截图保存路径由 `SIGNED_ANCESTOR_SCREENSHOT` 控制(默认 `checkin.png`)。 + +5. **青龙里命令执行不动或报路径错误** + - 确认命令中的绝对路径与实际文件路径一致; + - 如果你把文件放在其它目录,务必同步修改命令与 `ACCOUNT_FILE` 路径。 + +## 六、日志与产物 + +- **日志输出**:执行时会输出每一步进度与判定结果,便于排查。 +- **截图与状态**: + - `checkin.png`:成功时的页面关键区域截图; + - `state.json`:登录状态缓存(自动生成/刷新); + - 若启用内部调试流程,可能在同目录产生若干 `debug_*.png` 用于图像比对,这些文件可忽略或自行清理。 + +--- + +**至此即可完成配置与使用。** +如需嵌入到你现有的青龙仓库,只要保证 `main.py` 与 `account.txt` 的相对位置一致,并按上文的命令与定时规则召唤即可。祝签到愉快 🎯 \ No newline at end of file diff --git a/sakura_frp_auto_check/checkin.png b/sakura_frp_auto_check/checkin.png new file mode 100644 index 0000000..158a283 Binary files /dev/null and b/sakura_frp_auto_check/checkin.png differ diff --git a/sakura_frp_auto_check/main.py b/sakura_frp_auto_check/main.py new file mode 100644 index 0000000..8ed6fb7 --- /dev/null +++ b/sakura_frp_auto_check/main.py @@ -0,0 +1,346 @@ +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)