标签 容器化部署 下的文章

接:雨云无限白嫖 FRP 服务器攻略(无 aff)

@fatekey 大佬的方案是单独开一个容器,我想着反正我有青龙面板在跑脚本,站在巨人的肩膀上顺手写了一个,把多账户也写进来了

一、运行效果

先看实际运行的效果吧
【前两个账号运行已签到,仅测试多账户切换功能,临时注册的第三个号用于测试签到功能】

## 开始执行... 2026-01-23 16:57:53

2026-01-23 16:57:54.116950139 [W:onnxruntime:Default, cpuid_info.cc:91 LogEarlyWarning] Unknown CPU vendor. cpuinfo_vendor value: 16
2026-01-23 16:57:54,191 - INFO - --------------------------------------------------------------------------------
2026-01-23 16:57:54,191 - INFO - 雨云签到工具 by SerendipityR ~
2026-01-23 16:57:54,191 - INFO - Github发布页: https://github.com/SerendipityR-2022/Rainyun-Qiandao
2026-01-23 16:57:54,192 - INFO - --------------------------------------------------------------------------------
2026-01-23 16:57:54,192 - INFO - 雨云签到工具容器版 by fatekey ~
2026-01-23 16:57:54,192 - INFO - Github发布页: https://github.com/fatekey/Rainyun-Qiandao
2026-01-23 16:57:54,192 - INFO - --------------------------------------------------------------------------------
2026-01-23 16:57:54,192 - INFO -                    项目为二次开发青龙脚本化运行
2026-01-23 16:57:54,192 - INFO -                      本项目基于上述项目开发
2026-01-23 16:57:54,192 - INFO -                 本项目仅作为学习参考,请勿用于其他用途
2026-01-23 16:57:54,192 - INFO - --------------------------------------------------------------------------------
2026-01-23 16:57:54,192 - INFO - ✅ 成功解析3个账号
2026-01-23 16:57:54,192 - INFO - 
================= 处理第1个账号 ==================
2026-01-23 16:57:54,192 - INFO - 
========== 开始处理账号:TACGN ==========
2026-01-23 16:57:54,192 - INFO - ⏳ 随机延时 0 分钟 35 秒
2026-01-23 16:58:29,794 - INFO - ✅ Selenium驱动初始化成功,路径:/usr/bin/chromedriver
2026-01-23 16:58:29,809 - INFO - ✅ 已注入stealth.min.js反检测脚本
2026-01-23 16:58:29,809 - INFO - ⏳ 发起登录请求
2026-01-23 16:58:29,810 - INFO - 🌐 访问雨云登录页
2026-01-23 16:58:31,258 - INFO - 页面标题:登录 | 雨云
2026-01-23 16:58:31,258 - INFO - ⏳ 等待登录表单元素加载...
2026-01-23 16:58:31,376 - INFO - 📝 输入账号密码
2026-01-23 16:58:31,775 - INFO - ⏳ 正在登录中,耗时较长请稍等……
2026-01-23 16:58:54,899 - INFO - ✅ 未触发登录验证码
2026-01-23 16:58:59,906 - INFO - 当前页面: https://app.rainyun.com/dashboard
2026-01-23 16:58:59,910 - INFO - 页面标题: 总览 | 雨云
2026-01-23 16:58:59,940 - INFO - ✅ 账号登录成功:TACGN
2026-01-23 16:58:59,940 - INFO - 🌐 访问赚取积分页
2026-01-23 16:59:00,958 - INFO - 当前页面: https://app.rainyun.com/account/reward/earn
2026-01-23 16:59:00,962 - INFO - 页面标题: 赚取积分 | 雨云
2026-01-23 16:59:00,962 - INFO - 🔍 查找每日签到按钮
2026-01-23 16:59:01,016 - INFO - 📌 签到状态:已完成,无需重复签到
2026-01-23 16:59:01,047 - INFO - 💰 当前积分:700(约0.35元)
2026-01-23 16:59:01,131 - INFO - ✅ 账号TACGN浏览器已关闭
2026-01-23 16:59:01,131 - INFO - ✅ 临时文件清理完成
2026-01-23 16:59:01,131 - INFO - 
========== 账号TACGN处理完成 ==========

2026-01-23 16:59:05,996 - INFO - 
================= 处理第2个账号 ==================
2026-01-23 16:59:05,996 - INFO - 
========== 开始处理账号:ACGN_T ==========
2026-01-23 16:59:05,996 - INFO - ⏳ 随机延时 4 分钟 3 秒
2026-01-23 17:03:09,549 - INFO - ✅ Selenium驱动初始化成功,路径:/usr/bin/chromedriver
2026-01-23 17:03:09,564 - INFO - ✅ 已注入stealth.min.js反检测脚本
2026-01-23 17:03:09,564 - INFO - ⏳ 发起登录请求
2026-01-23 17:03:09,564 - INFO - 🌐 访问雨云登录页
2026-01-23 17:03:11,015 - INFO - 页面标题:登录 | 雨云
2026-01-23 17:03:11,016 - INFO - ⏳ 等待登录表单元素加载...
2026-01-23 17:03:11,155 - INFO - 📝 输入账号密码
2026-01-23 17:03:11,584 - INFO - ⏳ 正在登录中,耗时较长请稍等……
2026-01-23 17:03:34,692 - INFO - ✅ 未触发登录验证码
2026-01-23 17:03:39,699 - INFO - 当前页面: https://app.rainyun.com/dashboard
2026-01-23 17:03:39,703 - INFO - 页面标题: 总览 | 雨云
2026-01-23 17:03:39,733 - INFO - ✅ 账号登录成功:ACGN_T
2026-01-23 17:03:39,733 - INFO - 🌐 访问赚取积分页
2026-01-23 17:03:40,777 - INFO - 当前页面: https://app.rainyun.com/account/reward/earn
2026-01-23 17:03:40,782 - INFO - 页面标题: 赚取积分 | 雨云
2026-01-23 17:03:40,783 - INFO - 🔍 查找每日签到按钮
2026-01-23 17:03:40,858 - INFO - 📌 签到状态:已完成,无需重复签到
2026-01-23 17:03:40,881 - INFO - 💰 当前积分:4684(约2.34元)
2026-01-23 17:03:40,966 - INFO - ✅ 账号ACGN_T浏览器已关闭
2026-01-23 17:03:40,966 - INFO - ✅ 临时文件清理完成
2026-01-23 17:03:40,966 - INFO - 
========== 账号ACGN_T处理完成 ==========

