Files
yule153604-zglt_script/cu.py
T
yule153604 4be5bd6107 feat: 校园季转存三渠道与 AI 对话任务,清理下线模块并落实审计修复
- 校园季新增转存体系:mbh 沃视频转存协议(DES/AES 通道)、芒果TV 内容源
  (144 档综艺短剧,无会员权益秒退)、mbh 影视/教育频道兜底(不限会员),
  内容池耗尽自动切换、已转存内容自动跳过,转存任务不再漏做
- 新增学习助手 AI 对话任务(SSE 流式,独立新会话避免污染聊天记录)
- 校园季先查任务状态再执行:上传/AI 已满自动跳过,减少重复副作用
- 删除天天领现金(服务端返回"本活动已结束")与云盘上传大比拼(2026-08
  已下线)模块,仅保留校园季复用的文件加密与 UA
- 审计修复:日志凭据脱敏(token/ticket 等 11 类字段)、十连抽查询失败
  不再消耗积分、权益超市移除错误活动兜底、抽奖次数本地硬上限、账号
  异常进通知、UNICOM_SKIP_TASK 拼写警告、时区空串钉死、不可达分支清理
- 安全管家移除重复/假成功签到日志,章节编号重排
- README 同步任务表与环境变量说明
2026-09-12 14:37:25 +08:00

