永恒灵魂 Eversoul 快要关服,来个兄弟帮忙弄个全资源包呗。

如题,我试着下载资源。每次下个30-50M就直接闪退了,下载了好几次了,一直下不下来。来个大手子帮帮忙吧,谢谢啦。 :heart:
QAQ.zip (1.8 MB)
顺带分享一个我喜欢的spine m:123

正好问到我熟悉的地方了,我一直在做这游戏wiki的数据支持
国际服/日服通用下载/更新脚本(不含数据表)

import re
import sys
import json
import hashlib
import requests
import urllib3
import argparse
import concurrent.futures
from pathlib import Path
from typing import Dict, Any
import signal
from contextlib import contextmanager
from google_play_scraper import app as playstore_app
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


class TimeoutException(Exception):
    pass


@contextmanager
def timeout(seconds):
    """超时上下文管理器"""
    def signal_handler(signum, frame):
        raise TimeoutException("Timed out!")
    
    # 设置信号处理器
    signal.signal(signal.SIGALRM, signal_handler)
    signal.alarm(seconds)
    try:
        yield
    finally:
        signal.alarm(0)


class ServerConfig:
    """服务器配置类"""
    def __init__(self, name: str, app_id: str, country: str, patch_url: str, qoo_app_id: str):
        self.name = name
        self.app_id = app_id
        self.country = country
        self.patch_url = patch_url
        self.qoo_app_id = qoo_app_id


GL_SERVER = ServerConfig(
    name="gl_live",
    app_id="com.kakaogames.eversoul",
    country="kr",
    patch_url="https://patch.esoul.kakaogames.com",
    qoo_app_id="18929"
)

JP_SERVER = ServerConfig(
    name="jp_live",
    app_id="com.kakaogames.eversouljp",
    country="jp",
    patch_url="https://patch.evsjp.kakaogames.com",
    qoo_app_id="22122"
)


def get_version_from_qooapp(qoo_app_id: str) -> str:
    """从QooApp获取版本号"""
    try:
        url = f"https://m-apps.qoo-app.com/en-US/app/{qoo_app_id}"
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
        }
        
        response = requests.get(url, headers=headers, timeout=10, verify=False)
        response.raise_for_status()
    
        content = response.text
        start_marker = "window.__INITIAL_DATA__"
        end_marker = "; window.__INITIAL_LAYOUT_DATA__"
        
        start_pos = content.find(start_marker)
        if start_pos == -1:
            raise Exception("找不到初始数据标记")
        
        start_pos = content.find("=", start_pos) + 1
        end_pos = content.find(end_marker, start_pos)
        
        if end_pos == -1:
            raise Exception("找不到结束标记")
        
        json_str = content[start_pos:end_pos].strip()
        
        json_str = re.sub(r'\bundefined\b', '""', json_str)
        
        data = json.loads(json_str)
        version = data["app-detail-view"]["fetch"]["apk"]["versionName"]
        
        return version
        
    except Exception as e:
        print(f"从QooApp获取版本号失败: {e}")
        raise


def get_app_version(server_config: ServerConfig) -> str:
    """从Google Play获取最新版本号,超时后使用QooApp备用方案"""
    print(f"正在从Google Play获取 {server_config.name.upper()} 版本号...")
    
    try:
        with timeout(3):
            result = playstore_app(app_id=server_config.app_id, lang="en", country=server_config.country)
            version = result["version"]
            print(f"从Google Play获取版本号成功: {version}")
            return version
            
    except TimeoutException:
        print("Google Play请求超时,切换到QooApp备用方案...")
        
    except Exception as e:
        print(f"Google Play获取版本号失败: {e},切换到QooApp备用方案...")
    
    try:
        version = get_version_from_qooapp(server_config.qoo_app_id)
        print(f"从QooApp获取版本号成功: {version}")
        return version
        
    except Exception as e:
        print(f"QooApp备用方案也失败了: {e}")
        sys.exit(1)


def download_catalog(version: str, server_config: ServerConfig) -> dict:
    """下载游戏资源目录"""
    catalog_url = f"{server_config.patch_url}/Live/{version}/Android/catalog_eversoul.json"
    try:
        response = requests.get(catalog_url, verify=False)
        response.raise_for_status()
        return response.json()
    except Exception as e:
        print(f"download catalog file error: {e}")
        sys.exit(1)


def read_base64(data: str) -> bytes:
    """Base64解码"""
    import base64
    return base64.b64decode(data)


