Skip to content

Instantly share code, notes, and snippets.

@Zhou-Shilin
Created July 27, 2025 11:53
Show Gist options
  • Select an option

  • Save Zhou-Shilin/10e4e143139eb40948552c666c9216f5 to your computer and use it in GitHub Desktop.

Select an option

Save Zhou-Shilin/10e4e143139eb40948552c666c9216f5 to your computer and use it in GitHub Desktop.

![NOTE] README 使用 Gemini 2.5 Pro 生成,经过人工审查。

Minecraft 服务器在线/离线模式转换器

用于帮助 Minecraft 腐竹将服务器在在线模式和离线模式之间安全地切换,同时保留玩家数据的 Python 脚本。

这个项目是 Paul Ferlitz 的 MinecraftOfflineOnlineConverter Java 版本的 Python 实现,同时解决了离线服务器转换到在线服务器时非正版玩家无法获取 UUID 所导致的报错崩溃问题。

功能

  • 无缝转换:自动处理 UUID 的转换,确保玩家在模式切换后数据不会丢失。
  • 服务器类型检测:自动检测服务器是 VanillaSpigot/Paper 还是 Modded (Forge/Fabric),以应用正确的转换规则。
  • 全面覆盖:转换核心玩家文件,包括 playerdatastatsadvancements
  • 自定义路径:通过可选的 custom_paths.yml 文件,您可以指定需要转换的额外文件或目录,为 Mod 服或特殊服务器设置提供了极大的灵活性。
  • 跨平台:基于 Python,可在 Windows、macOS 和 Linux 上运行。
  • 安全可靠:在对服务器文件进行任何更改之前,会进行预检查。

⚠️ 重要:免责声明

在使用此工具前,请务必完整备份您的服务器文件!

虽然此脚本经过了详尽的测试,但总存在因服务器配置、插件或 Mod 不同而出现意外情况的风险。作者不对任何可能的数据丢失或损坏负责。

安装要求

  1. Python 3.6+
  2. 两个 Python 库: requestsPyYAML

安装步骤

  1. 下载脚本minecraft_converter.py 文件保存到您的电脑上。为了方便使用,建议将其放置在您的 Minecraft 服务器根目录中。

  2. 安装依赖库 在您的终端(命令提示符、PowerShell 或 Terminal)中运行以下命令来安装所需的库:

    pip install requests pyyaml

使用方法

通过终端运行脚本。您必须提供 --online--offline 参数之一来指定转换的目标模式。

基本用法

  • 转换为离线模式:

    python minecraft_converter.py --offline
  • 转换为在线模式:

    python minecraft_converter.py --online

高级选项

  • 指定服务器路径: 如果脚本没有放在服务器根目录,请使用 -p--path 参数:

    # Windows 示例
    python minecraft_converter.py --online -p "C:\Users\Admin\Desktop\MyServer"
    
    # Linux/macOS 示例
    python minecraft_converter.py --online -p "/home/user/minecraft_server"
  • 启用详细日志: 要查看更详细的转换过程输出,请添加 -v--verbose 标志。这对于排查问题非常有用。

    python minecraft_converter.py --offline --verbose

自定义路径配置 (可选)

对于非标准服务器(例如,使用大量 Mod 的服务器),某些玩家数据可能存储在默认位置之外。您可以通过在服务器根目录中创建一个名为 custom_paths.yml 的文件来告诉脚本转换这些额外的文件。

这是一个配置示例 (custom_paths.yml):

name: MOOC-Config
config:
  version: 1.0.0
paths:
  # 转换文件夹A及其所有子文件夹中的所有文件
  - type: folder
    path: ./path/to/folderA
    recursive: true
  
  # 仅转换文件夹B根目录下的文件,不包括子文件夹
  - type: folder
    path: ./path/to/folderB
    # recursive: false 是默认设置

  # 转换一个特定的文件
  - type: file
    path: ./path/to/fileA.json

许可证

此项目采用 MIT Lisense

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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment