# Source: https://github.com/CN-Grace/QinglongScripts/blob/main/skyland.py
# Raw: https://raw.githubusercontent.com/CN-Grace/QinglongScripts/main/skyland.py
# Repo: CN-Grace/QinglongScripts
# Path: skyland.py
# UploadedAt: 2026-05-23T15:56:46Z
# SHA256: 3ff34a02f788b8d949abe98d0670868ecd5dbd849cf18490c04ca20ae3641688
# Category: 微信小程序/抓包版
# Evidence: appid/openid/session_key/encryptedData等小程序字段

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
cron: 0 8 * * *
new Env("森空岛签到")
森空岛（Skland）自动签到脚本
- 模拟数美设备指纹，生成设备 ID（dId）
- 使用用户 Token 登录森空岛，获取凭证
- 遍历绑定角色，对明日方舟、终末地等游戏进行每日签到
"""

import base64
import gzip
import hashlib
import hmac
import json
import os
import re
import time
import uuid
from urllib import parse

import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from cryptography.hazmat.primitives.ciphers.modes import CBC, ECB

from utils import log_info, log_success, log_warning, log_error, beijing_time_str
from notifier import send as notify_send

# ==================== 用户配置 ====================
TOKENS = os.environ.get("SKYLAND_TOKEN", "").split(",")
APP_CODE = "4ca99fa6b56cc2ba"

# ==================== 数美加密常量 ====================
SM_CONFIG = {
    "organization": "UWXspnCCJN4sfYlNfqps",
    "appId": "default",
    "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmxMNr7n8ZeT0tE1R9j/mPixoinPkeM+k4VGIn/s0k7N5rJAfnZ0eMER+QhwFvshzo0LNmeUkpR8uIlU/GEVr8mN28sKmwd2gpygqj0ePnBmOW4v0ZVwbSYK+izkhVFk2V/doLoMbWy6b+UnA8mkjvg0iYWRByfRsK2gdl7llqCwIDAQAB",
    "protocol": "https",
    "apiHost": "fp-it.portal101.cn",
}
DES_RULE = {
    "appId": {"cipher": "DES", "is_encrypt": 1, "key": "uy7mzc4h", "obfuscated_name": "xx"},
    "box": {"is_encrypt": 0, "obfuscated_name": "jf"},
    "canvas": {"cipher": "DES", "is_encrypt": 1, "key": "snrn887t", "obfuscated_name": "yk"},
    "clientSize": {"cipher": "DES", "is_encrypt": 1, "key": "cpmjjgsu", "obfuscated_name": "zx"},
    "organization": {"cipher": "DES", "is_encrypt": 1, "key": "78moqjfc", "obfuscated_name": "dp"},
    "os": {"cipher": "DES", "is_encrypt": 1, "key": "je6vk6t4", "obfuscated_name": "pj"},
    "platform": {"cipher": "DES", "is_encrypt": 1, "key": "pakxhcd2", "obfuscated_name": "gm"},
    "plugins": {"cipher": "DES", "is_encrypt": 1, "key": "v51m3pzl", "obfuscated_name": "kq"},
    "pmf": {"cipher": "DES", "is_encrypt": 1, "key": "2mdeslu3", "obfuscated_name": "vw"},
    "protocol": {"is_encrypt": 0, "obfuscated_name": "protocol"},
    "referer": {"cipher": "DES", "is_encrypt": 1, "key": "y7bmrjlc", "obfuscated_name": "ab"},
    "res": {"cipher": "DES", "is_encrypt": 1, "key": "whxqm2a7", "obfuscated_name": "hf"},
    "rtype": {"cipher": "DES", "is_encrypt": 1, "key": "x8o2h2bl", "obfuscated_name": "lo"},
    "sdkver": {"cipher": "DES", "is_encrypt": 1, "key": "9q3dcxp2", "obfuscated_name": "sc"},
    "status": {"cipher": "DES", "is_encrypt": 1, "key": "2jbrxxw4", "obfuscated_name": "an"},
    "subVersion": {"cipher": "DES", "is_encrypt": 1, "key": "eo3i2puh", "obfuscated_name": "ns"},
    "svm": {"cipher": "DES", "is_encrypt": 1, "key": "fzj3kaeh", "obfuscated_name": "qr"},
    "time": {"cipher": "DES", "is_encrypt": 1, "key": "q2t3odsk", "obfuscated_name": "nb"},
    "timezone": {"cipher": "DES", "is_encrypt": 1, "key": "1uv05lj5", "obfuscated_name": "as"},
    "tn": {"cipher": "DES", "is_encrypt": 1, "key": "x9nzj1bp", "obfuscated_name": "py"},
    "trees": {"cipher": "DES", "is_encrypt": 1, "key": "acfs0xo4", "obfuscated_name": "pi"},
    "ua": {"cipher": "DES", "is_encrypt": 1, "key": "k92crp1t", "obfuscated_name": "bj"},
    "url": {"cipher": "DES", "is_encrypt": 1, "key": "y95hjkoo", "obfuscated_name": "cf"},
    "version": {"is_encrypt": 0, "obfuscated_name": "version"},
    "vpw": {"cipher": "DES", "is_encrypt": 1, "key": "r9924ab5", "obfuscated_name": "ca"},
}
BROWSER_ENV = {
    "plugins": "MicrosoftEdgePDFPluginPortableDocumentFormatinternal-pdf-viewer1,MicrosoftEdgePDFViewermhjfbmdgcfjbbpaeojofohoefgiehjai1",
    "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
    "canvas": "259ffe69",
    "timezone": -480,
    "platform": "Win32",
    "url": "https://www.skland.com/",
    "referer": "",
    "res": "1920_1080_24_1.25",
    "clientSize": "0_0_1080_1920_1920_1080_1920_1080",
    "status": "0011",
}
_PUBLIC_KEY = serialization.load_der_public_key(base64.b64decode(SM_CONFIG["publicKey"]))
_DID_CACHE = None


# ==================== 数美加密函数 ====================
def _des_encrypt(obj: dict) -> dict:
    result = {}
    for key, value in obj.items():
        rule = DES_RULE.get(key)
        if not rule:
            result[key] = value
            continue
        if rule["is_encrypt"] == 1:
            key = rule["key"].encode("utf-8")
            cipher = Cipher(TripleDES(key * 3), ECB())
            data = str(value).encode("utf-8")
            data += b"\x00" * (8 - len(data) % 8)
            encrypted = cipher.encryptor().update(data)
            result[rule["obfuscated_name"]] = base64.b64encode(encrypted).decode("utf-8")
        else:
            result[rule["obfuscated_name"]] = value
    return result


def _aes_encrypt(data: bytes, key: bytes) -> str:
    iv = b"0102030405060708"
    cipher = Cipher(AES(key), CBC(iv))
    encryptor = cipher.encryptor()
    pad_len = 16 - (len(data) % 16)
    data += b"\x00" * pad_len
    encrypted = encryptor.update(data) + encryptor.finalize()
    return encrypted.hex()


def _gzip_compress(obj: dict) -> bytes:
    json_str = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
    return base64.b64encode(gzip.compress(json_str.encode("utf-8"), compresslevel=2, mtime=0))


def _get_tn(obj: dict) -> str:
    sorted_keys = sorted(obj.keys())
    parts = []
    for k in sorted_keys:
        v = obj[k]
        if isinstance(v, (int, float)):
            parts.append(str(int(v * 10000)))
        elif isinstance(v, dict):
            parts.append(_get_tn(v))
        else:
            parts.append(str(v))
    return "".join(parts)


def _get_smid() -> str:
    t = time.localtime()
    time_str = f"{t.tm_year}{t.tm_mon:02d}{t.tm_mday:02d}{t.tm_hour:02d}{t.tm_min:02d}{t.tm_sec:02d}"
    uid = str(uuid.uuid4())
    md5_uid = hashlib.md5(uid.encode()).hexdigest()
    base = time_str + md5_uid + "00"
    smsk = hashlib.md5(("smsk_web_" + base).encode()).hexdigest()[:14]
    return base + smsk + "0"


def get_device_id() -> str:
    global _DID_CACHE
    if _DID_CACHE:
        log_info(f"使用缓存的设备 ID: {_DID_CACHE}")
        return _DID_CACHE

    log_info("开始生成设备 ID...")
    uid = str(uuid.uuid4()).encode("utf-8")
    pri_id = hashlib.md5(uid).hexdigest()[:16]
    ep = _PUBLIC_KEY.encrypt(uid, padding.PKCS1v15())
    ep_b64 = base64.b64encode(ep).decode("utf-8")

    browser = BROWSER_ENV.copy()
    current_time = int(time.time() * 1000)
    browser.update({"vpw": str(uuid.uuid4()), "svm": current_time, "trees": str(uuid.uuid4()), "pmf": current_time})

    des_target = {
        **browser,
        "protocol": 102, "organization": SM_CONFIG["organization"], "appId": SM_CONFIG["appId"],
        "os": "web", "version": "3.0.0", "sdkver": "3.0.0",
        "box": "", "rtype": "all", "smid": _get_smid(), "subVersion": "1.0.0", "time": 0,
    }
    des_target["tn"] = hashlib.md5(_get_tn(des_target).encode()).hexdigest()

    des_encrypted = _des_encrypt(des_target)
    compressed = _gzip_compress(des_encrypted)
    aes_encrypted = _aes_encrypt(compressed, pri_id.encode("utf-8"))

    url = f"https://{SM_CONFIG['apiHost']}/deviceprofile/v4"
    payload = {"appId": "default", "compress": 2, "data": aes_encrypted, "encode": 5, "ep": ep_b64, "organization": SM_CONFIG["organization"], "os": "web"}
    log_info("向数美服务请求设备 ID...")
    resp = requests.post(url, json=payload).json()
    if resp.get("code") != 1100:
        raise RuntimeError(f"获取设备 ID 失败: {resp}")
    _DID_CACHE = "B" + resp["detail"]["deviceId"]
    log_success(f"设备 ID 生成成功: {_DID_CACHE}")
    return _DID_CACHE


# ==================== 签到相关函数 ====================
def generate_signature(path: str, body_or_query: str, token: str, d_id: str) -> tuple:
    timestamp = str(int(time.time()) - 2)
    header_ca = {"platform": "3", "timestamp": timestamp, "dId": d_id, "vName": "1.0.0"}
    header_str = json.dumps(header_ca, separators=(",", ":"))
    raw_string = path + body_or_query + timestamp + header_str
    hmac_sha256 = hmac.new(token.encode(), raw_string.encode(), hashlib.sha256).hexdigest()
    sign = hashlib.md5(hmac_sha256.encode()).hexdigest()
    return sign, header_ca


def get_sign_headers(url: str, method: str, body, token: str, d_id: str, cred: str) -> dict:
    parsed = parse.urlparse(url)
    if method.lower() == "get":
        sign, header_ca = generate_signature(parsed.path, parsed.query, token, d_id)
    else:
        body_str = json.dumps(body) if body else ""
        sign, header_ca = generate_signature(parsed.path, body_str, token, d_id)
    return {
        "cred": cred,
        "User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-A5560 Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Safari/537.36; SKLand/1.52.1",
        "Accept-Encoding": "gzip", "Connection": "close",
        "X-Requested-With": "com.hypergryph.skland",
        "sign": sign, **header_ca,
    }


def get_grant_code(token: str, d_id: str) -> str:
    url = "https://as.hypergryph.com/user/oauth2/v2/grant"
    headers = {
        "User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-A5560 Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Safari/537.36; SKLand/1.52.1",
        "Accept-Encoding": "gzip", "Connection": "close",
        "dId": d_id, "X-Requested-With": "com.hypergryph.skland",
    }
    payload = {"appCode": APP_CODE, "token": token, "type": 0}
    log_info("正在获取 grant code...")
    resp = requests.post(url, json=payload, headers=headers).json()
    if resp.get("status") != 0:
        raise RuntimeError(f"获取 grant code 失败: {resp.get('msg')}")
    code = resp["data"]["code"]
    log_info(f"grant code 获取成功: {code[:10]}...")
    return code


def get_cred(grant_code: str, d_id: str) -> dict:
    url = "https://zonai.skland.com/web/v1/user/auth/generate_cred_by_code"
    headers = {
        "User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-A5560 Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Safari/537.36; SKLand/1.52.1",
        "Accept-Encoding": "gzip", "Connection": "close",
        "dId": d_id, "X-Requested-With": "com.hypergryph.skland",
    }
    payload = {"code": grant_code, "kind": 1}
    log_info("正在获取 cred...")
    resp = requests.post(url, json=payload, headers=headers).json()
    if resp.get("code") != 0:
        raise RuntimeError(f"获取 cred 失败: {resp.get('message')}")
    cred_data = resp["data"]
    log_info(f"cred 获取成功，token: {cred_data['token'][:10]}...")
    return cred_data


def get_binding_list(cred: str, token: str, d_id: str) -> list:
    url = "https://zonai.skland.com/api/v1/game/player/binding"
    headers = get_sign_headers(url, "get", None, token, d_id, cred)
    log_info("正在获取绑定角色列表...")
    resp = requests.get(url, headers=headers).json()
    if resp.get("code") != 0:
        if resp.get("message") == "用户未登录":
            log_error("用户登录失效，请重新获取 token")
        else:
            log_error(f"获取角色列表失败: {resp.get('message')}")
        return []
    characters = []
    for game in resp["data"]["list"]:
        if game.get("appCode") not in ("arknights", "endfield"):
            continue
        for char in game.get("bindingList", []):
            char["appCode"] = game["appCode"]
            characters.append(char)
    log_info(f"获取到 {len(characters)} 个可签到角色")
    return characters


def sign_arknights(character: dict, cred: str, token: str, d_id: str) -> list:
    url = "https://zonai.skland.com/api/v1/game/attendance"
    body = {"gameId": character.get("gameId"), "uid": character.get("uid")}
    headers = get_sign_headers(url, "post", body, token, d_id, cred)
    log_info(f"正在为角色 {character.get('nickName')} 进行明日方舟签到...")
    resp = requests.post(url, headers=headers, json=body).json()
    game_name = character.get("gameName", "明日方舟")
    channel = character.get("channelName", "")
    nickname = character.get("nickName") or ""
    if resp.get("code") != 0:
        msg = f"[{game_name}] 角色 {nickname}({channel}) 签到失败: {resp.get('message')}"
        log_warning(msg)
        return [f"❌ {msg}"]
    awards = resp["data"]["awards"]
    award_text = "".join([f"{a['resource']['name']}x{a.get('count', 1)}" for a in awards])
    msg = f"[{game_name}] 角色 {nickname}({channel}) 签到成功，获得 {award_text}"
    log_success(msg)
    return [f"✅ {msg}"]


def do_sign_endfield(role: dict, cred: str, token: str, d_id: str) -> requests.Response:
    url = "https://zonai.skland.com/web/v1/game/endfield/attendance"
    headers = get_sign_headers(url, "post", None, token, d_id, cred)
    headers.update({
        "Content-Type": "application/json",
        "sk-game-role": f"3_{role['roleId']}_{role['serverId']}",
        "referer": "https://game.skland.com/",
        "origin": "https://game.skland.com/",
    })
    return requests.post(url, headers=headers)


def sign_endfield(character: dict, cred: str, token: str, d_id: str) -> list:
    roles = character.get("roles", [])
    game_name = character.get("gameName", "终末地")
    channel = character.get("channelName", "")
    results = []
    for role in roles:
        nickname = role.get("nickname") or ""
        log_info(f"正在为角色 {nickname} 进行终末地签到...")
        resp = do_sign_endfield(role, cred, token, d_id)
        data = resp.json()
        if data.get("code") != 0:
            msg = f"[{game_name}] 角色 {nickname}({channel}) 签到失败: {data.get('message')}"
            log_warning(msg)
            results.append(f"❌ {msg}")
        else:
            awards = []
            info_map = data["data"]["resourceInfoMap"]
            for award_id in data["data"]["awardIds"]:
                aid = award_id["id"]
                awards.append(f"{info_map[aid]['name']}x{info_map[aid]['count']}")
            msg = f"[{game_name}] 角色 {nickname}({channel}) 签到成功，获得 {','.join(awards)}"
            log_success(msg)
            results.append(f"✅ {msg}")
    return results


def process_token(user_token: str, d_id: str, account_index: int) -> list:
    logs = []
    try:
        log_info(f"===== 开始处理第 {account_index} 个账号 =====")
        grant_code = get_grant_code(user_token, d_id)
        cred_data = get_cred(grant_code, d_id)
        cred = cred_data["cred"]
        token = cred_data["token"]
        characters = get_binding_list(cred, token, d_id)
        if not characters:
            logs.append("ℹ️ 该账号下无绑定角色或无支持签到的游戏")
            return logs
        for char in characters:
            if char["appCode"] == "arknights":
                logs.extend(sign_arknights(char, cred, token, d_id))
            elif char["appCode"] == "endfield":
                logs.extend(sign_endfield(char, cred, token, d_id))
        log_info(f"===== 第 {account_index} 个账号处理完成 =====")
    except Exception as e:
        err_msg = f"处理账号 {user_token[:8]}... 时出错: {e}"
        log_error(err_msg)
        logs.append(f"❌ {err_msg}")
    return logs


def parse_log_line(log: str):
    pattern = r'^(✅|❌|ℹ️)\s*\[(.+?)\]\s*角色\s+(.+?)\s*\((.+?)\)\s*签到(成功|失败)[：，:]?\s*(.*)$'
    match = re.match(pattern, log)
    if match:
        emoji, game, role, channel, status, extra = match.groups()
        return {"emoji": emoji, "game": game, "role": role, "channel": channel, "status": status, "extra": extra.strip()}
    return None


def format_push_message(accounts_logs):
    lines = ["📋 森空岛签到服务报告", f"执行时间: {beijing_time_str()}", ""]
    for acc_idx, logs in enumerate(accounts_logs, 1):
        game_dict = {}
        info_lines = []
        for log in logs:
            parsed = parse_log_line(log)
            if parsed:
                game = parsed["game"]
                if game not in game_dict:
                    game_dict[game] = []
                game_dict[game].append(parsed)
            else:
                if log.strip():
                    info_lines.append(f"  {log}")

        if info_lines:
            lines.extend(info_lines)
            lines.append("")

        for game, roles in game_dict.items():
            lines.append(f"  {game}")
            for role_info in roles:
                emoji = role_info["emoji"]
                status = role_info["status"]
                extra = role_info["extra"]
                if emoji == "✅":
                    lines.append(f"    📝 签到: ✅ 成功")
                    if extra:
                        lines.append(f"    🎁 奖励: {extra}")
                else:
                    lines.append(f"    📝 签到: ❌ 失败")
                    if extra:
                        lines.append(f"    ℹ️ 原因: {extra}")
            lines.append("")

    return "\n".join(lines).rstrip()


def main():
    if not TOKENS or TOKENS == [""]:
        log_error("未设置 SKYLAND_TOKEN 环境变量，请设置后运行")
        return

    try:
        d_id = get_device_id()
    except Exception as e:
        log_error(f"获取设备 ID 失败，无法继续: {e}")
        return

    all_accounts_logs = []
    for idx, token in enumerate(TOKENS, 1):
        token = token.strip()
        if not token:
            continue
        logs = process_token(token, d_id, idx)
        all_accounts_logs.append(logs)

    push_content = format_push_message(all_accounts_logs)
    print("\n" + push_content + "\n")
    notify_send("森空岛 签到报告", push_content)


if __name__ == "__main__":
    main()