2026-01-23 17:03:43,883 - INFO - 
================= 处理第3个账号 ==================
2026-01-23 17:03:43,883 - INFO - 
========== 开始处理账号:ACGN ==========
2026-01-23 17:03:43,883 - INFO - ⏳ 随机延时 4 分钟 50 秒
2026-01-23 17:08:34,684 - INFO - ✅ Selenium驱动初始化成功,路径:/usr/bin/chromedriver
2026-01-23 17:08:34,698 - INFO - ✅ 已注入stealth.min.js反检测脚本
2026-01-23 17:08:34,699 - INFO - ⏳ 发起登录请求
2026-01-23 17:08:34,699 - INFO - 🌐 访问雨云登录页
2026-01-23 17:08:36,143 - INFO - 页面标题:登录 | 雨云
2026-01-23 17:08:36,143 - INFO - ⏳ 等待登录表单元素加载...
2026-01-23 17:08:36,285 - INFO - 📝 输入账号密码
2026-01-23 17:08:36,657 - INFO - ⏳ 正在登录中,耗时较长请稍等……
2026-01-23 17:08:59,802 - INFO - ✅ 未触发登录验证码
2026-01-23 17:09:04,809 - INFO - 当前页面: https://app.rainyun.com/dashboard
2026-01-23 17:09:04,813 - INFO - 页面标题: 总览 | 雨云
2026-01-23 17:09:04,842 - INFO - ✅ 账号登录成功:ACGN
2026-01-23 17:09:04,842 - INFO - 🌐 访问赚取积分页
2026-01-23 17:09:05,841 - INFO - 当前页面: https://app.rainyun.com/account/reward/earn
2026-01-23 17:09:05,871 - INFO - 页面标题: 赚取积分 | 雨云
2026-01-23 17:09:05,871 - INFO - 🔍 查找每日签到按钮
2026-01-23 17:09:05,940 - INFO - 📌 签到状态:领取奖励,开始领取
2026-01-23 17:09:06,072 - INFO - ⚠️ 触发签到验证码
2026-01-23 17:09:06,253 - INFO - 🔄 验证码处理第1次尝试(最大10次)
2026-01-23 17:09:06,810 - INFO - 开始下载验证码图片(1):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=1&image=02680900003d283800000015123b75d53fed&sess=s0HtD0kpY6pcWGI6FzFpt6ZiszTr2EhH-VfdHwPwxIdqv34Z-7I44K0-_RhKCQ_D1pczn56AhHTy7TzWXqVjayAnecALMlUWYf152tXUBM_URxYIPDxvoDD7jXbk7mwSIeJKDAUtmTTnuuaRcoqdw3DlBpEXv3Xc4RbCewuRGJZUAkZPrzzB8njktvXIOPrqAhs4UafKm96GgAUPJExW9_2PkkRGBKSTS43H1uLzB9el3g70xLMDYSd2TywoxM5Ps2idtfMPBn_aMw93gVXYLGwpX0Iztn4QG1vFv9VJj6NgCvOU2YSfCmTrGyEXxdzPnGglGJAKJFB0FfuxP4bM3-0O4DQt4l-5NsT52KR_8WcG7rvohxQXZy1sRw9MY84c31oFqKfUyPsa49v1VdtmranaOtiaDLX6SjgI6rJPvt2_kSelSHRNUWtA**
2026-01-23 17:09:07,077 - INFO - 开始下载验证码图片(2):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=0&image=02680900003d283800000015123b75d53fed&sess=s0HtD0kpY6pcWGI6FzFpt6ZiszTr2EhH-VfdHwPwxIdqv34Z-7I44K0-_RhKCQ_D1pczn56AhHTy7TzWXqVjayAnecALMlUWYf152tXUBM_URxYIPDxvoDD7jXbk7mwSIeJKDAUtmTTnuuaRcoqdw3DlBpEXv3Xc4RbCewuRGJZUAkZPrzzB8njktvXIOPrqAhs4UafKm96GgAUPJExW9_2PkkRGBKSTS43H1uLzB9el3g70xLMDYSd2TywoxM5Ps2idtfMPBn_aMw93gVXYLGwpX0Iztn4QG1vFv9VJj6NgCvOU2YSfCmTrGyEXxdzPnGglGJAKJFB0FfuxP4bM3-0O4DQt4l-5NsT52KR_8WcG7rvohxQXZy1sRw9MY84c31oFqKfUyPsa49v1VdtmranaOtiaDLX6SjgI6rJPvt2_kSelSHRNUWtA**
2026-01-23 17:09:07,375 - ERROR - ⚠️ 图案2识别率0.0000低于阈值0.4
2026-01-23 17:09:07,376 - ERROR - ❌ 验证码坐标重复,答案无效
2026-01-23 17:09:07,376 - ERROR - ❌ 验证码处理失败:验证码答案无效
2026-01-23 17:09:07,376 - ERROR - ⏳ 刷新验证码中,稍后重试……
2026-01-23 17:09:13,495 - INFO - 🔄 验证码处理第2次尝试(最大10次)
2026-01-23 17:09:13,518 - INFO - 开始下载验证码图片(1):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=1&image=0268090000946c2b0000000bb5e61fd63312&sess=s0_hrS7I5bVMRdivCWNVX_5xijZd5qBztok8b_H7bwMciiNFNIe3KMmj4IPktJO-cbs-8dl7upCI40ZosuxWWRjpXlIbF-P3ZWNoFjjg5G9dMFSybpTUgmgQO1lGEy1QSjGIghi44ITJTpCGcF4ym8wD4iU0xLCVakXfJvTvPiJbxl055LMVFM8W1FM1TtThPXpkg5h9JgYXRHols_wYhIgOI_dRxdgl3r_h-dSKI109RxypesTYee-w0m-Lw_41AM1etin4G_Iamp3lveRUaOtNV1JT4ssYxJ3DR1NZ8SEfN3yxvn9Z-_dxfifqGBxz8hkBmv4vsmx4M9imY60mxrr32HJt0K1ODVgIkzXKA0mgcq1DsSXM0AlcE765_pI_-NP9BgOPXEivjsEDpnxrS-nUFA1DJEz6urpWBwjgZN80OGgAAIs1XL1A**
2026-01-23 17:09:13,731 - INFO - 开始下载验证码图片(2):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=0&image=0268090000946c2b0000000bb5e61fd63312&sess=s0_hrS7I5bVMRdivCWNVX_5xijZd5qBztok8b_H7bwMciiNFNIe3KMmj4IPktJO-cbs-8dl7upCI40ZosuxWWRjpXlIbF-P3ZWNoFjjg5G9dMFSybpTUgmgQO1lGEy1QSjGIghi44ITJTpCGcF4ym8wD4iU0xLCVakXfJvTvPiJbxl055LMVFM8W1FM1TtThPXpkg5h9JgYXRHols_wYhIgOI_dRxdgl3r_h-dSKI109RxypesTYee-w0m-Lw_41AM1etin4G_Iamp3lveRUaOtNV1JT4ssYxJ3DR1NZ8SEfN3yxvn9Z-_dxfifqGBxz8hkBmv4vsmx4M9imY60mxrr32HJt0K1ODVgIkzXKA0mgcq1DsSXM0AlcE765_pI_-NP9BgOPXEivjsEDpnxrS-nUFA1DJEz6urpWBwjgZN80OGgAAIs1XL1A**
2026-01-23 17:09:14,002 - ERROR - ⚠️ 图案2识别率0.1515低于阈值0.4
2026-01-23 17:09:14,003 - ERROR - ❌ 验证码坐标重复,答案无效
2026-01-23 17:09:14,003 - ERROR - ❌ 验证码处理失败:验证码答案无效
2026-01-23 17:09:14,003 - ERROR - ⏳ 刷新验证码中,稍后重试……
2026-01-23 17:09:23,108 - INFO - 🔄 验证码处理第3次尝试(最大10次)
2026-01-23 17:09:23,131 - INFO - 开始下载验证码图片(1):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=1&image=0268090000f13d2300000015123b75d53f2f&sess=s02qPcN6ye2H2TPQfQ9ghy_0L3jB722YFRMCmx-rWnjm4UgxUo3F4WLoUzz5JczVgNJMtQwRWLFRo4OvXls1zjaajvPXch4RMoo6YZOavScFvdGaB-9B-ecxWvfcPx7ZTEb03-5MTmG-P2LipAwhLGAYKO0JOK6Rb6z3KYkAy9pxHIXYP9FaLlwdRvLsEDbqKWJZKCP4IHJ9mav4XH2EoTFfWGYMR-sA53gKcavXkSbzg2J_3ntSL6rszaLREZi9ZCSn1bPIDt16NYUXhHhlPFCJmBzIh41fG-nFTtpB-A8_i_vPaNo3mwlxJ9KojhSP37q7CfeASWq8-DTtI3OnT-mZbyVzoDHvBgQOiiQu5o0_VxQtxzWD9vNmVbErvVsP1VxQEVv0GCFywapI0H-R2DimaJI87vvVkVIzVce2MZQJ_lxWICubZ-RA**
2026-01-23 17:09:23,352 - INFO - 开始下载验证码图片(2):https://turing.captcha.qcloud.com/cap_union_new_getcapbysig?img_index=0&image=0268090000f13d2300000015123b75d53f2f&sess=s02qPcN6ye2H2TPQfQ9ghy_0L3jB722YFRMCmx-rWnjm4UgxUo3F4WLoUzz5JczVgNJMtQwRWLFRo4OvXls1zjaajvPXch4RMoo6YZOavScFvdGaB-9B-ecxWvfcPx7ZTEb03-5MTmG-P2LipAwhLGAYKO0JOK6Rb6z3KYkAy9pxHIXYP9FaLlwdRvLsEDbqKWJZKCP4IHJ9mav4XH2EoTFfWGYMR-sA53gKcavXkSbzg2J_3ntSL6rszaLREZi9ZCSn1bPIDt16NYUXhHhlPFCJmBzIh41fG-nFTtpB-A8_i_vPaNo3mwlxJ9KojhSP37q7CfeASWq8-DTtI3OnT-mZbyVzoDHvBgQOiiQu5o0_VxQtxzWD9vNmVbErvVsP1VxQEVv0GCFywapI0H-R2DimaJI87vvVkVIzVce2MZQJ_lxWICubZ-RA**
2026-01-23 17:09:23,677 - INFO - 🎯 图案 1 坐标(37,277),匹配率:0.6552
2026-01-23 17:09:23,677 - INFO - 🎯 图案 2 坐标(598,114),匹配率:0.5641
2026-01-23 17:09:23,677 - INFO - 🎯 图案 3 坐标(437,197),匹配率:0.6207
2026-01-23 17:09:26,517 - INFO - 📤 提交验证码
2026-01-23 17:09:31,670 - INFO - ✅ 验证码验证通过
2026-01-23 17:09:36,676 - INFO - ✅ 签到奖励领取成功
2026-01-23 17:09:36,699 - INFO - 💰 当前积分:700(约0.35元)
2026-01-23 17:09:36,788 - INFO - ✅ 账号ACGN浏览器已关闭
2026-01-23 17:09:36,788 - INFO - ✅ 临时文件清理完成
2026-01-23 17:09:36,788 - INFO - 
========== 账号ACGN处理完成 ==========

2026-01-23 17:09:40,305 - INFO - 
🎉 所有账号处理完成!

## 执行结束... 2026-01-23 17:09:40  耗时 707 秒   

二、前置条件

青龙面板:我是直接用的 1panel 应用商店里的青龙面板
雨云账号密码:自个注册去

三、准备工作

青龙面板安装依赖:安装不上的,不会的请自行搜索教程或者请教 AI 了哦
—— NodeJs:chromium
—— Python3:selenium
—— Linux:chromium-driver
青龙面板配置环境变量:想设置多少自己设置就好了
—— RAINYUN_ACCOUNT
—— [[“账号 1”,“账号 1 密码”],[“账号 2”,“账号 2 密码”]]