class BinaryReader:
    """二进制读取器,简化版"""
    def __init__(self, data: bytes):
        self.data = data
        self.position = 0
    
    @property
    def u8(self) -> int:
        value = self.data[self.position]
        self.position += 1
        return value
    
    @property
    def u32(self) -> int:
        value = int.from_bytes(self.data[self.position:self.position+4], byteorder='little')
        self.position += 4
        return value
    
    @property
    def i32(self) -> int:
        value = int.from_bytes(self.data[self.position:self.position+4], byteorder='little', signed=True)
        self.position += 4
        return value
    
    @property
    def u16(self) -> int:
        value = int.from_bytes(self.data[self.position:self.position+2], byteorder='little')
        self.position += 2
        return value
    
    def str(self, length: int, encoding: str = 'utf-8') -> str:
        value = self.data[self.position:self.position+length].decode(encoding)
        self.position += length
        return value
    
    @property
    def pos(self) -> int:
        return self.position
    
    @pos.setter
    def pos(self, value: int):
        self.position = value


def read_obj(reader: BinaryReader) -> Any:
    """读取对象"""
    objt = reader.u8
    if objt == 0:  # ascii string
        return reader.str(reader.u32)
    elif objt == 1:  # unicode(16) string
        return reader.str(reader.u32, 'utf-16')
    elif objt == 2:  # u16
        return reader.u16
    elif objt == 3:  # u32
        return reader.u32
    elif objt == 4:  # i32
        return reader.i32
    elif objt == 7:  # json object
        return {'an': reader.str(reader.u8),
                'cn': reader.str(reader.u8),
                'js': json.loads(reader.str(reader.i32, 'utf-16'))}
    elif objt == 8:
        objt = reader.u8
        return reader.u32
    else:
        raise RuntimeError(f'type {objt} not supported.')


def del_str(name: str) -> str:
    """处理文件名,移除哈希值"""
    if '%3D' in name:
        return name.split('%3D')[0]
    
    is_bundle = name.endswith('.bundle')
    base_name = name[:-7] if is_bundle else name
    
    hash_match = re.search(r'_[0-9a-f]{32}(?=\.bundle$|$)', name)
    if hash_match:
        clean_name = name[:hash_match.start()]
        if is_bundle:
            clean_name += '.bundle'
        return clean_name
    
    if is_bundle:
        n = base_name
        n = n[:-4] if n.endswith('_all') else n
        n = n[:-7] if n.endswith('_assets') else n
        return f'{n}.bundle'
    else:
        c = name.find('_') > 0 and len(name) > 32 and name[-33:-1].isalnum()
        n = name[:-33] if c else name
        n = n[:-4] if n.endswith('_all') else n
        n = n[:-7] if n.endswith('_assets') else n
        return n


def parse_catalog(catalog: dict, prefix: str = '', server_config: ServerConfig = GL_SERVER) -> Dict[str, Dict[str, Any]]:
    """解析游戏资源目录"""
    results = {}
    
    kds = BinaryReader(read_base64(catalog['m_KeyDataString']))
    eds = BinaryReader(read_base64(catalog['m_EntryDataString']))
    xds = BinaryReader(read_base64(catalog['m_ExtraDataString']))

    skds = [read_obj(kds) for _ in range(kds.u32)]
    mii = catalog['m_InternalIds']
    
    for _ in range(eds.u32):
        ii, pi, dki, dh, di, pk, rt = eds.i32, eds.i32, eds.i32, eds.i32, eds.i32, eds.i32, eds.i32
        obj, enckey = None, None
        
        if di >= 0:
            xds.pos = di
            obj = read_obj(xds)
            if isinstance(obj, dict):
                if 'm_EncryptKey' in obj['js']:
                    enckey = obj['js']['m_EncryptKey']
                if 'm_BundleSize' in obj['js']:
                    obj = obj['js']['m_BundleSize']
                elif 'm_FileSize' in obj['js']:
                    obj = obj['js']['m_FileSize']
                else:
                    obj = 0
        
        on, nn = mii[ii], skds[pk]
        d = None if dki < 0 else skds[dki]
        
        start_check = on.startswith('https://')
        end_check = on.endswith('.bundle')
        
        if start_check and end_check:
            n = on.split('https://eversoul.com/', 1)[1] if 'https://eversoul.com/' in on else on
            nn = del_str(n)
            url = f"{server_config.patch_url}/Live/{prefix}/Android/{n}" if prefix else on
            
            results[nn] = {
                'name': nn,
                'url': url,
                'size': obj
            }
            
            if enckey is not None:
                results[nn]['key'] = enckey
    
    return results


def calculate_file_md5(file_path: str) -> str:
    """计算文件MD5值"""
    hash_md5 = hashlib.md5()
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(4096), b""):
            hash_md5.update(chunk)
    return hash_md5.hexdigest()


