init_project.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 网文项目初始化脚本
  5. 目标:
  6. - 生成可运行的项目结构(webnovel-project)
  7. - 创建/更新 .webnovel/state.json(运行时真相)
  8. - 生成基础设定集与大纲模板文件(供 /webnovel-plan 与 /webnovel-write 使用)
  9. 说明:
  10. - 该脚本是命令 /webnovel-init 的“唯一允许的文件生成入口”(与命令文档保持一致)。
  11. - 生成的内容以“模板骨架”为主,便于 AI/作者后续补全;但保证所有关键文件存在。
  12. """
  13. from __future__ import annotations
  14. import argparse
  15. import json
  16. import subprocess
  17. import sys
  18. from datetime import datetime
  19. from pathlib import Path
  20. from typing import Any, Dict
  21. # 安全修复:导入安全工具函数
  22. from security_utils import sanitize_commit_message, atomic_write_json, is_git_available
  23. # Windows 编码兼容性修复
  24. if sys.platform == "win32":
  25. import io
  26. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  27. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
  28. def _read_text_if_exists(path: Path) -> str:
  29. if not path.exists():
  30. return ""
  31. return path.read_text(encoding="utf-8")
  32. def _write_text_if_missing(path: Path, content: str) -> None:
  33. path.parent.mkdir(parents=True, exist_ok=True)
  34. if path.exists():
  35. return
  36. path.write_text(content, encoding="utf-8")
  37. def _ensure_state_schema(state: Dict[str, Any]) -> Dict[str, Any]:
  38. """确保 state.json 具备 v5.0 架构所需的字段集合。"""
  39. state.setdefault("project_info", {})
  40. state.setdefault("progress", {})
  41. state.setdefault("protagonist_state", {})
  42. state.setdefault("relationships", {})
  43. state.setdefault("structured_relationships", [])
  44. state.setdefault("disambiguation_warnings", [])
  45. state.setdefault("disambiguation_pending", [])
  46. state.setdefault("world_settings", {"power_system": [], "factions": [], "locations": []})
  47. state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []})
  48. state.setdefault("review_checkpoints", [])
  49. state.setdefault(
  50. "strand_tracker",
  51. {
  52. "last_quest_chapter": 0,
  53. "last_fire_chapter": 0,
  54. "last_constellation_chapter": 0,
  55. "current_dominant": "quest",
  56. "chapters_since_switch": 0,
  57. "history": [],
  58. },
  59. )
  60. # v5.0: entities_v3 分组格式(按类型)
  61. state.setdefault(
  62. "entities_v3",
  63. {"角色": {}, "地点": {}, "物品": {}, "势力": {}, "招式": {}},
  64. )
  65. # v5.0: alias_index 一对多映射
  66. state.setdefault("alias_index", {})
  67. # progress schema evolution
  68. state["progress"].setdefault("current_chapter", 0)
  69. state["progress"].setdefault("total_words", 0)
  70. state["progress"].setdefault("last_updated", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
  71. state["progress"].setdefault("volumes_completed", [])
  72. state["progress"].setdefault("current_volume", 1)
  73. state["progress"].setdefault("volumes_planned", [])
  74. # protagonist schema evolution
  75. ps = state["protagonist_state"]
  76. ps.setdefault("name", "")
  77. ps.setdefault("power", {"realm": "", "layer": 1, "bottleneck": ""})
  78. ps.setdefault("location", {"current": "", "last_chapter": 0})
  79. ps.setdefault("golden_finger", {"name": "", "level": 1, "cooldown": 0, "skills": []})
  80. ps.setdefault("attributes", {})
  81. return state
  82. def _build_master_outline(target_chapters: int, *, chapters_per_volume: int = 50) -> str:
  83. volumes = (target_chapters - 1) // chapters_per_volume + 1 if target_chapters > 0 else 1
  84. lines: list[str] = [
  85. "# 总纲",
  86. "",
  87. "> 本文件为“总纲骨架”,用于 /webnovel-plan 细化为卷大纲与章纲。",
  88. "",
  89. "## 卷结构",
  90. "",
  91. ]
  92. for v in range(1, volumes + 1):
  93. start = (v - 1) * chapters_per_volume + 1
  94. end = min(v * chapters_per_volume, target_chapters)
  95. lines.extend(
  96. [
  97. f"### 第{v}卷(第{start}-{end}章)",
  98. "- 核心冲突:",
  99. "- 关键爽点:",
  100. "- 卷末高潮:",
  101. "- 主要登场角色:",
  102. "- 关键伏笔(埋/收):",
  103. "",
  104. ]
  105. )
  106. return "\n".join(lines).rstrip() + "\n"
  107. def init_project(
  108. project_dir: str,
  109. title: str,
  110. genre: str,
  111. *,
  112. protagonist_name: str = "",
  113. target_words: int = 2_000_000,
  114. target_chapters: int = 600,
  115. golden_finger_name: str = "",
  116. golden_finger_type: str = "",
  117. golden_finger_style: str = "",
  118. core_selling_points: str = "",
  119. protagonist_desire: str = "",
  120. protagonist_flaw: str = "",
  121. protagonist_archetype: str = "",
  122. antagonist_level: str = "",
  123. target_reader: str = "",
  124. platform: str = "",
  125. ) -> None:
  126. project_path = Path(project_dir).expanduser().resolve()
  127. project_path.mkdir(parents=True, exist_ok=True)
  128. # 目录结构(同时兼容“卷目录”与后续扩展)
  129. directories = [
  130. ".webnovel/backups",
  131. ".webnovel/archive",
  132. "设定集/角色库/主要角色",
  133. "设定集/角色库/次要角色",
  134. "设定集/角色库/反派角色",
  135. "设定集/物品库",
  136. "设定集/其他设定",
  137. "大纲",
  138. "正文/第1卷",
  139. "审查报告",
  140. ]
  141. for dir_path in directories:
  142. (project_path / dir_path).mkdir(parents=True, exist_ok=True)
  143. # state.json(创建或增量补齐)
  144. state_path = project_path / ".webnovel" / "state.json"
  145. if state_path.exists():
  146. try:
  147. state: Dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
  148. except json.JSONDecodeError:
  149. state = {}
  150. else:
  151. state = {}
  152. state = _ensure_state_schema(state)
  153. created_at = state.get("project_info", {}).get("created_at") or datetime.now().strftime("%Y-%m-%d")
  154. state["project_info"].update(
  155. {
  156. "title": title,
  157. "genre": genre,
  158. "created_at": created_at,
  159. "target_words": int(target_words),
  160. "target_chapters": int(target_chapters),
  161. # 下面字段属于“初始化元信息”,不影响运行时脚本
  162. "golden_finger_name": golden_finger_name,
  163. "golden_finger_type": golden_finger_type,
  164. "golden_finger_style": golden_finger_style,
  165. "core_selling_points": core_selling_points,
  166. "target_reader": target_reader,
  167. "platform": platform,
  168. }
  169. )
  170. if protagonist_name:
  171. state["protagonist_state"]["name"] = protagonist_name
  172. if golden_finger_name:
  173. state["protagonist_state"]["golden_finger"]["name"] = golden_finger_name
  174. # 确保 golden_finger 字段存在且可编辑
  175. if not state["protagonist_state"]["golden_finger"].get("name"):
  176. state["protagonist_state"]["golden_finger"]["name"] = "未命名金手指"
  177. state["progress"]["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  178. state_path.parent.mkdir(parents=True, exist_ok=True)
  179. # 使用原子化写入(初始化不需要备份旧文件)
  180. atomic_write_json(state_path, state, use_lock=True, backup=False)
  181. # 读取内置模板(可选)
  182. script_dir = Path(__file__).resolve().parent
  183. templates_dir = script_dir.parent / "templates"
  184. genre_key = (genre or "").strip()
  185. genre_template_key = {
  186. "修仙/玄幻": "修仙",
  187. "玄幻": "修仙",
  188. }.get(genre_key, genre_key)
  189. genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre_template_key}.md")
  190. golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
  191. # 基础文件(只在缺失时生成,避免覆盖已有内容)
  192. now = datetime.now().strftime("%Y-%m-%d")
  193. _write_text_if_missing(
  194. project_path / "设定集" / "世界观.md",
  195. "\n".join(
  196. [
  197. "# 世界观",
  198. "",
  199. f"> 项目:{title}|题材:{genre}|创建:{now}",
  200. "",
  201. "## 一句话世界观",
  202. "- (用一句话说明世界的核心规则与卖点)",
  203. "",
  204. "## 核心规则(设定即物理)",
  205. "- 规则1:",
  206. "- 规则2:",
  207. "- 规则3:",
  208. "",
  209. "## 势力与地理(简版)",
  210. "- 主要势力:",
  211. "- 关键地点:",
  212. "",
  213. "## 参考题材模板(可删/可改)",
  214. "",
  215. (genre_template.strip() + "\n") if genre_template else "(未找到对应题材模板,可自行补充)\n",
  216. ]
  217. ),
  218. )
  219. _write_text_if_missing(
  220. project_path / "设定集" / "力量体系.md",
  221. "\n".join(
  222. [
  223. "# 力量体系",
  224. "",
  225. f"> 项目:{title}|题材:{genre}|创建:{now}",
  226. "",
  227. "## 等级/境界划分",
  228. "- (列出从弱到强的等级,含突破条件与代价)",
  229. "",
  230. "## 技能/招式规则",
  231. "- 获得方式:",
  232. "- 成本与副作用:",
  233. "- 进阶与组合:",
  234. "",
  235. "## 禁止事项(防崩坏)",
  236. "- 未达等级不得使用高阶能力(设定即物理)",
  237. "- 新增能力必须申报并入库(发明需申报)",
  238. "",
  239. ]
  240. ),
  241. )
  242. _write_text_if_missing(
  243. project_path / "设定集" / "主角卡.md",
  244. "\n".join(
  245. [
  246. "# 主角卡",
  247. "",
  248. f"> 主角:{protagonist_name or '(待填写)'}|项目:{title}|创建:{now}",
  249. "",
  250. "## 三要素",
  251. f"- 欲望:{protagonist_desire or '(待填写)'}",
  252. f"- 弱点:{protagonist_flaw or '(待填写)'}",
  253. f"- 人设类型:{protagonist_archetype or '(待填写)'}",
  254. "",
  255. "## 初始状态(开局)",
  256. "- 身份:",
  257. "- 资源:",
  258. "- 约束:",
  259. "",
  260. "## 金手指概览",
  261. f"- 称呼:{golden_finger_name or '(待填写)'}",
  262. f"- 类型:{golden_finger_type or '(待填写)'}",
  263. f"- 风格:{golden_finger_style or '(待填写)'}",
  264. "- 成长曲线:",
  265. "",
  266. ]
  267. ),
  268. )
  269. _write_text_if_missing(
  270. project_path / "设定集" / "金手指设计.md",
  271. "\n".join(
  272. [
  273. "# 金手指设计",
  274. "",
  275. f"> 项目:{title}|题材:{genre}|创建:{now}",
  276. "",
  277. "## 选型",
  278. f"- 称呼:{golden_finger_name or '(待填写)'}",
  279. f"- 类型:{golden_finger_type or '(待填写)'}",
  280. f"- 风格:{golden_finger_style or '(待填写)'}",
  281. "",
  282. "## 规则(必须写清)",
  283. "- 触发条件:",
  284. "- 冷却/代价:",
  285. "- 上限:",
  286. "- 反噬/风险:",
  287. "",
  288. "## 成长曲线(章节规划)",
  289. "- Lv1:",
  290. "- Lv2:",
  291. "- Lv3:",
  292. "",
  293. "## 模板参考(可删/可改)",
  294. "",
  295. (golden_finger_templates.strip() + "\n") if golden_finger_templates else "(未找到金手指模板库)\n",
  296. ]
  297. ),
  298. )
  299. if antagonist_level:
  300. _write_text_if_missing(
  301. project_path / "设定集" / "反派设计.md",
  302. "\n".join(
  303. [
  304. "# 反派设计",
  305. "",
  306. f"> 项目:{title}|创建:{now}",
  307. "",
  308. f"- 反派等级:{antagonist_level}",
  309. "- 动机:",
  310. "- 资源/势力:",
  311. "- 与主角的镜像关系:",
  312. "- 终局:",
  313. "",
  314. ]
  315. ),
  316. )
  317. _write_text_if_missing(project_path / "大纲" / "总纲.md", _build_master_outline(int(target_chapters)))
  318. _write_text_if_missing(
  319. project_path / "大纲" / "爽点规划.md",
  320. "\n".join(
  321. [
  322. "# 爽点规划",
  323. "",
  324. f"> 项目:{title}|题材:{genre}|创建:{now}",
  325. "",
  326. "## 核心卖点(来自初始化输入)",
  327. f"- {core_selling_points or '(待填写,建议 1-3 条,用逗号分隔)'}",
  328. "",
  329. "## 密度目标(建议)",
  330. "- 每章至少 1 个小爽点",
  331. "- 每 5 章至少 1 个大爽点",
  332. "",
  333. "## 分布表(示例,可改)",
  334. "",
  335. "| 章节范围 | 主导爽点类型 | 备注 |",
  336. "|---|---|---|",
  337. "| 1-5 | 金手指/打脸/反转 | 开篇钩子 + 立人设 |",
  338. "| 6-10 | 升级/收获 | 进入主线节奏 |",
  339. "",
  340. ]
  341. ),
  342. )
  343. # Git 初始化(仅当项目目录内尚无 .git 且 Git 可用)
  344. git_dir = project_path / ".git"
  345. if not git_dir.exists():
  346. if not is_git_available():
  347. print("\n⚠️ Git 不可用,跳过版本控制初始化")
  348. print("💡 如需启用 Git 版本控制,请安装 Git: https://git-scm.com/")
  349. else:
  350. print("\nInitializing Git repository...")
  351. try:
  352. subprocess.run(["git", "init"], cwd=project_path, check=True, capture_output=True, text=True)
  353. gitignore_file = project_path / ".gitignore"
  354. if not gitignore_file.exists():
  355. gitignore_file.write_text(
  356. """# Python
  357. __pycache__/
  358. *.py[cod]
  359. *.so
  360. # Temporary files
  361. *.tmp
  362. *.bak
  363. .DS_Store
  364. # IDE
  365. .vscode/
  366. .idea/
  367. # Don't ignore .webnovel (we need to track state.json)
  368. # But ignore cache files
  369. .webnovel/context_cache.json
  370. .webnovel/*.lock
  371. .webnovel/*.bak
  372. """,
  373. encoding="utf-8",
  374. )
  375. subprocess.run(["git", "add", "."], cwd=project_path, check=True, capture_output=True)
  376. # 安全修复:清理 title 防止命令注入
  377. safe_title = sanitize_commit_message(title)
  378. subprocess.run(
  379. ["git", "commit", "-m", f"初始化网文项目:{safe_title}"],
  380. cwd=project_path,
  381. check=True,
  382. capture_output=True,
  383. )
  384. print("Git initialized.")
  385. except subprocess.CalledProcessError as e:
  386. print(f"Git init failed (non-fatal): {e}")
  387. print(f"\nProject initialized at: {project_path}")
  388. print("Key files:")
  389. print(" - .webnovel/state.json")
  390. print(" - 设定集/世界观.md")
  391. print(" - 设定集/力量体系.md")
  392. print(" - 设定集/主角卡.md")
  393. print(" - 设定集/金手指设计.md")
  394. print(" - 大纲/总纲.md")
  395. print(" - 大纲/爽点规划.md")
  396. def main() -> None:
  397. parser = argparse.ArgumentParser(description="网文项目初始化脚本(生成项目结构 + state.json + 基础模板)")
  398. parser.add_argument("project_dir", help="项目目录(建议 ./webnovel-project)")
  399. parser.add_argument("title", help="小说标题")
  400. parser.add_argument("genre", help="题材类型(如:修仙/系统流/都市异能/狗血言情/古言/现实题材/规则怪谈/知乎短篇)")
  401. parser.add_argument("--protagonist-name", default="", help="主角姓名")
  402. parser.add_argument("--target-words", type=int, default=2_000_000, help="目标总字数(默认 2000000)")
  403. parser.add_argument("--target-chapters", type=int, default=600, help="目标总章节数(默认 600)")
  404. parser.add_argument("--golden-finger-name", default="", help="金手指称呼/系统名(建议读者可见的代号)")
  405. parser.add_argument("--golden-finger-type", default="", help="金手指类型(如 系统流/鉴定流/签到流)")
  406. parser.add_argument("--golden-finger-style", default="", help="金手指风格(如 冷漠工具型/毒舌吐槽型)")
  407. parser.add_argument("--core-selling-points", default="", help="核心卖点(逗号分隔)")
  408. # 深度模式可选参数(用于预填模板)
  409. parser.add_argument("--protagonist-desire", default="", help="主角核心欲望(深度模式)")
  410. parser.add_argument("--protagonist-flaw", default="", help="主角性格弱点(深度模式)")
  411. parser.add_argument("--protagonist-archetype", default="", help="主角人设类型(深度模式)")
  412. parser.add_argument("--antagonist-level", default="", help="反派等级(深度模式)")
  413. parser.add_argument("--target-reader", default="", help="目标读者(深度模式)")
  414. parser.add_argument("--platform", default="", help="发布平台(深度模式)")
  415. args = parser.parse_args()
  416. init_project(
  417. args.project_dir,
  418. args.title,
  419. args.genre,
  420. protagonist_name=args.protagonist_name,
  421. target_words=args.target_words,
  422. target_chapters=args.target_chapters,
  423. golden_finger_name=args.golden_finger_name,
  424. golden_finger_type=args.golden_finger_type,
  425. golden_finger_style=args.golden_finger_style,
  426. core_selling_points=args.core_selling_points,
  427. protagonist_desire=args.protagonist_desire,
  428. protagonist_flaw=args.protagonist_flaw,
  429. protagonist_archetype=args.protagonist_archetype,
  430. antagonist_level=args.antagonist_level,
  431. target_reader=args.target_reader,
  432. platform=args.platform,
  433. )
  434. if __name__ == "__main__":
  435. main()