- 升格001—009章为正式正文,重写010—020章并迁移至正文目录 - 删除草稿目录中010—012章候选稿,统一以正式正文为准 - 新增黄氏莲、梁文和、武元甲人物卡,扩展朱文晋、农文禄、赫默边界 - 同步世界书、工业设定、章纲、伏笔承诺、资产索引与风格约束 - 新增 tasks/plan.md 与 todo.md 记录重写计划与验收
543 lines
22 KiB
Python
543 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""从本地 K2 Lua 源快照生成 Markdown 与 CSV 配方表。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import hashlib
|
||
import io
|
||
import re
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
ROOT = HERE.parents[1]
|
||
SOURCE_DIR = ROOT / "old" / "中间文件" / "recipe_sources" / "k2"
|
||
ZH_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2so" / "Krastorio2-zh-CN.cfg"
|
||
EN_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2" / "Krastorio2-en.cfg"
|
||
MD_PATH = HERE / "K2配方表.md"
|
||
CSV_PATH = HERE / "K2配方表.csv"
|
||
|
||
CATEGORY_ZH = {
|
||
"advanced-chemistry": "高级化学",
|
||
"air-purification": "空气净化",
|
||
"atmosphere-condensation": "大气冷凝",
|
||
"bioprocessing": "生物处理",
|
||
"centrifuging": "离心处理",
|
||
"chemistry": "化学",
|
||
"crafting": "制造",
|
||
"crafting-with-fluid": "带流体制造",
|
||
"crushing": "粉碎",
|
||
"electrolysis": "电解",
|
||
"fluid-filtration": "流体过滤",
|
||
"fuel-refinery": "燃料精炼",
|
||
"growing": "培育",
|
||
"matter-deconversion": "物质逆转换",
|
||
"nuclear-fusion": "核聚变",
|
||
"oil-processing": "石油处理",
|
||
"smelting": "冶炼",
|
||
"smelting-crafting": "冶炼制造",
|
||
"stabilizer-charging": "稳定器充能",
|
||
"t3-tech-cards": "三级科技卡",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Stack:
|
||
kind: str
|
||
name: str
|
||
amount: str
|
||
probability: str | None = None
|
||
temperature: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Recipe:
|
||
source_file: str
|
||
source_line: int
|
||
category: str
|
||
recipe_id: str
|
||
energy_required: str
|
||
enabled: bool
|
||
ingredients: tuple[Stack, ...]
|
||
results: tuple[Stack, ...]
|
||
|
||
|
||
def mask_strings_and_comments(text: str) -> str:
|
||
"""屏蔽字符串与注释内容,同时保留字符位置和换行。"""
|
||
chars = list(text)
|
||
i = 0
|
||
state = "code"
|
||
quote = ""
|
||
while i < len(chars):
|
||
c = chars[i]
|
||
n = chars[i + 1] if i + 1 < len(chars) else ""
|
||
if state == "code":
|
||
if c == "-" and n == "-":
|
||
chars[i] = chars[i + 1] = " "
|
||
i += 2
|
||
state = "comment"
|
||
continue
|
||
if c in {'"', "'"}:
|
||
quote = c
|
||
chars[i] = " "
|
||
i += 1
|
||
state = "string"
|
||
continue
|
||
elif state == "comment":
|
||
if c == "\n":
|
||
state = "code"
|
||
else:
|
||
chars[i] = " "
|
||
i += 1
|
||
continue
|
||
elif state == "string":
|
||
if c == "\\":
|
||
chars[i] = " "
|
||
if i + 1 < len(chars):
|
||
chars[i + 1] = " "
|
||
i += 2
|
||
continue
|
||
if c == quote:
|
||
chars[i] = " "
|
||
state = "code"
|
||
elif c != "\n":
|
||
chars[i] = " "
|
||
i += 1
|
||
continue
|
||
i += 1
|
||
return "".join(chars)
|
||
|
||
|
||
def brace_pairs(text: str) -> dict[int, int]:
|
||
masked = mask_strings_and_comments(text)
|
||
stack: list[int] = []
|
||
pairs: dict[int, int] = {}
|
||
for i, c in enumerate(masked):
|
||
if c == "{":
|
||
stack.append(i)
|
||
elif c == "}" and stack:
|
||
pairs[stack.pop()] = i
|
||
return pairs
|
||
|
||
|
||
def containing_block(text: str, position: int) -> tuple[int, int]:
|
||
pairs = brace_pairs(text)
|
||
candidates = [(start, end) for start, end in pairs.items() if start < position < end]
|
||
if not candidates:
|
||
raise ValueError(f"找不到位置 {position} 所属的 Lua 表")
|
||
return min(candidates, key=lambda pair: pair[1] - pair[0])
|
||
|
||
|
||
def extract_table(block: str, field: str) -> str:
|
||
match = re.search(rf"\b{re.escape(field)}\s*=\s*\{{", block)
|
||
if not match:
|
||
raise ValueError(f"配方缺少 {field} 表")
|
||
opening = block.find("{", match.start())
|
||
closing = brace_pairs(block).get(opening)
|
||
if closing is None:
|
||
raise ValueError(f"{field} 表的大括号不配对")
|
||
return block[opening : closing + 1]
|
||
|
||
|
||
def scalar(block: str, field: str, default: str | None = None) -> str:
|
||
match = re.search(rf"\b{re.escape(field)}\s*=\s*([^,\n}}]+)", block)
|
||
if not match:
|
||
if default is None:
|
||
raise ValueError(f"配方缺少字段 {field}")
|
||
return default
|
||
return match.group(1).strip().strip('"\'')
|
||
|
||
|
||
def parse_stacks(table: str) -> tuple[Stack, ...]:
|
||
stacks: list[Stack] = []
|
||
for match in re.finditer(r"\{([^{}]*)\}", table, flags=re.S):
|
||
entry = match.group(1)
|
||
name_match = re.search(
|
||
r'\bname\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))',
|
||
entry,
|
||
)
|
||
if not name_match:
|
||
continue
|
||
kind_match = re.search(r'\btype\s*=\s*"([^"]+)"', entry)
|
||
amount_match = re.search(r"\bamount\s*=\s*([0-9.]+)", entry)
|
||
min_match = re.search(r"\bamount_min\s*=\s*([0-9.]+)", entry)
|
||
max_match = re.search(r"\bamount_max\s*=\s*([0-9.]+)", entry)
|
||
probability_match = re.search(r"\bprobability\s*=\s*([0-9.]+)", entry)
|
||
temperature_match = re.search(r"\btemperature\s*=\s*([0-9.]+)", entry)
|
||
if amount_match:
|
||
amount = amount_match.group(1)
|
||
elif min_match and max_match:
|
||
amount = f"{min_match.group(1)}–{max_match.group(1)}"
|
||
else:
|
||
amount = "1"
|
||
stacks.append(
|
||
Stack(
|
||
kind=kind_match.group(1) if kind_match else "item",
|
||
name=name_match.group(1) or name_match.group(2),
|
||
amount=amount,
|
||
probability=probability_match.group(1) if probability_match else None,
|
||
temperature=temperature_match.group(1) if temperature_match else None,
|
||
)
|
||
)
|
||
return tuple(stacks)
|
||
|
||
|
||
def parse_recipe_file(path: Path) -> list[Recipe]:
|
||
text = path.read_text(encoding="utf-8-sig")
|
||
recipes: list[Recipe] = []
|
||
seen_blocks: set[tuple[int, int]] = set()
|
||
for match in re.finditer(r'\btype\s*=\s*"recipe"', text):
|
||
start, end = containing_block(text, match.start())
|
||
if (start, end) in seen_blocks:
|
||
continue
|
||
seen_blocks.add((start, end))
|
||
block = text[start : end + 1]
|
||
recipe_id = scalar(block, "name")
|
||
recipes.append(
|
||
Recipe(
|
||
source_file=path.name,
|
||
source_line=text.count("\n", 0, start) + 1,
|
||
category=scalar(block, "category", "crafting"),
|
||
recipe_id=recipe_id,
|
||
energy_required=scalar(block, "energy_required", "0.5"),
|
||
enabled=scalar(block, "enabled", "true").lower() == "true",
|
||
ingredients=parse_stacks(extract_table(block, "ingredients")),
|
||
results=parse_stacks(extract_table(block, "results")),
|
||
)
|
||
)
|
||
return recipes
|
||
|
||
|
||
def parse_locale(path: Path) -> dict[str, dict[str, str]]:
|
||
sections: dict[str, dict[str, str]] = {}
|
||
current = ""
|
||
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
|
||
line = raw_line.strip()
|
||
if not line or line.startswith(";") or line.startswith("#"):
|
||
continue
|
||
if line.startswith("[") and line.endswith("]"):
|
||
current = line[1:-1]
|
||
sections.setdefault(current, {})
|
||
continue
|
||
if "=" in line and current:
|
||
key, value = line.split("=", 1)
|
||
sections[current][key] = value
|
||
return sections
|
||
|
||
|
||
def expand_macros(value: str, locale: dict[str, dict[str, str]]) -> str:
|
||
macro_sections = {"ITEM": "item-name", "FLUID": "fluid-name", "ENTITY": "entity-name"}
|
||
pattern = re.compile(r"__(ITEM|FLUID|ENTITY)__([^_][^_]*)__")
|
||
for _ in range(5):
|
||
replaced = pattern.sub(
|
||
lambda m: locale.get(macro_sections[m.group(1)], {}).get(m.group(2), m.group(2)),
|
||
value,
|
||
)
|
||
if replaced == value:
|
||
break
|
||
value = replaced
|
||
return value
|
||
|
||
|
||
def localized_name(
|
||
name: str,
|
||
kind: str,
|
||
locale: dict[str, dict[str, str]],
|
||
*,
|
||
recipe: bool = False,
|
||
) -> str | None:
|
||
if recipe:
|
||
candidates = ("recipe-name", "item-name", "fluid-name", "entity-name")
|
||
elif kind == "fluid":
|
||
candidates = ("fluid-name", "item-name", "recipe-name")
|
||
else:
|
||
candidates = ("item-name", "entity-name", "recipe-name", "fluid-name")
|
||
for section in candidates:
|
||
value = locale.get(section, {}).get(name)
|
||
if value:
|
||
return expand_macros(value, locale)
|
||
return None
|
||
|
||
|
||
def source_digest(paths: list[Path]) -> str:
|
||
digest = hashlib.sha256()
|
||
for path in paths:
|
||
digest.update(path.name.encode("utf-8"))
|
||
digest.update(b"\0")
|
||
digest.update(path.read_bytes())
|
||
digest.update(b"\0")
|
||
return digest.hexdigest()
|
||
|
||
|
||
def escape_md(value: str) -> str:
|
||
return value.replace("|", "\\|").replace("\n", " ")
|
||
|
||
|
||
def story_duration_hours(game_seconds: str) -> str:
|
||
"""按全书统一比例把游戏配方秒数换算为正文小时数。"""
|
||
try:
|
||
return f"{float(game_seconds):g}小时"
|
||
except ValueError:
|
||
return f"{game_seconds}小时(待运行时解析)"
|
||
|
||
|
||
def story_duration_value(game_seconds: str) -> str:
|
||
"""供 CSV 使用的正文小时数;数值与游戏秒数保持一比一。"""
|
||
try:
|
||
return f"{float(game_seconds):g}"
|
||
except ValueError:
|
||
return game_seconds
|
||
|
||
|
||
def display_name(
|
||
name: str,
|
||
kind: str,
|
||
zh: dict[str, dict[str, str]],
|
||
en: dict[str, dict[str, str]],
|
||
*,
|
||
recipe: bool = False,
|
||
) -> str:
|
||
zh_name = localized_name(name, kind, zh, recipe=recipe)
|
||
en_name = localized_name(name, kind, en, recipe=recipe)
|
||
label = zh_name or en_name
|
||
if label and label != name:
|
||
return f"{escape_md(label)}<br>`{name}`"
|
||
return f"`{name}`"
|
||
|
||
|
||
def stack_amount(stack: Stack) -> str:
|
||
value = stack.amount
|
||
if stack.probability:
|
||
value += f"({float(stack.probability) * 100:g}% 概率)"
|
||
if stack.temperature:
|
||
value += f"({stack.temperature}℃)"
|
||
return value
|
||
|
||
|
||
def display_stacks(
|
||
stacks: tuple[Stack, ...],
|
||
zh: dict[str, dict[str, str]],
|
||
en: dict[str, dict[str, str]],
|
||
) -> str:
|
||
if not stacks:
|
||
return "无"
|
||
rendered = []
|
||
for stack in stacks:
|
||
type_note = "(流体)" if stack.kind == "fluid" else ""
|
||
rendered.append(
|
||
f"{display_name(stack.name, stack.kind, zh, en)} × {stack_amount(stack)}{type_note}"
|
||
)
|
||
return "<br>".join(rendered)
|
||
|
||
|
||
def plain_name(
|
||
name: str,
|
||
kind: str,
|
||
locale: dict[str, dict[str, str]],
|
||
*,
|
||
recipe: bool = False,
|
||
) -> str:
|
||
return localized_name(name, kind, locale, recipe=recipe) or ""
|
||
|
||
|
||
def plain_stacks(
|
||
stacks: tuple[Stack, ...],
|
||
zh: dict[str, dict[str, str]],
|
||
en: dict[str, dict[str, str]],
|
||
) -> str:
|
||
values = []
|
||
for stack in stacks:
|
||
zh_name = plain_name(stack.name, stack.kind, zh)
|
||
en_name = plain_name(stack.name, stack.kind, en)
|
||
names = "/".join(part for part in (zh_name, en_name, stack.name) if part)
|
||
type_note = "[fluid]" if stack.kind == "fluid" else "[item]"
|
||
values.append(f"{names} x {stack_amount(stack)} {type_note}")
|
||
return "; ".join(values)
|
||
|
||
|
||
def build_markdown(
|
||
recipes: list[Recipe],
|
||
zh: dict[str, dict[str, str]],
|
||
en: dict[str, dict[str, str]],
|
||
digest: str,
|
||
) -> str:
|
||
counts = Counter(recipe.category for recipe in recipes)
|
||
lines = [
|
||
"# K2配方表(本地源快照)",
|
||
"",
|
||
"## 文件状态",
|
||
"",
|
||
"本文件是写作参考资料,不是《越共》正文 canon,也不是一局实际安装模组后导出的最终配方数据库。",
|
||
"",
|
||
f"- 数据范围:`old/中间文件/recipe_sources/k2/` 内 {len(counts)} 个 Lua 文件,共 {len(recipes)} 条显式 `recipe` 定义。",
|
||
"- 中文名称:优先采用本地 `k2so/Krastorio2-zh-CN.cfg`;缺失时退回 K2 英文 locale 或内部 ID。中文只用于检索和阅读,不参与数值判定。",
|
||
"- 未包含:基础游戏配方、其他 K2 脚本在 `data-updates` 等阶段对基础配方的修改、建筑兼容列表、模组设置与实际存档中的最终覆盖结果。",
|
||
"- 源文件中无法仅靠本快照求值的 Lua 变量名会原样保留。例如 `kr_optimization_tech_card_name` 是运行时解析的配方名,不擅自猜成某个固定物品 ID。",
|
||
"- 本地没有 K2 `info.json` 或完整模组包,无法仅凭这批文件确认确切版本。",
|
||
f"- 源数据 SHA-256 汇总:`{digest}`。",
|
||
"- 本表由同目录 `生成K2配方表.py` 生成;源文件变化后可重新生成,勿手工修改表格主体。",
|
||
"",
|
||
"## 对小说写作的直接用法",
|
||
"",
|
||
"- 实验室负责研究和解锁;制造机载入已经取得的配方,检查原料并执行制造。实验室本身不直接制造手枪。",
|
||
"- 在没有传送带、机械臂和物流网络时,可以人工把金属、零件或标准料箱送入制造机。这与游戏中手动向机器上料的逻辑相容。",
|
||
"- 本地显式 K2 配方快照中没有 `pistol` 配方;小说采用用户确认截图中的手枪配方:制造时间5秒、铁板5、铜板5、产出1。K2归档源码中的铁板2、铜板1属于另一版本,不用于当前正文。",
|
||
"- 全书配方时间统一按1游戏秒=正文1小时换算。本表保留游戏原始秒数,并另列小说基准制造时长;排队、人工上料、质检、冷却、故障与停机时间另行累计。",
|
||
"- 配方中的物料单位是游戏单位,不能直接等同于现实千克或升。小说把游戏界面的“产出1”解释为机器完成一次固定生产;原料关系不变,实际用料按这次产出的全部成品核算。",
|
||
"- M1910式手枪在小说中每次固定生产32把,并封装为一只成品箱;32把约18.88千克,连同空箱与四层托盘仍低于24千克上限。制造完成后需由能源/解包节点开箱,才能取得和分配单把手枪。",
|
||
"",
|
||
"## 手枪与弹药补充参考",
|
||
"",
|
||
"| 对象 | K2/基础游戏原配方 | 小说适配层 |",
|
||
"|---|---|---|",
|
||
"| 当前采用的游戏手枪 | 制造时间5秒、铁板5、铜板5、产出1 | 自动实验室从M1910样枪得到本机可用的制造方法;5秒换算为一箱32把手枪制造5小时,实际取料按整箱质量与加工损耗缩放。枪械本身不消耗发射药 |",
|
||
"| 基础枪械弹匣 | 制造时间1秒、铁板4、成品弹匣1;每只弹匣10发 | M1910的7.65毫米弹匣容量为7发。小说适配为制造时间1秒、铁板3、铜板1、产出1箱;正文基准制造1小时。每箱192只满装弹匣,共1344发;公司新弹采用铜包软铁芯,不含铅。第一箱在枪箱完成后另行生产,调用一次性红封舰载发射药;后续批次须等待舰载应急化工机接通 |",
|
||
"",
|
||
"- K2归档手枪配方(仅作版本差异参考,当前正文不采用):<https://github.com/raiguard/Krastorio2/blob/master/prototypes/updates/base/recipes.lua#L2491-L2493>",
|
||
"- Factorio基础枪械弹匣:<https://wiki.factorio.com/Firearm_magazine>",
|
||
"- M1910式手枪的.32 ACP弹匣容量为7发:<https://triplek.com/browning-model-1910-32acp-7-rd-magazine-or-grips/>",
|
||
"- Factorio 2.0.7 已删除手枪制造配方,因此这里采用的是K2对应旧版基础游戏的历史逻辑,而不是当前2.x配方。",
|
||
"- 铁板、铜板和钢板按K2物料类型分别记账。任何配方中的钢板都保留为钢板,不向下折算成铁板;本书采用的基础弹匣配方不含钢板。",
|
||
"- 为保持物质守恒,小说中的手枪弹必须实际消耗弹壳、铜包软铁芯弹头与发射药。公司新弹完全不使用铅;击发所需的无铅组分封装在“发射药”受控配方内部,不拆成第二张读者或普通终端可见的配方。正文008的47发旧弹只用于建立模板,试射与配发均使用枪械完成后另行生产的新弹。",
|
||
"- 舰载应急化工机以空气、水、植物纤维和电力为输入,输出统一称为“发射药”。其催化核心会缓慢老化,现阶段既不能复制整机,也不能复制核心;接通该设备不代表本地已经完成完整化工自举。",
|
||
"- K2/基础游戏中的 `explosives` 是炸药中间品,不是小武器发射药,基础枪械弹匣也不消耗它。本书只把该科技用于爆破采矿和爆炸武器参考,不能用它替代或倒填第007—008章的手枪弹发射药。",
|
||
"- Factorio炸药科技:<https://wiki.factorio.com/Explosives_(research)>",
|
||
"",
|
||
"## 研究设施边界",
|
||
"",
|
||
"本地科技文件表明:`kr-laboratory` 科技解锁基础 `lab`;`kr-advanced-lab` 是更高效的实验室且不兼容基础科技卡;`kr-research-server` 用于生产复杂科技卡;`kr-singularity-lab` 属于更后期的研究设施。它们支持“研究—解锁—制造”的分工,不支持“实验室直接吐出成品”。",
|
||
"",
|
||
"## 分类统计",
|
||
"",
|
||
"| 内部类别 | 中文说明 | 配方数 |",
|
||
"|---|---:|---:|",
|
||
]
|
||
for category in sorted(counts):
|
||
lines.append(f"| `{category}` | {CATEGORY_ZH.get(category, '未译')} | {counts[category]} |")
|
||
lines.extend(["", "## 配方明细", ""])
|
||
for category in sorted(counts):
|
||
category_recipes = sorted(
|
||
(recipe for recipe in recipes if recipe.category == category),
|
||
key=lambda recipe: recipe.recipe_id,
|
||
)
|
||
lines.extend(
|
||
[
|
||
f"### {CATEGORY_ZH.get(category, category)}(`{category}`,{len(category_recipes)}条)",
|
||
"",
|
||
"| 配方 | 游戏耗时(秒) | 小说基准制造时长 | 初始可用 | 输入 | 输出 | 源位置 |",
|
||
"|---|---:|---:|:---:|---|---|---|",
|
||
]
|
||
)
|
||
for recipe in category_recipes:
|
||
source = (
|
||
f"[{recipe.source_file}:{recipe.source_line}]"
|
||
f"(../../old/中间文件/recipe_sources/k2/{recipe.source_file}#L{recipe.source_line})"
|
||
)
|
||
lines.append(
|
||
"| "
|
||
+ " | ".join(
|
||
[
|
||
display_name(recipe.recipe_id, "item", zh, en, recipe=True),
|
||
recipe.energy_required,
|
||
story_duration_hours(recipe.energy_required),
|
||
"是" if recipe.enabled else "否",
|
||
display_stacks(recipe.ingredients, zh, en),
|
||
display_stacks(recipe.results, zh, en),
|
||
source,
|
||
]
|
||
)
|
||
+ " |"
|
||
)
|
||
lines.append("")
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def build_csv(
|
||
recipes: list[Recipe],
|
||
zh: dict[str, dict[str, str]],
|
||
en: dict[str, dict[str, str]],
|
||
) -> bytes:
|
||
output = io.StringIO(newline="")
|
||
writer = csv.writer(output)
|
||
writer.writerow(
|
||
[
|
||
"source_file",
|
||
"source_line",
|
||
"category",
|
||
"category_zh",
|
||
"recipe_id",
|
||
"recipe_name_zh",
|
||
"recipe_name_en",
|
||
"energy_required",
|
||
"story_duration_hours",
|
||
"enabled",
|
||
"ingredients",
|
||
"results",
|
||
]
|
||
)
|
||
for recipe in sorted(recipes, key=lambda item: (item.category, item.recipe_id)):
|
||
writer.writerow(
|
||
[
|
||
recipe.source_file,
|
||
recipe.source_line,
|
||
recipe.category,
|
||
CATEGORY_ZH.get(recipe.category, ""),
|
||
recipe.recipe_id,
|
||
plain_name(recipe.recipe_id, "item", zh, recipe=True),
|
||
plain_name(recipe.recipe_id, "item", en, recipe=True),
|
||
recipe.energy_required,
|
||
story_duration_value(recipe.energy_required),
|
||
"true" if recipe.enabled else "false",
|
||
plain_stacks(recipe.ingredients, zh, en),
|
||
plain_stacks(recipe.results, zh, en),
|
||
]
|
||
)
|
||
return output.getvalue().encode("utf-8-sig")
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--check", action="store_true", help="只校验生成结果是否为最新")
|
||
args = parser.parse_args()
|
||
|
||
source_paths = sorted(SOURCE_DIR.glob("*.lua"), key=lambda path: path.name)
|
||
if not source_paths:
|
||
raise SystemExit(f"未找到 K2 配方源:{SOURCE_DIR}")
|
||
|
||
recipes = [recipe for path in source_paths for recipe in parse_recipe_file(path)]
|
||
if len(recipes) != 119:
|
||
raise SystemExit(f"配方数量异常:预期 119,实际 {len(recipes)}")
|
||
ids = [recipe.recipe_id for recipe in recipes]
|
||
duplicates = sorted(name for name, count in Counter(ids).items() if count > 1)
|
||
if duplicates:
|
||
raise SystemExit(f"发现重复配方 ID:{', '.join(duplicates)}")
|
||
|
||
zh = parse_locale(ZH_LOCALE)
|
||
en = parse_locale(EN_LOCALE)
|
||
markdown = build_markdown(recipes, zh, en, source_digest(source_paths)).encode("utf-8")
|
||
csv_bytes = build_csv(recipes, zh, en)
|
||
|
||
if args.check:
|
||
problems = []
|
||
if not MD_PATH.exists() or MD_PATH.read_bytes() != markdown:
|
||
problems.append(MD_PATH.name)
|
||
if not CSV_PATH.exists() or CSV_PATH.read_bytes() != csv_bytes:
|
||
problems.append(CSV_PATH.name)
|
||
if problems:
|
||
raise SystemExit("生成文件不是最新版本:" + "、".join(problems))
|
||
print(f"校验通过:{len(source_paths)} 个源文件,{len(recipes)} 条配方。")
|
||
return 0
|
||
|
||
MD_PATH.write_bytes(markdown)
|
||
CSV_PATH.write_bytes(csv_bytes)
|
||
print(f"已生成 {MD_PATH.name} 与 {CSV_PATH.name}:{len(recipes)} 条配方。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|