def check_assets(assets_dir: Path, asset_list: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
    """检查本地资产与远程资产的差异"""
    updated_files = {}
    
    if not assets_dir.exists():
        assets_dir.mkdir(parents=True, exist_ok=True)
        return asset_list
    
    for filename, info in asset_list.items():
        file_path = assets_dir / filename
        
        if not file_path.exists():
            updated_files[filename] = info
        elif 'size' in info and info['size'] > 0:
            local_size = file_path.stat().st_size
            remote_size = info['size']
            
            if local_size != remote_size:
                print(f"file size not match: {filename} (local: {local_size}, remote: {remote_size})")
                updated_files[filename] = info
    
    return updated_files


def download_file(url: str, file_path: Path, retries: int = 3) -> bool:
    """下载文件"""
    for attempt in range(retries):
        try:
            response = requests.get(url, stream=True, timeout=30, verify=False)
            response.raise_for_status()
            
            file_path.parent.mkdir(parents=True, exist_ok=True)
            
            with open(file_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=8192):
                    if chunk:
                        f.write(chunk)
            return True
        except Exception as e:
            print(f"download {url} failed (attempt {attempt+1}/{retries}): {e}")
            if attempt == retries - 1:
                return False
    return False


def download_assets(assets_to_download: Dict[str, Dict[str, Any]], target_dir: Path, max_workers: int = 100) -> Dict[str, bool]:
    """多线程下载资产"""
    results = {}
    
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_file = {
            executor.submit(
                download_file, 
                info['url'], 
                target_dir / filename
            ): filename
            for filename, info in assets_to_download.items()
        }
        
        for future in concurrent.futures.as_completed(future_to_file):
            filename = future_to_file[future]
            try:
                success = future.result()
                results[filename] = success
                status = "success" if success else "failed"
                print(f"download {filename}: {status}")
            except Exception as e:
                results[filename] = False
                print(f"download {filename} error: {e}")
    
    return results


def save_asset_list(asset_list: Dict[str, Dict[str, Any]], file_path: Path):
    """保存资产列表到JSON文件"""
    with open(file_path, 'w', encoding='utf-8') as f:
        json.dump(asset_list, f, ensure_ascii=False, indent=2)


def load_asset_list(file_path: Path) -> Dict[str, Dict[str, Any]]:
    """从JSON文件加载资产列表"""
    if not file_path.exists():
        return {}
    
    with open(file_path, 'r', encoding='utf-8') as f:
        return json.load(f)


def process_server(server_config: ServerConfig, base_dir: Path) -> None:
    """处理指定服务器的资产更新"""
    print(f"\nProcessing {server_config.name.upper()} server...")
    print("=" * 40)
    
    server_dir = base_dir / server_config.name
    assets_dir = server_dir / 'assets'
    update_dir = server_dir / 'update'
    asset_list_file = server_dir / 'asset_list.json'
    
    server_dir.mkdir(exist_ok=True)
    assets_dir.mkdir(exist_ok=True)
    
    has_assets = assets_dir.exists() and any(assets_dir.iterdir())
    
    print(f"Getting latest version info for {server_config.name.upper()} server...")
    version = get_app_version(server_config)
    print(f"Current version: {version}")

    print("Downloading catalog...")
    catalog = download_catalog(version, server_config)
    
    print("Parsing catalog...")
    asset_list = parse_catalog(catalog, version, server_config)
    print(f"Found {len(asset_list)} resource files")
    
    save_asset_list(asset_list, asset_list_file)
    
    print("Checking local assets...")
    updated_files = check_assets(assets_dir, asset_list)
    
    print(f"Number of files to update: {len(updated_files)}")
    
    if updated_files:
        print("Files to update:")
        for file, info in list(updated_files.items())[:10]:
            print(f"  - {file}")
        
        if len(updated_files) > 10:
            print(f"  ... {len(updated_files) - 10} more files")
        
        choice = input(f"Update these files for {server_config.name.upper()} server? (y/n): ").strip().lower()
        if choice == 'y':
            target_dir = assets_dir if not has_assets else update_dir
            target_dir.mkdir(parents=True, exist_ok=True)
            
            print(f"Start downloading files to {target_dir}...")
            download_results = download_assets(updated_files, target_dir)
            
            success_count = sum(1 for success in download_results.values() if success)
            print(f"Download success: {success_count}/{len(updated_files)}")
            
    else:
        print("All files are up to date, no updates needed")


def main():
    parser = argparse.ArgumentParser(description="Eversoul Resource Update Checker")
    parser.add_argument("--gl", action="store_true", help="Update global server assets")
    parser.add_argument("--jp", action="store_true", help="Update Japanese server assets")
    args = parser.parse_args()
    
    if not args.gl and not args.jp:
        args.gl = True
        args.jp = True
    
    base_dir = Path('.')
    
    print("Eversoul Asset Updater")
    print("=" * 40)
    
    if args.gl:
        process_server(GL_SERVER, base_dir)
    
    if args.jp:
        process_server(JP_SERVER, base_dir)
    
    print("\nAll update operations have been completed!")


if __name__ == "__main__":
    main() 

其他的一些玩意,有需要自取

感谢大佬分享

关服?国际服?

这版本号是多少啊

2022.3.20f1

感谢

可以看apk里面的文件,能直接看到。之前大佬有交

2022.3.54f1
这是日服版本号。
日服将于8月20日正式停服

感谢

我是直接用上面大佬提供的脚本下的数据包,所以看不到apk里面的版本