|
import argparse |
|
import json |
|
import logging |
|
import os |
|
import shutil |
|
import sys |
|
import uuid |
|
from pathlib import Path |
|
from typing import List, Dict, Optional, Any |
|
|
|
import requests |
|
import yaml |
|
|
|
# --- 全局配置 --- |
|
|
|
# 日志记录器 |
|
LOGGER = logging.getLogger("MinecraftOfflineOnlineConverter") |
|
|
|
# 忽略的文件扩展名 |
|
IGNORED_FILE_EXTENSIONS = { |
|
"mcr", "mca", "jar", "gz", "lock", "sh", "bat", "log", "mcmeta", |
|
"md", "snbt", "nbt", "zip", "cache", "png", "jpeg", "js", "DS_Store" |
|
} |
|
|
|
|
|
# --- 数据类 --- |
|
|
|
class Player: |
|
"""代表一个 Minecraft 玩家,包含名称和 UUID。""" |
|
|
|
def __init__(self, name: str, player_uuid: uuid.UUID): |
|
if not name: |
|
raise ValueError("玩家名称不能为空。") |
|
if not player_uuid: |
|
raise ValueError("玩家 UUID 不能为空。") |
|
self.name = name |
|
self.uuid = player_uuid |
|
|
|
def __repr__(self): |
|
return f"Player(name='{self.name}', uuid='{self.uuid}')" |
|
|
|
|
|
# --- Mojang API 和 UUID 处理 --- |
|
|
|
class UUIDHandler: |
|
"""处理 UUID 相关的操作,包括在线和离线 UUID 之间的转换。""" |
|
|
|
API_BASE_PATH = "https://api.mojang.com/" |
|
|
|
@staticmethod |
|
def offline_name_to_uuid(name: str) -> uuid.UUID: |
|
"""将玩家名称转换为离线 UUID。""" |
|
offline_uuid = uuid.uuid3(uuid.NAMESPACE_DNS, f"OfflinePlayer:{name}") |
|
LOGGER.info(f"为 '{name}' 生成的离线 UUID: {offline_uuid}") |
|
return offline_uuid |
|
|
|
@staticmethod |
|
def online_name_to_uuid(name: str) -> Optional[uuid.UUID]: |
|
"""通过查询 Mojang API 将玩家名称转换为在线 UUID。""" |
|
try: |
|
response = requests.get(f"{UUIDHandler.API_BASE_PATH}users/profiles/minecraft/{name}") |
|
if response.status_code == 200 and response.text: |
|
data = response.json() |
|
uuid_str = data.get("id") |
|
if uuid_str: |
|
full_uuid = f"{uuid_str[0:8]}-{uuid_str[8:12]}-{uuid_str[12:16]}-{uuid_str[16:20]}-{uuid_str[20:32]}" |
|
LOGGER.info(f"检索到玩家 '{name}' 的在线 UUID: {full_uuid}") |
|
return uuid.UUID(full_uuid) |
|
LOGGER.warning(f"未找到在线玩家 '{name}' 的 UUID。") |
|
return None |
|
except requests.RequestException as e: |
|
LOGGER.error(f"检索在线 UUID 时出错: {e}") |
|
return None |
|
|
|
@staticmethod |
|
def online_uuid_to_name(online_uuid: uuid.UUID) -> Optional[str]: |
|
"""使用 Mojang API 从在线 UUID 检索玩家名称。""" |
|
try: |
|
response = requests.get(f"{UUIDHandler.API_BASE_PATH}user/profile/{online_uuid.hex}") |
|
if response.status_code == 200 and response.text: |
|
data = response.json() |
|
name = data.get("name") |
|
if name: |
|
LOGGER.info(f"检索到 UUID '{online_uuid}' 的玩家名称: {name}") |
|
return name |
|
LOGGER.warning(f"未找到 UUID '{online_uuid}' 的名称。") |
|
return None |
|
except requests.RequestException as e: |
|
LOGGER.error(f"检索玩家名称时出错: {e}") |
|
return None |
|
|
|
@staticmethod |
|
def online_uuid_to_offline(online_uuid: uuid.UUID) -> Optional[uuid.UUID]: |
|
"""通过首先检索玩家名称将在线 UUID 转换为离线 UUID。""" |
|
player_name = UUIDHandler.online_uuid_to_name(online_uuid) |
|
if player_name: |
|
return UUIDHandler.offline_name_to_uuid(player_name) |
|
LOGGER.error(f"无法将在线 UUID '{online_uuid}' 转换为离线模式(未找到名称)。") |
|
return None |
|
|
|
|
|
# --- 文件处理 --- |
|
|
|
class FileHandler: |
|
"""处理文件操作的实用工具类。""" |
|
|
|
@staticmethod |
|
def rename_file(source_path: Path, new_file_name: str): |
|
"""重命名文件并保留源文件扩展名。""" |
|
if not source_path.is_file(): |
|
return |
|
extension = source_path.suffix |
|
target_path = source_path.with_name(f"{new_file_name}{extension}") |
|
try: |
|
shutil.move(str(source_path), str(target_path)) |
|
LOGGER.debug(f"文件已重命名:\n\t从: '{source_path}'\n\t到: '{target_path}'") |
|
except OSError as e: |
|
LOGGER.error(f"重命名文件 '{source_path}' 时出错: {e}") |
|
|
|
@staticmethod |
|
def load_array_from_usercache(path_to_usercache: Path) -> List[Dict[str, Any]]: |
|
"""从 usercache.json 文件加载玩家数组。""" |
|
if not path_to_usercache.is_file(): |
|
LOGGER.warning(f"找不到 usercache.json: {path_to_usercache}。在没有预取用户数据的情况下继续。") |
|
return [] |
|
try: |
|
with open(path_to_usercache, 'r', encoding='utf-8') as f: |
|
LOGGER.info("成功加载 usercache.json 文件。") |
|
return json.load(f) |
|
except (IOError, json.JSONDecodeError) as e: |
|
LOGGER.error(f"读取 usercache.json 时出错: {e}") |
|
return [] |
|
|
|
@staticmethod |
|
def read_world_name_from_properties(path_to_properties: Path) -> str: |
|
"""从 server.properties 文件读取世界名称。""" |
|
if not path_to_properties.is_file(): |
|
LOGGER.warning(f"找不到 server.properties: {path_to_properties}。假设世界名称为 'world'。") |
|
return "world" |
|
try: |
|
with open(path_to_properties, 'r', encoding='utf-8') as f: |
|
for line in f: |
|
if line.strip().startswith("level-name="): |
|
world_name = line.strip().split("=", 1)[1] |
|
LOGGER.info(f"找到的世界名称: '{world_name}'") |
|
return world_name |
|
except IOError as e: |
|
LOGGER.error(f"读取 server.properties 时出错: {e}") |
|
LOGGER.warning("在 server.properties 中未找到 'level-name'。假设为 'world'。") |
|
return "world" |
|
|
|
@staticmethod |
|
def write_to_properties(path_to_properties: Path, key: str, value: str): |
|
"""在 server.properties 文件中写入或更新键值对。""" |
|
if not path_to_properties.is_file(): |
|
LOGGER.error(f"找不到 server.properties: {path_to_properties}。无法更新属性。") |
|
return |
|
try: |
|
with open(path_to_properties, 'r+', encoding='utf-8') as f: |
|
lines = f.readlines() |
|
new_lines = [] |
|
key_found = False |
|
for line in lines: |
|
if line.strip().startswith(f"{key}="): |
|
new_lines.append(f"{key}={value}\n") |
|
key_found = True |
|
else: |
|
new_lines.append(line) |
|
if not key_found: |
|
new_lines.append(f"{key}={value}\n") |
|
f.seek(0) |
|
f.writelines(new_lines) |
|
f.truncate() |
|
LOGGER.debug(f"属性 '{key}' 已更新为值 '{value}'") |
|
except IOError as e: |
|
LOGGER.error(f"更新 server.properties 时出错: {e}") |
|
|
|
@staticmethod |
|
def is_text(path_to_file: Path) -> bool: |
|
"""确定文件是文本文件还是二进制文件。""" |
|
try: |
|
with open(path_to_file, 'r', encoding='utf-8') as f: |
|
f.read(1024) # 尝试读取一些字节 |
|
return True |
|
except (UnicodeDecodeError, IOError): |
|
return False |
|
|
|
|
|
# --- Minecraft 服务器风格检测 --- |
|
|
|
class MinecraftFlavor: |
|
"""枚举代表不同的 Minecraft 风格。""" |
|
VANILLA = "Vanilla" |
|
LIGHT_MODDED = "Lightly Modded (Bukkit,Paper,...)" |
|
MODDED = "Modded (Forge,Fabric,...)" |
|
|
|
|
|
class MinecraftFlavorDetection: |
|
"""根据目录结构和文件检测 Minecraft 服务器的风格。""" |
|
|
|
def __init__(self, base_directory: Path): |
|
if not base_directory.is_dir(): |
|
raise FileNotFoundError(f"提供的路径不是有效目录: {base_directory}") |
|
self.base_directory = base_directory |
|
|
|
def detect_minecraft_flavor(self) -> str: |
|
"""根据目录结构和文件检测 Minecraft 风格。""" |
|
if self._is_vanilla(): |
|
return MinecraftFlavor.VANILLA |
|
elif self._is_lightly_modded(): |
|
return MinecraftFlavor.LIGHT_MODDED |
|
else: |
|
return MinecraftFlavor.MODDED |
|
|
|
def _is_vanilla(self) -> bool: |
|
is_modded = self._is_modded() |
|
is_bukkit = self._is_lightly_modded() |
|
vanilla_style_world = all( |
|
not ("_nether" in d.name or "_the_end" in d.name) |
|
for d in self.base_directory.iterdir() if d.is_dir() |
|
) |
|
return not (is_modded or is_bukkit) and vanilla_style_world |
|
|
|
def _is_lightly_modded(self) -> bool: |
|
has_plugins = self._has_folder("plugins") |
|
has_bukkit_yml = self._has_file("bukkit.yml") |
|
return has_plugins and has_bukkit_yml |
|
|
|
def _is_modded(self) -> bool: |
|
return self._has_folder("mods") |
|
|
|
def _has_folder(self, folder_name: str) -> bool: |
|
return (self.base_directory / folder_name).is_dir() |
|
|
|
def _has_file(self, file_name: str) -> bool: |
|
return (self.base_directory / file_name).is_file() |
|
|
|
|
|
# --- 自定义路径解析器 --- |
|
|
|
class CustomPathParser: |
|
"""用于从 YAML 配置文件解析自定义路径的类。""" |
|
|
|
def __init__(self, base_directory: Path): |
|
self.base_directory = base_directory |
|
self.path_file = base_directory / "custom_paths.yml" |
|
if not self.path_file.is_file(): |
|
LOGGER.warning("未找到自定义路径文件 (custom_paths.yml)。在没有它的情况下继续。") |
|
self.path_file = None |
|
|
|
def get_paths(self) -> List[Path]: |
|
"""从 YAML 配置中解析并检索文件路径。""" |
|
if not self.path_file: |
|
return [] |
|
|
|
path_list = [] |
|
try: |
|
with open(self.path_file, 'r', encoding='utf-8') as f: |
|
yaml_data = yaml.safe_load(f) |
|
except (IOError, yaml.YAMLError) as e: |
|
LOGGER.error(f"读取或解析 custom_paths.yml 时出错: {e}") |
|
return [] |
|
|
|
if not yaml_data or 'paths' not in yaml_data: |
|
return path_list |
|
|
|
for path_info in yaml_data['paths']: |
|
path_str = path_info.get('path') |
|
path_type = path_info.get('type') |
|
recursive = path_info.get('recursive', False) |
|
full_path = self.base_directory / Path(path_str) |
|
|
|
if path_type == 'file' and full_path.is_file(): |
|
path_list.append(full_path) |
|
elif path_type == 'folder' and full_path.is_dir(): |
|
if recursive: |
|
path_list.extend(p for p in full_path.rglob('*') if p.is_file()) |
|
else: |
|
path_list.extend(p for p in full_path.glob('*') if p.is_file()) |
|
return path_list |
|
|
|
|
|
# --- 主转换器类 --- |
|
|
|
class Converter: |
|
"""处理 Minecraft 服务器玩家数据在在线和离线模式之间的转换。""" |
|
|
|
def __init__(self, server_path: Path): |
|
self.server_folder = server_path.resolve() |
|
if not self.server_folder.is_dir(): |
|
raise FileNotFoundError(f"服务器文件夹不存在: {self.server_folder}") |
|
|
|
self.world_name = FileHandler.read_world_name_from_properties(self.server_folder / "server.properties") |
|
self.world_folder = self.server_folder / self.world_name |
|
if not self.world_folder.is_dir(): |
|
raise FileNotFoundError(f"世界文件夹不存在: {self.world_folder}") |
|
|
|
self.uuid_map: Dict[uuid.UUID, Player] = {} |
|
LOGGER.info(f"服务器文件夹设置为: {self.server_folder}") |
|
LOGGER.info(f"世界文件夹设置为: {self.world_folder}") |
|
|
|
def _fetch_usercache(self, mode: str): |
|
"""从 usercache.json 获取所有已知玩家并将其存储在映射中。""" |
|
usercache_path = self.server_folder / "usercache.json" |
|
known_players = FileHandler.load_array_from_usercache(usercache_path) |
|
self.uuid_map.clear() |
|
|
|
for player_data in known_players: |
|
try: |
|
player_name = player_data["name"] |
|
online_uuid = uuid.UUID(player_data["uuid"]) |
|
converted_uuid = None |
|
if mode == "offline": |
|
converted_uuid = UUIDHandler.online_uuid_to_offline(online_uuid) |
|
else: # online mode |
|
converted_uuid = UUIDHandler.online_name_to_uuid(player_name) |
|
|
|
if converted_uuid: |
|
self.uuid_map[online_uuid] = Player(player_name, converted_uuid) |
|
LOGGER.info(f"预取玩家: {player_name} ({converted_uuid})") |
|
except (KeyError, ValueError) as e: |
|
LOGGER.warning(f"无法从 usercache.json 预取玩家: {e}") |
|
|
|
def _pre_check(self, mode: str) -> bool: |
|
"""在转换前执行预检查以确保有有效的玩家数据可用。""" |
|
is_online = (mode == "online") |
|
LOGGER.info(f"转换: {'离线 --> 在线' if is_online else '在线 --> 离线'}") |
|
|
|
self._fetch_usercache(mode) |
|
|
|
if is_online and not self.uuid_map: |
|
LOGGER.error("未找到可转换为在线配置文件的离线配置文件。正在中止...") |
|
return False |
|
return True |
|
|
|
def _get_files_to_convert(self, flavor: str) -> List[Path]: |
|
"""获取与指定 Minecraft 风格相关的要转换的文件路径列表。""" |
|
cpp = CustomPathParser(self.server_folder) |
|
paths = set() |
|
|
|
# 默认目录 |
|
default_dirs = [ |
|
self.server_folder, |
|
self.world_folder / "playerdata", |
|
self.world_folder / "advancements", |
|
self.world_folder / "stats", |
|
] |
|
for directory in default_dirs: |
|
if directory.is_dir(): |
|
paths.update(p for p in directory.glob("*") if p.is_file()) |
|
|
|
# 添加自定义路径 |
|
paths.update(cpp.get_paths()) |
|
|
|
# 添加特定于风格的路径 |
|
if flavor == MinecraftFlavor.VANILLA: |
|
paths.update(p for p in self.world_folder.rglob('*') if p.is_file()) |
|
|
|
return sorted(list(paths)) |
|
|
|
def convert(self, mode: str, flavor: str): |
|
"""将所有与玩家相关的文件和 UUID 转换为匹配所选模式。""" |
|
if not self._pre_check(mode): |
|
return |
|
|
|
online_mode_str = str(mode == "online").lower() |
|
FileHandler.write_to_properties(self.server_folder / "server.properties", "online-mode", online_mode_str) |
|
|
|
files_to_process = self._get_files_to_convert(flavor) |
|
|
|
for current_path in files_to_process: |
|
if current_path.suffix[1:] in IGNORED_FILE_EXTENSIONS: |
|
continue |
|
|
|
LOGGER.info(f"正在处理文件: {current_path}") |
|
|
|
# 1. 重命名文件 |
|
try: |
|
file_uuid_str = current_path.stem |
|
file_uuid = uuid.UUID(file_uuid_str) |
|
|
|
if file_uuid not in self.uuid_map and mode == "offline": |
|
player_name = UUIDHandler.online_uuid_to_name(file_uuid) |
|
offline_uuid = UUIDHandler.online_uuid_to_offline(file_uuid) |
|
if player_name and offline_uuid: |
|
self.uuid_map[file_uuid] = Player(player_name, offline_uuid) |
|
|
|
if file_uuid in self.uuid_map: |
|
new_uuid_str = str(self.uuid_map[file_uuid].uuid) |
|
FileHandler.rename_file(current_path, new_uuid_str) |
|
# 更新路径以反映重命名 |
|
current_path = current_path.with_name(f"{new_uuid_str}{current_path.suffix}") |
|
else: |
|
LOGGER.warning(f"在 uuid_map 中未找到文件 UUID {file_uuid}。跳过重命名。") |
|
|
|
except (ValueError, FileNotFoundError) as e: |
|
LOGGER.debug(f"跳过文件 {current_path},因为它不符合 UUID 文件命名约定: {e}") |
|
|
|
# 2. 替换文件内容 |
|
if current_path.is_file() and FileHandler.is_text(current_path): |
|
try: |
|
with open(current_path, 'r', encoding='utf-8') as f: |
|
content = f.read() |
|
|
|
did_replace = False |
|
for old_uuid, player in self.uuid_map.items(): |
|
if str(old_uuid) in content: |
|
content = content.replace(str(old_uuid), str(player.uuid)) |
|
did_replace = True |
|
|
|
if did_replace: |
|
with open(current_path, 'w', encoding='utf-8') as f: |
|
f.write(content) |
|
LOGGER.info(f"已更新文件中的 UUID: {current_path}") |
|
|
|
except IOError as e: |
|
LOGGER.error(f"处理文件内容时出错 {current_path}: {e}") |
|
|
|
|
|
# --- 主执行函数 --- |
|
|
|
def setup_logging(verbose: bool): |
|
"""配置日志记录系统。""" |
|
level = logging.DEBUG if verbose else logging.INFO |
|
log_format_console = ( |
|
"[%(asctime)s][%(levelname)-5s][%(name)s] - %(message)s" if verbose |
|
else "[%(asctime)s][%(levelname)-5s] - %(message)s" |
|
) |
|
log_format_file = "[%(asctime)s][%(levelname)-5s][%(name)s] - %(message)s" |
|
|
|
logging.basicConfig( |
|
level=level, |
|
format=log_format_console, |
|
handlers=[logging.StreamHandler(sys.stdout)] |
|
) |
|
|
|
file_handler = logging.FileHandler("MinecraftOfflineOnlineConverter.log", 'a', 'utf-8') |
|
file_handler.setLevel(logging.DEBUG) |
|
file_handler.setFormatter(logging.Formatter(log_format_file, datefmt="%Y-%m-%d %H:%M:%S")) |
|
logging.getLogger().addHandler(file_handler) |
|
|
|
|
|
def main(): |
|
"""主函数 - 应用程序的入口点。""" |
|
parser = argparse.ArgumentParser( |
|
description="一个在在线和离线模式之间转换 Minecraft 服务器的 Python 脚本。", |
|
formatter_class=argparse.RawTextHelpFormatter |
|
) |
|
parser.add_argument("-p", "--path", type=str, default=".", help="服务器文件夹的路径") |
|
parser.add_argument("-v", "--verbose", action="store_true", help="启用详细输出") |
|
|
|
mode_group = parser.add_mutually_exclusive_group(required=True) |
|
mode_group.add_argument("--offline", action="store_true", help="将服务器文件转换为离线模式") |
|
mode_group.add_argument("--online", action="store_true", help="将服务器文件转换为在线模式") |
|
|
|
args = parser.parse_args() |
|
|
|
setup_logging(args.verbose) |
|
|
|
start_time = System.nanoTime() if hasattr(os, 'times') else None |
|
|
|
LOGGER.info("启动 MinecraftOfflineOnlineConverter. 脚本作者 BaimoQilin") |
|
|
|
try: |
|
server_path = Path(args.path) |
|
converter = Converter(server_path) |
|
flavor_detector = MinecraftFlavorDetection(server_path) |
|
|
|
mc_flavor = flavor_detector.detect_minecraft_flavor() |
|
LOGGER.info(f"这是一个 {mc_flavor} Minecraft 服务器!") |
|
|
|
mode = "offline" if args.offline else "online" |
|
converter.convert(mode, mc_flavor) |
|
|
|
except Exception as e: |
|
LOGGER.critical(f"发生致命错误: {e}") |
|
sys.exit(1) |
|
|
|
if start_time: |
|
end_time = System.nanoTime() |
|
duration_s = (end_time - start_time) / 1_000_000_000.0 |
|
LOGGER.info(f"任务在 {duration_s:.3f} 秒内完成。") |
|
else: |
|
LOGGER.info("任务完成。") |
|
|
|
|
|
if __name__ == "__main__": |
|
# 模拟 System.nanoTime() 以获得更精确的时间 |
|
if hasattr(os, 'times'): |
|
from time import time_ns as nanoTime |
|
System = type("System", (), {"nanoTime": nanoTime}) |
|
else: |
|
System = None |
|
main() |