四、创建文件

同目录下创建以下两个文件

stealth.min.js
stealth.min.js.txt

rainyun.py
rainyun.py.txt

删掉 [.txt] 后缀上传就好了,顺便贴出 rainyun.py 的代码如下

import json
import logging
import os
import random
import re
import sys
import time
from typing import Tuple, Optional, List

import cv2
import ddddocr
import requests
from selenium import webdriver
from selenium.common import TimeoutException, WebDriverException, NoSuchElementException
from selenium.webdriver import ActionChains
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

# ===================== 青龙面板专属配置(常量不抽离) =====================
CONFIG = {
    "timeout": 20,  # 青龙面板网络可能不稳定,延长超时时间
    "max_delay": 5,  # 最大随机等待分钟数
    "max_captcha_retry": 10,  # 验证码最大重试次数(防止递归栈溢出)
    "similarity_threshold": 0.4,  # 降低阈值提升识别率
    "script_path": os.path.dirname(os.path.abspath(__file__)),  # 青龙脚本所在目录
    "temp_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp"),  # 临时文件路径
    "rainyun_login_url": "https://app.rainyun.com/auth/login",
    "rainyun_earn_url": "https://app.rainyun.com/account/reward/earn"
}

# 全局日志对象(仅日志全局化,核心变量均函数内初始化)
logger = logging.getLogger(__name__)

def init_logger():
    """初始化青龙面板日志格式(增强版)"""
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s - %(levelname)s - %(message)s',
        handlers=[logging.StreamHandler(sys.stdout)]
    )
    # 打印项目信息
    logger.info("-"*80)
    logger.info("雨云签到工具 by SerendipityR ~")
    logger.info("Github发布页: https://github.com/SerendipityR-2022/Rainyun-Qiandao")
    logger.info("-"*80)
    logger.info("雨云签到工具容器版 by fatekey ~")
    logger.info("Github发布页: https://github.com/fatekey/Rainyun-Qiandao")
    logger.info("-"*80)
    logger.info("                   项目为二次开发青龙脚本化运行")
    logger.info("                     本项目基于上述项目开发")
    logger.info("                本项目仅作为学习参考,请勿用于其他用途")
    logger.info("-"*80)

