feat(rainyun): 完成雨云纯协议自动登录签到脚本及青龙单目录部署包
- 逆向分析雨云登录与签到接口,打通腾讯天御点选验证码全协议过码闭环 - 支持 RAINYUN_ACCOUNT='账号#密码' 极简配置 (多账号支持 & 与换行分割) - 增加积分提现金额换算展示 (2000积分 = 1元现金) 及青龙系统通知推送 - 新增 dist/rainyun_qinglong 单层平铺部署目录与专属 README - 增强 solver.py 在模板缺失时的 ResNet18 分类器双模降级能力
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
cron: 30 8 * * *
|
||||
new Env('雨云每日签到');
|
||||
|
||||
雨云 (Rainyun) 自动登录与每日签到脚本 (适用于青龙面板)
|
||||
账号配置规范:
|
||||
环境变量 / 本地 .env 统一使用 RAINYUN_ACCOUNT="账号#密码"
|
||||
多账号分割:
|
||||
- 青龙环境变量: 使用 & 分割 (如 RAINYUN_ACCOUNT="13754650804#pwd1&13800000000#pwd2")
|
||||
- 本地 .env 文件: 支持 & 分割或换行分割
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
# 将项目根目录加入 sys.path 以便加载本地模块
|
||||
CURRENT_DIR = Path(__file__).resolve().parent
|
||||
if str(CURRENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(CURRENT_DIR))
|
||||
|
||||
# 配置日志格式
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("rainyun_checkin")
|
||||
|
||||
# 青龙通知模块导入
|
||||
try:
|
||||
from sendNotify import send
|
||||
except Exception:
|
||||
try:
|
||||
from notify import send
|
||||
except Exception:
|
||||
def send(title: str, content: str):
|
||||
pass
|
||||
|
||||
|
||||
# 默认配置
|
||||
RAINYUN_API_BASE = "https://api.v2.rainyun.com"
|
||||
TENCENT_AID = "2039519451"
|
||||
DEFAULT_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# 积分提现比例: 2000 积分 = 1 元现金 (支持直接提现)
|
||||
POINTS_PER_CNY = 2000.0
|
||||
|
||||
|
||||
def format_points(points: int) -> str:
|
||||
"""格式化积分及等值提现金额 (2000积分 = 1元)"""
|
||||
cny = points / POINTS_PER_CNY
|
||||
return f"{points} 积分 (≈ {cny:.2f} 元)"
|
||||
|
||||
|
||||
def mask_field(val: str) -> str:
|
||||
"""脱敏账号信息"""
|
||||
if not val:
|
||||
return ""
|
||||
if len(val) <= 4:
|
||||
return "***"
|
||||
if len(val) == 11 and val.isdigit():
|
||||
return f"{val[:3]}****{val[7:]}"
|
||||
return f"{val[:2]}****{val[-2:]}"
|
||||
|
||||
|
||||
class CaptchaManager:
|
||||
"""验证码求解调度器:支持本地 tcaptcha 模块或远程 HTTP 接口"""
|
||||
|
||||
def __init__(self, api_url: Optional[str] = None):
|
||||
self.api_url = api_url or os.getenv("TCAPTCHA_API_URL", "").strip()
|
||||
self.local_client = None
|
||||
|
||||
# 尝试初始化本地客户端
|
||||
if not self.api_url:
|
||||
try:
|
||||
from tcaptcha.client import TencentCaptchaClient
|
||||
self.local_client = TencentCaptchaClient(aid=TENCENT_AID)
|
||||
logger.info("✓ 成功加载本地 tcaptcha 纯算法求解引擎")
|
||||
except Exception as e:
|
||||
logger.warning(f"未能加载本地 tcaptcha 求解引擎 ({e}),将尝试远程接口或环境变量配置")
|
||||
|
||||
def get_ticket(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""获取腾讯天御点选验证码票据 (ticket, randstr)"""
|
||||
# 1. 优先使用远程 API (若已配置)
|
||||
if self.api_url:
|
||||
logger.info(f"正在调用远程验证码求解接口: {self.api_url} ...")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, trust_env=False) as client:
|
||||
resp = client.post(self.api_url, json={"aid": TENCENT_AID})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# 兼容直接返回或嵌套在 data 中
|
||||
res_data = data.get("data", data)
|
||||
ticket = res_data.get("ticket")
|
||||
randstr = res_data.get("randstr")
|
||||
if ticket and randstr:
|
||||
logger.info(f"✓ 远程验证码求解成功 (randstr: {randstr})")
|
||||
return ticket, randstr
|
||||
else:
|
||||
logger.error(f"远程验证码接口未返回有效票据: {data}")
|
||||
else:
|
||||
logger.error(f"远程验证码接口响应异常: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"调用远程验证码接口失败: {e}")
|
||||
|
||||
# 2. 本地求解引擎
|
||||
if self.local_client:
|
||||
logger.info("正在调用本地 tcaptcha AI 纯协议求解器...")
|
||||
try:
|
||||
res = self.local_client.solve()
|
||||
if res.get("success"):
|
||||
logger.info(f"✓ 本地求解成功 (耗时: {res.get('elapsed_ms')}ms, randstr: {res.get('randstr')})")
|
||||
return res.get("ticket"), res.get("randstr")
|
||||
else:
|
||||
logger.error(f"本地求解失败: {res.get('error')}")
|
||||
except Exception as e:
|
||||
logger.error(f"本地求解器执行异常: {e}")
|
||||
|
||||
logger.error("未找到可用的验证码求解方案!请确认部署了 tcaptcha 本地模型或配置了 TCAPTCHA_API_URL 环境变量")
|
||||
return None, None
|
||||
|
||||
|
||||
class RainyunClient:
|
||||
"""雨云 API 客户端"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: str,
|
||||
password: str,
|
||||
captcha_mgr: Optional[CaptchaManager] = None,
|
||||
):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.captcha_mgr = captcha_mgr or CaptchaManager()
|
||||
|
||||
self.account_name = mask_field(user) if user else "未知账号"
|
||||
self.points_before = 0
|
||||
self.points_after = 0
|
||||
|
||||
# 初始化 HTTP 客户端
|
||||
self.session = httpx.Client(
|
||||
base_url=RAINYUN_API_BASE,
|
||||
timeout=15.0,
|
||||
follow_redirects=True,
|
||||
trust_env=False, # 屏蔽环境代理干扰以兼容各类青龙网络
|
||||
)
|
||||
self._update_common_headers()
|
||||
|
||||
def _update_common_headers(self):
|
||||
rys = f"{uuid.uuid4().hex}{time.time():.3f}"
|
||||
self.session.headers.update({
|
||||
"User-Agent": DEFAULT_UA,
|
||||
"Origin": "https://app.rainyun.com",
|
||||
"Referer": "https://app.rainyun.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"rys": rys,
|
||||
})
|
||||
|
||||
def login(self) -> bool:
|
||||
"""执行登录并获取凭据"""
|
||||
if not self.user or not self.password:
|
||||
logger.error("缺少账号或密码!")
|
||||
return False
|
||||
|
||||
logger.info(f"正在登录雨云账号: {self.account_name} ...")
|
||||
self._update_common_headers()
|
||||
|
||||
payload = {
|
||||
"field": self.user,
|
||||
"password": self.password,
|
||||
"verifyCode": "",
|
||||
}
|
||||
|
||||
resp = self.session.post("/user/login", json=payload)
|
||||
res_json = {}
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否需要验证码
|
||||
if resp.status_code == 400 and res_json.get("code") in [10004, 10005]:
|
||||
logger.info("雨云登录要求完成人机验证,正在调用验证码求解器...")
|
||||
ticket, randstr = self.captcha_mgr.get_ticket()
|
||||
if not ticket or not randstr:
|
||||
logger.error("登录验证码获取失败,无法继续登录")
|
||||
return False
|
||||
payload["vticket"] = ticket
|
||||
payload["vrandstr"] = randstr
|
||||
resp = self.session.post("/user/login", json=payload)
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if resp.status_code == 200 and res_json.get("code") == 200:
|
||||
# 同步更新 CSRF Token
|
||||
csrf = self.session.cookies.get("X-CSRF-Token")
|
||||
if csrf:
|
||||
self.session.headers["x-csrf-token"] = csrf
|
||||
logger.info(f"✓ 账号 {self.account_name} 登录成功!")
|
||||
return True
|
||||
|
||||
err_msg = res_json.get("message") or resp.text
|
||||
logger.error(f"❌ 账号 {self.account_name} 登录失败: {err_msg}")
|
||||
return False
|
||||
|
||||
def fetch_user_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""获取当前用户信息(积分、用户名等)"""
|
||||
self._update_common_headers()
|
||||
try:
|
||||
resp = self.session.get("/user/?no_cache=true")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("code") == 200:
|
||||
info = data.get("data", {})
|
||||
self.account_name = info.get("Name") or self.account_name
|
||||
self.points_before = info.get("Points", 0)
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户信息异常: {e}")
|
||||
return None
|
||||
|
||||
def fetch_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""获取任务列表"""
|
||||
self._update_common_headers()
|
||||
try:
|
||||
resp = self.session.get("/user/reward/tasks")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("code") == 200:
|
||||
return data.get("data", [])
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务列表失败: {e}")
|
||||
return []
|
||||
|
||||
def claim_daily_checkin(self) -> Dict[str, Any]:
|
||||
"""
|
||||
执行每日签到核心业务逻辑:
|
||||
1. 检查任务列表与签到状态
|
||||
2. 若 Status == 2: 今日已完成,直接跳过
|
||||
3. 若 Status == 1: 待领取,获取验证码并提交
|
||||
"""
|
||||
user_info = self.fetch_user_info()
|
||||
user_display = f"{self.account_name} (ID: {user_info.get('ID') if user_info else '未知'})"
|
||||
|
||||
tasks = self.fetch_tasks()
|
||||
checkin_task = None
|
||||
for t in tasks:
|
||||
if t.get("Name") == "每日签到":
|
||||
checkin_task = t
|
||||
break
|
||||
|
||||
if not checkin_task:
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "error",
|
||||
"message": "未在任务列表中找到【每日签到】任务",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
task_status = checkin_task.get("Status")
|
||||
# Status 2: 今日已领取
|
||||
if task_status == 2:
|
||||
logger.info(f"✨ 账号【{user_display}】今日已完成每日签到,当前总资产: {format_points(self.points_before)}。")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "already_done",
|
||||
"message": "今日已签到 (无需重复领取)",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
# Status 1: 待签到
|
||||
logger.info(f"🎯 账号【{user_display}】今日尚未签到,开始启动签到流程...")
|
||||
|
||||
# 1. 求解验证码
|
||||
ticket, randstr = self.captcha_mgr.get_ticket()
|
||||
if not ticket or not randstr:
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "fail",
|
||||
"message": "验证码求解失败,签到中止",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
# 2. 提交签到请求
|
||||
self._update_common_headers()
|
||||
payload = {
|
||||
"task_name": "每日签到",
|
||||
"verifyCode": "",
|
||||
"vticket": ticket,
|
||||
"vrandstr": randstr,
|
||||
}
|
||||
|
||||
resp = self.session.post("/user/reward/tasks", json=payload)
|
||||
res_json = {}
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if resp.status_code == 200 and res_json.get("code") == 200:
|
||||
reward_data = res_json.get("data", {})
|
||||
earned_points = reward_data.get("points", 500)
|
||||
earned_cny = earned_points / POINTS_PER_CNY
|
||||
|
||||
# 刷新最新积分
|
||||
latest_info = self.fetch_user_info()
|
||||
self.points_after = latest_info.get("Points", self.points_before + earned_points) if latest_info else self.points_before + earned_points
|
||||
|
||||
msg = f"签到成功!获得 +{earned_points} 积分 (≈ {earned_cny:.2f} 元)"
|
||||
logger.info(f"🎉 账号【{user_display}】{msg},当前总资产: {format_points(self.points_after)}")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
"earned_points": earned_points,
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_after,
|
||||
}
|
||||
else:
|
||||
err_msg = res_json.get("message") or resp.text
|
||||
logger.error(f"❌ 账号【{user_display}】签到失败: {err_msg}")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "fail",
|
||||
"message": f"签到请求失败: {err_msg}",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
|
||||
def load_dotenv(env_path: Optional[Path] = None):
|
||||
"""自动加载本地 .env 文件到 os.environ,方便本地直接调试运行"""
|
||||
search_paths = [
|
||||
env_path,
|
||||
CURRENT_DIR / ".env",
|
||||
Path.cwd() / ".env",
|
||||
]
|
||||
for p in search_paths:
|
||||
if p and p.is_file():
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
v = v[1:-1]
|
||||
if k not in os.environ or not os.environ[k]:
|
||||
os.environ[k] = v
|
||||
logger.info(f"✓ 成功加载本地环境变量: {p}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 .env 异常: {e}")
|
||||
|
||||
|
||||
def parse_accounts() -> List[Dict[str, str]]:
|
||||
"""
|
||||
解析雨云账号配置 (优先加载本地 .env):
|
||||
仅支持 RAINYUN_ACCOUNT="账号#密码" 格式
|
||||
多账号使用 & 分割 (青龙面板推荐) 或 换行分割 (本地 .env 推荐)
|
||||
例如:
|
||||
RAINYUN_ACCOUNT="13754650804#pwd1&13800000000#pwd2"
|
||||
"""
|
||||
load_dotenv()
|
||||
accounts = []
|
||||
|
||||
acc_env = os.getenv("RAINYUN_ACCOUNT", "").strip()
|
||||
if not acc_env:
|
||||
return []
|
||||
|
||||
# 去除外层引号
|
||||
if (acc_env.startswith('"') and acc_env.endswith('"')) or (acc_env.startswith("'") and acc_env.endswith("'")):
|
||||
acc_env = acc_env[1:-1].strip()
|
||||
|
||||
# 按照 & 或 换行 分割多账号
|
||||
raw_list = re.split(r"[&\n]", acc_env)
|
||||
for item in raw_list:
|
||||
item = item.strip()
|
||||
if not item or "#" not in item:
|
||||
continue
|
||||
u, p = item.split("#", 1)
|
||||
u, p = u.strip(), p.strip()
|
||||
if u and p and not any(a.get("user") == u for a in accounts):
|
||||
accounts.append({"user": u, "password": p})
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 🚀 雨云 (Rainyun) 自动登录与每日签到任务启动")
|
||||
print("=" * 60)
|
||||
|
||||
accounts = parse_accounts()
|
||||
if not accounts:
|
||||
msg = (
|
||||
"未检测到雨云账号配置!请在青龙环境变量或本地 .env 中配置 RAINYUN_ACCOUNT\n"
|
||||
"格式: 账号#密码 (多账号用 & 或换行分割)\n"
|
||||
"例如: RAINYUN_ACCOUNT=\"13754650804#pwd1&13800000000#pwd2\"\n"
|
||||
"如需远程验证码服务,可添加 TCAPTCHA_API_URL (如 http://x.x.x.x:8000/solve)"
|
||||
)
|
||||
logger.warning(msg)
|
||||
send("雨云每日签到异常", msg)
|
||||
return
|
||||
|
||||
logger.info(f"已成功加载 {len(accounts)} 个雨云账号配置")
|
||||
captcha_mgr = CaptchaManager()
|
||||
|
||||
results = []
|
||||
for idx, acc in enumerate(accounts, 1):
|
||||
logger.info(f"\n--- [正在处理账号 {idx}/{len(accounts)}] ---")
|
||||
client = RainyunClient(
|
||||
user=acc["user"],
|
||||
password=acc["password"],
|
||||
captcha_mgr=captcha_mgr,
|
||||
)
|
||||
|
||||
login_ok = client.login()
|
||||
if not login_ok:
|
||||
results.append({
|
||||
"user": client.account_name,
|
||||
"status": "login_failed",
|
||||
"message": "登录失败",
|
||||
"points_before": 0,
|
||||
"points_after": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
res = client.claim_daily_checkin()
|
||||
results.append(res)
|
||||
|
||||
# 多账号之间随机休眠 2~4 秒
|
||||
if idx < len(accounts):
|
||||
sleep_sec = random.uniform(2.0, 4.0)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
# 统计汇总
|
||||
print("\n" + "=" * 60)
|
||||
print(" 📊 任务执行结果统计报表")
|
||||
print("=" * 60)
|
||||
|
||||
success_cnt = sum(1 for r in results if r["status"] in ["success", "already_done"])
|
||||
summary_lines = []
|
||||
total_points = 0
|
||||
|
||||
for idx, r in enumerate(results, 1):
|
||||
status_icon = "✅" if r["status"] in ["success", "already_done"] else "❌"
|
||||
pts = r.get("points_after", 0)
|
||||
total_points += pts
|
||||
cny_str = format_points(pts)
|
||||
line = f"[{idx}] {status_icon} 账号: {r['user']} | 结果: {r['message']} | 当前账户: {cny_str}"
|
||||
summary_lines.append(line)
|
||||
print(line)
|
||||
|
||||
total_cny = total_points / POINTS_PER_CNY
|
||||
total_summary = f"💰 全部账号总资产: {total_points} 积分 (可提现 ≈ {total_cny:.2f} 元)"
|
||||
summary_lines.append(total_summary)
|
||||
print(total_summary)
|
||||
print("=" * 60)
|
||||
|
||||
# 推送青龙系统通知
|
||||
notify_title = f"雨云签到: 成功 {success_cnt}/{len(results)} | 总资产 ≈ {total_cny:.2f}元"
|
||||
notify_content = "\n".join(summary_lines)
|
||||
send(notify_title, notify_content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user