4580 lines
185 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import base64
import hashlib
import hmac
import json
import os
import threading
import random
import re
import string
import time
import traceback
import uuid
from collections.abc import Mapping
from datetime import datetime
from urllib.parse import parse_qs, quote, unquote, urljoin, urlparse
import httpx
from Crypto.Cipher import AES, DES
from Crypto.Util.Padding import pad, unpad
from gmssl import sm2
def read_int_env(name, default=None, minimum=None):
"""模块启动时读取整数环境变量。未设置才用 default;空/非整数/低于最小值 raise。"""
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except (TypeError, ValueError) as e:
raise ValueError(f"环境变量 {name}={raw!r} 不是合法整数") from e
if minimum is not None and value < minimum:
raise ValueError(f"环境变量 {name}={raw!r} 低于最小值 {minimum}")
return value
# 常量
APP_VERSION = "iphone_c@11.0503"
UA = f"Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 unicom{{version:{APP_VERSION}}}"
APP_ID = "86b8be06f56ba55e9fa7dff134c6b16c62ca7f319da4a958dd0afa0bf9f36f1daa9922869a8d2313b6f2f9f3b57f2901f0021c4575e4b6949ae18b7f6761d465c12321788dcd980aa1a641789d1188bb"
WOCARE_CH = (
"beea1c7edf7c4989b2d3621c4255132f",
"f4cd4ffeb5554586acf65ba7110534f5",
"wocareMBHServiceLife1",
"1",
)
SEC_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15"
# 通通农场常量
TTXC_BASE_URL = "https://epay.10010.com/cu-ca-game-front"
TTXC_APP_BASE_URL = "https://epay.10010.com/cu-ca-app-front"
TTXC_CHANNEL = "225"
TTXC_REFERER = "https://epay.10010.com/cu-ca-game-web/index.html?channel=qdqp"
TTXC_UA = "Mozilla/5.0 (Linux; Android 10; MI 8 Build/QKQ1.190828.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/143.0.7499.146 Mobile Safari/537.36; unicom{version:android@11.0802,desmobile:0};devicetype{deviceBrand:Xiaomi,deviceModel:MI 8}"
TTXC_GARBAGE_WAIT_SECONDS = read_int_env(
"UNICOM_TTXC_GARBAGE_WAIT", default=28, minimum=0
)
TTXC_GROW_MAX_CHARGE_PER_LAND = read_int_env(
"UNICOM_TTXC_GROW_MAX_CHARGE_PER_LAND", default=20, minimum=0
)
TTXC_HARVEST_WAIT_SECONDS = read_int_env(
"UNICOM_TTXC_HARVEST_WAIT", default=3, minimum=0
)
MARKET_WAIT_MAX_SECONDS = read_int_env("UNICOM_MARKET_WAIT_MAX", default=300, minimum=0)
MARKET_API_RETRIES = read_int_env("UNICOM_MARKET_API_RETRIES", default=3, minimum=1)
MARKET_API_RETRY_BASE_SECONDS = 0.15
TTXC_NEWBIE_STEPS = [
"G01",
"G02",
"G03",
"G03_2",
"G04",
"G05",
"G09",
"G10",
"G11",
"G12",
]
# 云盘活动签名密钥(上传大比拼等 panservice 活动共用)
CLOUD_PAN_SIGN_SECRET = "s8Hf3LqP9xN2vM5bR7tY1wZ4cA6eG0K"
BATTLE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) unicom{version:iphone_c@12.1300};ltst;OSVersion/27.0"
)
CLOUD_BATTLE_FILEINFO_IV = "wNSOYIB1k1DjY5lA"
# 沃视频(5G宽视界)内容转存:密钥逆向自 YunPanVideoV4 前端 chunk
WO_VIDEO_BASE = "https://mbh.chinaunicomvideo.cn"
WO_VIDEO_CHANNEL = "1001000218"
WO_TRANSFER_DES_KEY = "LsgFQAYhXkjpqB7K"
WO_TRANSFER_AES_KEY = "9b54bfce4b4b4719"
WO_TRANSFER_AES_IV = "wNSOYIB1k1DjY5lA"
# 校园季转存兜底内容(正常路径从校园 tab 动态发现,兜底 id 抓包验证可用)
CAMPUS_EDU_FALLBACK_ID = os.environ.get(
"UNICOM_CAMPUS_EDU_ID", "0132429091704a6297fef31c7a6e2c5e"
)
CAMPUS_ENT_FALLBACK_ID = os.environ.get(
"UNICOM_CAMPUS_ENT_ID", "746e403f3b6348328d3c954edf3fd06e"
)
CAMPUS_FALLBACK_NAME = {
"edu": "考研数学概率论与数理统计第1集",
"ent": "寒战1994粤语版",
}
def wo_des_b64(text, key=WO_TRANSFER_DES_KEY):
"""沃视频 DES-CBC(key 取前 8 字节,iv=0x01..0x08,Pkcs7)→ base64。"""
cipher = DES.new(key.encode()[:8], DES.MODE_CBC, bytes(range(1, 9)))
return base64.b64encode(cipher.encrypt(pad(text.encode(), 8))).decode()
def wo_aes_b64(text, key=WO_TRANSFER_AES_KEY):
"""沃视频 AES-CBC(固定 IV,Pkcs7)→ base64。"""
cipher = AES.new(key.encode(), AES.MODE_CBC, WO_TRANSFER_AES_IV.encode())
return base64.b64encode(cipher.encrypt(pad(text.encode(), 16))).decode()
def wo_aes_unb64(data, key=WO_TRANSFER_AES_KEY):
"""沃视频 AES-CBC 解密(JudgeFileTransferStatus 响应 DATA)。"""
cipher = AES.new(key.encode(), AES.MODE_CBC, WO_TRANSFER_AES_IV.encode())
return unpad(cipher.decrypt(base64.b64decode(data)), 16).decode()
SHANGDU_BASE = "https://app.shangdu.com"
SHANGDU_ENTRY = f"{SHANGDU_BASE}/monthlyBenefit/static/index.html"
SHANGDU_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) unicom{version:iphone_c@12.1300};ltst;OSVersion/27.0"
SHANGDU_SIGN_SECRET = "Hwsm6r3afijk8CP9d5F4nZu9DDyeMKMJxRpUqJqxCZ4sZuiyRU"
SHANGDU_SM2_PRIVATE_KEY = (
"03659DDCBCA7513BDD930D4E19B8C216ECB714F1A70ACA2C155BCDD5886283F5"
)
SHANGDU_LOTTERY_PRIZES = [
"5元话费券",
"印鸽魔哒熊 电动牙刷",
"DIY掌中宝照片书",
"免费领麻布风手提包",
"免费领定制抱枕套",
"联通云盘 黄金会员-月卡",
"爱奇艺黄金VIP会员-月卡",
"腾讯视频VIP会员-月卡",
]
SHANGDU_LOTTERY_FESTIVAL_PRIZES = ["30元话费券", *SHANGDU_LOTTERY_PRIZES]
SHANGDU_LOTTERY_MAX = read_int_env("SHANGDU_LOTTERY_MAX", default=None, minimum=0)
# 云手机积分 / 夏日刮一刮 / 打卡挑战赛
UPHONE_H5API = "https://uphone.wostore.cn/h5api"
UPHONE_WO_ADV = "https://uphone.wo-adv.cn"
UPHONE_H5FORPHONE = "https://h5forphone.wostore.cn/h5forphone"
UPHONE_CHECKIN_PAGE = "https://h5forphone.wostore.cn/changXiangVip/CLD13.html"
UPHONE_CHANNEL = "ST-Wode"
UPHONE_CHANNEL_H5 = "ST-Jingang002"
UPHONE_EDOP_APP_ID = "edop_unicom_68e8fa69"
UPHONE_ACT_SIGN = "Points_Sign_2507"
UPHONE_ACT_OBTAIN = "Points_Obtain_2507"
UPHONE_ACT_EXCHANGE = "Points_Exchange_2507" # 2508 无效; 2507 列表无十连商品(2026031010) → 十连暂不可用
UPHONE_ACT_SUMMER = "HD2026062200218"
UPHONE_GOODS_LOTTERY_10 = "2026031010"
UPHONE_LOTTERY_COST = read_int_env("UNICOM_UPHONE_LOTTERY_COST", default=100, minimum=1)
UPHONE_OBTAIN_SKIP = frozenset({"012-4", "TASK2026010601"})
# 夏日刮一刮仅领此任务换抽奖次数,避免回退到 0514-001 等日限任务
UPHONE_SUMMER_CLAIM_TASK = "2508-01"
# 打卡挑战赛:需手填 QQ/微信 的奖品类型,跳过自动领取
UPHONE_CHECKIN_SKIP_ACTIVITY_TYPES = frozenset({2, "2"})
UPHONE_APP_UA = (
"ChinaUnicom4.x/12.13 (com.chinaunicom.mobilebusiness; build:1; iOS 27.0.0) "
"Alamofire/4.7.3 unicom{version:iphone_c@12.1300}"
)
UPHONE_H5_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) unicom{version:iphone_c@12.1300};ltst;OSVersion/27.0"
)
def require_int(value, field, minimum=None):
"""必填业务整数:仅接受非 bool 的 int,或可由 int() 完整解析的 str;可选 minimum 下限。"""
if isinstance(value, bool) or value is None:
raise ValueError(f"{field}={value!r} 不是合法整数")
if isinstance(value, int):
n = value
elif isinstance(value, str):
try:
n = int(value)
except ValueError as e:
raise ValueError(f"{field}={value!r} 不是合法整数") from e
else:
raise ValueError(f"{field}={value!r} 不是合法整数")
if minimum is not None and n < minimum:
raise ValueError(f"{field}={value!r} 低于最小值 {minimum}")
return n
def safe_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
_REDACT_FIELD_RE = re.compile(
r'(["\']?(token|ticket|cookie|verifycode|accesstoken|tokenid|authinfo|sessionid|'
r'authorization|access[-_]?token|secret)["\']?\s*[:=]\s*)(["\']?)[^"\',}&\s]+\3',
re.IGNORECASE,
)
def redact_text(text):
"""JSON/表单风格文本中的凭据字段打码(认证材料不入持久日志)。"""
return _REDACT_FIELD_RE.sub(r"\1\3***\3", str(text))
def response_summary(res):
if isinstance(res, dict):
meta = res.get("meta") or {}
if meta.get("message"):
return str(meta.get("message"))
if res.get("msg"):
return str(res.get("msg"))
return redact_text(res)[:120]
def format_exception_detail(e, action=None):
parts = []
if action:
parts.append(f"环节={action}")
parts.append(f"{type(e).__name__}: {e}")
if e.__traceback__:
frames = traceback.extract_tb(e.__traceback__)
if frames:
f = frames[-1]
parts.append(f"位置={os.path.basename(f.filename)}:{f.lineno}@{f.name}")
if f.line:
parts.append(f"代码={f.line.strip()}")
if e.__cause__:
parts.append(f"原因={type(e.__cause__).__name__}: {e.__cause__}")
return " | ".join(parts)
def api_response_err(r, action=None):
parts = []
if action:
parts.append(action)
if not r:
parts.append("无响应")
return " | ".join(parts)
if r.get("code") == -1:
parts.append("网络/请求异常")
if r.get("error"):
parts.append(str(r["error"]))
return " | ".join(parts)
http = r.get("code")
if http not in (200, 201, 302):
parts.append(f"HTTP={http}")
if r.get("error"):
parts.append("协议/JSON解析错误")
parts.append(str(r["error"]))
return " | ".join(parts)
data = r.get("data")
if isinstance(data, dict):
for k in (
"code",
"status",
"resultCode",
"desc",
"msg",
"message",
"resultMsg",
"resultDesc",
):
v = data.get(k)
if v not in (None, "", {}):
parts.append(f"{k}={v}")
if len(parts) == (1 if action else 0):
parts.append(f"body={redact_text(data)[:150]}")
elif data is not None:
text = str(data).strip()
if text:
parts.append(f"body={redact_text(text)[:150]}")
elif http in (200, 201):
parts.append("响应体为空")
return " | ".join(parts)
def require_dict_data(r, action=None):
data = r.get("data") if r else None
if isinstance(data, dict):
return data
raise ValueError(api_response_err(r, action))
def is_retryable_api_response(r) -> bool:
"""网络超时、5xx、无 JSON 体等可重试;业务错误不重试。"""
if not r:
return True
code = r.get("code")
if code == -1:
return True
if isinstance(code, int) and code >= 500:
return True
if r.get("error") and not isinstance(r.get("data"), dict):
return True
return False
NOTIFY_LINES = []
def notify_send(title, content):
# notify.py 的一言接口无超时、推送线程 join 也无超时,必须隔离,否则会卡死青龙任务槽
os.environ.setdefault("HITOKOTO", "false")
def _send():
try:
from notify import send
send(title, content)
except Exception as e:
print(f"推送失败: {e}")
worker = threading.Thread(target=_send, daemon=True)
worker.start()
worker.join(timeout=30)
if worker.is_alive():
print("推送超时 30s,放弃等待")
def iter_set_cookies(headers: httpx.Headers | Mapping[str, object]):
"""逐条产出 Set-Cookie 值,避免按逗号拆分误伤 Expires 日期。"""
if isinstance(headers, httpx.Headers):
for item in headers.get_list("set-cookie") or headers.get_list("Set-Cookie") or []:
if item:
yield item.strip()
return
sc = headers.get("set-cookie") or headers.get("Set-Cookie")
if not sc:
return
if isinstance(sc, list):
for item in sc:
if item:
yield item.strip()
else:
yield str(sc).strip()
class Unicom:
def __init__(self, cookie, idx=1):
self.cookie, self.idx = cookie.strip(), idx
self.mobile = self.province = self.province_code = self.ecs_token = ""
self.market_token = self.market_login_id = self.market_cycle_start_time = ""
self.xj_token = self.wocare_token = self.wocare_sid = ""
self.sec_ticket = self.sec_token = self.sec_jea_id = self.sec_key = ""
self.ttxc_token = self.ttxc_nick_name = self.ttxc_user_id = ""
self.ttxc_newbie_list = []
self.ttxc_charge_level = {}
self.ttxc_no_energy = False
self.last_ticket_url = ""
self.uphone_cp_token = self.uphone_usr_token = self.uphone_device_id = ""
# 生成UUID用于签到
self.uuid = str(uuid.uuid4())
self.client = httpx.AsyncClient(
http2=False,
follow_redirects=False,
timeout=50.0,
headers={"User-Agent": UA, "Connection": "keep-alive"},
)
# 设置cookie
self.unicom_token_id = "".join(
random.choices(string.ascii_letters + string.digits, k=32)
)
tid = self.unicom_token_id
for n, v in [
("TOKENID_COOKIE", f"chinaunicom-{tid}"),
("UNICOM_TOKENID", tid),
("sdkuuid", tid),
]:
self.client.cookies.set(n, v, domain=".10010.com")
def log(self, msg):
line = f"[{datetime.now():%H:%M:%S}] [账号{self.idx}] {msg}"
print(line, flush=True)
NOTIFY_LINES.append(line)
def task_log(self, tag, msg):
line = f"[{datetime.now():%H:%M:%S}] [账号{self.idx}] [{tag}] {msg}"
print(line, flush=True)
NOTIFY_LINES.append(line)
def tlog(self, msg):
tag = getattr(self, "_task_tag", None)
if tag:
self.task_log(tag, msg)
else:
self.log(msg)
def format_exc(self, e, action=None):
return format_exception_detail(e, action)
def log_exc(self, e, action=None):
self.log(f"异常: {self.format_exc(e, action)}")
def tlog_exc(self, e, action=None):
self.tlog(f"异常: {self.format_exc(e, action)}")
async def close(self):
await self.client.aclose()
async def req(self, method, url, **kw):
allow_redirects = kw.pop("allow_redirects", True)
try:
r = await self.client.request(
method, url, follow_redirects=allow_redirects, **kw
)
except httpx.RequestError as e:
path = urlparse(url).path or url
return {
"code": -1,
"headers": {},
"data": None,
"error": format_exception_detail(e, f"{method} {path}"),
}
body = r.text
stripped = body.strip()
if stripped.startswith(("{", "[")):
try:
data = json.loads(body)
except json.JSONDecodeError as e:
path = urlparse(url).path or url
return {
"code": r.status_code,
"headers": r.headers,
"data": None,
"error": format_exception_detail(e, f"{method} {path}"),
}
else:
data = body
return {"code": r.status_code, "headers": r.headers, "data": data}
async def get_ticket(self, url):
r = await self.req(
"GET",
"https://m.client.10010.com/mobileService/openPlatform/openPlatLineNew.htm",
params={"to_url": url},
allow_redirects=False,
)
if loc := r["headers"].get("location") or r["headers"].get("Location"):
# 完整落地页 URL 留给调用方当 Referer(woauth2 授权有 referer 校验)
self.last_ticket_url = loc
return parse_qs(urlparse(loc).query).get("ticket", [""])[0]
return ""
# === 登录 ===
async def login(self):
r = await self.req(
"POST",
"https://m.client.10010.com/mobileService/onLine.htm",
data={
"token_online": self.cookie,
"reqtime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"appId": APP_ID,
"version": APP_VERSION,
"step": "bindlist",
"isFirstInstall": 0,
"deviceModel": "iPhone14,6",
"uniqueIdentifier": "ios"
+ "".join(random.choices("0123456789abcdef", k=32)),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
res = r["data"]
if not isinstance(res, dict):
self.log(f"登录失败: {api_response_err(r, '在线登录')}")
return False
if str(res.get("code")) == "0":
self.mobile, self.ecs_token = (
str(res.get("desmobile") or ""),
str(res.get("ecs_token") or ""),
)
list_data = res.get("list") if isinstance(res.get("list"), list) else []
city_info = (
list_data[0] if list_data and isinstance(list_data[0], dict) else {}
)
self.province = str(city_info.get("proName") or "")
pro_code = str(
city_info.get("proCode") or city_info.get("standardProvinceCode") or ""
)
self.province_code = pro_code.lstrip("0") or pro_code
self.log(
f"登录成功: {self.mobile[:3]}****{self.mobile[-4:]} ({self.province})"
)
if self.ecs_token:
self.client.cookies.set(
"ecs_token", self.ecs_token, domain=".10010.com"
)
# 云盘登录
await self.cloudpan_login()
return True
self.log(f"登录失败: {api_response_err(r, '在线登录')}")
return False
async def cloudpan_login(self):
"""云盘登录获取userToken"""
try:
if not self.ecs_token:
return
# 获取ticket
r = await self.req(
"GET",
"https://m.client.10010.com/edop_ng/getTicketByNative",
params={"appId": "edop_unicom_d67b3e30", "token": self.ecs_token},
)
ticket = r["data"].get("ticket") if isinstance(r["data"], dict) else None
if not ticket:
return
# 获取userToken
timestamp = str(int(time.time() * 1000))
rnd = str(random.randint(123456, 199999))
sign = hashlib.md5(
f"HandheldHallAutoLoginV2{timestamp}{rnd}wohome".encode()
).hexdigest()
r = await self.req(
"POST",
"https://panservice.mail.wo.cn/wohome/dispatcher",
headers={
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 12; leijun Pro Build/SKQ1.22013.001)"
},
json={
"header": {
"key": "HandheldHallAutoLoginV2",
"resTime": timestamp,
"reqSeq": rnd,
"channel": "wohome",
"version": "",
"sign": sign,
},
"body": {"clientId": "1001000003", "ticket": ticket},
},
)
if isinstance(r["data"], dict):
# 尝试两种响应格式
token = r["data"].get("body", {}).get("token", "") or r["data"].get(
"RSP", {}
).get("DATA", {}).get("token", "")
if token:
self.cloud_disk = {"userToken": token}
else:
self.log(f"云盘登录失败: {api_response_err(r, '获取userToken')}")
except Exception as e:
self.log_exc(e, "云盘登录")
# === 1. 首页签到 ===
async def sign_get_task_ip(self):
"""上报任务 IP;本地 orderId 始终可用。接口常返回非标准 JSON,失败不刷屏。"""
order_id = "".join(random.choices(string.ascii_uppercase + string.digits, k=32))
r = await self.req(
"POST",
"https://m.client.10010.com/taskcallback/topstories/gettaskip",
data={"mobile": self.mobile, "orderId": order_id},
)
# 协议/JSON 脏响应极常见且不影响 completeTask,静默;仅真实断网首次提示
if r.get("code") == -1 and not getattr(self, "_gettaskip_net_warned", False):
self._gettaskip_net_warned = True
self.tlog(f"gettaskip网络失败(后续忽略): {api_response_err(r, 'gettaskip')}")
return order_id
async def sign_get_continuous(self):
r = await self.req(
"GET",
"https://activity.10010.com/sixPalaceGridTurntableLottery/signin/getContinuous",
params={"taskId": "", "channel": "wode", "imei": self.uuid},
)
res = require_dict_data(r, "查询签到状态")
if str(res.get("code")) != "0000":
err = api_response_err(r, "查询签到状态")
self.tlog(f"查询签到状态失败: {err}")
return
signed = res.get("data", {}).get("todayIsSignIn", "n") == "y"
self.tlog(f"今天{'已' if signed else '未'}签到")
if not signed:
await asyncio.sleep(1)
await self.sign_day_sign()
async def sign_day_sign(self):
r = await self.req(
"POST",
"https://activity.10010.com/sixPalaceGridTurntableLottery/signin/daySign",
data={},
)
res = require_dict_data(r, "每日签到")
code = str(res.get("code", ""))
if code == "0000":
data = res.get("data", {}) or {}
self.tlog(
f"签到成功: [{data.get('statusDesc', '')}]{data.get('redSignMessage', '')}"
)
elif code == "0002" and "已经签到" in str(res.get("desc", "")):
self.tlog("签到成功: 今日已完成签到!")
else:
self.tlog(f"签到失败[{code}]: {res.get('desc', '')}")
async def sign_get_telephone(self, is_initial=False):
r = await self.req(
"POST", "https://act.10010.com/SigninApp/convert/getTelephone", data={}
)
res = require_dict_data(r, "查询话费红包")
if str(res.get("status")) != "0000" or not res.get("data"):
self.tlog(f"查询话费红包失败: {api_response_err(r, '查询话费红包')}")
return None
tel_val = res["data"].get("telephone", 0)
try:
amount = float(tel_val)
except (TypeError, ValueError):
amount = 0.0
if is_initial:
self.sign_initial_amount = amount
self.tlog(f"话费红包: 运行前总额 {amount:.2f}元")
else:
if hasattr(self, "sign_initial_amount"):
self.tlog(
f"话费红包: 本次运行增加 {amount - self.sign_initial_amount:.2f}元"
)
msg = f"话费红包: 总额 {amount:.2f}元"
try:
exp_num = float(res["data"].get("needexpNumber", 0) or 0)
except (TypeError, ValueError):
exp_num = 0.0
if exp_num > 0:
msg += f",其中 {res['data'].get('needexpNumber', '0')}元 将于 {res['data'].get('month', '')}月底到期"
self.tlog(msg)
return amount
async def sign_do_task_from_list(self, task):
url = task.get("url") or ""
if url != "1" and url.startswith("http"):
await self.req(
"GET", url, headers={"Referer": "https://img.client.10010.com/"}
)
self.tlog(f"任务中心: 浏览页面 [{task.get('taskName')}]")
await asyncio.sleep(random.uniform(5, 7))
order_id = await self.sign_get_task_ip()
r = await self.req(
"GET",
"https://activity.10010.com/sixPalaceGridTurntableLottery/task/completeTask",
params={
"taskId": task.get("id"),
"orderId": order_id,
"systemCode": "QDQD",
},
)
res = require_dict_data(r, "完成签到任务")
code = str(res.get("code", ""))
if code == "0000":
self.tlog(f"任务中心: 任务 [{task.get('taskName')}] 已完成")
else:
self.tlog(
f"任务中心: 任务 [{task.get('taskName')}] 完成失败[{code}]: {res.get('desc', '未知错误')}"
)
async def sign_get_task_reward(self, task_id, task_type=None, record_id=None):
params = {"taskId": task_id}
if task_type is not None:
params["taskType"] = task_type
if record_id is not None:
params["id"] = record_id
headers = (
{"Referer": "https://img.client.10010.com/"}
if task_type is not None
else None
)
r = await self.req(
"GET",
"https://activity.10010.com/sixPalaceGridTurntableLottery/task/getTaskReward",
params=params,
headers=headers or {},
)
res = require_dict_data(r, "领取任务奖励")
code = str(res.get("code", ""))
data = res.get("data", {}) or {}
if code == "0000" and str(data.get("code", "")) == "0000":
prize = (
f"[{data.get('prizeName', '')}] {data.get('prizeNameRed', '')}".strip()
)
self.tlog(f"领取奖励: {prize or data.get('statusDesc', '领取成功')}")
else:
self.tlog(
f"领取奖励失败[{data.get('code') or code}]: {data.get('desc') or res.get('desc', '')}"
)
async def sign_get_task_list(self):
url = "https://activity.10010.com/sixPalaceGridTurntableLottery/task/taskList"
headers = {"Referer": "https://img.client.10010.com/"}
for i in range(30):
r = await self.req("GET", url, params={"type": "2"}, headers=headers)
res = require_dict_data(r, "查询任务列表")
code = str(res.get("code", ""))
if code == "0329" or "火爆" in str(res.get("desc", "")):
self.tlog("系统繁忙(0329),停止后续尝试")
break
if code != "0000":
self.tlog(f"任务中心: 获取任务列表失败[{code}]: {res.get('desc', '')}")
return
tag_list = res.get("data", {}).get("tagList", []) or []
task_list = res.get("data", {}).get("taskList", []) or []
all_tasks = task_list + [
t for tag in tag_list for t in tag.get("taskDTOList", [])
]
all_tasks = [t for t in all_tasks if t]
if not all_tasks:
if i == 0:
self.tlog("任务中心: 当前无任何任务。")
break
do_task = next(
(
t
for t in all_tasks
if t.get("taskState") == "1" and t.get("taskType") == "5"
),
None,
)
if do_task:
self.tlog(f"任务中心: 开始执行任务 [{do_task.get('taskName')}]")
await self.sign_do_task_from_list(do_task)
await asyncio.sleep(3)
continue
claim_task = next((t for t in all_tasks if t.get("taskState") == "0"), None)
if claim_task:
self.tlog(
f"任务中心: 发现可领取奖励的任务 [{claim_task.get('taskName')}]"
)
await self.sign_get_task_reward(claim_task.get("id"))
await asyncio.sleep(2)
continue
if i == 0:
self.tlog("任务中心: 没有可执行或可领取的任务。")
else:
self.tlog("任务中心: 所有任务处理完毕。")
break
async def sign_get_month_sign_reward(self, task):
task_name = task.get("taskName") or "月签奖励"
r = await self.req(
"GET",
"https://activity.10010.com/sixPalaceGridTurntableLottery/task/getTaskReward",
params={
"taskId": task.get("taskId"),
"taskType": "30",
"id": task.get("id"),
},
headers={"Referer": "https://img.client.10010.com/"},
)
res = require_dict_data(r, "领取月签奖励")
code = str(res.get("code", ""))
data = res.get("data", {}) or {}
if code == "0000" and str(data.get("code", "")) == "0000":
prize_name = data.get("prizeName", "")
prize_red = data.get("prizeNameRed", "")
reward = (
f"[{prize_name}] {prize_red}".strip()
if prize_name or prize_red
else data.get("statusDesc", "领取成功")
)
self.tlog(f"月签有礼: [{task_name}] {reward}")
else:
msg = data.get("desc") or res.get("desc") or res.get("msg") or "未知错误"
self.tlog(
f"月签有礼: [{task_name}] 领取失败[{data.get('code') or code}]: {msg}"
)
async def sign_month_sign_gift(self):
r = await self.req(
"GET",
"https://activity.10010.com/sixPalaceGridTurntableLottery/floor/getMonthSign",
headers={"Referer": "https://img.client.10010.com/"},
)
res = require_dict_data(r, "查询月签有礼")
if str(res.get("code")) != "0000":
self.tlog(f"月签有礼: 查询失败[{res.get('code')}]: {res.get('desc', '')}")
return
task_list = res.get("data", {}).get("taskList", []) or []
if not task_list:
self.tlog("月签有礼: 暂无月签任务")
return
claim_tasks = [
t
for t in task_list
if str(t.get("taskStatus")) == "1" and t.get("taskId") and t.get("id")
]
claimed_count = sum(1 for t in task_list if str(t.get("taskStatus")) == "2")
if not claim_tasks:
self.tlog(
f"月签有礼: 暂无可领取奖励,已领取 {claimed_count}/{len(task_list)}"
)
return
for task in claim_tasks:
await self.sign_get_month_sign_reward(task)
await asyncio.sleep(1)
async def sign_query_my_prizes(self):
r = await self.req(
"POST",
"https://act.10010.com/SigninApp/convert/phoneDetails",
data={"log_type": "1", "number": "1", "list_num": ""},
headers={"Origin": "https://img.client.10010.com"},
)
res = require_dict_data(r, "查询账户明细")
if str(res.get("status")) != "0000":
return
data = res.get("data", {}).get("detailedBO", []) or []
logged = 0
for item in data:
if logged >= 5:
break
remark = item.get("remark", "")
buss_name = item.get("from_bussname", "")
if "兑换" not in remark and "兑换" not in buss_name:
continue
if logged == 0:
self.tlog("账户明细: 最近兑换记录")
self.tlog(
f" {item.get('order_time', '')} | {remark} (变动:{item.get('booksNumber') or item.get('books_number') or '0'})"
)
logged += 1
async def sign_task(self):
self._task_tag = "签到区"
self.tlog("开始")
await self.sign_get_telephone(is_initial=True)
await self.sign_get_continuous()
await self.sign_month_sign_gift()
await self.sign_get_task_list()
await self.sign_get_telephone()
await self.sign_query_my_prizes()
# === 2. 权益超市 ===
def market_auth_headers(self):
return {
"Authorization": f"Bearer {self.market_token}",
"Content-Type": "application/json",
}
@staticmethod
def market_current_date():
"""对齐 HAR:currentTime 不补零,如 2026-7-7"""
now = datetime.now()
return f"{now.year}-{now.month}-{now.day}"
@staticmethod
def market_is_open():
"""对齐前端 getDailyTimeRange:每天 10:00~24:00 可领"""
now = datetime.now()
return now.hour * 60 + now.minute >= 600
async def market_wait_until_open(self):
now = datetime.now()
start = now.replace(hour=10, minute=0, second=0, microsecond=0)
if now >= start:
return True
wait = (start - now).total_seconds()
max_wait = MARKET_WAIT_MAX_SECONDS
if wait > max_wait:
self.tlog(f"未到10点(距离开抢{wait:.0f}秒),跳过优享权益")
return False
self.tlog(f"等待开抢 {wait:.0f}秒")
await asyncio.sleep(wait)
return True
async def market_req_dict(self, action, method, url, **kw):
"""权益超市请求:要求 data 为 dict;网络/5xx 短重试,失败软返回 None。"""
last_r = None
for attempt in range(MARKET_API_RETRIES):
r = await self.req(method, url, **kw)
last_r = r
data = r.get("data") if r else None
if isinstance(data, dict):
return data, r
err = api_response_err(r, action)
if attempt + 1 < MARKET_API_RETRIES and is_retryable_api_response(r):
self.tlog(
f"优享权益: {action} 重试{attempt + 1}/{MARKET_API_RETRIES - 1} {err}"
)
await asyncio.sleep(MARKET_API_RETRY_BASE_SECONDS * (attempt + 1))
continue
return None, last_r
return None, last_r
async def market_fetch_select_info(self):
"""对齐 HAR:premium 优享会员 vipType=0"""
res, r = await self.market_req_dict(
"selectInfo",
"POST",
"https://backward.bol.wo.cn/prod-api/market/contactVip/selectInfo/v2?vipType=0",
headers=self.market_auth_headers(),
json={},
)
if res is None:
self.tlog(f"优享权益: 会员状态查询失败 {api_response_err(r, 'selectInfo')}")
return None
data = res.get("data")
if res.get("code") != 200 or not data:
self.tlog(f"优享权益: 会员状态查询失败 {api_response_err(r, 'selectInfo')}")
return None
if isinstance(data, list):
first = data[0]
if not isinstance(first, dict):
self.tlog(
f"优享权益: selectInfo data[0] 须为 dict,实际为 {type(first).__name__}"
)
return None
info = first
elif isinstance(data, dict):
info = data
else:
self.tlog(
f"优享权益: selectInfo data 须为非空 dict 或非空 list,实际为 {type(data).__name__}"
)
return None
try:
state_ok = require_int(info.get("state"), "selectInfo.state") == 1
except ValueError as e:
self.tlog(f"优享权益: 会员状态字段异常 {e}")
return None
if not state_ok:
self.tlog("优享权益: 非有效优享会员,跳过")
return None
self.market_cycle_start_time = str(info.get("cycleStartTime") or "")
return info
async def market_fetch_youchoice_activity(self):
payload = {
"majorId": 3,
"subCodeList": ["COMMUNICATE_GOODS", "YOUCHOICEONE"],
"currentTime": self.market_current_date(),
"withUserStatus": 1,
}
if self.market_cycle_start_time:
payload["cycleStartTime"] = self.market_cycle_start_time
res, r = await self.market_req_dict(
"getActivitiesDetail/v2",
"POST",
"https://backward.bol.wo.cn/prod-api/promotion/activity/roll/getActivitiesDetail/v2",
headers=self.market_auth_headers(),
json=payload,
)
if res is None:
self.tlog(
f"优享权益: 活动查询失败 {api_response_err(r, 'getActivitiesDetail/v2')}"
)
return None
data = res.get("data")
if res.get("code") != 200 or not data:
self.tlog(
f"优享权益: 活动查询失败 {api_response_err(r, 'getActivitiesDetail/v2')}"
)
return None
if not isinstance(data, list):
self.tlog(
f"优享权益: getActivitiesDetail/v2 data 须为 list,实际为 {type(data).__name__}"
)
return None
for act in data:
if not isinstance(act, dict):
self.tlog(
f"优享权益: getActivitiesDetail/v2 data 元素须为 dict,实际为 {type(act).__name__}"
)
return None
if act.get("activityCode") == "YOUCHOICEONE":
return act
self.tlog(
"优享权益: 未找到 YOUCHOICEONE 活动, 不领取兜底第一项 "
f"(共 {len(data)} 项: {[str(a.get('activityCode')) for a in data][:5]})"
)
return None
def market_build_try_order(self, detail_list):
"""优先当日惊喜[0];其后按 n-1..1 倒序尝试基础权益,跳过 status=3。
惊喜售罄/拦截/失败时仍继续后面的基础商品,不再只试惊喜一项。
"""
detail_list = detail_list or []
if not detail_list:
return []
order = []
first = detail_list[0]
surprise = require_int(first.get("isSurprise"), "isSurprise") == 1
if surprise:
if require_int(first.get("status"), "status") != 3:
order.append(first)
for idx in range(len(detail_list) - 1, 0, -1):
item = detail_list[idx]
if require_int(item.get("status"), "status") == 3:
continue
order.append(item)
return order
# 无惊喜位:整表倒序
for idx in range(len(detail_list) - 1, -1, -1):
item = detail_list[idx]
if require_int(item.get("status"), "status") == 3:
continue
order.append(item)
return order
@staticmethod
def market_should_try_next(msg: str) -> bool:
"""领取失败文案是否应继续尝试下一商品。"""
if not msg:
return False
keywords = (
"已领取",
"已经抢过",
"本周",
"售罄",
"抢完",
"已抢空",
"抢光",
"库存不足",
"无库存",
"已领完",
"领完",
"明天十点",
"明日再来",
)
return any(k in msg for k in keywords)
def market_item_blocked_reason(self, act, item):
"""对齐前端 handleReceiveRights 的拦截条件"""
if not self.market_is_open():
return "本场活动暂未开启"
times = require_int(act.get("userAvailableTimes"), "userAvailableTimes")
left = require_int(item.get("leftQuantity"), "leftQuantity")
status = require_int(item.get("status"), "status")
cycle = str(item.get("cycle") or "")
if times > 0 and left <= 0:
return "已抢空,明天十点再来"
if times <= 0:
if cycle == "WEEK" and status == 2:
return "本周已领取过该权益"
return "今天已经抢过啦"
if times > 0 and status == 2 and left > 0 and cycle == "WEEK":
return "本周已领取过该权益"
if status == 3:
return "本周期已领取一次"
if status == 2:
return "已抢到"
if left <= 0:
return "已抢空"
return ""
async def market_unlock_surprise(self, act):
"""对齐 HAR openNew:解锁使用 detailList[0]"""
detail_list = act.get("detailList") or []
if not detail_list:
return False
first = detail_list[0]
if (
require_int(act.get("isUnlock"), "isUnlock") == 1
or require_int(first.get("isSurprise"), "isSurprise") != 1
):
return True
if not self.mobile:
self.tlog("优享权益: 缺少手机号,无法解锁惊喜权益")
return False
ts = int(time.time() * 1000)
xbsosjl = "".join(random.choices(string.ascii_letters + string.digits, k=12))
res, r = await self.market_req_dict(
"unlockSurprise",
"POST",
f"https://backward.bol.wo.cn/prod-api/promotion/activity/roll/unlock/surpriseInterest"
f"?xbsosjl={xbsosjl}&timeVerRan={ts}&diceid={self.market_login_id}",
headers=self.market_auth_headers(),
json={
"timeVerRan": ts,
"mobile": self.mobile,
"id": first.get("id"),
"activityId": first.get("activityId") or act.get("activityId"),
},
)
if res is None:
self.tlog(
f"优享权益: 惊喜解锁失败 {api_response_err(r, 'unlockSurprise')}"
)
return False
if res.get("code") == 200:
self.tlog(f"优享权益: 惊喜解锁成功 {res.get('msg', '')}")
await asyncio.sleep(1.5)
return True
self.tlog(
f"优享权益: 惊喜解锁失败 {res.get('msg') or api_response_err(r, 'unlockSurprise')}"
)
return False
async def market_receive_rights(self, act, item):
payload = {
"channelId": item.get("channelId"),
"activityId": act.get("activityId"),
"productId": item.get("id"),
"productCode": item.get("productCode", ""),
"currentTime": self.market_current_date(),
"accountType": item.get("accountType", "4"),
}
r = await self.req(
"POST",
"https://backward.bol.wo.cn/prod-api/promotion/activity/roll/receiveRightsVip",
headers=self.market_auth_headers(),
json=payload,
)
return require_dict_data(r, "receiveRightsVip")
async def market_man_machine(self, activity_id):
if not activity_id:
return False
r = await self.req(
"POST",
f"https://backward.bol.wo.cn/prod-api/promotion/activity/roll/receive/manMachine?activityId={activity_id}",
headers=self.market_auth_headers(),
json={},
)
res = require_dict_data(r, "manMachine")
return res.get("code") == 200
async def market_login(self):
try:
ticket = await self.get_ticket("https://contact.bol.wo.cn/")
if not ticket:
self.tlog("登录失败: 获取ticket为空")
return False
r = await self.req(
"POST",
f"https://backward.bol.wo.cn/prod-api/auth/marketUnicomLogin?ticket={ticket}&channel=unicomTab",
headers={"Content-Type": "application/json"},
json={},
)
if (res := r["data"]) and res.get("code") == 200:
self.market_token = res.get("data", {}).get("token", "")
if self.market_token:
# 解析JWT获取login_id
try:
payload = self.market_token.split(".")[1]
payload += "=" * (-len(payload) % 4)
jwt = json.loads(base64.urlsafe_b64decode(payload).decode())
self.market_login_id = jwt.get("loginId", "")
except Exception as e:
self.tlog_exc(e, "解析权益超市JWT")
self.tlog("登录成功")
return True
self.tlog(
f"登录失败: 响应无token | {api_response_err(r, '权益超市登录')}"
)
return False
self.tlog(f"登录失败: {api_response_err(r, '权益超市登录')}")
except Exception as e:
self.tlog_exc(e, "权益超市登录")
return False
async def market_privilege(self):
"""优享权益(权益天天领)"""
try:
if not self.market_token:
return
# 预拉优化: 未到10点时先查会员状态, 避开开抢瞬间接口洪峰
self.market_prefetched_info = None
self.market_prefetched_act = None
if not self.market_is_open():
self.market_prefetched_info = await self.market_fetch_select_info()
if self.market_prefetched_info:
self.tlog("优享权益: 开抢前已预拉会员状态 ✅")
self.market_prefetched_act = await self.market_fetch_youchoice_activity()
if self.market_prefetched_act:
self.tlog("优享权益: 开抢前已预拉活动数据 ✅")
if not await self.market_wait_until_open():
return
if not self.market_is_open():
self.tlog("优享权益: 本场活动暂未开启")
return
await asyncio.sleep(max(0, (self.idx - 1) * 0.15))
if self.market_prefetched_info is None:
if not await self.market_fetch_select_info():
return
# 10点直发: 用预拉活动数据直接提交抢购(跳过查询/解锁/拦截判断)
if self.market_prefetched_act:
act = self.market_prefetched_act
items = self.market_build_try_order(act.get("detailList") or [])
if items:
self.tlog(f"优享权益: 10点直发抢购({len(items)}个商品)")
for i, item in enumerate(items):
name = item.get("productName", "")
try:
res = await self.market_receive_rights(act, item)
except ValueError as e:
self.tlog(f"优享权益: [{name}] 直发失败 {e}")
continue
code = safe_int(res.get("code"), -1)
if code == 200:
self.tlog(f"优享权益: [{name}] 成功(10点直发)")
return
msg = str(res.get("msg", ""))
self.tlog(f"优享权益: [{name}] 直发失败 {msg or res}")
if self.market_should_try_next(msg):
continue
break
self.tlog("优享权益: 直发未成功, 降级走常规流程")
act = await self.market_fetch_youchoice_activity()
if not act:
return
if require_int(act.get("userAvailableTimes"), "userAvailableTimes") <= 0:
self.tlog("优享权益: 今日已领")
return
detail_list = act.get("detailList") or []
items = self.market_build_try_order(detail_list)
if not items:
self.tlog("优享权益: 无可尝试商品")
return
if detail_list:
first = detail_list[0]
if (
require_int(first.get("isSurprise"), "isSurprise") == 1
and require_int(first.get("status"), "status") == 3
):
self.tlog("优享权益: 当日惊喜本周期已领,按倒序尝试基础权益")
elif require_int(first.get("isSurprise"), "isSurprise") == 1:
base_n = max(0, len(items) - 1)
self.tlog(
f"优享权益: 优先尝试惊喜 [{first.get('productName', '')}]"
f",失败后可尝试 {base_n} 个基础权益"
)
else:
self.tlog(f"优享权益: 按规则尝试 {len(items)} 个商品")
need_surprise_unlock = (
require_int(act.get("isUnlock"), "isUnlock") != 1
and detail_list
and require_int(detail_list[0].get("isSurprise"), "isSurprise") == 1
)
if need_surprise_unlock:
unlocked = await self.market_unlock_surprise(act)
act = await self.market_fetch_youchoice_activity() or act
detail_list = act.get("detailList") or detail_list
items = self.market_build_try_order(detail_list)
if not unlocked:
# 解锁失败:去掉惊喜项,只领基础权益
first = detail_list[0] if detail_list else None
if first and require_int(first.get("isSurprise"), "isSurprise") == 1:
fid = first.get("id")
items = [
it
for it in items
if it is not first and it.get("id") != fid
]
self.tlog("优享权益: 惊喜解锁失败,改为尝试基础权益")
if not items:
self.tlog("优享权益: 无可尝试商品")
return
for i, item in enumerate(items):
name = item.get("productName", "")
reason = self.market_item_blocked_reason(act, item)
if reason:
self.tlog(f"优享权益: [{name}] 跳过({reason})")
continue
try:
res = await self.market_receive_rights(act, item)
except ValueError as e:
self.tlog(f"优享权益: [{name}] 领取请求失败 {e}")
continue
code = safe_int(res.get("code"), -1)
if code == 200:
self.tlog(f"优享权益: [{name}] 成功")
return
if code == -2:
self.tlog(f"优享权益: [{name}] 需人机验证,尝试 manMachine")
try:
if await self.market_man_machine(act.get("activityId")):
res = await self.market_receive_rights(act, item)
if safe_int(res.get("code")) == 200:
self.tlog(f"优享权益: [{name}] 成功")
return
except ValueError as e:
self.tlog(f"优享权益: [{name}] 人机/重试失败 {e}")
self.tlog(f"优享权益: [{name}] 人机验证后仍未成功,尝试下一商品")
continue
msg = str(res.get("msg", ""))
self.tlog(f"优享权益: [{name}] 失败 {msg or res}")
if self.market_should_try_next(msg):
if i + 1 < len(items):
nxt = items[i + 1].get("productName", "")
self.tlog(f"优享权益: 按规则继续下一商品 [{nxt}]")
continue
# 明确不可再领(次数用尽等)时停止
if any(
k in msg
for k in ("今天已经抢过", "今日已领", "没有领取次数", "次数不足")
):
break
else:
self.tlog("优享权益: 已尝试全部可领商品,均未成功")
except Exception as e:
self.tlog_exc(e, "优享权益")
async def market_raffle(self):
"""抽奖"""
try:
if not self.market_token:
return
r = await self.req(
"POST",
"https://backward.bol.wo.cn/prod-api/promotion/home/raffleActivity/getUserRaffleCount?id=12&channel=unicomTab",
headers={"Authorization": f"Bearer {self.market_token}"},
json={},
)
res = require_dict_data(r, "查询抽奖次数")
if res.get("code") != 200:
self.tlog(
f"查询抽奖次数失败: code={res.get('code')} {response_summary(res)}"
)
return
count = require_int(res.get("data"), "raffleCount", minimum=0)
self.tlog(f"抽奖次数: {count}")
for _ in range(min(count, 10)): # 服务端次数硬上限, 防异常值失控
await asyncio.sleep(2)
ts = int(time.time() * 1000)
r = await self.req(
"POST",
f"https://backward.bol.wo.cn/prod-api/promotion/home/raffleActivity/userRaffle?id=12&channel=unicomTab&timeVerRan={ts}",
headers={"Authorization": f"Bearer {self.market_token}"},
json={},
)
res = require_dict_data(r, "抽奖")
if res.get("code") != 200:
self.tlog(
f"抽奖失败: code={res.get('code')} {response_summary(res)}"
)
break
data = res.get("data", {})
if data.get("isWinning") and (prize := data.get("prizesName")):
self.tlog(f"抽奖: {prize}")
else:
self.tlog("抽奖: 未中奖")
except Exception as e:
self.tlog_exc(e, "抽奖")
async def market_task(self):
self._task_tag = "权益超市"
self.tlog("开始")
if await self.market_login():
await self.market_privilege()
await self.market_raffle()
# === 3. 联通祝福 ===
def wocare_decode(self, result):
"""解码wocare响应的messageContent字段"""
if not isinstance(result, dict) or "messageContent" not in result:
return result
try:
content = (
result["messageContent"]
.replace("\n", "")
.replace("\r", "")
.replace(" ", "")
)
content = content.replace("-", "+").replace("_", "/")
missing = len(content) % 4
if missing:
content += "=" * (4 - missing)
decoded = json.loads(base64.b64decode(content).decode("utf-8"))
except (AttributeError, TypeError, ValueError, UnicodeError) as e:
raise ValueError("联通祝福响应解码失败") from e
if not isinstance(decoded, dict):
raise ValueError(
f"联通祝福响应解码失败: decoded 非dict (type={type(decoded).__name__})"
)
result.update(decoded)
return result
def wocare_body(self, api, data):
ts = datetime.now().strftime("%Y%m%d%H%M%S%f")[:-3]
body = {
"version": WOCARE_CH[3],
"apiCode": api,
"channelId": WOCARE_CH[0],
"transactionId": ts + "".join(random.choices(string.digits, k=6)),
"timeStamp": ts,
"messageContent": base64.b64encode(
json.dumps(data, separators=(",", ":")).encode()
).decode(),
}
sign_str = (
"&".join(f"{k}={body[k]}" for k in sorted(body))
+ f"&sign={WOCARE_CH[1]}"
)
body["sign"] = hashlib.md5(sign_str.encode()).hexdigest()
return body
async def wocare_post(self, api, data, retries=3):
# wocare 服务端 7 月起变慢,默认 50s 超时会拖死任务,改短超时+重试
for i in range(retries):
r = await self.req(
"POST",
f"https://wocare.unisk.cn/api/v1/{api}",
data=self.wocare_body(api, data),
timeout=15,
)
if is_retryable_api_response(r) and i < retries - 1:
# 措辞避开"失败/异常",否则重试成功也会被推送过滤器当成故障
self.tlog(
f"{api} 第{i + 1}/{retries}次未响应,重试: {api_response_err(r, api)}"
)
await asyncio.sleep(1.5 * (i + 1))
continue
return self.wocare_decode(require_dict_data(r, api))
async def ltzf_task(self):
self._task_tag = "联通祝福"
self.tlog("开始")
ticket = await self.get_ticket(
f"https://wocare.unisk.cn/mbh/getToken?channelType={WOCARE_CH[2]}&homePage=home&duanlianjieabc=qAz2m"
)
if not ticket:
self.tlog("获取ticket失败")
return
# 获取sid和token
r = await self.req(
"GET",
"https://wocare.unisk.cn/mbh/getToken",
params={
"channelType": WOCARE_CH[2],
"type": "02",
"ticket": ticket,
"version": APP_VERSION,
"timestamp": datetime.now().strftime("%Y%m%d%H%M%S%f")[:-3],
"desmobile": self.mobile,
"num": 0,
"postage": "".join(
random.choices(string.ascii_letters + string.digits, k=32)
),
"homePage": "home",
"duanlianjieabc": "qAz2m",
"userNumber": self.mobile,
},
allow_redirects=False,
)
if r["code"] == 302 and (
loc := r["headers"].get("location") or r["headers"].get("Location")
):
self.wocare_sid = parse_qs(urlparse(loc).query).get("sid", [""])[0]
if not self.wocare_sid:
self.wocare_sid = parse_qs(urlparse(loc).query).get("uuid", [""])[0]
if not self.wocare_sid:
self.tlog("重定向缺少sid/uuid")
return
# 登录
res = await self.wocare_post(
"loginmbh",
{
"sid": self.wocare_sid,
"channelType": WOCARE_CH[2],
"apiCode": "loginmbh",
},
)
if str(res.get("resultCode")) == "0000":
self.wocare_token = (res.get("data") or {}).get("token")
if not self.wocare_token:
self.tlog("登录响应缺少token")
return
self.tlog("登录成功")
# 获取动态Banner列表
res = await self.wocare_post(
"getSpecificityBanner",
{
"token": self.wocare_token,
"apiCode": "getSpecificityBanner",
},
)
if str(res.get("resultCode")) != "0000":
self.tlog(
f"接口失败: getSpecificityBanner resultCode={res.get('resultCode')} {res.get('resultMsg') or res.get('resultDesc') or ''}"
)
return
raw_data = res.get("data")
if raw_data is None:
raw_data = []
elif not isinstance(raw_data, list):
self.tlog(
f"接口失败: getSpecificityBanner 响应data格式错误(type={type(raw_data).__name__})"
)
return
banner_list = [
b
for b in raw_data
if isinstance(b, dict)
and str(b.get("activityStatus")) == "0"
and str(b.get("isDeleted")) == "0"
]
if not banner_list:
self.tlog("未返回可用动态活动,停止")
return
# 执行任务和抽奖
for activity in banner_list:
await asyncio.sleep(1)
try:
res = await self.wocare_post(
"loadInit",
{
"token": self.wocare_token,
"channelType": WOCARE_CH[2],
"type": activity["id"],
"apiCode": "loadInit",
},
)
if str(res.get("resultCode")) == "0000":
data = res.get("data", {}) or {}
group_id = data.get("zActiveModuleGroupId") or 0
aid = activity["id"]
# 计算抽奖次数
if aid == 2:
is_partake = (data.get("data") or {}).get("isPartake") or 0
count = 0 if is_partake else 1
elif aid == 3:
count = int(data.get("raffleCountValue", 0) or 0)
elif aid == 4:
count = int(data.get("mhRaffleCountValue", 0) or 0)
else:
count = 0
if count > 0:
self.tlog(f"{activity.get('name', '')}: 可抽奖{count}次")
# 执行抽奖;触发"频繁"风控时该次不计数,重试同一次
done = retry = 0
while done < min(count, 10) and retry < 30: # 双重硬上限
await asyncio.sleep(random.uniform(3, 5))
res = await self.wocare_post(
"luckDraw",
{
"token": self.wocare_token,
"channelType": WOCARE_CH[2],
"zActiveModuleGroupId": group_id,
"type": aid,
"apiCode": "luckDraw",
},
)
if str(res.get("resultCode")) == "0000":
draw_data = res.get("data", {}) or {}
draw_code = str(draw_data.get("resultCode", "-1"))
if draw_code == "0000":
prize = (
draw_data.get("data") or {}
).get("prize") or {}
if pn := prize.get("prizeName"):
pd = prize.get("prizeDesc", "")
self.tlog(
f"{activity.get('name', '')}: {pn}[{pd}]"
)
else:
msg = (
draw_data.get("resultMsg")
or draw_data.get("resultDesc")
or res.get("resultMsg")
or res.get("resultDesc")
or ""
)
if "频繁" in msg and retry < 3:
retry += 1
self.tlog(
f"{activity.get('name', '')}: {msg},5秒后重试"
)
await asyncio.sleep(5)
continue
if msg.lower() == "success":
self.tlog(
f"{activity.get('name', '')}: 未中奖"
)
else:
self.tlog(
f"{activity.get('name', '')}: {msg if msg else '抽奖完成'}"
)
done += 1
retry = 0
else:
self.tlog(f"{activity.get('name', '')}: 今日已无抽奖机会")
except (ValueError, AttributeError, TypeError, KeyError) as e:
# 单个 banner 的畸形响应不能拖垮后面的 banner
self.tlog_exc(e, activity.get("name") or "banner")
continue
else:
self.tlog(
f"登录失败: loginmbh resultCode={res.get('resultCode')} {res.get('resultMsg') or res.get('resultDesc') or ''}"
)
else:
self.tlog(f"获取sid失败: {api_response_err(r, 'getToken')}")
# === 4. 新疆联通 ===
async def xj_task(self):
self._task_tag = "新疆联通"
if "新疆" not in self.province:
return
self.tlog("开始")
ticket = await self.get_ticket(
"https://zy100.xj169.com/touchpoint/openapi/jumpHandRoom1G?source=155&type=02"
)
if not ticket:
return
# 9 月改版后 draw 接口有会话校验:必须先访问活动落地页,让服务端下发会话 cookie
if self.last_ticket_url:
await self.req("GET", self.last_ticket_url, headers={"User-Agent": UA})
# 完整落地页 URL 作 Referer(9 月改版后 draw 系列接口校验 referer/origin)
xj_referer = self.last_ticket_url or (
"https://zy100.xj169.com/touchpoint/openapi/jumpHandRoom1G"
f"?source=155&type=02&ticket={ticket}"
)
# 获取token
r = await self.req(
"POST",
"https://zy100.xj169.com/touchpoint/openapi/getTokenAndCity",
headers={
"Referer": xj_referer,
"Origin": "https://zy100.xj169.com",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
data={"ticket": ticket},
)
token = None
if (res := r["data"]) and isinstance(res, dict):
if res.get("code") == 0:
token = res.get("data", {}).get("token")
if not token:
return
self.tlog("登录成功")
# 获取活动ID
month_abbr = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
now = datetime.now()
month_activity_id = f"{month_abbr[now.month - 1]}{now.year}Act"
# 获取commHighFlag(服务端要求小写字符串 true/false)
xj_comm_high_flag = "false"
r = await self.req(
"POST",
"https://zy100.xj169.com/touchpoint/openapi/themeAct/commonHightRest",
headers={
"userToken": token,
"Referer": xj_referer,
"Origin": "https://zy100.xj169.com",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
data={"activityId": month_activity_id},
)
if (
(res := r["data"])
and isinstance(res, dict)
and res.get("code") in [0, "SUCCESS"]
):
raw_flag = res.get("data", False)
xj_comm_high_flag = (
"true" if raw_flag is True or str(raw_flag).lower() == "true" else "false"
)
# 每月主题抽奖
r = await self.req(
"POST",
f"https://zy100.xj169.com/touchpoint/openapi/themeAct/draw_{month_activity_id}",
headers={
"userToken": token,
"Referer": xj_referer,
"Origin": "https://zy100.xj169.com",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
data={
"activityId": month_activity_id,
"prizeId": "",
"commHighFlag": xj_comm_high_flag,
},
)
if (res := r["data"]) and isinstance(res, dict):
msg, data = res.get("msg"), res.get("data")
if msg == "thanks1":
self.tlog(f"每月抽奖: {data}")
elif res.get("code") in [0, "SUCCESS"]:
self.tlog(f"每月抽奖: {msg or data}")
# 中奖后与活动页一致:下发短信
prize_code = str(msg or "")
if prize_code and prize_code != "thanks1":
await self.req(
"POST",
"https://zy100.xj169.com/touchpoint/openapi/marchAct/draw_SMS",
headers={
"userToken": token,
"Referer": xj_referer,
"Origin": "https://zy100.xj169.com",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
data={
"activityId": month_activity_id,
"prizeId": prize_code,
},
)
elif data and (
"已经打过卡" in str(data)
or "机会已用完" in str(data)
or "已达上限" in str(data)
):
self.tlog(f"每月抽奖: {data}")
else:
self.tlog(f"每月抽奖: {data or msg}")
# 每日打卡抽奖(页面上有两个打卡按钮,逐个尝试)
for idx, daka_prize in enumerate(
("daka_sftczxqb_twenty", "daka_mtwmhblly"), start=1
):
await asyncio.sleep(1)
r = await self.req(
"POST",
f"https://zy100.xj169.com/touchpoint/openapi/themeAct/draw_{month_activity_id}",
headers={
"userToken": token,
"Referer": xj_referer,
"Origin": "https://zy100.xj169.com",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
data={
"activityId": f"daka{month_activity_id}",
"prizeId": daka_prize,
"commHighFlag": xj_comm_high_flag,
},
)
if (res := r["data"]) and isinstance(res, dict):
msg, data = res.get("msg"), res.get("data")
if msg == "thanks1":
self.tlog(f"每日打卡{idx}: {data}")
elif res.get("code") in [0, "SUCCESS"]:
self.tlog(f"每日打卡{idx}: {msg or data}")
elif res.get("msgType") == "200":
self.tlog(f"每日打卡{idx}: 已打过卡")
elif data and ("已经打过卡" in str(data) or "机会已用完" in str(data)):
self.tlog(f"每日打卡{idx}: {data}")
else:
self.tlog(f"每日打卡{idx}: {data or msg}")
# === 5. 安全管家 ===
async def sec_task(self):
self._task_tag = "安全管家"
self.tlog("开始")
if not self.ecs_token or not self.mobile:
return
# 获取ticket
ts = datetime.now().strftime("%Y%m%d%H%M%S")
r = await self.req(
"GET",
f"https://m.client.10010.com/edop_ng/getTicketByNative?token={self.ecs_token}&appId=edop_unicom_3a6cc75a",
headers={
"Cookie": f"PvSessionId={ts}{''.join(random.choices(string.ascii_letters + string.digits, k=32))};c_mobile={self.mobile};c_version=iphone_c@11.0800;ecs_token={self.ecs_token}",
"User-Agent": "ChinaUnicom4.x/12.3.1 (com.chinaunicom.mobilebusiness; build:77; iOS 16.6.0) Alamofire/4.7.3",
},
)
ticket1 = require_dict_data(r, "安全管家ticket").get("ticket")
if not ticket1:
return
# 获取token
r = await self.req(
"POST",
"https://uca.wo116114.com/api/v1/auth/ticket?product_line=uasp&entry_point=h5&entry_point_id=edop_unicom_3a6cc75a",
headers={
"clientType": "uasp_unicom_applet",
"Content-Type": "application/json",
},
json={"productId": "", "type": 1, "ticket": ticket1},
)
res = require_dict_data(r, "安全管家auth/ticket")
if not res.get("data"):
return
self.sec_token = res["data"].get("access_token")
if not self.sec_token:
return
# 获取积分ticket
r = await self.req(
"POST",
"https://uca.wo116114.com/api/v1/auth/getTicket?product_line=uasp&entry_point=h5&entry_point_id=edop_unicom_3a6cc75a",
headers={
"auth-sa-token": self.sec_token,
"clientType": "uasp_unicom_applet",
"Content-Type": "application/json",
},
json={"productId": "91311616", "phone": self.mobile},
)
res = require_dict_data(r, "安全管家getTicket")
if not res.get("data"):
return
self.sec_ticket = res["data"].get("ticket")
# 获取密钥
sec_jea_id = ""
t = unquote(self.sec_ticket) if self.sec_ticket else ""
r = await self.req(
"GET",
"https://m.jf.10010.com/jf-external-application/jftask/getSecretKey",
headers={
"ticket": t,
"partnersid": "1702",
"clienttype": "uasp_unicom_applet",
"Accept": "application/json, text/plain, */*",
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15",
"pageId": "s768590754920323072",
},
)
for cookie in iter_set_cookies(r["headers"]):
if "_jea_id=" in cookie:
sec_jea_id = cookie.split("_jea_id=")[1].split(";")[0]
break
res = require_dict_data(r, "获取密钥")
if str(res.get("code")) != "0000":
self.tlog(f"获取密钥失败: {res}")
return
self.sec_key = res.get("data", {}).get("secretKey")
if not self.sec_key:
return
self.sec_jea_id = sec_jea_id
# 执行任务
def sec_headers():
ts, nonce = (
str(int(time.time() * 1000)),
"".join(random.choices(string.ascii_lowercase + string.digits, k=8)),
)
sig = hmac.new(
self.sec_key.encode(), f"{nonce}{ts}".encode(), hashlib.sha256
).hexdigest()
return {
"ticket": t,
"User-Agent": SEC_UA,
"partnersid": "1702",
"clienttype": "uasp_unicom_applet",
"Cookie": f"_jea_id={self.sec_jea_id}",
"X-Request-Timestamp": ts,
"X-Request-Nonce": nonce,
"X-Request-Signature": sig,
}
# 签到任务
r = await self.req(
"POST",
"https://m.jf.10010.com/jf-external-application/jftask/taskDetail",
headers=sec_headers(),
json={},
)
if (res := r["data"]) and res.get("data", {}).get("taskDetail"):
for task in res["data"]["taskDetail"].get("taskList", []):
if "签到" in task.get("taskName", "") and task.get(
"needCount", 1
) > task.get("finishCount", 0):
await self.req(
"POST",
"https://m.jf.10010.com/jf-external-application/uasptask/sign",
headers=sec_headers(),
json={"taskCode": task.get("taskCode")},
)
await self.req(
"POST",
"https://m.jf.10010.com/jf-external-application/jftask/receive",
headers=sec_headers(),
json={"taskCode": task.get("taskCode")},
)
self.tlog("签到完成")
break
# 注: 代接/号段拦截/黑名单等操作任务需真机微信小程序(瑞数设备指纹), VPS无法完成
# 仅签到可自动化 (2026-08-08 实测确认)
# === 6. 通通农场(通通乡村)===
def ttxc_headers(self, auth=True, ecs=False):
headers = {
"User-Agent": TTXC_UA,
"Content-Type": "application/json",
"Accept": "*/*",
"Origin": "https://epay.10010.com",
"Referer": TTXC_REFERER,
"X-Requested-With": "com.sinovatech.unicom.ui",
}
if auth and self.ttxc_token:
headers["Authorization"] = self.ttxc_token
if ecs and self.ecs_token:
self.client.cookies.set("ecs_token", self.ecs_token, domain=".10010.com")
return headers
async def ttxc_post(self, path, payload=None, auth=True, with_user=True, ecs=False):
data = dict(payload or {})
if with_user:
data.setdefault("userId", self.ttxc_user_id or "")
data.setdefault("channel", TTXC_CHANNEL)
r = await self.req(
"POST",
f"{TTXC_BASE_URL}{path}",
json=data,
headers=self.ttxc_headers(auth=auth, ecs=ecs),
)
return require_dict_data(r, path)
def ttxc_snapshot_cookies(self):
return [(c.name, c.value, c.domain) for c in self.client.cookies.jar]
def ttxc_restore_cookies(self, saved):
for name, value, domain in saved:
self.client.cookies.set(name, value, domain=domain or ".10010.com")
async def ttxc_finish_woauth(self, login_url, saved_cookies=None):
headers = {"Referer": "https://epay.10010.com/", "User-Agent": TTXC_UA}
r = await self.req("GET", login_url, headers=headers)
if r["code"] != 200:
return False
text = r["data"] if isinstance(r["data"], str) else ""
match = re.search(r'var token = "([^"]+)"', text)
if not match:
return False
next_url = (
"https://epay.10010.com/woauth2/after-collected-device-digest"
f"?deviceDigestTraceId=&deviceDigestTokenId=&token={quote(match.group(1))}&source=app_sjyyt"
)
referer = login_url
for _ in range(6):
r = await self.req(
"GET",
next_url,
allow_redirects=False,
headers={"Referer": referer, "User-Agent": TTXC_UA},
)
location = r["headers"].get("location") or r["headers"].get("Location")
if not location:
ok = r["code"] == 200
if ok and saved_cookies:
self.ttxc_restore_cookies(saved_cookies)
if self.ecs_token:
self.client.cookies.set(
"ecs_token", self.ecs_token, domain=".10010.com"
)
return ok
referer, next_url = next_url, location
return False
async def ttxc_init_ttgame(self):
if not self.ecs_token:
return False
self.client.cookies.set("ecs_token", self.ecs_token, domain=".10010.com")
url = f"{TTXC_APP_BASE_URL}/v1/login/ttGame?channel={TTXC_CHANNEL}&rptId="
last = {}
for attempt in range(1, 4):
if self.ecs_token:
self.client.cookies.set(
"ecs_token", self.ecs_token, domain=".10010.com"
)
r = await self.req(
"POST",
url,
json={"unicomTokenId": self.unicom_token_id},
headers=self.ttxc_headers(auth=False, ecs=True),
)
last = r["data"] if isinstance(r["data"], dict) else {}
if last.get("code") == "0000":
return True
if last.get("code") == "4003" and last.get("data"):
saved = self.ttxc_snapshot_cookies()
if await self.ttxc_finish_woauth(last.get("data"), saved):
r = await self.req(
"POST",
url,
json={"unicomTokenId": self.unicom_token_id},
headers=self.ttxc_headers(auth=False, ecs=True),
)
last = r["data"] if isinstance(r["data"], dict) else {}
if last.get("code") == "0000":
return True
if attempt < 3:
await asyncio.sleep(2)
self.tlog(f"初始化失败[{last.get('code')}]: {last.get('msg', '')}")
return False
async def ttxc_login(self, update_nick=True):
if not self.ecs_token:
self.tlog("缺少 ecs_token,跳过")
return False
if not await self.ttxc_init_ttgame():
return False
data = await self.ttxc_post(
"/user/v1/login", auth=False, with_user=False, ecs=True
)
if data.get("code") != 0:
self.tlog(f"登录失败[{data.get('code')}]: {data.get('msg', '')}")
return False
user = data.get("data") or {}
self.ttxc_user_id = user.get("userId", "")
self.ttxc_token = data.get("token", "")
self.ttxc_charge_level = user.get("chargeLevel") or {}
self.ttxc_newbie_list = user.get("newbieList")
self.ttxc_nick_name = user.get("nickName") or ""
if not self.ttxc_user_id or not self.ttxc_token:
self.tlog("登录响应缺少 userId/token")
return False
carbon = self.ttxc_charge_level.get("carbonNum", 0)
eco = self.ttxc_charge_level.get("ecologyAmount", 0)
self.tlog(f"登录成功,碳能量{carbon}g,生态值{eco}")
if update_nick and not self.ttxc_nick_name and self.ttxc_newbie_done():
await self.ttxc_update_nick()
return True
async def ttxc_update_nick(self):
nick = (self.mobile or "")[-4:] or str(random.randint(1000, 9999))
data = await self.ttxc_post("/user/v1/updateNick", {"nickName": nick})
if data.get("code") == 0:
self.ttxc_nick_name = nick
self.tlog(f"已设置昵称 {nick}")
return True
self.tlog(f"设置昵称失败[{data.get('code')}]: {data.get('msg', '')}")
return False
def ttxc_newbie_done(self):
steps = getattr(self, "ttxc_newbie_list", None)
if not isinstance(steps, list):
# 接口改版/畸形响应: 维持跳过新手任务的现行为, 但留痕便于排查
self.tlog(f"新手任务列表异常({type(steps).__name__}), 按已完成处理")
return True
return all(step in steps for step in TTXC_NEWBIE_STEPS)
async def ttxc_newbie_mark(self, step):
target = []
for item in TTXC_NEWBIE_STEPS:
target.append(item)
if item == step:
break
data = await self.ttxc_post(
"/user/v1/newbie", {"newbieList": target, "type": 1}
)
if data.get("code") == 0:
self.ttxc_newbie_list = data.get("data") or target
return True
self.tlog(f"新手步骤{step}失败[{data.get('code')}]: {data.get('msg', '')}")
return False
def ttxc_newbie_need(self, step):
steps = getattr(self, "ttxc_newbie_list", None)
return isinstance(steps, list) and step not in steps
async def ttxc_first_newbie_land(self, lands=None):
lands = lands if lands is not None else await self.ttxc_get_lands()
active = next(
(
land
for land in lands
if land.get("status") in [2, 3]
and (land.get("plant") or {}).get("plantId")
),
None,
)
if active:
return active
return next((land for land in lands if land.get("status") == 1), None)
async def ttxc_newbie_charge_land(self):
lands = await self.ttxc_get_lands()
active = [
land
for land in lands
if land.get("status") == 3 and (land.get("plant") or {}).get("plantId")
]
return next(
(
land
for land in active
if str((land.get("plant") or {}).get("curLevel")) in ["0", "1"]
),
None,
) or (active[0] if active else None)
async def ttxc_harvest_land(self, land, newbie=False):
if not land:
return None
plant = land.get("plant") or {}
plant_id, land_index = plant.get("plantId"), land.get("landIndex")
if not plant_id or not land_index:
return None
if land.get("status") == 2 and TTXC_HARVEST_WAIT_SECONDS > 0:
await asyncio.sleep(TTXC_HARVEST_WAIT_SECONDS)
path = "/plant/v1/newHarvest" if newbie else "/plant/v1/harvest"
data = await self.ttxc_post(
path, {"landIndex": land_index, "plantId": plant_id}
)
if data.get("code") == 0:
self.tlog(f"地块{land_index}收获成功")
return data.get("data") or {
"landIndex": land_index,
"status": 1,
"plant": None,
}
self.tlog(
f"地块{land_index}收获失败[{data.get('code')}]: {data.get('msg', '')}"
)
return None
async def ttxc_newbie_task(self):
if self.ttxc_newbie_done():
return False
need_farm = any(
self.ttxc_newbie_need(step)
for step in ["G03", "G03_2", "G04", "G05", "G09", "G10"]
)
lands = await self.ttxc_get_lands() if need_farm else []
plant_id, current = "", None
if need_farm:
current = await self.ttxc_first_newbie_land(lands)
await self.ttxc_post("/client/v1/plant/type", {})
plant_id = await self.ttxc_get_plant_id()
if not plant_id:
self.tlog("新手任务缺少作物ID")
return False
if self.ttxc_newbie_need("G03") and not await self.ttxc_newbie_mark("G03"):
return False
if self.ttxc_newbie_need("G03_2"):
has_crop = (
current
and current.get("status") in [2, 3]
and (current.get("plant") or {}).get("plantId")
)
if not has_crop:
data = await self.ttxc_post(
"/client/v1/plant/buy", {"plantId": plant_id, "gameCfgId": ""}
)
if data.get("code") != 0:
self.tlog(
f"新手购买作物失败[{data.get('code')}]: {data.get('msg', '')}"
)
return False
if not await self.ttxc_newbie_mark("G03_2"):
return False
if self.ttxc_newbie_need("G04"):
if not current or not current.get("landIndex"):
self.tlog("新手任务缺少可种植地块")
return False
if not (
current.get("status") in [2, 3]
and (current.get("plant") or {}).get("plantId")
):
data = await self.ttxc_post(
"/plant/v1/planting",
{"landIndex": current.get("landIndex"), "plantId": plant_id},
)
if data.get("code") != 0:
self.tlog(
f"新手种植失败[{data.get('code')}]: {data.get('msg', '')}"
)
return False
current = data.get("data") or {
"landIndex": current.get("landIndex"),
"status": 3,
"plant": {"plantId": plant_id},
}
if not await self.ttxc_newbie_mark("G04"):
return False
if self.ttxc_newbie_need("G05"):
current = (
current
if current and current.get("plant")
else await self.ttxc_first_newbie_land()
)
current = await self.ttxc_charge_land(current)
if not current or not await self.ttxc_newbie_mark("G05"):
return False
if self.ttxc_newbie_need("G09"):
plant = (current or {}).get("plant") or {}
level = str(plant.get("curLevel") or "")
if not (
current
and current.get("status") == 3
and plant.get("plantId")
and level in ["", "0", "1"]
):
current = await self.ttxc_newbie_charge_land()
current = await self.ttxc_charge_land(current, mock=1)
if not current or not await self.ttxc_newbie_mark("G09"):
return False
if self.ttxc_newbie_need("G10"):
current = (
current
if current and current.get("plant")
else await self.ttxc_first_newbie_land()
)
if not await self.ttxc_harvest_land(
current, newbie=True
) or not await self.ttxc_newbie_mark("G10"):
return False
if self.ttxc_newbie_need("G11") and not await self.ttxc_newbie_mark("G11"):
return False
if self.ttxc_newbie_need("G12"):
if not self.ttxc_nick_name and not await self.ttxc_update_nick():
return False
if not await self.ttxc_newbie_mark("G12"):
return False
self.tlog("新手任务已完成")
return True
async def ttxc_sign(self):
info = await self.ttxc_post("/client/v1/sign/info", {})
code = (info.get("data") or {}).get("signinCode")
if not code:
self.tlog("获取签到码失败")
return
user = await self.ttxc_post("/client/v1/sign/user", {"code": code})
last_time = str((user.get("data") or {}).get("lastSigninTime") or "")
if last_time[:10] == datetime.now().strftime("%Y-%m-%d"):
self.tlog("今日已签到")
return
data = await self.ttxc_post("/client/v1/sign/signIn", {"code": code})
if data.get("code") == 0:
sign_data = data.get("data") or {}
keep_value = safe_int(
sign_data.get("keepSigninValue")
or sign_data.get("lastKeepSigninValue")
or sign_data.get("totalSigninValue")
)
award_items = (info.get("data") or {}).get("awards") or []
energy = 0
for item in award_items:
if (
item.get("awardType") == "KEEP"
and safe_int(item.get("signinValue")) == keep_value
):
energy = safe_int(item.get("carbonEnergyAmount"))
break
if not energy:
charge_level = data.get("chargeLevel") or {}
before = safe_int((self.ttxc_charge_level or {}).get("carbonNum"))
after = safe_int(charge_level.get("carbonNum"))
energy = max(after - before, 0)
if data.get("chargeLevel"):
self.ttxc_charge_level = (
data.get("chargeLevel") or self.ttxc_charge_level
)
self.tlog(f"签到成功 +{energy}g" if energy else "签到成功")
else:
self.tlog(f"签到失败[{data.get('code')}]: {data.get('msg', '')}")
async def ttxc_get_tasks(self):
data = await self.ttxc_post("/client/v1/task/list", {})
if data.get("code") != 0:
self.tlog(f"获取任务列表失败[{data.get('code')}]: {data.get('msg', '')}")
return []
tasks = []
for group in data.get("data") or []:
for task in group.get("taskList") or []:
task["taskGroupName"] = group.get("taskGroupName", "")
tasks.append(task)
return tasks
async def ttxc_finish_task(self, task):
task_id = task.get("taskCode")
if not task_id:
return False
data = await self.ttxc_post("/client/v1/task/finish", {"taskId": task_id})
name = task.get("taskTitle", task_id)
if data.get("code") == 0:
reward = task.get("carbonEnergyAmount") or 0
self.tlog(f"领取[{name}]成功 +{reward}g")
return True
self.tlog(f"领取[{name}]失败[{data.get('code')}]: {data.get('msg', '')}")
return False
async def ttxc_do_task(self, task):
data = await self.ttxc_post(
"/client/v1/task/do", {"taskId": task.get("taskCode")}
)
name = task.get("taskTitle", task.get("taskCode", ""))
if data.get("code") == 0:
self.tlog(f"已执行[{name}]")
return True
self.tlog(f"执行[{name}]失败[{data.get('code')}]: {data.get('msg', '')}")
return False
async def ttxc_claim_ready_tasks(self, tasks, claimed=None):
if claimed is None:
claimed = set()
count = 0
for task in tasks:
task_id = task.get("taskCode")
if task.get("taskStatus") == "UNCLA" and task_id not in claimed:
if await self.ttxc_finish_task(task):
claimed.add(task_id)
count += 1
return count
async def ttxc_do_jump_tasks(self, tasks):
count = 0
for task in tasks:
if (
task.get("taskType") == "GAME"
and task.get("taskStatus") == "UNDO"
and task.get("jumpUrl")
):
if await self.ttxc_do_task(task):
count += 1
await asyncio.sleep(1)
return count
async def ttxc_do_garbage_task(self, tasks):
task = next(
(
t
for t in tasks
if t.get("taskType") == "GAME"
and t.get("taskStatus") == "UNDO"
and "垃圾分类" in t.get("taskTitle", "")
),
None,
)
if not task:
return False
start = await self.ttxc_post("/user/v1/start", {})
answer_no = (start.get("data") or {}).get("answerNo")
if not answer_no:
self.tlog("垃圾分类开始失败")
return False
await asyncio.sleep(TTXC_GARBAGE_WAIT_SECONDS)
data = await self.ttxc_post("/user/v1/finish", {"answerNo": answer_no})
if data.get("code") == 0:
self.tlog("垃圾分类已通关")
return True
self.tlog(f"垃圾分类通关失败[{data.get('code')}]: {data.get('msg', '')}")
return False
async def ttxc_prepare_newbie_energy(self, claimed=None):
if claimed is None:
claimed = set()
await self.ttxc_sign()
tasks = await self.ttxc_get_tasks()
await self.ttxc_claim_ready_tasks(tasks, claimed)
await self.ttxc_do_jump_tasks(tasks)
await self.ttxc_do_garbage_task(tasks)
tasks = await self.ttxc_get_tasks()
await self.ttxc_claim_ready_tasks(tasks, claimed)
async def ttxc_get_lands(self):
land = safe_int((self.ttxc_charge_level or {}).get("land"), 4)
data = await self.ttxc_post("/plant/v1/user", {"land": land})
if data.get("code") != 0:
self.tlog(f"获取土地失败[{data.get('code')}]: {data.get('msg', '')}")
return []
return data.get("data") or []
async def ttxc_get_plant_id(self):
data = await self.ttxc_post(
"/client/v1/plant/page", {"itemType": "SPE", "pageNum": 1, "pageSize": 20}
)
items = (data.get("data") or {}).get("list") or []
return items[0].get("itemNo", "") if items else ""
async def ttxc_plant_land(self, land_index, plant_id=None):
plant_id = plant_id or await self.ttxc_get_plant_id()
if not plant_id or not land_index:
return None
buy = await self.ttxc_post(
"/client/v1/plant/buy", {"plantId": plant_id, "gameCfgId": ""}
)
if buy.get("code") != 0:
self.tlog(f"购买种子失败[{buy.get('code')}]: {buy.get('msg', '')}")
return None
data = await self.ttxc_post(
"/plant/v1/planting", {"landIndex": land_index, "plantId": plant_id}
)
if data.get("code") == 0:
self.tlog(f"已在地块{land_index}种植作物")
return {
"landIndex": land_index,
"status": 3,
"plant": {"plantId": plant_id},
}
self.tlog(
f"地块{land_index}种植失败[{data.get('code')}]: {data.get('msg', '')}"
)
return None
async def ttxc_ensure_planted_lands(self, lands):
active = [
l
for l in lands
if l.get("status") in [2, 3] and (l.get("plant") or {}).get("plantId")
]
empty = [l for l in lands if l.get("status") == 1]
if not empty:
return active
plant_id = await self.ttxc_get_plant_id()
if not plant_id:
return active
for land in empty:
planted = await self.ttxc_plant_land(land.get("landIndex"), plant_id)
if planted:
active.append(planted)
return active
async def ttxc_charge_land(self, land, mock=None):
if not land:
return None
plant = land.get("plant") or {}
plant_id, land_index = plant.get("plantId"), land.get("landIndex")
if not plant_id or not land_index:
return None
payload = {"landIndex": land_index, "plantId": plant_id}
if mock is not None:
payload["mock"] = mock
data = await self.ttxc_post("/plant/v1/charge", payload)
if data.get("code") == 0:
self.tlog(f"地块{land_index}充能成功")
result = data.get("data") or {}
if result and not result.get("plant"):
result["plant"] = plant
return result or land
self.tlog(
f"地块{land_index}充能失败[{data.get('code')}]: {data.get('msg', '')}"
)
if "余额不足" in str(data.get("msg", "")):
self.ttxc_no_energy = True
return None
async def ttxc_harvest_and_replant(self, land):
harvested = await self.ttxc_harvest_land(land)
if harvested and land:
return await self.ttxc_plant_land(land.get("landIndex"))
return None
async def ttxc_grow_land_to_harvest(self, land):
current = land
if current.get("status") == 2:
await self.ttxc_harvest_and_replant(current)
return
charged = 0
while current.get("status") == 3 and charged < TTXC_GROW_MAX_CHARGE_PER_LAND:
if self.ttxc_no_energy:
return
current = await self.ttxc_charge_land(current)
if not current:
return
charged += 1
if current.get("status") == 2:
await self.ttxc_harvest_and_replant(current)
return
if charged < TTXC_GROW_MAX_CHARGE_PER_LAND:
await asyncio.sleep(1)
if current.get("status") == 3:
self.tlog(f"地块{current.get('landIndex')}催熟达到上限,跳过")
def ttxc_replace_land(self, lands, updated):
land_index = updated.get("landIndex")
for i, land in enumerate(lands):
if land.get("landIndex") == land_index:
lands[i] = updated
return
lands.append(updated)
async def ttxc_complete_charge_task(self, active, remaining):
while remaining > 0:
if self.ttxc_no_energy:
return
immature = [
land
for land in active
if land.get("status") == 3 and (land.get("plant") or {}).get("plantId")
]
if not immature:
self.tlog("未成熟作物不足,提前结束10次充能补足")
return
progressed = False
for land in immature:
if remaining <= 0 or self.ttxc_no_energy:
return
result = await self.ttxc_charge_land(land)
if result:
self.ttxc_replace_land(active, result)
remaining -= 1
progressed = True
await asyncio.sleep(1)
if not progressed:
self.tlog("充能未成功,提前结束10次充能补足")
return
async def ttxc_farm_tasks(self, tasks):
charge_task = next(
(t for t in tasks if "10次作物充能" in t.get("taskTitle", "")), None
)
land_task = next(
(t for t in tasks if "三块不同" in t.get("taskTitle", "")), None
)
harvest_task = next(
(t for t in tasks if "收获一次作物" in t.get("taskTitle", "")), None
)
if not charge_task and not land_task and not harvest_task:
return
charge_pending = (
charge_task if (charge_task or {}).get("taskStatus") == "UNDO" else None
)
land_pending = (
land_task if (land_task or {}).get("taskStatus") == "UNDO" else None
)
harvest_pending = (
harvest_task if (harvest_task or {}).get("taskStatus") == "UNDO" else None
)
if not charge_pending and not land_pending and not harvest_pending:
return
lands = await self.ttxc_get_lands()
active = await self.ttxc_ensure_planted_lands(lands)
need_land = max(
safe_int((land_pending or {}).get("finishValue"))
- safe_int((land_pending or {}).get("doneValue")),
0,
)
if harvest_pending and not charge_pending and not land_pending:
for land in active:
if land.get("status") == 2:
await self.ttxc_harvest_and_replant(land)
if not active:
self.tlog("没有可充能作物")
return
charged = 0
for i, land in enumerate(active[:need_land]):
if self.ttxc_no_energy:
break
result = await self.ttxc_charge_land(land)
if result:
active[i] = result
charged += 1
await asyncio.sleep(1)
need_charge = max(
safe_int((charge_pending or {}).get("finishValue"))
- safe_int((charge_pending or {}).get("doneValue"))
- charged,
0,
)
await self.ttxc_complete_charge_task(active, need_charge)
for land in active:
await self.ttxc_grow_land_to_harvest(land)
async def farm_task(self):
self._task_tag = "通通乡村"
self.tlog("开始")
if not await self.ttxc_login():
return
claimed = set()
if not self.ttxc_newbie_done():
await self.ttxc_prepare_newbie_energy(claimed)
if not self.ttxc_newbie_done() and not await self.ttxc_newbie_task():
return
await self.ttxc_sign()
tasks = await self.ttxc_get_tasks()
await self.ttxc_claim_ready_tasks(tasks, claimed)
await self.ttxc_do_jump_tasks(tasks)
await self.ttxc_do_garbage_task(tasks)
await self.ttxc_farm_tasks(tasks)
tasks = await self.ttxc_get_tasks()
await self.ttxc_claim_ready_tasks(tasks, claimed)
# === 7. 商都福利 ===
def shangdu_token(self):
"""获取 app.shangdu.com 下发的 token cookie"""
for c in self.client.cookies.jar:
if c.name == "token" and "shangdu.com" in (c.domain or ""):
return c.value
return self.client.cookies.get("token", "")
def shangdu_apply_set_cookie(self, headers):
"""从响应头提取 token 并写入 cookie jar"""
for item in iter_set_cookies(headers):
if "token=" not in item:
continue
token = item.split("token=", 1)[1].split(";", 1)[0].strip()
if token:
self.client.cookies.set("token", token, domain="app.shangdu.com")
return
@staticmethod
def shangdu_sorted_json(data):
"""对齐前端 request.f6956a2d.js 的 It(): 去空值后按 key 排序 JSON.stringify"""
if not data:
return "{}"
def clean(v):
if isinstance(v, dict):
return {k: clean(v[k]) for k in sorted(v) if v[k] not in (None, "")}
if isinstance(v, list):
return [clean(x) for x in v if x not in (None, "")]
return v
return json.dumps(clean(data), ensure_ascii=False, separators=(",", ":"))
def shangdu_decrypt(self, raw):
"""解密 /unicomapp/* 返回的 SM2 密文(HAR: request.f6956a2d.js)"""
if raw is None:
return None
if isinstance(raw, dict):
return raw
text = str(raw).strip()
if not text:
return None
if text[0] in "{[":
try:
return json.loads(text)
except json.JSONDecodeError:
return {"raw": text}
text = text.strip('"')
if not text.startswith("04"):
return {"raw": text}
try:
plain = sm2.CryptSM2(
private_key=SHANGDU_SM2_PRIVATE_KEY, public_key=""
).decrypt(bytes.fromhex(text[2:].lower()))
if not isinstance(plain, (bytes, bytearray)) or not plain:
raise ValueError(
f"SM2解密返回非法类型或空值: {type(plain).__name__}"
)
return json.loads(plain.decode("utf-8"))
except Exception as e:
self.tlog_exc(e, "SM2解密")
return {"_cipher": text}
def shangdu_headers(self, method, path, params=None, data=None):
"""生成商都福利 H5 接口签名头;已与 HAR signature 校验一致"""
request_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=11)
)
ts = str(int(time.time() * 1000))
merged = {}
if "?" in path:
merged.update({k: v[-1] for k, v in parse_qs(urlparse(path).query).items()})
if params:
merged.update(params)
if data:
merged.update(data)
body = self.shangdu_sorted_json(merged)
sign_raw = f"{SHANGDU_SIGN_SECRET}{request_id}{ts}{body}"
token = self.shangdu_token()
headers = {
"Accept": "application/json, text/plain, */*",
"User-Agent": SHANGDU_UA,
"Referer": f"{SHANGDU_BASE}/unicomapp/",
"X-Client-Type": "H5",
"timestamp": ts,
"requestId": request_id,
"signature": hashlib.md5(sign_raw.encode()).hexdigest().upper(),
}
if token:
headers["Authorization"] = token
headers["Cookie"] = f"token={token}"
if method.upper() == "POST":
headers["Origin"] = SHANGDU_BASE
return headers
async def shangdu_login(self):
"""HAR: openPlatLineNew -> monthlyBenefit/static/index.html -> Set-Cookie token -> /unicomapp/"""
if self.ecs_token:
self.client.cookies.set("ecs_token", self.ecs_token, domain=".10010.com")
r = await self.req(
"GET",
"https://m.client.10010.com/mobileService/openPlatform/openPlatLineNew.htm",
params={
"to_url": SHANGDU_ENTRY,
"channel": "KSTD",
"duanlianjieabc": "tbVtc",
},
headers={
"User-Agent": SHANGDU_UA,
"Referer": "https://img.client.10010.com/",
},
allow_redirects=False,
)
loc = r["headers"].get("location") or r["headers"].get("Location")
if not loc:
return False
r = await self.req(
"GET",
loc,
headers={
"User-Agent": SHANGDU_UA,
"Referer": "https://img.client.10010.com/",
},
allow_redirects=False,
)
self.shangdu_apply_set_cookie(r["headers"])
if self.shangdu_token():
return True
loc2 = r["headers"].get("location") or r["headers"].get("Location")
if loc2:
if loc2.startswith("/"):
loc2 = urljoin(SHANGDU_BASE, loc2)
loc2 = loc2.split("#", 1)[0]
r = await self.req(
"GET",
loc2,
headers={
"User-Agent": SHANGDU_UA,
"Referer": "https://img.client.10010.com/",
},
allow_redirects=False,
)
self.shangdu_apply_set_cookie(r["headers"])
if self.shangdu_token():
await self.req(
"GET",
f"{SHANGDU_BASE}/unicomapp/",
headers={
"User-Agent": SHANGDU_UA,
"Referer": "https://img.client.10010.com/",
},
)
return True
return False
async def shangdu_api(self, method, path, params=None, data=None):
"""调用 /unicomapp/* 并解密 SM2 响应"""
url = f"{SHANGDU_BASE}/unicomapp{path}"
headers = self.shangdu_headers(method, path, params, data)
r = await self.req(
method,
url,
headers=headers,
params=params or None,
json=data if data else None,
)
payload = self.shangdu_decrypt(r.get("data"))
return {
"code": r.get("code", -1),
"headers": r.get("headers", {}),
"data": payload,
}
async def shangdu_click_log(self, event_code, event_name, module, seq_no, url=""):
return await self.shangdu_api(
"GET",
"/activityArea/recordClickOperationLog",
params={
"pageName": "MonthRechargeWelfare",
"activeName": "福利月月冲",
"pageSeq": "1",
"seqNo": seq_no,
"eventCode": event_code,
"eventName": event_name,
"module": module,
"url": url,
},
)
def shangdu_api_ok(self, resp):
data = resp.get("data") if isinstance(resp, dict) else None
return (
resp.get("code") == 200
and isinstance(data, dict)
and data.get("code") == 200
)
def shangdu_api_msg(self, resp, default=""):
data = resp.get("data") if isinstance(resp, dict) else None
if isinstance(data, dict):
return data.get("msg") or data.get("message") or default
return default
def shangdu_lottery_prize_name(self, prize_index, is_festival=False):
"""lottery 接口 data 为转盘奖品下标,非金额(对齐 MonthRechargeWelfare.js)"""
prizes = (
SHANGDU_LOTTERY_FESTIVAL_PRIZES if is_festival else SHANGDU_LOTTERY_PRIZES
)
if isinstance(prize_index, int) and 0 <= prize_index < len(prizes):
return prizes[prize_index]
return str(prize_index)
async def shangdu_task(self):
self._task_tag = "商都福利"
if "河南" not in self.province:
self.tlog(f"非河南归属地({self.province}),跳过")
return
self.tlog("开始")
if not await self.shangdu_login():
self.tlog("登录失败")
return
self.tlog("登录成功")
# HAR 顺序: init -> rollingText -> signIn -> SIGN_CLICK -> init -> lottery -> LOTTERY_CLICK -> init
init_r = await self.shangdu_api("GET", "/monthlyRecharge/init")
if not self.shangdu_api_ok(init_r):
self.tlog(
f"初始化失败 HTTP {init_r.get('code')} {self.shangdu_api_msg(init_r)}"
)
return
init_data = init_r["data"].get("data") or {}
sign_info = init_data.get("signInInfo") or {}
lottery_info = init_data.get("lotteryInfo") or {}
reward_nodes = sign_info.get("rewardNodes") or []
self.tlog(
f"初始化完成 签到状态={sign_info.get('status')} 抽奖次数={lottery_info.get('remainCount')}"
)
await self.shangdu_api("GET", "/monthlyRecharge/rollingText")
if sign_info.get("status") == 1:
self.tlog(f"今日已签到(累计{sign_info.get('signInCount', 0)}天)")
else:
sign_r = await self.shangdu_api("POST", "/monthlyRecharge/signIn")
await self.shangdu_click_log("SIGN_CLICK", "签到点击", "signIn", "A")
if self.shangdu_api_ok(sign_r):
self.tlog(f"签到成功 {self.shangdu_api_msg(sign_r)}")
else:
self.tlog(
f"签到失败 HTTP {sign_r.get('code')} {self.shangdu_api_msg(sign_r)}"
)
# 检查节点奖励补领 (如 7天、14天累签节点奖)
for node in reward_nodes:
day = node.get("day")
status = node.get("status")
if status == "CLAIMABLE" and day:
self.tlog(f"发现可领取/补领的 {day} 天节点大奖,正在提交补领...")
claim_r = await self.shangdu_api("POST", f"/monthlyRecharge/signIn/reward/{day}/claim")
if self.shangdu_api_ok(claim_r):
self.tlog(f"{day} 天节点大奖补领成功! 响应: {claim_r.get('data')}")
else:
self.tlog(f"{day} 天节点大奖补领失败: HTTP {claim_r.get('code')} {self.shangdu_api_msg(claim_r)}")
await asyncio.sleep(1)
init_r = await self.shangdu_api("GET", "/monthlyRecharge/init")
if self.shangdu_api_ok(init_r):
lottery_info = (init_r["data"].get("data") or {}).get(
"lotteryInfo"
) or lottery_info
remain = require_int(
lottery_info.get("remainCount"), "shangdu.remainCount", minimum=0
)
max_lottery = remain if SHANGDU_LOTTERY_MAX is None else SHANGDU_LOTTERY_MAX
draw_times = min(remain, max_lottery) if remain > 0 else 0
if draw_times <= 0:
self.tlog("无剩余抽奖次数")
else:
for i in range(draw_times):
await asyncio.sleep(1)
lottery_r = await self.shangdu_api("POST", "/monthlyRecharge/lottery")
await self.shangdu_click_log(
"LOTTERY_CLICK", "抽奖点击", "lottery", "C"
)
if self.shangdu_api_ok(lottery_r):
body = lottery_r.get("data")
if not isinstance(body, dict):
body = {}
prize_idx = body.get("data")
prize_name = self.shangdu_lottery_prize_name(
prize_idx, bool(lottery_info.get("isFestival"))
)
self.tlog(
f"第{i + 1}次抽奖成功 原始返回={body} 奖品=[{prize_name}]"
)
else:
self.tlog(
f"第{i + 1}次抽奖失败 HTTP {lottery_r.get('code')} {self.shangdu_api_msg(lottery_r)}"
)
break
await self.shangdu_api("GET", "/monthlyRecharge/init")
def cloud_disk_token(self):
"""联通云盘 userToken(上传大比拼等活动共用)。"""
return (getattr(self, "cloud_disk", None) or {}).get("userToken", "")
def cloud_activity_sign(self, payload):
"""panservice 活动请求签名。"""
raw = (
"&".join(f"{k}={payload[k]}" for k in sorted(payload))
+ f"&secret={CLOUD_PAN_SIGN_SECRET}"
)
return hmac.new(
CLOUD_PAN_SIGN_SECRET.encode(), raw.encode(), hashlib.sha256
).hexdigest()
# --- 校园季上传复用: 文件信息加密(源自上传大比拼协议, 该活动已下线) ---
def battle_encrypt_fileinfo(self, info, token):
key = token[:16].encode()
iv = CLOUD_BATTLE_FILEINFO_IV.encode()
plaintext = json.dumps(info, separators=(",", ":"))
cipher = AES.new(key, AES.MODE_CBC, iv)
return base64.b64encode(
cipher.encrypt(pad(plaintext.encode(), AES.block_size))
).decode()
# === 8. 云手机积分 / 夏日刮一刮 / 打卡挑战赛 ===
def uphone_biz_ok(self, data):
"""业务成功:data 必须为 dict 且 code 表示成功。"""
if not isinstance(data, dict):
return False
return data.get("code") in (200, "200", 0, "0")
def uphone_biz_data(self, r):
"""取出 req() 的 data 字段;非 dict 时返回 None。"""
if not isinstance(r, dict):
return None
data = r.get("data")
return data if isinstance(data, dict) else None
def uphone_app_headers(self, extra=None):
h = {
"User-Agent": UPHONE_APP_UA,
"Accept": "*/*",
"channel": UPHONE_CHANNEL,
"channelCode": UPHONE_CHANNEL,
"deviceId": self.uphone_device_id or "0",
}
if self.uphone_cp_token:
h["Authorization"] = self.uphone_cp_token
if extra:
h.update(extra)
return h
def uphone_h5_headers(self, extra=None):
h = {
"User-Agent": UPHONE_H5_UA,
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json; charset=UTF-8",
"Origin": "https://uphone.wostore.cn",
}
if self.uphone_usr_token:
h["X-USR-TOKEN"] = self.uphone_usr_token
if extra:
h.update(extra)
return h
def uphone_checkin_headers(self):
"""打卡挑战赛(h5forphone CLD13),复用 SSO cpToken。"""
token = self.uphone_cp_token or ""
return {
"User-Agent": UPHONE_H5_UA,
"Accept": "*/*",
"Content-Type": "application/json",
"Origin": "https://h5forphone.wostore.cn",
"Referer": f"{UPHONE_CHECKIN_PAGE}?token={token}",
"X-Requested-With": "XMLHttpRequest",
}
async def uphone_checkin_post(self, path, extra_json=None):
"""POST h5forphone/activity/*,body 带 accesstoken=cpToken。"""
if not self.uphone_cp_token:
return None
payload = {"accesstoken": self.uphone_cp_token}
if extra_json:
payload.update(extra_json)
r = await self.req(
"POST",
f"{UPHONE_H5FORPHONE}/{path.lstrip('/')}",
headers=self.uphone_checkin_headers(),
json=payload,
)
return self.uphone_biz_data(r)
async def uphone_sso_login(self):
"""ecs_token → ticket → cpToken"""
if not self.ecs_token:
self.tlog("无 ecs_token")
return False
self.uphone_device_id = hashlib.md5(
f"{self.mobile or self.uuid}".encode()
).hexdigest()
r = await self.req(
"GET",
"https://m.client.10010.com/edop_ng/getTicketByNative",
params={"token": self.ecs_token, "appId": UPHONE_EDOP_APP_ID},
headers={"User-Agent": UPHONE_APP_UA, "Accept": "*/*"},
)
body = self.uphone_biz_data(r)
ticket = body.get("ticket") if body else None
if not ticket:
self.tlog(f"换 ticket 失败: {api_response_err(r, 'getTicketByNative')}")
return False
r = await self.req(
"POST",
f"{UPHONE_H5API}/token-service/getTokenByTicket",
headers=self.uphone_app_headers({"Content-Type": "application/json"}),
json={"channel": UPHONE_CHANNEL, "ticket": ticket},
)
body = self.uphone_biz_data(r)
token = body.get("data") if body else None
if not (
body
and self.uphone_biz_ok(body)
and isinstance(token, str)
and token.startswith("eyJ")
):
self.tlog(f"换 cpToken 失败: {api_response_err(r, 'getTokenByTicket')}")
return False
self.uphone_cp_token = token
self.tlog("云手机 SSO 成功")
return True
async def uphone_activity_login(self, activity_id):
if not self.uphone_cp_token:
return False
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/user/login",
headers=self.uphone_h5_headers(),
json={
"identityType": "cloudPhoneLogin",
"code": self.uphone_cp_token,
"channelId": UPHONE_CHANNEL_H5,
"activityId": activity_id,
"device": "device",
},
)
body = self.uphone_biz_data(r)
nested = body.get("data") if body else None
tok = nested.get("user_token") if isinstance(nested, dict) else None
if not (body and self.uphone_biz_ok(body) and tok):
self.tlog(f"活动登录失败({activity_id}): {api_response_err(r, 'user/login')}")
return False
self.uphone_usr_token = str(tok)
return True
async def uphone_points_summary(self):
r = await self.req(
"GET",
f"{UPHONE_H5API}/activity-service/points/v1/summary",
headers=self.uphone_h5_headers(),
params={"activityCode": UPHONE_ACT_EXCHANGE},
)
body = self.uphone_biz_data(r)
if not body or not self.uphone_biz_ok(body):
return None
nested = body.get("data")
return nested if isinstance(nested, dict) else None
async def uphone_sign(self):
# sign/history 的 signRecords[0] 是最近一次已签记录(通常为昨天),
# 拿它判"今日已签"会隔天漏签;直接签到,把"已签"业务响应当正常态
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/points/v1/sign",
headers=self.uphone_h5_headers(),
json={"activityCode": UPHONE_ACT_SIGN},
)
body = self.uphone_biz_data(r)
if body and self.uphone_biz_ok(body):
self.tlog(f"签到成功: {body.get('msg') or 'ok'}")
return True
msg = str((body or {}).get("msg") or "")
if any(k in msg for k in ("已签", "签过", "重复")):
self.tlog(f"今日已签到: {msg}")
return True
self.tlog(f"签到失败: {api_response_err(r, 'points/sign')}")
return False
async def uphone_task_list(self, activity_code):
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/user/task/list",
headers=self.uphone_h5_headers(),
json={"activityCode": activity_code},
)
body = self.uphone_biz_data(r)
if not body or not self.uphone_biz_ok(body):
return [], 0
task_list = body.get("taskList") or []
if not isinstance(task_list, list):
task_list = []
return task_list, safe_int(body.get("rafflesLeftCount"), 0)
async def uphone_task_logs(self, task_code, detail):
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/user/task/logs",
headers=self.uphone_h5_headers(),
json={
"logType": "01",
"logCode": task_code,
"logSource": "01",
"logDetail": detail,
},
)
body = self.uphone_biz_data(r)
return self.uphone_biz_ok(body), body
async def uphone_raffle_get(self, activity_code, task_code):
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/user/task/raffle/get",
headers=self.uphone_h5_headers(),
json={"activityCode": activity_code, "taskCode": task_code},
)
body = self.uphone_biz_data(r)
if isinstance(body, dict) and body.get("code") in (10301, "10301"):
self.tlog(f"领奖受限 {task_code}: {body.get('msg')}")
return False, body
return self.uphone_biz_ok(body), body
async def uphone_obtain_tasks(self):
"""积分任务:logs → 领奖(跳过需真实行为的任务)"""
if not await self.uphone_activity_login(UPHONE_ACT_OBTAIN):
return
tasks, _ = await self.uphone_task_list(UPHONE_ACT_OBTAIN)
claimed = logged = skipped = 0
for t in tasks:
if t.get("status") != "UNCLAIMED":
continue
code = str(t.get("taskCode") or "")
ok, _ = await self.uphone_raffle_get(UPHONE_ACT_OBTAIN, code)
if ok:
claimed += 1
self.tlog(f"领取积分任务 {code} {t.get('taskName')} +{t.get('pointsCount')}")
await asyncio.sleep(0.25)
tasks, _ = await self.uphone_task_list(UPHONE_ACT_OBTAIN)
for t in tasks:
code = str(t.get("taskCode") or "")
name = str(t.get("taskName") or code)
if t.get("status") != "INCOMPLETE":
continue
if code in UPHONE_OBTAIN_SKIP:
skipped += 1
continue
ok, body = await self.uphone_task_logs(code, name)
if not ok:
self.tlog(f"任务上报失败 {code}: {(body or {}).get('msg')}")
await asyncio.sleep(0.2)
continue
logged += 1
await asyncio.sleep(0.3)
tasks2, _ = await self.uphone_task_list(UPHONE_ACT_OBTAIN)
t2 = next((x for x in tasks2 if x.get("taskCode") == code), None)
if not t2 or t2.get("status") != "UNCLAIMED":
self.tlog(f"任务未达可领 {code} status={t2.get('status') if t2 else '?'}")
continue
ok, _ = await self.uphone_raffle_get(UPHONE_ACT_OBTAIN, code)
if ok:
claimed += 1
self.tlog(f"完成 {code} {name} +{t.get('pointsCount')}")
await asyncio.sleep(0.25)
summary = await self.uphone_points_summary()
bal = (summary or {}).get("balanceScoreNum")
self.tlog(
f"积分任务结束 claimed={claimed} logged={logged} skipped={skipped} 余额={bal}"
)
async def uphone_lottery10_remain(self):
r = await self.req(
"GET",
f"{UPHONE_H5API}/activity-service/points/v1/exchange/list",
headers=self.uphone_h5_headers(),
params={
"activityCode": UPHONE_ACT_EXCHANGE,
"token": self.uphone_cp_token,
},
)
body = self.uphone_biz_data(r)
if not body or not self.uphone_biz_ok(body):
return -1
goods = body.get("data") or []
if not isinstance(goods, list):
return -1
# 打印全部商品 ID 便于排查 (2026-08-06: 十连商品可能不在列表)
ids = [str(g.get("goodsId")) for g in goods if isinstance(g, dict)]
self.tlog(f"兑换商品列表 goodsId={ids}")
for g in goods:
if isinstance(g, dict):
self.tlog(f"商品: id={g.get('goodsId')} name={str(g.get('goodsName'))[:40]} remain={g.get('remainUser')} price={g.get('score') or g.get('price')}")
for g in goods:
if not isinstance(g, dict):
continue
if str(g.get("goodsId")) == UPHONE_GOODS_LOTTERY_10:
ru = g.get("remainUser")
# 打印完整字段便于排查 (2026-08-06: 余额200+但remainUser=0)
self.tlog(f"十连商品完整字段: {str(g)[:300]}")
return -1 if ru is None else safe_int(ru, 0)
return 0
async def uphone_lottery_10(self):
"""余额≥阈值且有剩余次数则十连抽"""
await self.uphone_activity_login(UPHONE_ACT_SIGN)
summary = await self.uphone_points_summary()
balance = safe_int((summary or {}).get("balanceScoreNum"), 0)
if balance < UPHONE_LOTTERY_COST:
self.tlog(f"积分不足十连 余额={balance} 需≥{UPHONE_LOTTERY_COST}")
return
remain = await self.uphone_lottery10_remain()
if remain <= 0:
self.tlog(
"十连次数已用完 remainUser=0"
if remain == 0
else "十连次数查询失败, 跳过(状态不明不消耗积分)"
)
return
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/points/v1/lottery",
headers=self.uphone_h5_headers(),
json={
"activityCode": UPHONE_ACT_EXCHANGE,
"goodsId": UPHONE_GOODS_LOTTERY_10,
},
)
body = self.uphone_biz_data(r)
if not body or not self.uphone_biz_ok(body):
self.tlog(f"十连失败: {api_response_err(r, 'points/lottery')}")
return
raw_data = body.get("data")
data = raw_data if isinstance(raw_data, dict) else {}
raw_sm = data.get("summary")
sm = raw_sm if isinstance(raw_sm, dict) else {}
raw_results = data.get("results")
results = raw_results if isinstance(raw_results, list) else []
prizes = [
x.get("prizeName")
for x in results
if isinstance(x, dict) and x.get("isDrawn")
]
prize_text = ",".join(str(p) for p in prizes if p) if prizes else "无"
self.tlog(
f"十连完成 total={sm.get('total')} won={sm.get('won')} 奖品={prize_text}"
)
summary = await self.uphone_points_summary()
self.tlog(f"十连后余额={(summary or {}).get('balanceScoreNum')}")
async def uphone_summer_claim_one(self):
"""夏日刮一刮:仅领 2508-01(去赚积分)换 1 次次数;无此可领任务则跳过。"""
if not await self.uphone_activity_login(UPHONE_ACT_SUMMER):
return 0
tasks, left = await self.uphone_task_list(UPHONE_ACT_SUMMER)
pick = next(
(
t
for t in tasks
if str(t.get("taskCode") or "") == UPHONE_SUMMER_CLAIM_TASK
and t.get("status") == "UNCLAIMED"
),
None,
)
if not pick:
self.tlog(
f"夏日 {UPHONE_SUMMER_CLAIM_TASK} 无可领 "
f"rafflesLeft={left}(已领过或未达条件,跳过)"
)
return left
code = UPHONE_SUMMER_CLAIM_TASK
name = pick.get("taskName") or code
ok, body = await self.uphone_raffle_get(UPHONE_ACT_SUMMER, code)
if ok:
self.tlog(f"夏日领次数成功 {code} {name}")
else:
msg = (body or {}).get("msg") if isinstance(body, dict) else body
biz = (body or {}).get("code") if isinstance(body, dict) else None
if biz in (10301, "10301"):
self.tlog(f"夏日领次数跳过(日限) {code}: {msg}")
else:
self.tlog(f"夏日领次数失败 {code}: {msg or body}")
_, left2 = await self.uphone_task_list(UPHONE_ACT_SUMMER)
self.tlog(f"夏日抽奖次数 rafflesLeft={left}→{left2}")
return left2
async def uphone_summer_lottery(self, max_draws=20):
draws = 0
while draws < max_draws:
_, left = await self.uphone_task_list(UPHONE_ACT_SUMMER)
if left <= 0:
break
r = await self.req(
"POST",
f"{UPHONE_H5API}/activity-service/lottery",
headers=self.uphone_h5_headers(),
json={"activityCode": UPHONE_ACT_SUMMER},
)
body = self.uphone_biz_data(r)
if not body or not self.uphone_biz_ok(body):
self.tlog(f"夏日抽奖失败: {api_response_err(r, 'lottery')}")
break
draws += 1
self.tlog(
f"夏日抽奖#{draws} {body.get('prizeName') or body.get('msg')} "
f"type={body.get('prizeType')}"
)
await asyncio.sleep(0.4)
if draws == 0:
self.tlog("夏日无抽奖次数")
else:
self.tlog(f"夏日抽奖结束 draws={draws}")
return draws
async def uphone_checkin_query(self):
"""查询打卡挑战赛状态。"""
body = await self.uphone_checkin_post("activity/querySignInList")
if not body:
self.tlog("打卡查询无响应")
return None
if str(body.get("code")) == "50020":
self.tlog("打卡 token 失效(50020)")
return None
if not self.uphone_biz_ok(body):
self.tlog(f"打卡查询失败: {body.get('msg') or body}")
return None
return body
async def uphone_checkin_raffle(self, order_id, account="", account_type=""):
"""打卡奖品核销 raffleSignIn(字段 activityOrderid 小写 id)。"""
if not order_id:
return False
body = await self.uphone_checkin_post(
"activity/raffleSignIn",
{
"activityOrderid": order_id,
"account": account or "",
"accountType": account_type or "",
},
)
if body and self.uphone_biz_ok(body):
redir = body.get("redirectUrl") or ""
self.tlog(
f"打卡领奖成功 order={order_id}"
+ (f" redirect={redir[:80]}" if redir else "")
)
return True
self.tlog(
f"打卡领奖失败 order={order_id}: "
f"{(body or {}).get('msg') if isinstance(body, dict) else body}"
)
return False
async def uphone_checkin_handle_prize(self, body, source="签到"):
"""处理签到/奖品中的 activityOrderId;type=2 需 QQ/微信则跳过。"""
if not isinstance(body, dict):
return
order_id = body.get("activityOrderId") or body.get("activityOrderid")
if not order_id:
return
act_type = body.get("activityType")
name = body.get("activityName") or body.get("name") or ""
if act_type in UPHONE_CHECKIN_SKIP_ACTIVITY_TYPES:
self.tlog(
f"打卡奖品需手填账号,跳过({source}) type={act_type} "
f"{name} order={order_id}"
)
return
self.tlog(f"打卡中奖({source}) {name or order_id} type={act_type}")
await self.uphone_checkin_raffle(order_id)
await asyncio.sleep(0.3)
async def uphone_checkin_claim_pending(self):
"""我的奖品:自动领取可核销订单(跳过 type=2)。"""
body = await self.uphone_checkin_post("activity/signInRightList")
if not body or not self.uphone_biz_ok(body):
if body:
self.tlog(f"打卡奖品列表失败: {body.get('msg') or body}")
return
goods = body.get("goodsList") or []
if not isinstance(goods, list) or not goods:
self.tlog("打卡奖品列表为空")
return
claimed = 0
for g in goods:
if not isinstance(g, dict):
continue
state = g.get("state")
# 前端 award:state 1/2 可点;2 多为已发,失败则跳过
if state not in (1, 2, "1", "2"):
continue
act_type = g.get("activityType")
order_id = g.get("activityOrderId") or g.get("activityOrderid")
name = g.get("name") or ""
if not order_id:
continue
if act_type in UPHONE_CHECKIN_SKIP_ACTIVITY_TYPES:
self.tlog(f"打卡待领跳过(需账号) {name} type={act_type}")
continue
# state=2 历史已领过的不重复核销,仅补 state=1
if state not in (1, "1"):
continue
self.tlog(f"打卡补领 {name} order={order_id}")
if await self.uphone_checkin_raffle(order_id):
claimed += 1
await asyncio.sleep(0.3)
self.tlog(f"打卡奖品补领结束 claimed={claimed} total={len(goods)}")
async def uphone_checkin(self):
"""打卡挑战赛 CLD13:query → signIn → 条件 raffle → 奖品补领。
复用 uphone_sso_login 的 cpToken,无需 activity-service 登录。
"""
if not self.uphone_cp_token:
self.tlog("打卡跳过: 无 cpToken")
return
info = await self.uphone_checkin_query()
if info is None:
return
day = info.get("signDayNum")
left = info.get("surpriseDayNum")
self.tlog(f"打卡状态 已签{day}天 再签{left}天有惊喜")
if info.get("refreshFlag") == "refresh":
self.tlog("打卡完成一轮,开启新周期")
body = await self.uphone_checkin_post("activity/signIn")
if not body:
self.tlog("打卡签到无响应")
return
code = str(body.get("code", ""))
msg = body.get("msg") or ""
if code == "30038":
self.tlog(f"打卡今日已签: {msg or '今日已签过'}")
elif self.uphone_biz_ok(body):
self.tlog(f"打卡成功: {msg or 'ok'}")
await self.uphone_checkin_handle_prize(body, source="签到")
await asyncio.sleep(0.3)
info2 = await self.uphone_checkin_query()
if info2:
self.tlog(
f"打卡后 已签{info2.get('signDayNum')}天 "
f"再签{info2.get('surpriseDayNum')}天有惊喜"
)
else:
self.tlog(f"打卡失败[{code}]: {msg or body}")
await self.uphone_checkin_claim_pending()
async def uphone_task(self):
"""云手机:SSO → 打卡挑战赛 → 积分签到/任务/十连 → 夏日刮一刮"""
self._task_tag = "云手机积分"
self.tlog("开始")
if not await self.uphone_sso_login():
return
# 打卡挑战赛仅需 cpToken
await self.uphone_checkin()
if not await self.uphone_activity_login(UPHONE_ACT_SIGN):
return
await self.uphone_sign()
await self.uphone_obtain_tasks()
await self.uphone_lottery_10()
await self.uphone_summer_claim_one()
await self.uphone_summer_lottery()
self.tlog("结束")
# === 9. 沃阅读/爱听积分 (每账号自动换票) ===
async def woread_task(self):
"""沃阅读积分: ecs_token → accountLogin 换凭证 → 换票 → 刷时长 → 领积分
链路: chinaUnicomCookie → onLine(ecs_token) → /account/login(手机号) → token/verifyCode
→ getPointCenterTicket → well ticket → addReadTime 刷时长 → jftask toFinish
"""
self._task_tag = "沃阅读积分"
self.tlog("开始")
import json as _json
BASE = "https://10010.woread.com.cn/ng_woread_service/rest"
KEY = b"woreadst^&*12345"
IV = b"16-Bytes--String"
APPID = "10000002"
APPSECRET = "7k1HcDL8RKvc"
def get_aes(data: dict) -> str:
"""getAes: AES-CBC(key,iv) 加密 JSON → hex → base64"""
plain = _json.dumps(data, separators=(",", ":"))
c = AES.new(KEY, AES.MODE_CBC, iv=IV)
ct = c.encrypt(pad(plain.encode(), 16))
return base64.b64encode(ct.hex().encode()).decode()
def get_aes2(phone: str) -> str:
"""getAes2: 同 AES-CBC 加密手机号"""
c = AES.new(KEY, AES.MODE_CBC, iv=IV)
ct = c.encrypt(pad(phone.encode(), 16))
return base64.b64encode(ct.hex().encode()).decode()
def ts_now() -> str:
return datetime.now().strftime("%Y%m%d%H%M%S")
# woread H5 请求头 (带 App 登录态 cookie)
hdrs = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) unicom{version:iphone_c@12.1400}",
"Content-Type": "application/json;charset=UTF-8",
"Referer": "https://10010.woread.com.cn/ng_woread/",
"Origin": "https://10010.woread.com.cn",
}
# 种 App 登录态 cookie (ecs_token/t3_token 等来自 self.client)
self.client.cookies.set("ecs_token", self.ecs_token, domain=".woread.com.cn")
self.client.cookies.set("ecs_token", self.ecs_token, domain=".10010.com.cn")
self.client.cookies.set("u_account", self.mobile, domain=".woread.com.cn")
async def woread_post(path: str, data: dict, **kw):
return await self.req("POST", BASE + path, headers=hdrs, json={"sign": get_aes(data)}, **kw)
try:
# ① getAccessToken (无需登录态)
ts_ms = str(int(time.time() * 1000))
md5sig = hashlib.md5(f"{APPID}{APPSECRET}{ts_ms}".encode()).hexdigest()
r = await self.req("POST", f"{BASE}/app/auth/{APPID}/{ts_ms}/{md5sig}",
headers=hdrs,
json={"sign": get_aes({"timestamp": ts_now()})})
accesstoken = ""
if (res := r["data"]) and res.get("code") == "0000":
accesstoken = res.get("data", {}).get("accesstoken", "")
if not accesstoken:
self.tlog(f"getAccessToken 失败: {api_response_err(r, 'app/auth')}")
return
hdrs["accesstoken"] = accesstoken
# ② accountLogin: 手机号 AES2 加密 → token/verifyCode
payload = {"phone": get_aes2(self.mobile), "timestamp": ts_now()}
r = await self.req("POST", BASE + "/account/login",
headers={**hdrs, "noPassToken": "true"},
json={"sign": get_aes(payload)})
if not (res := r["data"]) or res.get("code") != "0000":
self.tlog(f"accountLogin 失败: {api_response_err(r, 'account/login')}")
return
d = res.get("data", {})
token = d.get("token", "")
verify_code = d.get("verifycode", "")
user_id = d.get("userid", "")
user_index = d.get("userindex", "")
if not token or not verify_code:
missing = ",".join(k for k in ("token", "verifycode") if not d.get(k))
self.tlog(f"accountLogin 凭证不完整: 缺少 {missing}")
return
self.tlog(f"登录成功: {self.mobile[:3]}****{self.mobile[-4:]} 凭证已换")
user_fields = {
"token": token, "userId": user_id, "userIndex": user_index,
"userAccount": self.mobile, "verifyCode": verify_code,
}
# ③ 换票: getPointCenterTicket → well URL + ticket
r = await woread_post("/activity/getPointCenterTicket", {"timestamp": ts_now(), **user_fields})
well_url = ""
if (res := r["data"]) and res.get("code") == "0000":
well_url = res.get("data", "") or ""
if not well_url or "ticket=" not in well_url:
self.tlog(f"换票失败: {api_response_err(r, 'getPointCenterTicket')}")
return
ticket = re.search(r"ticket=([^&]+)", well_url).group(1)
self.tlog("换票成功") # ticket 不入日志
# ④ getSecretKey
WELL_UA = hdrs["User-Agent"]
r = await self.req(
"GET",
"https://m.jf.10010.com/jf-external-application/jftask/getSecretKey",
headers={
"ticket": ticket, "partnersid": "1706", "clienttype": "aiting_unicom",
"User-Agent": WELL_UA, "Referer": well_url,
"Accept": "application/json, text/plain, */*",
},
)
if not (res := r["data"]) or str(res.get("code")) != "0000":
self.tlog(f"getSecretKey 失败: {api_response_err(r, 'getSecretKey')}")
return
secret_key = res.get("data", {}).get("secretKey", "")
if not secret_key:
self.tlog("getSecretKey 无密钥")
return
jea = ""
for ck in iter_set_cookies(r["headers"]):
if "_jea_id=" in ck:
jea = ck.split("_jea_id=")[1].split(";")[0]
break
def jf_headers():
ts, nonce = str(int(time.time() * 1000)), "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
sig = hmac.new(secret_key.encode(), f"{nonce}{ts}".encode(), hashlib.sha256).hexdigest()
return {
"ticket": ticket, "User-Agent": WELL_UA, "partnersid": "1706",
"clienttype": "aiting_unicom", "Cookie": f"_jea_id={jea}",
"X-Request-Timestamp": ts, "X-Request-Nonce": nonce,
"X-Request-Signature": sig, "Referer": well_url,
"Origin": "https://m.jf.10010.com",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
}
# ⑤ 阅读刷时长: readChapter → newreadadd → addReadTime 三步完整复刻
# (新账号必须先读章节建立阅读记录, 每次运行报1次+2分钟, 服务端约10-15分钟冷却
# 0/7/10点三次运行累计 → 读听看4分钟任务 2/2 完成)
chapter_params = {
"chapterSeno": "3", "cntIndex": 3120941, "beginChapter": 4,
"timestamp": ts_now(), **user_fields,
}
# 必须 POST (HAR 实测: GET 报"请求方式不正确", 阅读会话不建立,
# addReadTime 虽返回0000但任务不计时 → toFinish 假成功)
r = await self.req(
"POST",
f"{BASE}/cnt/readChapter?cntindex=3120941&chapterallindex=120470473&chapterseno=3",
headers=hdrs, json={"sign": get_aes(chapter_params)},
)
if (res := r["data"]) and res.get("code") == "0000":
self.tlog(f"readChapter 读章节成功 (章节数={len(res.get('list', []))})")
else:
self.tlog(f"readChapter: {api_response_err(r, 'readChapter')}")
await asyncio.sleep(1)
newreadadd_data = {
"userid": user_fields["userId"], "cntindex": 3120941,
"chapterallindex": 120470473, "cnttype": 1,
"cntname": "大清权臣李鸿章", "chaptertitle": "少年,胸怀壮志",
"readtype": 1, "timestamp": ts_now(), **user_fields,
}
r = await self.req("POST", BASE + "/basics/newreadadd",
headers=hdrs, json={"sign": get_aes(newreadadd_data)})
if (res := r["data"]) and res.get("code") == "0000":
self.tlog("newreadadd 更新阅读进度成功")
await asyncio.sleep(1)
read_data = {
"readTime": 2,
"cntIndex": 3120941, "cntType": 1, "cntindex": 3120941, "cnttype": 1,
"chapterallindex": 120470473, "chapterseno": 3,
"channelid": "18000688", "chapterid": "13120941003",
"readtype": 1, "isend": "0",
"timestamp": ts_now(), **user_fields,
}
r = await self.req("POST", BASE + "/history/addReadTime",
headers=hdrs, json={"sign": get_aes(read_data)})
if (res := r["data"]) and res.get("code") == "0000":
self.tlog(f"阅读上报: +2分钟 (weektime={res.get('data', {}).get('weektime')})")
else:
self.tlog(f"阅读上报: {api_response_err(r, 'addReadTime')} (冷却中/已满, 0点下次再报)")
# ⑥ 签到 (爱看/爱听签到, 每日+1)
r = await self.req("POST", "https://m.jf.10010.com/jf-external-application/uasptask/sign",
headers=jf_headers(), json={"taskCode": "s818795876839563264"})
if (res := r["data"]) and res.get("code") == "0000":
self.tlog(f"签到成功: {res.get('data', {}).get('title', '')} (+{res.get('data', {}).get('score', '')})")
else:
self.tlog(f"签到: {api_response_err(r, 'sign')} (可能已签)")
# ⑥ taskDetail + toFinish 领积分
r = await self.req("POST", "https://m.jf.10010.com/jf-external-application/jftask/taskDetail",
headers=jf_headers(), json={})
if not (res := r["data"]) or res.get("code") != "0000":
self.tlog(f"taskDetail 失败: {api_response_err(r, 'taskDetail')}")
return
tasks = res.get("data", {}).get("taskDetail", {}).get("taskList", [])
# 已实测(2026-08-07 taskRecords 验证):
# ✅ 真到账: 登录并读听看4分钟(读听看/新手) + 签到 → 保留
# ❌ 假成功: 分享爱听内容 toFinish 返回 true 但积分不到账
# (需真实微信分享回调, API 直调无效) → 跳过
# ❌ 不可行: 邀请(需真新用户) / 会员(需付费) / 限时福利(需爱看App) → 跳过
skip_kw = ("邀请", "会员", "分享", "限时福利")
claimed = 0
for t in tasks:
name = t.get("taskName", "")
if any(k in name for k in skip_kw):
continue
if t.get("finishCount", 0) >= t.get("needCount", 1):
continue
r2 = await self.req("POST", "https://m.jf.10010.com/jf-external-application/jftask/toFinish",
headers=jf_headers(), json={"taskCode": t.get("taskCode")})
ok = (res2 := r2["data"]) and res2.get("data") is True
self.tlog(f"领积分 [{name}]: {'成功' if ok else '未达条件'}")
if ok:
claimed += 1
await asyncio.sleep(1)
# 查询今日积分
r3 = await self.req("POST", "https://m.jf.10010.com/jf-external-application/jftask/userInfo",
headers=jf_headers(), json={})
if (res3 := r3["data"]) and res3.get("code") == "0000":
ui = res3.get("data", {})
self.tlog(f"今日积分: {ui.get('todayEarnScore')} 可用: {ui.get('availableScore')} 累计: {ui.get('allEarnScore')}")
self.tlog(f"结束: 共领 {claimed} 项积分")
except Exception as e:
self.tlog_exc(e, "沃阅读积分")
# === 10. 云盘校园季(燃动开学季): 每日上传 + 抽奖 ===
CAMPUS_ACTIVITY_ID = "MzU="
CAMPUS_UPLOAD_URL = "https://tjupload.pan.wo.cn/openapi/client/upload2C"
def campus_headers(self):
token = self.cloud_disk_token()
return {
"X-YP-Access-Token": token,
"token": token,
"Access-Token": token,
"source-type": "woapi",
"clientId": "1001000165",
"X-YP-Client-Id": "1001000003",
"Content-Type": "application/json",
"requestTime": str(int(time.time() * 1000)),
"User-Agent": BATTLE_UA,
"Origin": "https://panservice.mail.wo.cn",
"Referer": (
"https://panservice.mail.wo.cn/h5/activitymobile/campusSeason"
f"?activityId={quote(self.CAMPUS_ACTIVITY_ID)}&type=02"
f"&token={token}&clientid=1001000003"
),
}
async def campus_signed_post(self, path, key, extra=None):
"""取时间戳 -> 签名 -> 提交校园季活动接口"""
ts = await self.req(
"POST",
"https://panservice.mail.wo.cn/activity/getTimestamp",
headers=self.campus_headers(),
json={"key": key},
)
result = (ts.get("data") or {}).get("result") or {}
nonce, timestamp = result.get("nonce"), result.get("timestamp")
if not nonce or not timestamp:
self.tlog(f"getTimestamp 失败 {response_summary(ts)}")
return {}
body = {"activityId": self.CAMPUS_ACTIVITY_ID, **(extra or {})}
body["nonce"] = nonce
body["timestamp"] = timestamp
body["sign"] = self.cloud_activity_sign(body)
r = await self.req(
"POST",
f"https://panservice.mail.wo.cn{path}",
headers=self.campus_headers(),
json=body,
)
return r.get("data") or {}
async def campus_upload(self):
"""上传 1 字节 1.txt 完成上传任务"""
token = self.cloud_disk_token()
content = b"1"
file_name = "1.txt"
file_info = {
"batchNo": datetime.now().strftime("%Y%m%d%H%M%S"),
"fileName": file_name,
"fileSize": len(content),
"fileType": 1,
"directoryId": "0",
"spaceType": "0",
}
form = {
"uniqueId": f"{int(time.time() * 1000)}_{random.randint(100000, 999999)}",
"accessToken": token,
"psToken": "",
"totalPart": "1",
"partSize": str(len(content)),
"partIndex": "1",
"channel": "wocloud",
"fileName": file_name,
"fileSize": str(len(content)),
"directoryId": "0",
"spaceType": "0",
"fileInfo": self.battle_encrypt_fileinfo(file_info, token),
}
headers = {
"User-Agent": BATTLE_UA,
"Referer": "https://panservice.mail.wo.cn/",
"accessToken": token,
"access-token": token,
}
try:
r = await self.client.post(
self.CAMPUS_UPLOAD_URL,
data=form,
files={"file": (file_name, content, "text/plain")},
headers=headers,
timeout=60.0,
)
try:
d = r.json()
except Exception:
d = {}
if d.get("code") == "0000":
return True, d.get("msg") or "上传成功"
return False, response_summary(d or {"text": r.text[:120]})
except Exception as e:
return False, str(e)[:120]
# --- 沃视频内容转存(校园季"转存教育内容"/"转存娱乐盘内容"任务) ---
WO_VIDEO_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) LianTongYunPan/6.2.0 (iPhone; iOS 27.0)"
)
def pan_app_headers(self):
"""s.pan.wo.cn 竖版 App 请求头(ticket / 校园 tab 用)。"""
token = self.cloud_disk_token()
return {
"X-YP-Access-Token": token,
"Access-Token": token,
"Client-Id": "1001000035",
"app-type": "liantongyunpanapp",
"App-Version": "yp-app/6.2.0",
"bundleid": "com.chinaunicom.bol.cloud",
"User-Agent": "LianTongYunPan/6.2.0 (iPhone;iOS 27.0)",
"Content-Type": "application/json",
}
async def wo_ticket(self):
"""取云盘 ticket(进沃视频详情页的通行证)。"""
r = await self.req(
"GET",
"https://s.pan.wo.cn/api-user/api/user/ticket",
headers=self.pan_app_headers(),
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
return str(result.get("ticket") or "")
async def wo_enter(self, content_id, ticket):
"""打开沃视频详情页,换取 mbh 侧会话;返回 (Referer, h5Token)。"""
ua = {"User-Agent": self.WO_VIDEO_UA}
referer = (
f"{WO_VIDEO_BASE}/YunPanVideoV4/index.html?id={content_id}"
f"&ticket={quote(ticket)}"
)
await self.req("GET", referer, headers=ua)
ts = datetime.now().strftime("%Y%m%d%H%M%S")
sign = hashlib.md5(f"{ticket}dncj@2181{ts}spamx1a7s".encode()).hexdigest().upper()
await self.req(
"POST",
f"{WO_VIDEO_BASE}/TjCenter/wovideo/h5/getCookie",
headers={**ua, "Content-Type": "application/json"},
json={"timestamp": ts, "md5": sign, "mobile": ticket, "clientType": "3"},
)
info = await self.req(
"GET",
f"{WO_VIDEO_BASE}/TjCenter/wovideo/check/getUserInfoByCookie",
headers=ua,
)
data = info.get("data") if isinstance(info.get("data"), dict) else {}
inner = data.get("data") if isinstance(data.get("data"), dict) else {}
return referer, str(inner.get("h5Token") or "")
async def wo_dispatcher(self, api_key, body, referer):
"""POST /WoYunPanA/woapi/dispatcher(body AES 加密),返回 RSP。"""
r = await self.req(
"POST",
f"{WO_VIDEO_BASE}/WoYunPanA/woapi/dispatcher",
headers={
"User-Agent": self.WO_VIDEO_UA,
"Content-Type": "application/json",
"Origin": WO_VIDEO_BASE,
"Referer": referer,
"key": api_key,
"channel": WO_VIDEO_CHANNEL,
},
json={
"header": {"key": api_key, "channel": WO_VIDEO_CHANNEL},
"body": wo_aes_b64(
json.dumps(body, ensure_ascii=False, separators=(",", ":"))
),
},
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
rsp = data.get("RSP")
if str(data.get("STATUS")) != "200" or not isinstance(rsp, dict):
return {}
return rsp
async def wo_transfer_status(self, media_id, referer):
"""转存状态:True=已转存,False=未转存,None=查询失败。"""
rsp = await self.wo_dispatcher(
"JudgeFileTransferStatus",
{"userId": self.mobile, "fileUniqueValue": wo_des_b64(media_id)},
referer,
)
if str(rsp.get("RSP_CODE")) != "0000":
return None
try:
return wo_aes_unb64(str(rsp.get("DATA") or "")) != "0"
except Exception:
return None
async def wo_file_transfer(
self, main_id, media_id, name, file_size, referer, space_type=None
):
"""WoFileTransfer 提交转存;返回 (是否成功, 说明)。"""
now = datetime.now().strftime("%Y%m%d%H%M%S")
detail = {
"mainContentId": main_id,
"contentId": media_id,
"userId": self.mobile,
"time": now,
}
body = {
"userId": self.mobile,
"clientId": WO_VIDEO_CHANNEL,
"fileName": name,
"fileSize": int(file_size or 0)
or random.randint(1024 * 1024, 2 * 1024 * 1024),
"fileUniqueValue": wo_des_b64(media_id),
"fileDetailLink": wo_des_b64(
json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
),
}
if space_type:
body["spaceType"] = str(space_type)
rsp = await self.wo_dispatcher("WoFileTransfer", body, referer)
code = str(rsp.get("RSP_CODE") or "")
if code == "0000":
return True, "成功"
return False, f"RSP_CODE={code or '无'}"
async def wo_content_package(self, content_id):
"""查教育课程包,返回第 1 集 (media_id, name, file_size);非课程包返回 None。"""
ts = datetime.now().strftime("%Y%m%d%H%M%S")
signature = (
hashlib.sha256(
f"foQ9ma89UoOkDMFs{self.mobile}{content_id}{ts}".encode()
).hexdigest()
+ ts
)
r = await self.req(
"POST",
f"{WO_VIDEO_BASE}/GW/queryContentPackageDetailForWV",
headers={"User-Agent": self.WO_VIDEO_UA, "Content-Type": "application/json"},
json={
"userId": self.mobile,
"contentId": content_id,
"count": 50,
"offset": 0,
"accessChannel": "91",
"signature": signature,
},
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
detail = (
data.get("contentPackageDetail")
if isinstance(data.get("contentPackageDetail"), dict)
else {}
)
episodes = (
detail.get("mobVodDetails")
if isinstance(detail.get("mobVodDetails"), list)
else []
)
if not episodes or not isinstance(episodes[0], dict):
return None
ep = episodes[0]
media_id = str(ep.get("id") or ep.get("code") or "")
if not media_id:
return None
name = re.sub(r"\s+", "", str(ep.get("name") or ""))
media_files = ep.get("mediaFiles") if isinstance(ep.get("mediaFiles"), list) else []
size = 0
if media_files and isinstance(media_files[0], dict):
sizes = media_files[0].get("fileSize") or []
size = int(sizes[0]) if sizes else 0
return media_id, name, size
async def wo_authorize_edu(self, h5_token, referer):
"""教育产品授权(转存教育内容前置,失败不阻塞)。"""
code = quote(quote(wo_des_b64("authType=1", "8wrap2ih"), safe=""), safe="")
r = await self.req(
"POST",
f"{WO_VIDEO_BASE}/TjCenter/cloudDrive/authorizeEduProduct",
params={"code": code, "platform": "h5"},
headers={
"User-Agent": self.WO_VIDEO_UA,
"h5-token": h5_token,
"Referer": referer,
},
json={},
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
inner = data.get("data") if isinstance(data.get("data"), dict) else {}
return str(data.get("status")) == "0" and inner.get("isSuccess") == 1
async def campus_tab_videos(self, tab_code):
"""校园 tab 内指向沃视频详情页的内容 [(content_id, title)]。"""
r = await self.req(
"GET",
"https://s.pan.wo.cn/api/bff/home/school/v2/tab/content",
params={"tabCode": tab_code},
headers=self.pan_app_headers(),
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
sections = data.get("result") if isinstance(data.get("result"), list) else []
videos = []
for section in sections:
items = section.get("data") if isinstance(section, dict) else None
for item in items or []:
if not isinstance(item, dict):
continue
m = re.search(
r"YunPanVideoV4/index\.html\?id=([0-9A-Za-z]+)",
str(item.get("jumpUrl") or ""),
)
if m and m.group(1) not in {v[0] for v in videos}:
videos.append((m.group(1), str(item.get("title") or "")))
return videos
async def campus_transfer_targets(self, kind):
"""候选转存内容(校园 tab 动态发现 + 兜底 id)。
返回 [(main_id, media_id, name, file_size, space_type)]。
"""
tab = "school_tab_learning" if kind == "edu" else "school_tab_entertainment"
targets = []
for content_id, title in await self.campus_tab_videos(tab):
name = re.sub(r"\s+", "", title)
if kind == "edu":
ep = await self.wo_content_package(content_id)
if ep:
targets.append((content_id, ep[0], ep[1], ep[2], "76"))
continue
targets.append((content_id, content_id, name, 0, "76"))
else:
targets.append((content_id, content_id, name, 0, None))
fallback = CAMPUS_EDU_FALLBACK_ID if kind == "edu" else CAMPUS_ENT_FALLBACK_ID
if fallback:
ep = await self.wo_content_package(fallback) if kind == "edu" else None
if ep:
targets.append((fallback, ep[0], ep[1], ep[2], "76"))
else:
targets.append(
(fallback, fallback, CAMPUS_FALLBACK_NAME[kind], 0, "76" if kind == "edu" else None)
)
return targets[:12] # 候选过多时只试前几个,避免无谓耗时
async def campus_transfer(self, kind):
"""校园季转存任务:kind=edu 转存教育内容 / ent 转存娱乐盘内容。"""
label = "教育" if kind == "edu" else "娱乐"
targets = await self.campus_transfer_targets(kind)
if not targets:
self.tlog(f"转存{label}: 未找到可转存内容")
return False
skipped = None
for main_id, media_id, name, size, space_type in targets:
ticket = await self.wo_ticket()
if not ticket:
self.tlog(f"转存{label}: ticket 获取失败")
return False
referer, h5_token = await self.wo_enter(main_id, ticket)
if kind == "edu" and h5_token:
await self.wo_authorize_edu(h5_token, referer)
status = await self.wo_transfer_status(media_id, referer)
if status is None:
self.tlog(f"转存{label}: [{name}] 状态查询失败, 试下一个")
continue
if status:
skipped = skipped or (main_id, media_id, name, size, space_type, referer)
continue
ok, msg = await self.wo_file_transfer(
main_id, media_id, name, size, referer, space_type
)
if ok:
self.tlog(f"转存{label}: [{name}] 成功")
return True
self.tlog(f"转存{label}: [{name}] 失败 {msg}")
if skipped:
# 候选都已转存过:重转一条碰运气(服务端裁决,可能计入当日任务)
main_id, media_id, name, size, space_type, referer = skipped
ok, msg = await self.wo_file_transfer(
main_id, media_id, name, size, referer, space_type
)
self.tlog(
f"转存{label}: [{name}] 已转存过"
+ (",重复转存已计入" if ok else f"(服务端未计入: {msg})")
)
if ok:
return True
if kind == "ent":
# mbh 渠道无新内容 → 芒果TV(需会员)→ mbh 影视库(不限会员)
ok, info = await self.mgtv_transfer()
if ok:
self.tlog(f"转存{label}: 芒果TV内容源 成功 [{info}]")
return True
self.tlog(f"转存{label}: 芒果TV内容源 {info}")
ok, info = await self.mbh_channel_transfer(self.MBH_MOVIE_SUBJECTS)
self.tlog(
f"转存{label}: mbh影视库 "
+ (f"成功 [{info}]" if ok else f"未找到可用内容: {info}")
)
return ok
# 教育:mbh 教育频道兜底(学习盘课程耗尽后)
ok, info = await self.mbh_channel_transfer(
self.MBH_EDU_SUBJECTS, space_type="76", package_mode=True
)
self.tlog(
f"转存{label}: mbh教育频道 " + (f"成功 [{info}]" if ok else f"未找到可用内容: {info}")
)
return ok
# --- 芒果TV(mgtv) 内容转存:娱乐盘任务长期内容源 ---
MGTV_ACT_API = "https://mgcact.api.mgtv.com"
MGTV_CLUB_API = "https://mgcclub.api.mgtv.com"
MGTV_CLUB_ACTIVITY = "d5b02f34be217c4d"
MGTV_FALLBACK_HIDS = ("896231", "867929", "859271")
def mgtv_headers(self):
"""芒果侧请求头。"""
return {
"User-Agent": self.WO_VIDEO_UA,
"Origin": "https://pop.mgtv.com",
"Referer": "https://pop.mgtv.com/",
}
async def mgtv_session(self):
"""用云盘 ticket 换 mgtv 会话 ticket。"""
ts = await self.req(
"GET",
"https://s.pan.wo.cn/api-user/api/user/ticket",
headers=self.pan_app_headers(),
)
ticket = ((ts.get("data") or {}).get("result") or {}).get("ticket")
if not ticket:
return ""
r = await self.req(
"GET",
f"{self.MGTV_ACT_API}/api/cu/login",
params={"ticket": ticket, "t": int(time.time() * 1000)},
headers=self.mgtv_headers(),
)
data = (r.get("data") or {}).get("data") or {}
return str(data.get("ticket") or "")
async def mgtv_hids(self, st):
"""club/homepage 取全部可转存内容 hid(约 140 档)。"""
r = await self.req(
"POST",
f"{self.MGTV_CLUB_API}/api/mgc/club/homepage",
headers={**self.mgtv_headers(), "Content-Type": "application/json"},
json={
"ticket": st,
"t": int(time.time() * 1000),
"activityId": self.MGTV_CLUB_ACTIVITY,
"isMgApp": 0,
},
)
data = (r.get("data") or {}).get("data") or {}
hids = []
for model in data.get("list") or []:
for item in model.get("modelDataList") or []:
hid = str(item.get("words1") or "").strip()
if hid.isdigit() and hid not in hids:
hids.append(hid)
return hids or list(self.MGTV_FALLBACK_HIDS)
async def mgtv_episodes(self, st, hid):
"""encrypt + episodes:返回 (hcode, 集列表)。"""
r = await self.req(
"POST",
f"{self.MGTV_ACT_API}/api/cu/mediaId/encrypt",
headers={**self.mgtv_headers(), "Content-Type": "application/json"},
json={
"ticket": st,
"t": int(time.time() * 1000),
"hid": str(hid),
"vids": "1",
},
)
hcode = ((r.get("data") or {}).get("data") or {}).get("hcode")
if not hcode:
return "", []
r2 = await self.req(
"GET",
f"{self.MGTV_ACT_API}/api/cu/media/episodes",
params={"ticket": st, "t": int(time.time() * 1000), "mediaId": hcode},
headers=self.mgtv_headers(),
)
eps = (r2.get("data") or {}).get("data")
return str(hcode), eps if isinstance(eps, list) else []
async def mgtv_transfer_ep(self, st, hcode, ep):
"""转存单个分集。"""
r = await self.req(
"POST",
f"{self.MGTV_ACT_API}/api/cu/woFileTransfer",
headers={**self.mgtv_headers(), "Content-Type": "application/json"},
json={
"ticket": st,
"t": int(time.time() * 1000),
"fileName": str(ep.get("title") or "视频"),
"clipId": hcode,
"partId": str(ep.get("vcode") or ""),
"fileType": "2",
},
)
return r.get("data") or {}
async def mgtv_transfer(self, max_tries=15):
"""扫描芒果内容池,转存一集未转存内容;返回 (是否成功, 说明)。"""
st = await self.mgtv_session()
if not st:
return False, "mgtv 会话获取失败"
hids = list(await self.mgtv_hids(st))
random.shuffle(hids) # 每次随机顺序,避免总扫前面已耗尽的档
tries = 0
for hid in hids[:24]: # 单次运行最多扫 24 档
hcode, eps = await self.mgtv_episodes(st, hid)
if not eps:
continue
free = [e for e in eps if int(e.get("isPay") or 0) == 0]
paid = [e for e in eps if int(e.get("isPay") or 0) != 0][:3]
for ep in free + paid:
if tries >= max_tries:
return False, f"尝试 {tries} 集均未成功"
tries += 1
rsp = await self.mgtv_transfer_ep(st, hcode, ep)
code = str(rsp.get("code") or "")
if code == "0000":
return True, str(ep.get("title") or "")[:40]
if "无会员权益" in str(rsp.get("msg") or ""):
return False, "账号无芒果会员, 跳过芒果内容源"
return False, f"扫描 {tries} 集均未成功"
# --- mbh 频道内容源(不限会员,电影/课程)---
MBH_MOVIE_SUBJECTS = ("catauto1111125306", "catauto1111125307")
MBH_EDU_SUBJECTS = ("catauto1111134283", "catauto1111134308")
async def mbh_channel_items(self, subjects, count=50):
"""拉 mbh 频道内容列表,返回 [(code, name)]。"""
r = await self.req(
"POST",
f"{WO_VIDEO_BASE}/jsp/V3/QueryVODAndChannelBatch",
headers={"User-Agent": self.WO_VIDEO_UA, "Content-Type": "application/json"},
json={
"subjects": [
{"woSubjectID": s, "count": count, "offset": 0} for s in subjects
],
"versionName": "8.1.0",
},
)
data = r.get("data") if isinstance(r.get("data"), dict) else {}
items = []
for channel in data.get("contents") or []:
if not isinstance(channel, dict):
continue
for v in channel.get("vc") or []:
content = v.get("woContent") if isinstance(v, dict) else None
if not isinstance(content, dict):
continue
code = str(content.get("code") or "")
name = str(content.get("name") or "")
if code and name and (code, name) not in items:
items.append((code, name))
return items
async def mbh_channel_transfer(
self, subjects, space_type=None, package_mode=False, max_tries=10
):
"""扫描 mbh 频道内容,转存一条未转存内容;返回 (是否成功, 说明)。"""
items = await self.mbh_channel_items(subjects)
if not items:
return False, "频道列表获取失败"
random.shuffle(items)
for code, name in items[:max_tries]:
media_id, file_name, file_size = code, name, 0
if package_mode:
ep = await self.wo_content_package(code)
if ep:
media_id, file_name, file_size = ep[0], ep[1], ep[2]
ticket = await self.wo_ticket()
if not ticket:
return False, "ticket 获取失败"
referer, h5_token = await self.wo_enter(code, ticket)
if package_mode and h5_token:
await self.wo_authorize_edu(h5_token, referer)
status = await self.wo_transfer_status(media_id, referer)
if status is not False:
continue # 已转存或查询失败,试下一个
ok, _ = await self.wo_file_transfer(
code, media_id, file_name, file_size, referer, space_type
)
if ok:
return True, file_name[:40]
return False, f"尝试 {min(len(items), max_tries)} 条均未成功"
# --- 学习助手 AI 对话("体验学习助手"任务 30008)---
AI_WORKFLOW_REFERER = "https://panservice.mail.wo.cn/h5/wocloud_ai_1/workFlow?activeTab=2"
def ai_headers(self):
"""沃云盘 AI 工作流请求头。"""
token = self.cloud_disk_token()
return {
"X-YP-Access-Token": token,
"token": token,
"accesstoken": token,
"Access-Token": token,
"source-type": "woapi",
"clientId": "1001000035",
"Client-Id": "1001000035",
"X-YP-Client-Id": "1001000035",
"X-YP-App-Version": "6.2.0",
"X-YP-Open-Version": "v1.0",
"Content-Type": "application/json",
"requestTime": str(int(time.time() * 1000)),
"User-Agent": self.WO_VIDEO_UA,
"Origin": "https://panservice.mail.wo.cn",
"Referer": self.AI_WORKFLOW_REFERER,
}
async def campus_ai_chat(self):
"""体验学习助手:发起一次 AI 对话(SSE 流式),返回是否成功。
总是新建会话,不查询/复用已有会话,避免污染用户聊天记录。"""
conv_id = ""
body = {
"input": "你好,请用一句话介绍一下你自己",
"modelId": 0,
"platform": 2,
"tag": 0,
"conversationId": conv_id,
"knowledgeId": "",
"referFileInfo": [],
"messageId": "",
"conversationType": "bot",
"recipient": "",
"aiExtra": "",
"async": "1",
}
headers = {**self.ai_headers(), "Accept": "text/event-stream"}
url = "https://panservice.mail.wo.cn/wohome/ai/assistant/query"
bytes_read = 0
async def read_stream():
nonlocal bytes_read
async with self.client.stream(
"POST", url, headers=headers, json=body, timeout=120.0
) as r:
if r.status_code != 200:
return f"HTTP {r.status_code}"
async for chunk in r.aiter_bytes():
bytes_read += len(chunk)
return ""
try:
err = await asyncio.wait_for(read_stream(), timeout=90)
except asyncio.TimeoutError:
self.tlog(f"AI对话: 流读取超时(90 秒), 已收 {bytes_read} 字节")
return bytes_read > 0
except Exception as e:
self.tlog(f"AI对话: 异常 {self.format_exc(e)}")
return False
if err:
self.tlog(f"AI对话: 失败 {err}")
return False
self.tlog(f"AI对话: 完成 (会话 {conv_id or '新建'}, 流 {bytes_read} 字节)")
return True
async def campus_task(self):
"""校园季: 激活 -> 上传 -> 查进度 -> 自动抽奖"""
self._task_tag = "校园季"
if not self.cloud_disk_token():
self.tlog("缺少云盘 userToken, 跳过")
return
try:
r = await self.req(
"POST",
"https://panservice.mail.wo.cn/activity/task/activate",
headers=self.campus_headers(),
json={"activityId": self.CAMPUS_ACTIVITY_ID},
)
d = r.get("data") or {}
if str((d.get("meta") or {}).get("code")) == "200":
self.tlog("激活成功")
else:
self.tlog(f"激活失败 {response_summary(d or r)}")
return
# 先查任务状态:已满的任务不再执行,避免重复上传/转存/AI 对话。
# 查询失败时 done 为空 -> 全部照常执行,保持原行为。
tl = await self.campus_signed_post(
"/activity/school/task/list", "activity:school:activate"
)
done = {}
for t in (tl.get("result") or {}).get("taskList") or []:
code = str(t.get("taskCode") or "")
if code in ("30004", "30005", "30007", "30008"):
try:
done[code] = int(t.get("doneCount") or 0) >= int(
t.get("dailyLimit") or 0
)
except (TypeError, ValueError):
done[code] = False
self.tlog(
f"任务[{t.get('taskName')}]: {t.get('doneCount')}/{t.get('dailyLimit')}"
+ (" (已满)" if done[code] else "")
)
if done.get("30004"):
self.tlog("上传任务已满, 跳过")
else:
ok, msg = await self.campus_upload()
self.tlog(f"上传{'成功' if ok else '失败'}: {msg}")
if ok:
await asyncio.sleep(3)
# 转存任务(教育/娱乐各 1 条)——独立于上传,失败只记录不阻断抽奖
for kind, tcode in (("edu", "30005"), ("ent", "30007")):
if done.get(tcode):
self.tlog("教育转存任务已满, 跳过" if kind == "edu" else "娱乐转存任务已满, 跳过")
continue
try:
await self.campus_transfer(kind)
except Exception as e:
self.tlog_exc(e, "转存")
await asyncio.sleep(2)
# 学习助手体验任务(30008):发起一次 AI 对话
if done.get("30008"):
self.tlog("AI任务已满, 跳过")
else:
try:
await self.campus_ai_chat()
except Exception as e:
self.tlog_exc(e, "AI对话")
lt = await self.req(
"GET",
"https://panservice.mail.wo.cn/activity/lottery/lottery-times"
f"?activityId={self.CAMPUS_ACTIVITY_ID}",
headers=self.campus_headers(),
)
times = (lt.get("data") or {}).get("result")
times = times if isinstance(times, int) else 0
if times <= 0:
self.tlog("当前无可抽奖次数")
return
for _ in range(min(times, 5)): # 上限保护: 防服务端异常次数导致长时间循环
res = await self.campus_signed_post("/activity/lottery", "activity:lottery")
if str((res.get("meta") or {}).get("code")) == "200":
prize = (res.get("result") or {}).get("prizeName") or "未知奖品"
self.tlog(f"抽奖成功: {prize}")
else:
self.tlog(f"抽奖失败 {response_summary(res)}")
break
await asyncio.sleep(1.5)
except Exception as e:
self.tlog(f"异常: {self.format_exc(e)}")
# === 主任务 ===
async def run(self):
try:
if not await self.login():
return
only = os.environ.get("UNICOM_ONLY_TASK", "").strip()
task_map = {
"market": self.market_task,
"sign": self.sign_task,
"xj": self.xj_task,
"ltzf": self.ltzf_task,
"sec": self.sec_task,
"farm": self.farm_task,
"shangdu": self.shangdu_task,
"uphone": self.uphone_task,
"woread": self.woread_task,
"campus": self.campus_task,
}
if only:
names = [n.strip() for n in only.split(",") if n.strip()]
tasks = [task_map[n] for n in names if n in task_map]
if not tasks:
self.log(f"UNICOM_ONLY_TASK={only!r} 无匹配任务,可选: {list(task_map)}")
return
else:
tasks = [
self.market_task,
self.sign_task,
self.xj_task,
# singer/puzzle/ttlxj 已整体删除(2026-09-12): 活动均已结束,代码不再保留
self.ltzf_task,
self.sec_task,
self.farm_task,
self.shangdu_task,
self.uphone_task,
self.woread_task,
self.campus_task,
]
# 服务端长期不可用的模块(如 wocare 接口整体 5xx)可用环境变量停掉,
# 避免每次运行都产生注定失败的异常并淹没推送
skip = {
n.strip()
for n in os.environ.get("UNICOM_SKIP_TASK", "").split(",")
if n.strip()
}
if skip:
skipped = [n for n in skip if n in task_map]
unknown = sorted(n for n in skip if n not in task_map)
if unknown:
self.log(
f"UNICOM_SKIP_TASK 含未知任务名: {','.join(unknown)} (可选: {list(task_map)})"
)
tasks = [t for t in tasks if t not in {task_map[n] for n in skipped}]
if skipped:
self.log(f"已按 UNICOM_SKIP_TASK 跳过: {','.join(sorted(skipped))}")
for task in tasks:
try:
await task()
except Exception as e:
self.tlog_exc(e, task.__name__)
finally:
await self.close()
async def main():
NOTIFY_LINES.clear() # 进程内重复调用 main() 时不累积上次通知
# 10点开抢/日期比对/周日判断都依赖本地时区,容器 TZ 为空(含空串)时显式钉死
if not os.environ.get("TZ"):
os.environ["TZ"] = "Asia/Shanghai"
if hasattr(time, "tzset"):
time.tzset()
print(f"开始: {datetime.now():%Y-%m-%d %H:%M:%S} TZ={os.environ.get('TZ')}")
cookies = os.environ.get("chinaUnicomCookie", "")
if not cookies:
print("未找到环境变量 chinaUnicomCookie")
return
tasks = [
Unicom(c, i + 1).run() for i, c in enumerate(cookies.split("@")) if c.strip()
]
print(f"启动 {len(tasks)} 个账号")
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
line = f"账号{i + 1} 未捕获异常: {format_exception_detail(result)}"
print(line, flush=True)
NOTIFY_LINES.append(line)
push_lines = [
l
for l in NOTIFY_LINES
if any(k in l for k in ("失败", "异常", "成功", "抽奖"))
]
if len(push_lines) > 100: # 通知体积上限
push_lines = push_lines[:100] + [f"... 其余 {len(push_lines) - 100} 行已省略"]
if push_lines:
notify_send("中国联通任务", "\n".join(push_lines))
print("结束")
if __name__ == "__main__":
asyncio.run(main())