def init_selenium() -> WebDriver:
    """初始化青龙面板专用Selenium驱动(每次调用新建实例,避免缓存污染)"""
    ops = Options()
    # 容器环境必需配置
    ops.add_argument("--no-sandbox")
    ops.add_argument("--disable-dev-shm-usage")
    ops.add_argument("--headless=new")
    ops.add_argument("--disable-gpu")
    ops.add_argument("--window-size=1920,1080")
    ops.add_argument("--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
    # 反爬配置
    ops.add_experimental_option("excludeSwitches", ["enable-automation"])
    ops.add_experimental_option('useAutomationExtension', False)
    ops.add_argument("--disable-blink-features=AutomationControlled")
    
    # 青龙面板固定驱动路径校验
    driver_path = "/usr/bin/chromedriver"
    if not os.path.exists(driver_path) or not os.access(driver_path, os.X_OK):
        raise FileNotFoundError(
            f"青龙面板未安装chromium-driver!\n"
            f"请在青龙终端执行:apt update && apt install -y chromium-driver"
        )

    try:
        service = Service(executable_path=driver_path)
        driver = webdriver.Chrome(service=service, options=ops)
        # 清空缓存(双重保障)
        driver.delete_all_cookies()
        logger.info(f"✅ Selenium驱动初始化成功,路径:{driver_path}")
        return driver
    except WebDriverException as e:
        logger.error(f"❌ 驱动启动失败:{str(e)}")
        raise

def check_stealth_js() -> str:
    """检查青龙脚本目录下的stealth.min.js"""
    js_path = os.path.join(CONFIG["script_path"], "stealth.min.js")
    if not os.path.exists(js_path):
        logger.error(f"❌ 未找到stealth.min.js!请将文件上传到青龙脚本目录:{CONFIG['script_path']}")
        logger.info("📥 下载地址:https://raw.githubusercontent.com/berstend/puppeteer-extra/master/packages/puppeteer-extra-plugin-stealth/evasions/stealth.min.js")
        sys.exit(1)
    return js_path

def inject_stealth_js(driver: WebDriver):
    """注入反检测脚本(传入driver实例,解耦全局变量)"""
    js_path = check_stealth_js()
    with open(js_path, "r", encoding="utf-8") as f:
        js = f.read()
    driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": js})
    logger.info("✅ 已注入stealth.min.js反检测脚本")

def download_image(url: str, filename: str, img_index: int) -> bool:
    """下载图片(带URL日志+青龙面板加请求头防拦截)"""
    os.makedirs(CONFIG["temp_path"], exist_ok=True)
    try:
        logger.info(f"开始下载验证码图片({img_index}):{url}")
        headers = {
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            "Referer": "https://app.rainyun.com/"
        }
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        path = os.path.join(CONFIG["temp_path"], filename)
        with open(path, "wb") as f:
            f.write(response.content)
        return True
    except Exception as e:
        logger.error(f"❌ 下载图片失败 {url}:{str(e)}")
        return False

# ========== 工具函数(精简+健壮性优化) ==========
def get_url_from_style(style: str) -> Optional[str]:
    """从style属性提取URL"""
    try:
        match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
        return match.group(1) if match else None
    except Exception:
        return None

def get_width_from_style(style: str) -> str:
    """从style属性提取宽度"""
    match = re.search(r'width:\s*([\d.]+)px', style)
    return match.group(1) if match else "300"

def get_height_from_style(style: str) -> str:
    """从style属性提取高度"""
    match = re.search(r'height:\s*([\d.]+)px', style)
    return match.group(1) if match else "150"

def compute_similarity(img1_path: str, img2_path: str) -> Tuple[float, int]:
    """青龙面板适配:SIFT不可用时用ORB(增加异常兜底)"""
    try:
        img1 = cv2.imread(img1_path, cv2.IMREAD_GRAYSCALE)
        img2 = cv2.imread(img2_path, cv2.IMREAD_GRAYSCALE)
        if img1 is None or img2 is None:
            logger.warning(f"❌ 图片读取失败:{img1_path} 或 {img2_path}")
            return 0.0, 0

        # 优先SIFT,降级ORB
        try:
            sift = cv2.SIFT_create()
            norm = cv2.NORM_L2
        except AttributeError:
            sift = cv2.ORB_create()
            norm = cv2.NORM_HAMMING
            logger.warning("⚠️ SIFT不可用,使用ORB匹配")

        kp1, des1 = sift.detectAndCompute(img1, None)
        kp2, des2 = sift.detectAndCompute(img2, None)
        if des1 is None or des2 is None:
            return 0.0, 0

        bf = cv2.BFMatcher(norm, crossCheck=False)
        matches = bf.knnMatch(des1, des2, k=2)
        good = [m for m, n in matches if m.distance < 0.8 * n.distance]
        similarity = len(good) / len(matches) if matches else 0.0
        return similarity, len(good)
    except Exception as e:
        logger.error(f"❌ 相似度计算失败:{str(e)}")
        return 0.0, 0

def download_captcha_img(driver: WebDriver, wait: WebDriverWait) -> bool:
    """下载并分割验证码图片(解耦全局变量)"""
    try:
        # 清空旧临时文件
        if os.path.exists(CONFIG["temp_path"]):
            for f in os.listdir(CONFIG["temp_path"]):
                os.remove(os.path.join(CONFIG["temp_path"], f))

        # 定位验证码背景图
        slideBg = wait.until(EC.visibility_of_element_located((By.XPATH, '//*[@id="slideBg"]')))
        img1_url = get_url_from_style(slideBg.get_attribute("style"))
        if not img1_url or not download_image(img1_url, "captcha.jpg", 1):
            return False

        # 定位验证码碎片图
        sprite = wait.until(EC.visibility_of_element_located((By.XPATH, '//*[@id="instruction"]/div/img')))
        if not download_image(sprite.get_attribute("src"), "sprite.jpg", 2):
            return False

        # 分割碎片图
        raw = cv2.imread(os.path.join(CONFIG["temp_path"], "sprite.jpg"))
        if raw is None:
            logger.error("❌ 验证码碎片图读取失败")
            return False
        w = raw.shape[1]
        for i in range(3):
            cv2.imwrite(
                os.path.join(CONFIG["temp_path"], f"sprite_{i+1}.jpg"),
                raw[:, w//3*i : w//3*(i+1)]
            )
        return True
    except TimeoutException:
        logger.error("❌ 验证码图片加载超时")
        return False
    except Exception as e:
        logger.error(f"❌ 验证码图片处理失败:{str(e)}")
        return False

def check_answer(result: dict) -> bool:
    """检查验证码答案有效性(带识别率日志)"""
    valid = True
    for i in range(3):
        sim = float(result.get(f"sprite_{i+1}.similarity", 0))
        if sim < CONFIG["similarity_threshold"]:
            logger.error(f"⚠️ 图案{i+1}识别率{sim:.4f}低于阈值{CONFIG['similarity_threshold']}")
            valid = False
            break
    # 检查坐标唯一性
    positions = [result.get(f"sprite_{i+1}.position") for i in range(3)]
    if len(set(positions)) != 3:
        logger.error("❌ 验证码坐标重复,答案无效")
        valid = False
    return valid

def process_captcha(driver: WebDriver, wait: WebDriverWait) -> bool:
    """处理验证码(改递归为循环,提升健壮性,解耦全局变量)"""
    captcha_retry_count = 0
    ocr = ddddocr.DdddOcr(ocr=True, show_ad=False)
    det = ddddocr.DdddOcr(det=True, show_ad=False)

    while captcha_retry_count < CONFIG["max_captcha_retry"]:
        captcha_retry_count += 1
        logger.info(f"🔄 验证码处理第{captcha_retry_count}次尝试(最大{CONFIG['max_captcha_retry']}次)")
        try:
            # 下载验证码图片
            if not download_captcha_img(driver, wait):
                raise Exception("验证码图片下载失败")

            # 校验验证码有效性
            valid = True
            for i in range(3):
                sprite_path = os.path.join(CONFIG["temp_path"], f"sprite_{i+1}.jpg")
                with open(sprite_path, "rb") as f:
                    if ocr.classification(f.read()) in ["0", "1"]:
                        valid = False
                        break
            if not valid:
                raise Exception("验证码碎片无效")

            # 识别验证码
            captcha = cv2.imread(os.path.join(CONFIG["temp_path"], "captcha.jpg"))
            if captcha is None:
                raise Exception("验证码背景图读取失败")
            with open(os.path.join(CONFIG["temp_path"], "captcha.jpg"), "rb") as f:
                bboxes = det.detection(f.read())
            if not bboxes:
                raise Exception("未检测到验证码图案")

            # 匹配碎片与背景图
            result = {}
            for i, (x1, y1, x2, y2) in enumerate(bboxes):
                # 裁剪背景图中的图案
                cv2.imwrite(os.path.join(CONFIG["temp_path"], f"spec_{i+1}.jpg"), captcha[y1:y2, x1:x2])
                # 计算与每个碎片的相似度
                for j in range(3):
                    sim, _ = compute_similarity(
                        os.path.join(CONFIG["temp_path"], f"sprite_{j+1}.jpg"),
                        os.path.join(CONFIG["temp_path"], f"spec_{i+1}.jpg")
                    )
                    key_sim = f"sprite_{j+1}.similarity"
                    key_pos = f"sprite_{j+1}.position"
                    if sim > float(result.get(key_sim, 0)):
                        result[key_sim] = sim
                        result[key_pos] = f"{int((x1+x2)/2)},{int((y1+y2)/2)}"

            # 校验答案
            if not check_answer(result):
                raise Exception("验证码答案无效")

            # 打印匹配结果
            for i in range(3):
                pos = result[f"sprite_{i+1}.position"]
                sim = result[f"sprite_{i+1}.similarity"]
                x, y = pos.split(",")
                logger.info(f"🎯 图案 {i+1} 坐标({x},{y}),匹配率:{sim:.4f}")

            # 点击验证码图案
            slideBg = wait.until(EC.visibility_of_element_located((By.XPATH, '//*[@id="slideBg"]')))
            style = slideBg.get_attribute("style")
            width, height = float(get_width_from_style(style)), float(get_height_from_style(style))
            width_raw, height_raw = captcha.shape[1], captcha.shape[0]

            for i in range(3):
                pos = result[f"sprite_{i+1}.position"]
                x, y = map(int, pos.split(","))
                # 计算实际点击坐标(适配页面缩放)
                final_x = int(-width/2 + x/width_raw * width) + random.randint(-1, 1)
                final_y = int(-height/2 + y/height_raw * height) + random.randint(-1, 1)
                ActionChains(driver).move_to_element_with_offset(slideBg, final_x, final_y).click().perform()
                time.sleep(random.uniform(0.5, 1))

            # 提交验证码
            logger.info("📤 提交验证码")
            confirm = wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="tcStatus"]/div[2]/div[2]/div/div')))
            confirm.click()
            time.sleep(5)

            # 校验验证码结果
            tc_operation = wait.until(EC.visibility_of_element_located((By.XPATH, '//*[@id="tcOperation"]')))
            if tc_operation.get_attribute("class") == "tc-opera pointer show-success":
                logger.info("✅ 验证码验证通过")
                return True
            else:
                raise Exception("验证码验证失败")

        except Exception as e:
            logger.error(f"❌ 验证码处理失败:{str(e)}")
            # 刷新验证码重试
            try:
                logger.error("⏳ 刷新验证码中,稍后重试……")
                reload = driver.find_element(By.XPATH, '//*[@id="reload"]')
                time.sleep(3)
                reload.click()
                time.sleep(min(3 * (2 ** (captcha_retry_count - 1)), 30))  # 指数退避重试间隔,上限30秒
            except NoSuchElementException:
                logger.error("❌ 验证码刷新按钮未找到,重试失败")
                return False

    logger.error(f"❌ 验证码重试{CONFIG['max_captcha_retry']}次仍失败,放弃")
    return False

def clean_temp():
    """清理青龙面板临时文件(增加容错)"""
    try:
        if os.path.exists(CONFIG["temp_path"]):
            for f in os.listdir(CONFIG["temp_path"]):
                file_path = os.path.join(CONFIG["temp_path"], f)
                try:
                    os.remove(file_path)
                except Exception as e:
                    logger.warning(f"⚠️ 删除临时文件{file_path}失败:{str(e)}")
            os.rmdir(CONFIG["temp_path"])
        logger.info("✅ 临时文件清理完成")
    except Exception as e:
        logger.warning(f"⚠️ 清理临时文件失败:{str(e)}")

def parse_accounts() -> List[List[str]]:
    """解析青龙面板RAINYUN_ACCOUNT环境变量"""
    account_str = os.getenv("RAINYUN_ACCOUNT")
    if not account_str:
        logger.error("❌ 未配置RAINYUN_ACCOUNT环境变量!格式应为[[账号1,密码1],[账号2,密码2]]")
        sys.exit(1)
    
    try:
        # 解析JSON格式的账号列表(兼容单引号/双引号)
        account_str = account_str.replace("'", "\"")  # 统一为双引号
        accounts = json.loads(account_str)
        # 校验格式
        if not isinstance(accounts, list):
            raise ValueError("环境变量值不是列表类型")
        for idx, account in enumerate(accounts):
            if not isinstance(account, list) or len(account) != 2:
                raise ValueError(f"第{idx+1}个账号格式错误,应为[账号,密码]")
            if not account[0] or not account[1]:
                raise ValueError(f"第{idx+1}个账号/密码为空")
        logger.info(f"✅ 成功解析{len(accounts)}个账号")
        return accounts
    except json.JSONDecodeError as e:
        logger.error(f"❌ RAINYUN_ACCOUNT格式解析失败:{str(e)},请检查格式是否为合法JSON")
        sys.exit(1)
    except ValueError as e:
        logger.error(f"❌ RAINYUN_ACCOUNT格式错误:{str(e)}")
        sys.exit(1)

def sign_in_rainyun(username: str, password: str):
    """单账号签到核心逻辑(独立封装,支持多账号循环调用)"""
    driver = None
    try:
        logger.info(f"\n========== 开始处理账号:{username} ==========")
        # 随机延时(可选)
        delay = random.randint(0, CONFIG["max_delay"])
        delay_sec = random.randint(0, 60)
        logger.info(f"⏳ 随机延时 {delay} 分钟 {delay_sec} 秒")
        time.sleep(delay * 60 + delay_sec)

        # 初始化Selenium(每次新建实例,清空缓存)
        driver = init_selenium()
        inject_stealth_js(driver)
        wait = WebDriverWait(driver, CONFIG["timeout"])

        # 访问登录页
        logger.info("⏳ 发起登录请求")
        logger.info("🌐 访问雨云登录页")
        driver.get(CONFIG["rainyun_login_url"])
        logger.info(f"页面标题:{driver.title}")

        # 输入账号密码
        logger.info("⏳ 等待登录表单元素加载...")
        username_elem = wait.until(EC.visibility_of_element_located((By.NAME, "login-field")))
        password_elem = wait.until(EC.visibility_of_element_located((By.NAME, "login-password")))
        login_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="app"]/div[1]/div[1]/div/div[2]/fade/div/div/span/form/button')))
        logger.info("📝 输入账号密码")
        username_elem.send_keys(username)
        password_elem.send_keys(password)
        login_btn.click()
        logger.info("⏳ 正在登录中,耗时较长请稍等……")
        time.sleep(3)

        # 处理登录验证码
        try:
            wait.until(EC.visibility_of_element_located((By.ID, "tcaptcha_iframe_dy")))
            logger.warning("⚠️ 触发登录验证码")
            driver.switch_to.frame("tcaptcha_iframe_dy")
            if not process_captcha(driver, wait):
                raise Exception("登录验证码验证失败")
        except TimeoutException:
            logger.info("✅ 未触发登录验证码")

        # 校验登录状态
        time.sleep(5)
        driver.switch_to.default_content()
        logger.info(f"当前页面: {driver.current_url}")
        logger.info(f"页面标题: {driver.title}")
        if driver.current_url != "https://app.rainyun.com/dashboard":
            raise Exception("登录失败!请检查账号密码或网络")
        user_name = driver.find_element(By.XPATH, '//*[@id="app"]/div[1]/nav/div[1]/ul/div[6]/li/a/div/div/p').text.strip()
        logger.info(f"✅ 账号登录成功:{user_name}")
        
        # 访问签到页
        logger.info("🌐 访问赚取积分页")
        driver.get(CONFIG["rainyun_earn_url"])
        driver.implicitly_wait(5)
        logger.info(f"当前页面: {driver.current_url}")
        logger.info(f"页面标题: {driver.title}")

        # 查找并点击签到按钮
        logger.info("🔍 查找每日签到按钮")
        earn_btn_qddiv = driver.find_element(By.XPATH, '//*[@id="app"]/div[1]/div[3]/div[2]/div/div/div[2]/div[2]/div/div/div/div[1]/div')
        earn_btn_qd = earn_btn_qddiv.find_element(By.XPATH, './/span[contains(text(),"每日签到")]')
        status_elem = earn_btn_qd.find_element(By.XPATH, './following-sibling::span[1]')
        status_text = status_elem.text.strip()

        if status_text == "领取奖励":
            earn_btn = status_elem.find_element(By.XPATH, './a')
            logger.info(f"📌 签到状态:{status_text},开始领取")
            earn_btn.click()

            # 处理签到验证码
            logger.info("⚠️ 触发签到验证码")
            driver.switch_to.frame("tcaptcha_iframe_dy")
            if not process_captcha(driver, wait):
                raise Exception("签到验证码验证失败")
            driver.switch_to.default_content()

            # 校验签到结果
            time.sleep(5)
            logger.info("✅ 签到奖励领取成功")
        else:
            logger.info(f"📌 签到状态:{status_text},无需重复签到")

        # 获取当前积分
        try:
            points_elem = driver.find_element(By.XPATH, '//*[@id="app"]/div[1]/div[3]/div[2]/div/div/div[2]/div[1]/div[1]/div/p/div/h3')
            current_points = int(''.join(re.findall(r'\d+', points_elem.text)))
            logger.info(f"💰 当前积分:{current_points}(约{current_points/2000:.2f}元)")
        except Exception as e:
            logger.warning(f"⚠️ 积分获取失败:{str(e)}")

        

    except Exception as e:
        logger.error(f"❌ 账号{username}处理失败:{str(e)}", exc_info=True)
    finally:
        # 关闭浏览器,彻底清空缓存
        if driver:
            try:
                driver.quit()
                logger.info(f"✅ 账号{username}浏览器已关闭")
            except Exception as e:
                logger.warning(f"⚠️ 关闭浏览器失败:{str(e)}")
        # 清理临时文件
        clean_temp()
    logger.info(f"\n========== 账号{username}处理完成 ==========\n")

