SakuraFrp 自动签到(main + sakura_frp_auto_check + SakuraFrpQiandao),已排除 .env 与日志

This commit is contained in:
RRRosmontis
2026-08-27 21:43:38 +08:00
commit 2a9f96fb18
21 changed files with 2209 additions and 0 deletions
+13
View File
@@ -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/
+42
View File
@@ -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"]
+21
View File
@@ -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.
+171
View File
@@ -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)
+256
View File
@@ -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
+298
View File
@@ -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
+61
View File
@@ -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)
)
+18
View File
@@ -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))
+26
View File
@@ -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()
+14
View File
@@ -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
+96
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+358
View File
@@ -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()
+10
View File
@@ -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
+77
View File
@@ -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`
+85
View File
@@ -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. 签到成功后会保存截图作为凭证
+201
View File
@@ -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.
+116
View File
@@ -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

+346
View File
@@ -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)