SakuraFrp 自动签到(main + sakura_frp_auto_check + SakuraFrpQiandao),已排除 .env 与日志
This commit is contained in:
@@ -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/
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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. 签到成功后会保存截图作为凭证
|
||||||
@@ -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.
|
||||||
@@ -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` 的相对位置一致,并按上文的命令与定时规则召唤即可。祝签到愉快 🎯
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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)
|
||||||
Reference in New Issue
Block a user