def main():
    """主函数:解析多账号,依次执行签到"""
    init_logger()
    # 解析账号列表
    accounts = parse_accounts()
    # 依次处理每个账号
    for idx, (username, password) in enumerate(accounts, 1):
        logger.info(f"\n================= 处理第{idx}个账号 ==================")
        sign_in_rainyun(username, password)
        # 账号间间隔(可选)
        time.sleep(random.uniform(2, 5))
    
    logger.info("\n🎉 所有账号处理完成!")

if __name__ == "__main__":
    main()

五、最后

后面的设置定时任务之类的就懒得写啦,顺便我也懒得写通知之类的了,感谢前人的开发让我站于巨人肩膀上,要是有后来者优化了就更好了

哦,对了,懒得上传 github 了,就丢 linux.do 里好了


📌 转载信息
原作者:
T_ACGN
转载时间:
2026/1/24 07:01:47

RustFS 支持容器化部署模式,可以用 docker run 命令或 docker compose 来快速安装一个 RustFS 实例。由于 podman 也是一个可以对容器进行管理的工具,大多数情况下是可以兼容 docker 命令的。因此,也可以用 podman 对 RustFS 进行容器化安装。本文分享两种安装方式。

安装前提

  • podman 环境,本文所需的 podman 环境信息如下
# podman 版本
podman --version

# podman-compose 版本
podman-compose --version
podman-compose version: 1.0.6
['podman', '--version', '']
using podman version: 4.9.3
podman-compose version 1.0.6
podman --version 
podman version 4.9.3
exit code: 0

安装方式

可以使用 podman runpodman compose 进行安装。

podman run 安装

使用如下命令即可:

podman run -d -p 9000:9000 -p 9001:9001  \
    -v $(pwd)/data:/data -v $(pwd)/logs:/logs \
    docker.io/rustfs/rustfs:latest
注意,需要把 datalogs 目录的权限改成 10001,因为 RustFS 是非 root 用户运行,不修改权限,会导致权限问题。

查看容器状态:

podman ps
CONTAINER ID  IMAGE                           COMMAND     CREATED       STATUS       PORTS                             NAMES
593c5bffbce9  docker.io/rustfs/rustfs:latest  rustfs      21 hours ago  Up 21 hours  0.0.0.0:9000-9001->9000-9001/tcp  exciting_herschel

podman compose 安装

将如下内容写入 podman-compose.yml 文件:

services:
  rustfs:
    image: docker.io/dllhb/disk-cap:0.0.1
    container_name: rustfs
    hostname: rustfs
    environment:
      - RUSTFS_VOLUMES=/data/rustfs{1...4}
      - RUSTFS_ADDRESS=0.0.0.0:9000
      - RUSTFS_CONSOLE_ENABLE=true
      - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
      - RUSTFS_ACCESS_KEY=rustfsadmin
      - RUSTFS_SECRET_KEY=rustfsadmin
      - RUST_LOG=warn
    ports:
      - "9000:9000"  # API endpoint
      - "9001:9001"  # Console
    volumes:
      - ./data1:/data/rustfs1
      - ./data2:/data/rustfs2
      - ./data3:/data/rustfs3
      - ./data4:/data/rustfs4

    networks:
      - rustfs

networks:
  rustfs:
    driver: bridge
    name: rustfs

接着执行:

podman compose up -d

查看容器状态:

podman compose ps
CONTAINER ID  IMAGE                           COMMAND          CREATED             STATUS             PORTS                             NAMES
f6496b7856f3  docker.io/dllhb/disk-cap:0.0.1  /usr/bin/rustfs  About a minute ago  Up About a minute  0.0.0.0:9000-9001->9000-9001/tcp  rustfs
注意,需要把 data* 目录的权限改成 10001,因为 RustFS 是非 root 用户运行,不修改权限,会导致权限问题。

使用 RustFS

不管用哪种方式,当 RustFS 运行正常后,就可以通过 http://IP:9001 的方式登录 RustFS,默认用户名和密码都是 rustfsadmin/rustfsadmin

缘起

只用服务器搭建 memos 未免太大材小用了,而且也浪费钱。所以就想尽量用无服务器部署 memos。

render

render 由于免费存储空间过低,不是优选。

  • 使用 render 创建 Web Service
  • Image:填写为 neosmemo/memos:stable
  • Environment Variables 分别填入:
    • Key、port
    • Value、5230

保活方式: https://github.com/hoochanlon/keep-alive

zeabur

memos 官方镜像按照如图所示填写相关参数

🖼️ 图片加载失败

使用 hu3rror/memos-litestream (该项目解决了备份换机迁移数据的痛点)项目镜像的填写方式

S3 配置如图及相关解答(建议看完该 issue 链接内容): https://github.com/hu3rror/memos-litestream/issues/67

  • b2

  • memos

CF 代理 B2 配置见(适用于图床、文件管理免流服务): https://github.com/hoochanlon/CF-Proxy-B2

我把 swarm 端口改成 4002 是因为 Planet 抢占了 4001 端口。


这脚本大概这么干活:

  1. 先瞅一眼:看看 Docker 装了没,别忙活半天白干。再检查一下有没有叫 ipfs_host 的老容器赖着不走,有的话就报错开溜,坚决不给自己留烂摊子。
  2. 搭俩小窝:在当前目录下建 ipfs_stagingipfs_data 两个文件夹,给 IPFS 的数据找个地方住。
  3. 门户大开(但换了号):因为 4001 被占了,咱就让 swarm 走 4002。API 端口是 8080,网关端口是 5001,按你的习惯随便改。
  4. 拉起来跑:用最新的 ipfs/kubo 镜像把容器跑起来,把刚才设的端口和文件夹都挂载好。
  5. 直接开门迎客:跑起来没问题的话,自动帮你打开浏览器,跳到 http://localhost:5001/webui 这个管理页面。接下来传点猫图试试手呗。


怎么用?

简单到不行:

复制
# 1. 给脚本加个执行权限
chmod +x deployment.sh

# 2. 运行它
./deployment.sh

脚本跑完没报错,你的浏览器就会蹦出 IPFS 的 Web 界面了。


脚本在这儿 (deployment.sh)

复制
#!/bin/bash

# 检查 Docker 是否安装
if ! [ -x "$(command -v docker)" ]; then
  echo 'Error: Docker is not installed.' >&2
  exit 1
fi
echo 'Docker is installed.'

# 检查是否有正在运行的 IPFS 容器
if [ "$(docker ps -q -f name=ipfs_host)" ]; then
  echo 'Error: An IPFS container is already running.' >&2
  exit 1
