我是基于Kimi moonshot-v1-8k实现的AI助手,在此博客上负责整理和概括文章
本文介绍了一个2dfan自动签到程序的开发历程。开发者最初使用selenium进行网页自动化,但因cloudflare检测而失败。后来发现DrissonPage库,成功通过cloudflare验证。关键步骤包括点击验证框,使用opencv模板匹配定位checkbox。自动部署在GitHub Actions中实现,难点在于自动化需要屏幕,使用虚拟屏幕Xvfb解决。主代码中解决了cloudflare元素获取问题,通过监控元素状态变化确定操作时机。最终,程序稳定运行,完成了自动签到任务。此外,还解决了他人fork后运行出错的问题。
编辑记录
2024-12-05 18:11:45 第一次编辑
- 正文
2024-12-06 15:51:45 第二次编辑
- 完善主代码,设置无痕模式 (防止保存密码窗口弹出),并改进登录机制 (报错尝试重新登录)。
2025-01-13 11:51:45 第三次编辑
- 解决 bug:别人 fork 时运行出问题
# 开发历程
一开始使用 selenium 进行网页自动化任务,但后面发现会被 cloudflare 检测到,导致验证失败,而且该方法还需要下载对应的 chromedriver,麻烦且不适用。
但后面打算放弃时,逛 github 项目无意中发现一个好用的网页自动化的库 DrissonPage,比 selenium 简单且功能强大,尝试性地去进行 cloudflare 验证后,发现居然可以验证通过。于是后续用此库进行开发。
# 点击验证框
作为最关键的一步,因为 cloudflare 里的元素无法通过普通的网页定位获得,所有难以点击。最简单的就是通过其在屏幕坐标的形式点击,但受限很大。通过 AI 在 github 找到一个项目,是用 opencv 模板匹配做的,我居然忘了还有这个。运行后发现,确实能获取到 checkbox 的位置并点击。只需要提供一张模板图片和网页截图,再根据情况修改下点击的位置通过验证了。主要代码如下:
"Hansimov/captcha-bypass" | |
import cv2 | |
import pyautogui | |
from mss import mss | |
from pathlib import Path | |
from PIL import ImageGrab | |
class ImageMatcher: | |
def __init__(self, source_image_path, template_image_path): | |
self.source_image = cv2.imread(str(source_image_path)) | |
self.template_image = cv2.imread(str(template_image_path)) | |
self.detected_image_path = source_image_path.parent / "screenshot_detected.png" | |
def match(self): | |
res = cv2.matchTemplate( | |
self.source_image, self.template_image, cv2.TM_CCOEFF_NORMED | |
) | |
_, _, _, match_location = cv2.minMaxLoc(res) | |
match_left = match_location[0] | |
match_top = match_location[1] | |
match_right = match_location[0] + self.template_image.shape[1] | |
match_bottom = match_location[1] + self.template_image.shape[0] | |
match_region = (match_left, match_top, match_right, match_bottom) | |
self.match_region = match_region | |
return match_region | |
def draw_rectangle(self): | |
cv2.rectangle( | |
img=self.source_image, | |
pt1=self.match_region[:2], | |
pt2=self.match_region[2:], | |
color=(0, 255, 0), | |
thickness=2, | |
) | |
cv2.imwrite(str(self.detected_image_path), self.source_image) | |
class CaptchaBypasser: | |
def __init__(self): | |
self.captcha_image_path = ( | |
Path(__file__).parent / "captcha-verify-you-are-human-eg.png" # 自动部署图片加 eg,本地不加 | |
) | |
self.screen_shot_image_path = Path(__file__).parent / "screenshot.png" | |
def get_screen_shots(self): | |
ImageGrab.grab(all_screens=True).save(self.screen_shot_image_path) | |
def get_captcha_location(self): | |
with mss() as sct: | |
all_monitor = sct.monitors[0] | |
monitor_left_offset = all_monitor["left"] | |
monitor_top_offset = all_monitor["top"] | |
image_matcher = ImageMatcher( | |
source_image_path=self.screen_shot_image_path, | |
template_image_path=self.captcha_image_path, | |
) | |
match_region = image_matcher.match() | |
image_matcher.draw_rectangle() | |
match_region_in_monitor = ( | |
match_region[0] + monitor_left_offset, | |
match_region[1] + monitor_top_offset, | |
match_region[2] + monitor_left_offset, | |
match_region[3] + monitor_top_offset, | |
) | |
checkbox_center = ( | |
int(match_region_in_monitor[0] + 40), #本地 60 自动部署 40 | |
int((match_region_in_monitor[1] + match_region_in_monitor[3]) / 2), | |
) | |
# 该处画点并保存图片 | |
cv2.circle( | |
img=image_matcher.source_image, | |
center=checkbox_center, | |
radius=2, | |
color=(0, 0, 255), | |
thickness=-1, | |
) | |
cv2.imwrite(str(image_matcher.detected_image_path), image_matcher.source_image) | |
return checkbox_center | |
def click_target_checkbox(self): | |
captcha_checkbox_center = self.get_captcha_location() | |
pyautogui.moveTo(*captcha_checkbox_center) | |
pyautogui.click() | |
def run(self): | |
self.get_screen_shots() | |
self.get_captcha_location() | |
self.click_target_checkbox() | |
if __name__ == "__main__": | |
captcha_bypasser = CaptchaBypasser() | |
captcha_bypasser.run() |
在本地运行时,使用显示中文的模板图片,自动部署时则使用英文的模板图片,另外本地的间距是 60,自动部署的间距是 40。
在主代码中,只需要引入该代码,运行:
captcha_bypasser = CaptchaBypasser() | |
captcha_bypasser.run() |
就能实现点击 checkbox 通过验证。
# 自动部署
还是在 github actions 中实现,借助 gpt 得到了代码并修改完整吧。在 .github\workflows
里创建 yml 文件。主要代码为:
name: 2dfan Task Runner | |
on: | |
push: | |
branches: | |
- main | |
schedule: | |
- cron: '0 19 * * *' # UTC 时间 19 点对应北京时间 3 点. | |
- cron: '0 22 * * *' # UTC 时间 22 点对应北京时间 6 点. | |
workflow_dispatch: # 手动触发 | |
jobs: | |
run-task: | |
runs-on: ubuntu-latest | |
steps: | |
- name: Checkout code | |
uses: actions/checkout@v3 | |
- name: Set up Python | |
uses: actions/setup-python@v4 | |
with: | |
python-version: 3.9 | |
- name: Install dependencies | |
run: | | |
sudo apt-get update | |
sudo apt-get install -y xvfb libx11-dev xauth fonts-noto-cjk # 安装中文字体 | |
python -m pip install --upgrade pip | |
pip install -r requirements.txt | |
- name: Start Xvfb | |
run: | | |
nohup Xvfb :99 -screen 0 1280x1024x24 & | |
- name: Set DISPLAY and XAUTHORITY environment variables | |
run: | | |
echo "DISPLAY=:99" >> $GITHUB_ENV | |
echo "XAUTHORITY=/home/runner/.Xauthority" >> $GITHUB_ENV | |
touch /home/runner/.Xauthority # 创建一个空的 Xauthority 文件 | |
- name: Run Python script | |
env: | |
USER_EMAIL: $<!--swig0--> | |
USER_PASSWORD: $ | |
run: | | |
python 2dfan_DrissionPage.py | |
- name: Upload screenshots as artifacts | |
uses: actions/upload-artifact@v3 | |
with: | |
name: screenshots | |
path: | | |
./screenshot.png | |
./screenshot_detected.png | |
./pic1.png | |
./pic2.png | |
./pic3.png | |
./pic4.png |
自动部署遇到的难点之一是自动化需要一个屏幕,所有这里使用了虚拟屏幕 Xvfb,在安装依赖性里,安装了虚拟屏幕以及中文字体(使网页中文能正常显示),以及需要使用到的包(包含在 requirements.txt 里)。后面就是虚拟屏幕的一些关键配置保证屏幕能正常运行。接下来就是运行 python 代码,这里先添加了 github actions 的 secrets,分别是 2dfan 网站的邮箱和密码用于登录。最后一步就是用于调试,由于看不到屏幕情况,在代码中添加截图代码,再在自动部署代码中获取这些图片,这些图片会下载到 screenshots.zip
中,这里图片保存在代码同一目录,所以路径为 ./xxx.png
。 触发方式为推送代码、定时、手动。
补充:后续需要批量删除过多的 workflow 时,可以使用命令行,步骤为:
- 安装
GitHub CLI
- cmd 终端运行按提示登录,并确保选择正确的权限。
h gh auth login
- 验证登录成功
h gh auth status
- 执行命令批量删除,这里是在 git bash 里执行,因为 cmd 不支持 xargs其中的
h gh api repos/USER/REPO/actions/runs --paginate | \
jq -r '.workflow_runs[].id' | \
tr -d '\r' | \
xargs -I {} gh api -X DELETE repos/USER/REPO/actions/runs/{}
USER
是 github 用户名,REPO
是要进行操作的仓库名 (注意有两处修改),执行命令后会批量删除该仓库下 actions 的所有 workflow。
单个的删除命令为:最后的数字为 Workflow Run ID。h gh api -X DELETE repos/USER/REPO/actions/runs/123456789
# 主代码
其实难点就在于那个 cloudflare 的元素获取吧,普通的方式难以获取,受 https://github.com/sarperavci/CloudflareBypassForScraping.git
启发,找到了定位的思路。完成全部代码并由 gpt 优化结构和命名等,代码为:
import time, os | |
import logging | |
from DrissionPage import ChromiumPage | |
from bypass_captcha import CaptchaBypasser | |
from DrissionPage import ChromiumOptions | |
# 配置日志记录 | |
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') | |
# 全局变量 | |
LOGIN_URL = "https://2dfan.com/users/421136/recheckin" | |
MAX_RETRIES = 3 # 最大重试次数 | |
MAX_LOGIN_ATTEMPTS = 3 # 最大重新登录次数 | |
def locate_button(ele, tag="tag:svg", retries=MAX_RETRIES): | |
""" | |
尝试定位按钮,最多尝试 `retries` 次。 | |
""" | |
for attempt in range(retries): | |
try: | |
button = ele.parent().shadow_root.child()(f"tag:body").shadow_root(tag) | |
if button: | |
logging.info(f"按钮定位成功 (尝试次数: {attempt + 1})") | |
return button | |
else: | |
logging.warning(f"按钮为空,重新尝试定位 (尝试次数: {attempt + 1})") | |
except Exception as e: | |
logging.error(f"定位按钮时出错: {e} (尝试次数: {attempt + 1})") | |
time.sleep(1) | |
raise RuntimeError("按钮定位失败,已达到最大重试次数") | |
def process_captcha(tab, eles, tag="tag:circle"): | |
""" | |
定位验证码中相关元素并返回该元素。 | |
""" | |
for ele in eles: | |
if "name" in ele.attrs and "type" in ele.attrs: | |
if "turnstile" in ele.attrs["name"] and ele.attrs["type"] == "hidden": | |
button = locate_button(ele, tag=tag) | |
logging.info(f"验证相关按钮:{button}") | |
tab.wait(1) | |
return button | |
raise RuntimeError("未找到验证码相关按钮") | |
def login_process(tab): | |
""" | |
执行登录的输入账号、密码和验证码绕过的流程。 | |
""" | |
# 输入账号 | |
user_email = os.getenv("USER_EMAIL", "") | |
if not user_email: | |
raise ValueError("环境变量 USER_EMAIL 未设置") | |
logging.info(f"输入账号: {user_email}") | |
tab.ele('@name=login').input(user_email) | |
# 输入密码 | |
user_password = os.getenv("USER_PASSWORD", "") | |
if not user_password: | |
raise ValueError("环境变量 USER_PASSWORD 未设置") | |
logging.info("输入密码") | |
tab.ele('@name=password').input(user_password) | |
# 验证验证码 | |
tab.wait.eles_loaded("tag:input") | |
eles = tab.eles("tag:input") | |
button = process_captcha(tab, eles, tag="tag:svg") | |
tab.wait.ele_hidden(button) | |
logging.info("开始验证") | |
tab.wait(3) | |
# 初始化验证码绕过程序 | |
logging.info("初始化验证码绕过程序...") | |
captcha_bypasser = CaptchaBypasser() | |
logging.info("运行验证码绕过程序...") | |
captcha_bypasser.run() | |
# 检验是否成功 | |
button = process_captcha(tab, eles, tag="tag:circle") | |
tab.wait.ele_displayed(button) | |
logging.info("验证成功") | |
tab.wait(2) | |
tab.get_screenshot(name='pic1.png', full_page=True) | |
# 点击登录按钮 | |
logging.info("查找并点击登录按钮...") | |
login_button = tab.ele('@type=submit') | |
if login_button: | |
login_button.click() | |
logging.info("登录按钮已点击") | |
else: | |
raise RuntimeError("未找到登录按钮") | |
def main(): | |
try: | |
# 启动浏览器 | |
logging.info("启动浏览器...") | |
co = ChromiumOptions() | |
# 禁止所有弹出窗口 | |
# co.set_pref(arg='profile.default_content_settings.popups', value='0') | |
# # 隐藏是否保存密码的提示 | |
# co.set_pref('credentials_enable_service', False) | |
#设置无痕模式,防止弹出是否保存密码的提示. | |
co.incognito(True) | |
tab = ChromiumPage(co) | |
# 跳转到登录页面 | |
logging.info("跳转到登录页面...") | |
tab.get(LOGIN_URL) | |
logging.info("已跳转到登录页面") | |
login_attempts = 0 # 登录尝试计数 | |
while login_attempts < MAX_LOGIN_ATTEMPTS: | |
login_attempts += 1 | |
logging.info(f"执行登录流程(尝试第 {login_attempts} 次)...") | |
# 执行登录流程 | |
try: | |
login_process(tab) | |
# 检查当前页面 URL | |
tab.wait.new_tab() | |
current_url = tab.url | |
logging.info(f"当前页面URL: {current_url}") | |
if current_url == "https://2dfan.com/users/sign_in": | |
logging.warning("仍处于登录页面,重新尝试登录...") | |
tab.refresh() | |
tab.wait.doc_loaded() | |
tab.get_screenshot(name='pic_error.png', full_page=True) | |
else: | |
logging.info("成功跳转到主页,继续后续操作...") | |
break # 登录成功,退出循环 | |
except Exception as e: | |
logging.error(f"登录尝试失败: {e}") | |
else: | |
logging.error("达到最大登录尝试次数,退出程序") | |
return | |
# 等待页面加载 | |
tab.wait.eles_loaded("tag:input") | |
eles = tab.eles("tag:input") | |
logging.info("登录成功") | |
tab.get_screenshot(name='pic2.png', full_page=True) | |
# 检测签到状态 | |
checkin_status = tab.ele('text:今日已签到') | |
if checkin_status: | |
logging.info("今日已签到!") | |
else: | |
logging.info("未签到,尝试签到...") | |
# 再次运行验证码绕过程序 | |
logging.info("再次运行验证码绕过程序...") | |
captcha_bypasser = CaptchaBypasser() | |
captcha_bypasser.run() | |
# 检验是否成功 | |
button = process_captcha(tab, eles, tag="tag:circle") | |
tab.wait.ele_displayed(button) | |
logging.info("验证成功") | |
tab.wait(2) | |
tab.get_screenshot(name='pic3.png', full_page=True) | |
# 点击签到按钮 | |
logging.info("查找并点击签到按钮...") | |
checkin_button = tab.ele('@type=submit') | |
if checkin_button: | |
checkin_button.click() | |
logging.info("签到按钮已点击") | |
else: | |
raise RuntimeError("未找到签到按钮") | |
tab.wait(5) | |
tab.refresh() | |
tab.wait.doc_loaded() | |
tab.wait(3) | |
logging.info("刷新页面成功") | |
# 检测签到状态 | |
checkin_status = tab.ele('text:今日已签到') | |
if checkin_status: | |
logging.info("签到成功!") | |
else: | |
logging.info("签到失败!") | |
except Exception as e: | |
logging.error(f"运行过程中发生错误: {e}") | |
finally: | |
# 确保浏览器关闭 | |
logging.info("关闭浏览器...") | |
tab.close() | |
logging.info("浏览器已关闭") | |
if __name__ == "__main__": | |
main() |
主要解决不知道什么时候进行下一步的问题,通过 sleep 来延时和下一步的时间间隔,会使在自动部署下每次运行不稳定,难以确定合适的间隔。于是,通过 cloudflare 的加载变化来确定时间点。验证刚加载时会一直转圈,获取对应的元素 "tag:svg",当其变为隐藏时,开始验证,使用了 tab.wait.ele_hidden(button)
函数等待。点击验证后,获取元素 "tag:circle"(就是成功的圈圈),当其变为显示时,使用了 tab.wait.ele_displayed(button)
函数等待,表明验证成功,之后稍微间隔下 tab.wait(1)
再点击登录按钮。此时会切换界面,使用了函数 tab.wait.new_tab()
等待页面加载完成。之后就是先检测是否已签到,如果没有则直接开始点击验证,验证是否成功使用前面的方法等待 "tag:circle" 显示。验证成功后则点击签到按钮,之后刷新网页验证是否签到成功。
其中还加入了多次检测机制,因为可能第一次定位不到,需要多次定位,于是写了 locate_button(ele, tag="tag:svg", retries=MAX_RETRIES)
函数。定位 cloudflare 内元素采用了 process_captcha(tab, eles, tag="tag:circle")
函数,原理就是先 eles = tab.eles("tag:input")
找所有 input 标签,这些 eles 判断是否有属性 name 和 type,再进一步判断 name 的值中是否有 "turnstile",type 的值是否为 "hidden",同时满足时则为对应的 cloudflare 区域,使用 locate_button()
设置其中的参数 tag 来获取到需要的元素。部分代码:
def process_captcha(tab, eles, tag="tag:circle"): | |
""" | |
定位验证码中相关元素并返回该元素。 | |
""" | |
for ele in eles: | |
if "name" in ele.attrs and "type" in ele.attrs: | |
if "turnstile" in ele.attrs["name"] and ele.attrs["type"] == "hidden": | |
button = locate_button(ele, tag=tag) | |
logging.info(f"验证相关按钮:{button}") | |
tab.wait(1) | |
return button | |
raise RuntimeError("未找到验证码相关按钮") |
# 结语
至此,代码相对稳定,在自动部署下,各环节没有异常,都能实现预计的操作并完成签到任务。
# 解决 bug
别人运行时报错:
2025-01-11 22:14:18,172 - ERROR - 运行过程中发生错误: | |
The browser connection fails. | |
Address: 127.0.0.1:44774 | |
Tip: | |
1, the user folder does not conflict with the open browser | |
2, if no interface system, please add '--headless=new' startup parameter | |
3, if the system is Linux, try adding '--no-sandbox' boot parameter | |
The port and user folder paths can be set using ChromiumOptions. | |
Version: 4.1.0.17 | |
2025-01-18 22:14:18,172 - INFO - 关闭浏览器... | |
Traceback (most recent call last): | |
File "/home/runner/work/2dfan_autosign/2dfan_autosign/2dfan_DrissionPage.py", line 204, in <module> | |
main() | |
File "/home/runner/work/2dfan_autosign/2dfan_autosign/2dfan_DrissionPage.py", line 200, in main | |
tab.close() | |
UnboundLocalError: local variable 'tab' referenced before assignment |
解决方法:
在主程序中添加代码:
co = ChromiumOptions().set_paths(user_data_path=r'/tmp/chrome_user_data').auto_port() | |
co.incognito(True) # 启用无痕模式 | |
co.set_argument('--no-sandbox') | |
co.set_argument('--disable-gpu') | |
co.set_argument('--disable-dev-shm-usage') |
成功解决!