python 让DBC2000数据库转换为sqlite
一句话:DeepSeek V4 Falsh 秒了
#!/usr/bin/env python3
"""Convert DBC2000 (Borland Database Engine) Paradox tables into CSV.
DBC2000 is the Borland Database Engine, so a Mir2 (传奇) server's `mud2/db/*.DB`
files are plain Paradox tables: not encrypted, no password. Point this script at
the folder that holds them and it writes one CSV per table right next to the .DB
files, keeping the original column names and row order. Text columns of these
tables are GBK encoded and are stored as UTF-8 with a BOM, so Excel and WPS detect
the encoding instead of showing mojibake.
python3 main.py --file "/path/to/mud2/db"
python3 main.py --file "/path/to/mud2/db/Magic.DB"
python3 main.py --file "/path/to/mud2/db" --out-dir /tmp/csv
python3 main.py --file "/path/to/mud2/db" --sqlite /tmp/mir2.sqlite
Needs pypxlib (tools/.venv/bin/pip install pypxlib). pypxlib ships an x86_64-only
pxlib binary, so on Apple Silicon the script re-executes itself through Rosetta
(`arch -x86_64 /usr/bin/python3`) rather than requiring a native pxlib build.
"""
import argparse, csv, glob, os, platform, shutil, sqlite3, sys, tempfile
from datetime import date, time, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
NULL_INT = -2147483648 # BDE sentinel written for a blank integer column
SQL_TYPES = {
'AlphaField': 'TEXT', 'DateField': 'TEXT', 'TimeField': 'TEXT',
'TimestampField': 'TEXT', 'LongField': 'INTEGER', 'LogicalField': 'INTEGER',
'DoubleField': 'REAL', 'BytesField': 'BLOB',
}
# Site-packages that may already hold pypxlib, looked up relative to this file.
SITE_PATTERNS = [
'vendor', 'vendor/lib/python*/site-packages',
'.venv/lib/python*/site-packages', '../.venv/lib/python*/site-packages',
'../../.venv/lib/python*/site-packages',
'tools/.venv/lib/python*/site-packages', '../tools/.venv/lib/python*/site-packages',
'../../tools/.venv/lib/python*/site-packages',
'~/.local/lib/python*/site-packages',
'/opt/homebrew/lib/python*/site-packages',
'/usr/local/lib/python*/site-packages',
'/Library/Frameworks/Python.framework/Versions/*/lib/python*/site-packages',
]
_temp_dirs = []
def pypxlib_dirs():
"""Directories that already contain a pypxlib package (absolute and ~ patterns allowed)."""
found = []
for pattern in SITE_PATTERNS:
pattern = os.path.expanduser(pattern)
if not pattern.startswith('/'):
pattern = str(HERE / pattern)
for match in sorted(glob.glob(pattern)):
directory = Path(match)
if (directory / 'pypxlib').is_dir() and directory not in found:
found.append(directory)
return found
def load_reader():
"""Import pypxlib, re-running through Rosetta when its pxlib binary is x86_64-only."""
found = pypxlib_dirs()
for directory in found:
sys.path.insert(0, str(directory))
try:
import pypxlib
return pypxlib
except Exception as exc:
failure = exc
if (sys.platform == 'darwin' and platform.machine() == 'arm64'
and not os.environ.get('DBC2CSV_REEXEC') and Path('/usr/bin/arch').exists()
and Path('/usr/bin/python3').exists()):
env = dict(os.environ, DBC2CSV_REEXEC='1')
env['PYTHONPATH'] = os.pathsep.join(
[str(p) for p in found] + [env.get('PYTHONPATH', '')]).strip(os.pathsep)
os.execve('/usr/bin/arch',
['/usr/bin/arch', '-x86_64', '/usr/bin/python3', str(HERE / 'main.py')] + sys.argv[1:],
env)
searched = '\n '.join(str(p) for p in found) or '(nothing)'
raise SystemExit(
f'pypxlib could not be loaded: {failure}\n'
f'Looked in:\n {searched}\n'
'Install it with: python3 -m pip install pypxlib\n'
' (or into a venv: python3 -m venv .venv && .venv/bin/pip install pypxlib)')
def open_table(pypxlib, path, encoding):
"""A non-ASCII path can still fail inside pxlib, so those fall back to a plain temp copy."""
try:
return pypxlib.Table(str(path), encoding=encoding)
except Exception:
tmp_dir = Path(tempfile.mkdtemp(prefix='paradox-'))
_temp_dirs.append(tmp_dir)
for src in [path] + [path.with_suffix(s) for s in ('.MB', '.mb')]:
if src.exists():
shutil.copy2(src, tmp_dir / src.name)
return pypxlib.Table(str(tmp_dir / path.name), encoding=encoding)
def clean(value):
"""Return an output-friendly value; None means a blank Paradox field."""
if value is None:
return None
if isinstance(value, int) and not isinstance(value, bool) and value == NULL_INT:
return None
if isinstance(value, (date, time, datetime)):
return value.isoformat()
if isinstance(value, str):
return value.rstrip('\x00 \t\r\n')
return value
def csv_name(path):
"""Paradox table file name -> CSV file name (Magic.DB -> Magic.csv)."""
stem = ''.join(c if c.isalnum() or c == '_' else '_' for c in path.stem).strip('_')
return f'{stem or "table"}.csv'
def read_table(pypxlib, source, encoding):
"""Return (columns, sqlite_types, rows, trimmed) for one Paradox table."""
table = open_table(pypxlib, source, encoding)
try:
columns = list(table.fields.keys())
types = [SQL_TYPES.get(type(f).__name__, 'BLOB') for f in table.fields.values()]
rows, trimmed = [], 0
for i in range(len(table)):
row = table[i]
values = []
for column in columns:
raw = row[column]
value = clean(raw)
if isinstance(raw, str) and value != raw:
trimmed += 1
values.append(value)
rows.append(values)
return columns, types, rows, trimmed
finally:
table.close()
def write_csv(path, columns, rows):
with open(path, 'w', newline='', encoding='utf-8-sig') as handle:
writer = csv.writer(handle)
writer.writerow(columns)
writer.writerows(rows)
def collect_sources(items):
sources = []
for item in items:
path = Path(item).expanduser()
if path.is_file():
sources.append(path)
continue
if not path.is_dir():
raise SystemExit(f'no such file or folder: {path}')
found = sorted(p for p in path.iterdir() if p.suffix.lower() == '.db' and p.is_file())
if not found and (path / 'db').is_dir():
nested = path / 'db'
found = sorted(p for p in nested.iterdir() if p.suffix.lower() == '.db' and p.is_file())
if found:
print(f'note: {path} holds no .DB files, using {nested}')
if not found:
hint = ''
sibling = path.parent / 'db'
if sibling.is_dir() and any(sibling.glob('*.DB')):
hint = f'\nThe .DB files look like they are in {sibling}'
raise SystemExit(f'no .DB files found in {path}{hint}')
sources += found
return sources
def main():
parser = argparse.ArgumentParser(
description='Convert DBC2000 (Paradox) .DB tables into CSV next to the .DB files.')
parser.add_argument('--file', nargs='+', required=True, metavar='PATH', dest='files',
help='.DB file, or the folder holding the .DB files')
parser.add_argument('--out-dir', type=Path,
help='CSV destination (default: the folder holding the .DB files)')
parser.add_argument('--sqlite', type=Path, metavar='PATH',
help='additionally write every table into this SQLite file')
parser.add_argument('--encoding', default='gbk',
help='encoding of the Paradox text columns (default: gbk)')
args = parser.parse_args()
sources = collect_sources(args.files)
out_dir = args.out_dir.expanduser() if args.out_dir else None
if out_dir:
if out_dir.exists() and not out_dir.is_dir():
raise SystemExit(f'--out-dir points at a file, not a folder: {out_dir}')
out_dir.mkdir(parents=True, exist_ok=True)
sqlite_path = args.sqlite.expanduser() if args.sqlite else None
if sqlite_path:
if sqlite_path.exists() and sqlite_path.is_dir():
raise SystemExit(f'--sqlite points at a folder, not a file: {sqlite_path}')
parent = sqlite_path.parent
if parent.exists() and not parent.is_dir():
raise SystemExit(f'--sqlite folder is a file: {parent}')
parent.mkdir(parents=True, exist_ok=True)
if sqlite_path.exists():
sqlite_path.unlink()
pypxlib = load_reader()
pypxlib.Table.PX_ENCODING = 'utf-8' # allow non-ASCII table paths
con = sqlite3.connect(sqlite_path) if sqlite_path else None
written = set()
for source in sources:
columns, types, rows, trimmed = read_table(pypxlib, source, args.encoding)
destination = out_dir or source.parent
csv_path = destination / csv_name(source)
write_csv(csv_path, columns, rows)
written.add(csv_path)
if con:
name = csv_path.stem
quoted = ', '.join(f'"{c}" {t}' for c, t in zip(columns, types))
con.execute(f'CREATE TABLE "{name}" ({quoted})')
con.executemany(f'INSERT INTO "{name}" VALUES ({", ".join("?" * len(columns))})', rows)
con.execute(f'CREATE INDEX "idx_{name}_{columns[0]}" ON "{name}" ("{columns[0]}")')
print(f'{source.name:16} -> {csv_path.name:16} {len(rows):5} rows x {len(columns):3} columns'
+ (f' ({trimmed} padded values trimmed)' if trimmed else ''))
if con:
con.commit()
con.close()
for tmp_dir in _temp_dirs:
shutil.rmtree(tmp_dir, ignore_errors=True)
if sqlite_path:
print(f'\nSQLite : {sqlite_path}')
print(f'CSV : {len(written)} file(s) in {out_dir or "(each .DB folder)"}')
if __name__ == '__main__':
main()AI 解析过程
思路
用 pypxlib(pxlib 的 Python 绑定)解析 Paradox 表,再把每个表写成一份 CSV:
pip install pypxlib
python3 main.py --file "/path/to/mud2/db"Magic.DB -> Magic.csv 102 rows x 36 columns
Monster.DB -> Monster.csv 546 rows x 25 columns
StdItems.DB -> StdItems.csv 624 rows x 58 columnsCSV 直接生成在 .DB 旁边,UTF-8 BOM + CRLF,Excel / WPS 双击打开不乱码;表头保留原始字段名(MagID、MagName、NeedL1…),行序与原表一致。
四个坑
写这个脚本真正花时间的不是解析格式,而是下面这几处:
1. 中文乱码。 pypxlib 默认按 cp850 解码文本字段,必须显式指定:
table = Table(path, encoding='gbk') # MagName: '火球术'2. 空值有两种表示。 Paradox 除了带 isnull 标记的空字段,还会把空白整数写成 BDE 的哨兵值 -2147483648。两种都要转成 CSV 空列:
NULL_INT = -2147483648
def clean(value):
if value is None or value == NULL_INT:
return None
return value.rstrip('\x00 \t\r\n') if isinstance(value, str) else value顺便一提,转出来的空值不是转换损失而是真实数据:102 个技能里只有 24 个填了 L4–L9 的九重技能数据,StdItems 有 17 个字段(马匹、倍率类)这个版本压根没用。
3. 路径带中文会崩。 pypxlib 用 ascii 编码文件路径:
UnicodeEncodeError: 'ascii' codec can't encode characters in position 16-17覆盖掉即可(或者退化成拷贝到纯英文临时路径再读):
pypxlib.Table.PX_ENCODING = 'utf-8'4. Apple Silicon 上装得上、跑不起来。 pypxlib 自带的 pxlib 动态库只有 x86_64,arm64 的 Python 加载会报 incompatible architecture,而 Homebrew 也没有 pxlib 的 formula。不用自己编译,让脚本用 Rosetta 重新执行自己就行:
os.execve('/usr/bin/arch',
['/usr/bin/arch', '-x86_64', '/usr/bin/python3', __file__] + sys.argv[1:],
dict(os.environ, REEXEC='1', PYTHONPATH=...))
评论
发表评论