fi
echo 'No running IPFS container found.'

# 检查是否有同名的停止状态的 IPFS 容器
if [ "$(docker ps -aq -f status=exited -f name=ipfs_host)" ]; then
  echo 'Error: A stopped IPFS container with the same name already exists.' >&2
  exit 1
fi
echo 'No stopped IPFS container with the same name found.'

echo 'Proceeding with deployment...'
# 创建存储目录
current_dir=$(pwd)
cd $current_dir
mkdir -p ./ipfs_staging
mkdir -p ./ipfs_data

echo 'Storage directories created.'

# 设置环境变量
export ipfs_staging=./ipfs_staging
export ipfs_data=./ipfs_data
export ipfs_swarm_port=4002
export ipfs_api_port=8080
export ipfs_gateway_port=5001

echo "IPFS staging directory: ${ipfs_staging}"
echo "IPFS data directory: ${ipfs_data}"
echo "IPFS swarm port: ${ipfs_swarm_port}"
echo "IPFS API port: ${ipfs_api_port}"
echo "IPFS gateway port: ${ipfs_gateway_port}"

# 运行 IPFS 容器
docker run -d --name ipfs_host -v ${ipfs_staging}:/export -v ${ipfs_data}:/data/ipfs -p ${ipfs_swarm_port}:4001 -p ${ipfs_api_port}:8080 -p ${ipfs_gateway_port}:5001 ipfs/kubo:latest
if [ $? -ne 0 ]; then
  docker rm -f ipfs_host 2>/dev/null
  rm -rf ./ipfs_staging
  rm -rf ./ipfs_data
  echo 'Error: Failed to start the IPFS container.' >&2
  exit 1
fi
echo 'IPFS container started successfully.'

open "http://localhost:${ipfs_gateway_port}/webui"


说实话, AI 润色的确实不咋地 😂

背景:
昨天开始就频繁的无法连接位于海外 vps 的反代,然后 ping 一下发现丢包率好高,今天突发奇想我的 vps 是带 ipv6 的,因为我部署酒馆的服务器是家庭宽带有公网的 ipv6,我就 ping6 试了一下发现延迟低不少而且丢包率为 0,以此开始以下环节。

适用人群:
使用国内 nas 或者小主机等在自己家部署的酒馆,也就是家庭网络,现在大部分家庭网络都有公网的 ipv6

教程开始:

  1. 检查服务器主机当前 DNS 解析顺序
getent ahosts 你的域名

看输出结果是 ipv6 在前还是在后

1-1.ipv6 在下时,设置 ipv6 优先
查询配置文件

cat /etc/gai.conf

如果出现以下默认配置则代表是 ipv6 优先

#precedence  ::1/128       50 #precedence  ::/0          40 #precedence  2002::/16     30 #precedence ::/96          20 #precedence ::ffff:0:0/96  10 

如果是出现以下,需要给前面加上 #号

precedence ::ffff:0:0/96 100 
  1. 检查 Docker 是否启用 IPv6
docker network inspect bridge | grep -i ipv6

出现以下则代表开启
“EnableIPv6”: true,

2-1. 开启 docker 的 ipv6
1Panel 面板
docker 界面设置,开启 ipv6,子网 fd00::/80
并重启 docker 和容器

  1. 检查容器是否有 IPv6 地址
docker exec 容器名(sillytavern) ip -6 addr

如果出现
enp1s0 和 ipv6 地址则代表获取成功

3-1. 测试容器 ipv6 连通性

docker exec 容器名 ping6 -c 2 你的ipv6地址
  1. 设置容器 IPv6 优先级
    酒馆是 Alpine 容器,在环境变量里添加
NODE_OPTIONS=--dns-result-order=ipv6first

因为 docker 的网段设置是默认不带 ipv6 的所有在 1panel 里添加环境变量的时候,把网络段设置为 host
在 ssh 里输入如下

docker exec 容器名 node -e "
2const dns = require('dns');
3dns.lookup('你的域名', { all: true }, (err, addrs) => {
4 console.log(addrs);
5});
6"
7

ipv6 在前则设置成功
在验证一下

docker exec 容器名 node -e "
const dns = require('dns');
dns.lookup('你的域名', (err, addr, family) => {
console.log('实际使用 IP:', addr);
console.log('IP 版本:', family === 6 ? 'IPv6' : 'IPv4');
});
"

结果显示的是实际使用 ip:你的公网 ipv6 则成功

  1. 修改容器的 config.yaml
protocol: ipv4: true ipv6: true dnsPreferIPv6: true 

📌 转载信息
原作者:
ak7876
转载时间:
2026/1/12 15:39:49

分享一个自用的 Certimate docker compose file 配置,一个支持 SSL 证书从申请到部署完全自动化的开源软件。

文档 | Certimate

https://docs.certimate.me/zh-CN/docs/introduction/

services: certimate:  certimate/certimate:latest container_name: certimate restart: always ports: - "127.0.0.1:8090:8090" volumes: - /etc/localtime:/etc/localtime:ro - ./data:/app/pb_data environment: - TZ=Asia/Shanghai deploy: resources: limits: memory: 512M reservations: memory: 128M security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp:size=64M,mode=1777 healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
      interval: 30s timeout: 10s retries: 3 start_period: 10s logging: driver: json-file options: max-size: "10m" max-file: "3" compress: "true" networks: default: driver: bridge 

📌 转载信息
原作者:
puppetdevz
转载时间:
2026/1/12 15:02:10

分享一个自用的 Certimate docker compose file 配置,一个支持 SSL 证书从申请到部署完全自动化的开源软件。

文档 | Certimate

https://docs.certimate.me/zh-CN/docs/introduction/

services: certimate:  certimate/certimate:latest container_name: certimate restart: always ports: - "127.0.0.1:8090:8090" volumes: - /etc/localtime:/etc/localtime:ro - ./data:/app/pb_data environment: - TZ=Asia/Shanghai deploy: resources: limits: memory: 512M reservations: memory: 128M security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp:size=64M,mode=1777 healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
      interval: 30s timeout: 10s retries: 3 start_period: 10s logging: driver: json-file options: max-size: "10m" max-file: "3" compress: "true" networks: default: driver: bridge 

📌 转载信息
原作者:
puppetdevz
转载时间:
2026/1/12 11:38:43

账号密码需要自己改下,搜索xxx改为你想设置的,首次加载网页的时候会有点慢,我没写成两个容器

注意:部署完别大量分享

# =============================================================================
# Z-Image-Turbo Gradio Web UI (文生图) - Gradio + ComfyUI 后端
# =============================================================================
# 部署命令: modal deploy z_image_turbo_gradio_deploy.py
# =============================================================================

import modal
import json
import os
import subprocess
from pathlib import Path

# =============================================================================
# 镜像配置 - 强制重建: 2025-12-02-v15 (参考wan2简洁风格)
# =============================================================================
comfy_image = (
    modal.Image.debian_slim(python_version="3.11")
    .apt_install("git", "wget", "curl")
    .pip_install(
        "fastapi[standard]==0.115.4",
        "comfy-cli==1.5.3",
        "requests==2.32.3",
        "huggingface_hub[hf_transfer]==0.34.4",
        "pillow",
        "websocket-client",
    )
    .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
    .run_commands(
        "comfy --skip-prompt install --fast-deps --nvidia",
        # 更新到最新 master 代码
        "cd /root/comfy/ComfyUI && git fetch origin && git reset --hard origin/master",
        # 添加 z_image 到 DualCLIPLoader 类型列表 (nodes.py)
        "sed -i 's/\"hunyuan_video_15\"\\]/\"hunyuan_video_15\", \"z_image\"]/g' /root/comfy/ComfyUI/nodes.py",
        # 添加 Z_IMAGE 到 CLIPType 枚举 (sd.py)
        "sed -i 's/CHROMA = 15/CHROMA = 15\\n    Z_IMAGE = 16/g' /root/comfy/ComfyUI/comfy/sd.py",
        # 添加 z_image 处理逻辑到 load_dual_clip
        "sed -i 's/elif clip_type == CLIPType.HUNYUAN_IMAGE:/elif clip_type == CLIPType.Z_IMAGE:\\n            clip_target.clip = comfy.text_encoders.z_image.te(**llama_detect(clip_data))\\n            clip_target.tokenizer = comfy.text_encoders.z_image.ZImageTokenizer\\n        elif clip_type == CLIPType.HUNYUAN_IMAGE:/g' /root/comfy/ComfyUI/comfy/sd.py",
    )
    .pip_install("gradio==3.41.0")
)

app = modal.App(name="z-image-turbo-gradio", image=comfy_image)
vol = modal.Volume.from_name("z-image-turbo-gradio-cache", create_if_missing=True)


