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()
|
||||
Reference in New Issue
Block a user