# =============================================================================
# 模型下载函数
# =============================================================================
def download_models():
    """下载 Z-Image-Turbo 模型"""
    from huggingface_hub import hf_hub_download

    hf_token = os.getenv("HF_TOKEN")
    repo_id = "Comfy-Org/z_image_turbo"

    print(f"? 从 {repo_id} 下载模型...")

    models = [
        {
            "filename": "split_files/diffusion_models/z_image_turbo_bf16.safetensors",
            "target_dir": "/root/comfy/ComfyUI/models/diffusion_models",
            "target_name": "z_image_turbo_bf16.safetensors",
            "desc": "主扩散模型"
        },
        {
            "filename": "split_files/text_encoders/qwen_3_4b.safetensors",
            "target_dir": "/root/comfy/ComfyUI/models/text_encoders",
            "target_name": "qwen_3_4b.safetensors",
            "desc": "Qwen3 文本编码器"
        },
        {
            "filename": "split_files/vae/ae.safetensors",
            "target_dir": "/root/comfy/ComfyUI/models/vae",
            "target_name": "ae.safetensors",
            "desc": "VAE 解码器"
        }
    ]

    for model in models:
        target_path = f"{model['target_dir']}/{model['target_name']}"

        if os.path.exists(target_path) or os.path.islink(target_path):
            print(f"   ✅ {model['desc']} 已存在,跳过")
            continue

        print(f"? 下载 {model['desc']}: {model['target_name']}...")

        cached_path = hf_hub_download(
            repo_id=repo_id,
            filename=model["filename"],
            cache_dir="/cache",
            token=hf_token
        )

        Path(model["target_dir"]).mkdir(parents=True, exist_ok=True)
        subprocess.run(f"ln -sf {cached_path} {target_path}", shell=True, check=True)
        print(f"   ✅ {model['desc']} 完成")

    print("? 所有模型准备就绪!")


# =============================================================================
# Gradio 应用
# =============================================================================
@app.function(
    max_containers=1,
    gpu="L40S",
    volumes={"/cache": vol},
    timeout=86400,
    scaledown_window=600,
)
@modal.web_server(7860, startup_timeout=600)
def serve():
    """Z-Image-Turbo Gradio Web UI"""

    # 下载模型
    download_models()

    # 启动 ComfyUI 后端 (端口 8188)
    print("? 启动 ComfyUI 后端...")
    subprocess.Popen(
        "comfy launch -- --listen 127.0.0.1 --port 8188",
        shell=True
    )

    # 等待 ComfyUI 启动
    import time
    time.sleep(30)

    # 写入 Gradio 脚本
    gradio_script = '''
import gradio as gr
import requests
import json
import uuid
import time
import os
import io
import threading
from PIL import Image
import websocket

COMFYUI_URL = "http://127.0.0.1:8188"

# 队列管理 - 使用文件持久化统计
STATS_FILE = "/cache/stats.json"
queue_lock = threading.Lock()
queue_count = 0

# 内存缓存,避免频繁读取文件
_stats_cache = {'total': 0, 'date': ''}

def get_today():
    """获取今天日期 (UTC+8)"""
    import datetime
    # 使用 UTC+8 时区
    return (datetime.datetime.utcnow() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d')

def load_stats():
    """从文件加载统计"""
    global _stats_cache
    try:
        if os.path.exists(STATS_FILE):
            with open(STATS_FILE, 'r') as f:
                data = json.load(f)
                _stats_cache['total'] = data.get('total_generated', 0)
                _stats_cache['date'] = data.get('date', '')
                print(f"[STATS] 加载统计: {_stats_cache}", flush=True)
                return _stats_cache['total'], _stats_cache['date']
    except Exception as e:
        print(f"[STATS] 加载失败: {e}", flush=True)
    return 0, ''

def save_stats(total):
    """保存统计到文件"""
    global _stats_cache
    try:
        today = get_today()
        _stats_cache = {'total': total, 'date': today}
        with open(STATS_FILE, 'w') as f:
            json.dump({'total_generated': total, 'date': today}, f)
            f.flush()
            os.fsync(f.fileno())  # 强制刷新到磁盘
        print(f"[STATS] 保存统计: total={total}, date={today}", flush=True)
    except Exception as e:
        print(f"[STATS] 保存失败: {e}", flush=True)

def get_total_generated():
    """获取今日生成总数"""
    global _stats_cache
    today = get_today()
    # 优先使用内存缓存
    if _stats_cache['date'] == today and _stats_cache['total'] > 0:
        return _stats_cache['total']
    # 否则从文件加载
    total, date = load_stats()
    if date != today:
        return 0  # 新的一天重置
    return total

def increment_total():
    """增加生成计数"""
    global _stats_cache
    today = get_today()
    # 使用内存缓存
    if _stats_cache['date'] == today:
        total = _stats_cache['total']
    else:
        total, date = load_stats()
        if date != today:
            total = 0
    total += 1
    save_stats(total)
    print(f"[STATS] 生成计数+1, 今日总计: {total}", flush=True)
    return total

def get_queue_status():
    """获取当前队列状态"""
    with queue_lock:
        if queue_count == 0:
            return "✅ 当前无排队,可立即生成"
        else:
            return f"⏳ 当前排队: {queue_count} 个任务等待中"

def get_stats():
    """获取统计信息"""
    with queue_lock:
        total = get_total_generated()
        return f"? 今日已生成: {total} 张 | 当前队列: {queue_count} 个"

# 启动时初始化加载统计
print("[STATS] 初始化加载统计...", flush=True)
load_stats()
print(f"[STATS] 初始化完成, 缓存: {_stats_cache}", flush=True)

# 分辨率选项
RESOLUTIONS = {
    "1:1 (1024x1024)": (1024, 1024),
    "16:9 (1024x576)": (1024, 576),
    "9:16 (576x1024)": (576, 1024),
    "4:3 (1024x768)": (1024, 768),
}

def create_workflow(prompt, width, height, steps, seed):
    """创建 ComfyUI 工作流"""
    return {
        "1": {
            "class_type": "UNETLoader",
            "inputs": {
                "unet_name": "z_image_turbo_bf16.safetensors",
                "weight_dtype": "default"
            }
        },
        "2": {
            "class_type": "DualCLIPLoader",
            "inputs": {
                "clip_name1": "qwen_3_4b.safetensors",
                "clip_name2": "qwen_3_4b.safetensors",
                "type": "z_image"
            }
        },
        "3": {
            "class_type": "VAELoader",
            "inputs": {
                "vae_name": "ae.safetensors"
            }
        },
        "4": {
            "class_type": "CLIPTextEncode",
            "inputs": {
                "text": prompt,
                "clip": ["2", 0]
            }
        },
        "6": {
            "class_type": "EmptyLatentImage",
            "inputs": {
                "width": width,
                "height": height,
                "batch_size": 1
            }
        },
        "7": {
            "class_type": "KSampler",
            "inputs": {
                "model": ["1", 0],
                "positive": ["4", 0],
                "negative": ["4", 0],
                "latent_image": ["6", 0],
                "seed": seed if seed != -1 else int(time.time() * 1000) % (2**32),
                "steps": steps,
                "cfg": 1.0,
                "sampler_name": "euler",
                "scheduler": "simple",
                "denoise": 1.0
            }
        },
        "8": {
            "class_type": "VAEDecode",
            "inputs": {
                "samples": ["7", 0],
                "vae": ["3", 0]
            }
        },
        "9": {
            "class_type": "SaveImage",
            "inputs": {
                "filename_prefix": "z_image_turbo",
                "images": ["8", 0]
            }
        }
    }

def generate_image(prompt, resolution, steps, seed):
    """生成图像"""
    global queue_count, total_generated

    if not prompt.strip():
        raise gr.Error("请输入提示词")

    # 加入队列
    with queue_lock:
        queue_count += 1
        my_position = queue_count

    print(f"[{time.strftime('%H:%M:%S')}] 任务加入队列,当前位置: {my_position}", flush=True)

    start_time = time.time()
    width, height = RESOLUTIONS[resolution]

    print(f"[{time.strftime('%H:%M:%S')}] 开始生成: {width}x{height}, {steps}步", flush=True)

    try:
        # 创建工作流
        workflow = create_workflow(prompt, width, height, int(steps), int(seed))

        # 生成客户端 ID
        client_id = str(uuid.uuid4())

        # 提交任务
        response = requests.post(
            f"{COMFYUI_URL}/prompt",
            json={"prompt": workflow, "client_id": client_id}
        )

        if response.status_code != 200:
            raise gr.Error(f"提交任务失败: {response.text}")

        prompt_id = response.json()["prompt_id"]
        print(f"[{time.strftime('%H:%M:%S')}] 任务已提交: {prompt_id}", flush=True)

        # 等待完成
        while True:
            time.sleep(0.5)
            history_response = requests.get(f"{COMFYUI_URL}/history/{prompt_id}")

            if history_response.status_code == 200:
                history = history_response.json()
                if prompt_id in history:
                    outputs = history[prompt_id].get("outputs", {})
                    if "9" in outputs and "images" in outputs["9"]:
                        image_info = outputs["9"]["images"][0]
                        filename = image_info["filename"]
                        subfolder = image_info.get("subfolder", "")

                        # 获取图像
                        params = {"filename": filename, "subfolder": subfolder, "type": "output"}
                        img_response = requests.get(f"{COMFYUI_URL}/view", params=params)

                        if img_response.status_code == 200:
                            # 保存图像
                            image_dir = "/tmp/gradio_images"
                            os.makedirs(image_dir, exist_ok=True)
                            image_path = f"{image_dir}/{uuid.uuid4()}.png"

                            image = Image.open(io.BytesIO(img_response.content))
                            image.save(image_path)

                            elapsed = time.time() - start_time
                            print(f"[{time.strftime('%H:%M:%S')}] 生成完成! 耗时: {elapsed:.1f}秒", flush=True)

                            # 完成,减少队列并更新统计
                            with queue_lock:
                                queue_count -= 1
                            increment_total()

                            return image_path

            # 超时检查 (5分钟)
            if time.time() - start_time > 300:
                with queue_lock:
                    queue_count -= 1
                raise gr.Error("生成超时,请重试")

    except gr.Error:
        raise
    except Exception as e:
        # 出错,减少队列
        with queue_lock:
            queue_count -= 1
        elapsed = time.time() - start_time
        print(f"[{time.strftime('%H:%M:%S')}] 错误: {e}")
        raise gr.Error(f"生成失败 ({elapsed:.0f}秒): {str(e)[:200]}")

# 示例提示词
example_prompts = [
    ["一只可爱的橘猫在阳光下打盹"],
    ["赛博朋克风格的未来城市夜景"],
    ["中国水墨画风格的山水"],
    ["宇航员在月球上骑自行车"],
]

# CSS - 参考 wan2 简洁风格
custom_css = """
html, body {
    background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%) !important;
    min-height: 100vh !important;
}
.gradio-container {
    background: transparent !important;
}
h1, h2, h3 {
    background: linear-gradient(90deg, #10b981, #3b82f6);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
}
/* 圆角样式 */
.gr-button, .gr-input, .gr-textbox textarea, .gr-box {
    border-radius: 8px !important;
}
.gr-image {
    border-radius: 12px !important;
    background: #fff !important;
}
"""

with gr.Blocks(css=custom_css, title="Z-Image-Turbo") as demo:
    gr.Markdown("# ? Z-Image-Turbo AI 图像生成")
    gr.Markdown("**文生图 (T2I) | Turbo 快速生成 | Powered by Z-Image**")

    # 队列状态显示
    with gr.Row():
        queue_status = gr.Markdown(value=get_queue_status())
        refresh_btn = gr.Button("? 刷新状态", scale=0)

    with gr.Row():
        stats_display = gr.Markdown(value=get_stats())

    with gr.Row():
        with gr.Column():
            prompt = gr.Textbox(
                label="提示词",
                lines=5,
                value="",
                placeholder="请输入您想要生成的图像描述..."
            )

            gr.Markdown("### ? 提示词示例 (点击使用)")
            gr.Examples(
                examples=example_prompts,
                inputs=prompt,
                label=""
            )

            with gr.Accordion("⚙️ 高级设置", open=False):
                resolution = gr.Dropdown(
                    choices=list(RESOLUTIONS.keys()),
                    value="1:1 (1024x1024)",
                    label="分辨率"
                )
                steps = gr.Slider(
                    minimum=4,
                    maximum=20,
                    value=4,
                    step=1,
                    label="采样步数"
                )
                seed = gr.Number(value=-1, label="种子 (-1 为随机)")

            gr.Markdown("? **推荐设置**: 4-8步即可获得高质量图像")
            btn = gr.Button("✨ 生成图像", variant="primary")

        with gr.Column():
            output = gr.Image(label="生成结果", type="filepath")

    # 刷新状态按钮 - 不走队列,立即执行
    def refresh_status():
        return get_queue_status(), get_stats()

    refresh_btn.click(
        refresh_status,
        outputs=[queue_status, stats_display],
        queue=False,  # 不走队列,避免等待
        api_name=False  # 不创建 API 端点
    )

    # 生成按钮 - 生成后自动刷新状态
    def generate_and_refresh(prompt, resolution, steps, seed):
        result = generate_image(prompt, resolution, steps, seed)
        return result, get_queue_status(), get_stats()

    btn.click(generate_and_refresh, [prompt, resolution, steps, seed], [output, queue_status, stats_display])

print("? 启动 Gradio 界面...")
demo.launch(
    server_name="0.0.0.0",
    server_port=7860,
    share=False,
    auth=("xxx", "xxx"), // 账号密码自己改下
    allowed_paths=["/tmp/gradio_images", "/tmp/gradio", "/tmp"]
)

import time
while True:
    time.sleep(1)
'''

    script_path = "/tmp/gradio_app.py"
    with open(script_path, "w") as f:
        f.write(gradio_script)

    subprocess.Popen(["python", script_path])


# =============================================================================
# 本地入口
# =============================================================================
@app.local_entrypoint()
def main():
    print("=" * 60)
    print("Z-Image-Turbo Gradio Web UI")
    print("=" * 60)
    print("\n? 模型: Comfy-Org/z_image_turbo")
    print("\n? GPU: L40S")
    print("\n? 特点:")
    print("   - Gradio 前端界面")
    print("   - ComfyUI 后端推理")
    print("   - 支持多种分辨率")
    print("   - 4-20步快速生成")
    print("\n? 部署命令: modal deploy z_image_turbo_gradio_deploy.py")
    print("=" * 60)

【MODAL】发个简洁版的modal z-image-turbo 部署脚本

[bsgit user="2erTwo6"]Smooth-Gateway[/bsgit]
上游 API 提供商提供的流式传输可能是粗糙的、一大块一大块出现的,体感上不 “丝滑”

Gemini Balance 的流式传输优化功能就解决了这个痛点,在玩酒馆等需要流式传输的场景下,能够极大的提高体验,但是就如项目名那样,只能给 Gemini 用。

于是就有了这个项目,参考了 Gemini Balance 的思路,可以插入到任何一个 OpenAI 格式的 API 服务中间,对流式传输进行后处理,把粗糙的流切成细腻的流,再推送给最终的 AI 应用。

目前仅支持接入 OpenAI 格式的 API,推荐的使用方法是先接入 New API,再套一层这个

快速开始

前提: 您已安装 Docker。

  1. 克隆本仓库:

    git clone https://github.com/2erTwo6/Smooth-Gateway.git
    cd Smooth-Gateway
  2. 创建并编辑配置文件:
    将模板文件复制为您的本地配置文件。

    cp .env.example .env

    然后使用您喜欢的编辑器(如 nano 或 vim)打开 .env 文件,并至少填入必需的 UPSTREAM_API_URL。

  3. 构建 Docker 镜像:

    docker build -t smooth-gateway .
  4. 运行容器:
    使用 --env-file 参数,Docker 会自动加载您的 .env 文件。

    docker run -d \
      --name my-smooth-gateway \
      -p 3001:3001 \
      --env-file .env \
      --restart unless-stopped \
      smooth-gateway

    现在,您的流式优化网关已根据您的 .env 文件配置,在 http://localhost:3001 上运行!

接下来,只需要在你的 AI 应用的 API URL 那里输入 http://localhost:3001 即可(假设你的 AI 应用和此 API 网关部署在同一机器?)

本次开源了一个我自用的工具 IPA-Harbor, 基于 ipatool ,用来下载 ipa ,避免重复的抓包下载 ipa 等操作,使用 Web 面板访问,支持 App 搜索、历史版本下载,支持 Docker 部署。

Docker 仓库地址 https://hub.docker.com/r/uuphy/ipa-harbor

Github 源码地址 https://github.com/ij369/ipa-harbor

GitHub 文档里有更详细的说明,感兴趣的可以点个小星星,有问题提 issue.

我以前每次想下一个旧版 ipa 都要抓包,然后 AirDrop 给 iPhone , 后面逛帖子时发现 ipatool ,后面拿电脑抠命令,是在是厌烦了,可读的版本号也没有,所以有了想法写这个。

另外,有一个 ipatool.ts 的项目,也非常好,不过我不想维护 ipatool 核心的部分,直接去 ipatool 项目的发版页下载最新的二进制文件,拷贝到我这个项目的 bin 目录即可,正所谓大树下好乘凉,感谢 ipatool 的贡献者,同时省去大家时间。

目前我 ipatool 自用到现在已经有一年时间,两个地区的 ID (美区和日区)都没被封过,非常建议使用的话拿独立的 Apple ID 独立的容器运行,看了源码且如果对 ipatool 项目信任的话,再使用主力 Apple ID 。

没有花钱购买应用的 ID ,这样能避免损失,具体可以去 App Store 进行切换登录,其实折腾这个的不一定只有一个 ID 吧。

整个项目拿 Cursor 断断续续写的, 前期几乎是 Vibe Coding ,后续人手改,所以后端实现以及界面啥的都有点糙,不想投入大精力在这方面,主打安全,能用,后续慢慢打磨。 因为我的文件夹辗转腾挪,我导出过提示词,看了下很多都包含敏感内容,脱敏工作量有点大,就 git 忽略了,后续我如果有空再阅读完整理下放出来。

侧载功能我按照好几个帖子试了下,好像是不可用, 前端已经暂时隐藏了该功能, 看看有没有大佬熟悉这这块帮忙看下能不能实现。

我目前一直挂在外网在用,方便手机领免费应用啥的,以下截图的域名我已经做了更换。 截图里的内容仅供参考,仅作为功能演示:

开源一个可以 Docker 容器部署的 ipa 下载工具,用于下载历史版本的 iOS 应用
开源一个可以 Docker 容器部署的 ipa 下载工具,用于下载历史版本的 iOS 应用1