init_project.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  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 runtime_compat import enable_windows_utf8_stdio
  21. from typing import Any, Dict, List
  22. import re
  23. # 安全修复:导入安全工具函数
  24. from security_utils import sanitize_commit_message, atomic_write_json, is_git_available
  25. from project_locator import write_current_project_pointer
  26. # Windows 编码兼容性修复
  27. if sys.platform == "win32":
  28. enable_windows_utf8_stdio()
  29. def _read_text_if_exists(path: Path) -> str:
  30. if not path.exists():
  31. return ""
  32. return path.read_text(encoding="utf-8")
  33. def _write_text_if_missing(path: Path, content: str) -> None:
  34. path.parent.mkdir(parents=True, exist_ok=True)
  35. if path.exists():
  36. return
  37. path.write_text(content, encoding="utf-8")
  38. def _split_genre_keys(genre: str) -> list[str]:
  39. raw = (genre or "").strip()
  40. if not raw:
  41. return []
  42. # 支持复合题材:A+B / A+B / A、B / A与B
  43. raw = re.sub(r"[+/、]", "+", raw)
  44. raw = raw.replace("与", "+")
  45. parts = [p.strip() for p in raw.split("+") if p.strip()]
  46. return parts or [raw]
  47. def _normalize_genre_key(key: str) -> str:
  48. aliases = {
  49. "修仙/玄幻": "修仙",
  50. "玄幻修仙": "修仙",
  51. "玄幻": "修仙",
  52. "修真": "修仙",
  53. "都市修真": "都市异能",
  54. "都市高武": "高武",
  55. "都市奇闻": "都市脑洞",
  56. "古言脑洞": "古言",
  57. "游戏电竞": "电竞",
  58. "电竞文": "电竞",
  59. "直播": "直播文",
  60. "直播带货": "直播文",
  61. "主播": "直播文",
  62. "克系": "克苏鲁",
  63. "克系悬疑": "克苏鲁",
  64. }
  65. return aliases.get(key, key)
  66. def _apply_label_replacements(text: str, replacements: Dict[str, str]) -> str:
  67. if not text or not replacements:
  68. return text
  69. lines = text.splitlines()
  70. for i, line in enumerate(lines):
  71. stripped = line.lstrip()
  72. for label, value in replacements.items():
  73. if not value:
  74. continue
  75. prefix = f"- {label}:"
  76. if stripped.startswith(prefix):
  77. leading = line[: len(line) - len(stripped)]
  78. lines[i] = f"{leading}{prefix}{value}"
  79. return "\n".join(lines)
  80. def _parse_tier_map(raw: str) -> Dict[str, str]:
  81. result: Dict[str, str] = {}
  82. if not raw:
  83. return result
  84. for part in raw.split(";"):
  85. part = part.strip()
  86. if not part:
  87. continue
  88. if ":" in part:
  89. key, val = part.split(":", 1)
  90. result[key.strip()] = val.strip()
  91. return result
  92. def _render_team_rows(names: List[str], roles: List[str]) -> List[str]:
  93. rows = []
  94. for idx, name in enumerate(names):
  95. role = roles[idx] if idx < len(roles) else ""
  96. rows.append(f"| {name} | {role or '主线/副线'} | | | |")
  97. return rows
  98. def _ensure_state_schema(state: Dict[str, Any]) -> Dict[str, Any]:
  99. """确保 state.json 具备 v5.1 架构所需的字段集合(v5.4 沿用)。
  100. v5.1 变更:
  101. - entities_v3 和 alias_index 已迁移到 index.db,不再存储在 state.json
  102. - structured_relationships 已迁移到 index.db relationships 表
  103. - state.json 保持精简 (< 5KB)
  104. """
  105. state.setdefault("project_info", {})
  106. state.setdefault("progress", {})
  107. state.setdefault("protagonist_state", {})
  108. state.setdefault("relationships", {}) # update_state.py 需要此字段
  109. state.setdefault("disambiguation_warnings", [])
  110. state.setdefault("disambiguation_pending", [])
  111. state.setdefault("world_settings", {"power_system": [], "factions": [], "locations": []})
  112. state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []})
  113. state.setdefault("review_checkpoints", [])
  114. state.setdefault("chapter_meta", {})
  115. state.setdefault(
  116. "strand_tracker",
  117. {
  118. "last_quest_chapter": 0,
  119. "last_fire_chapter": 0,
  120. "last_constellation_chapter": 0,
  121. "current_dominant": "quest",
  122. "chapters_since_switch": 0,
  123. "history": [],
  124. },
  125. )
  126. # v5.1: entities_v3, alias_index, structured_relationships 已迁移到 index.db
  127. # 不再在 state.json 中初始化这些字段
  128. # progress schema evolution
  129. state["progress"].setdefault("current_chapter", 0)
  130. state["progress"].setdefault("total_words", 0)
  131. state["progress"].setdefault("last_updated", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
  132. state["progress"].setdefault("volumes_completed", [])
  133. state["progress"].setdefault("current_volume", 1)
  134. state["progress"].setdefault("volumes_planned", [])
  135. # protagonist schema evolution
  136. ps = state["protagonist_state"]
  137. ps.setdefault("name", "")
  138. ps.setdefault("power", {"realm": "", "layer": 1, "bottleneck": ""})
  139. ps.setdefault("location", {"current": "", "last_chapter": 0})
  140. ps.setdefault("golden_finger", {"name": "", "level": 1, "cooldown": 0, "skills": []})
  141. ps.setdefault("attributes", {})
  142. return state
  143. def _build_master_outline(target_chapters: int, *, chapters_per_volume: int = 50) -> str:
  144. volumes = (target_chapters - 1) // chapters_per_volume + 1 if target_chapters > 0 else 1
  145. lines: list[str] = [
  146. "# 总纲",
  147. "",
  148. "> 本文件为“总纲骨架”,用于 /webnovel-plan 细化为卷大纲与章纲。",
  149. "",
  150. "## 卷结构",
  151. "",
  152. ]
  153. for v in range(1, volumes + 1):
  154. start = (v - 1) * chapters_per_volume + 1
  155. end = min(v * chapters_per_volume, target_chapters)
  156. lines.extend(
  157. [
  158. f"### 第{v}卷(第{start}-{end}章)",
  159. "- 核心冲突:",
  160. "- 关键爽点:",
  161. "- 卷末高潮:",
  162. "- 主要登场角色:",
  163. "- 关键伏笔(埋/收):",
  164. "",
  165. ]
  166. )
  167. return "\n".join(lines).rstrip() + "\n"
  168. def _inject_volume_rows(template_text: str, target_chapters: int, *, chapters_per_volume: int = 50) -> str:
  169. """在总纲模板的卷表中注入卷行(若存在表头)。"""
  170. lines = template_text.splitlines()
  171. header_idx = None
  172. for i, line in enumerate(lines):
  173. if line.strip().startswith("| 卷号"):
  174. header_idx = i
  175. break
  176. if header_idx is None:
  177. return template_text
  178. insert_idx = header_idx + 2 if header_idx + 1 < len(lines) else len(lines)
  179. volumes = (target_chapters - 1) // chapters_per_volume + 1 if target_chapters > 0 else 1
  180. rows = []
  181. for v in range(1, volumes + 1):
  182. start = (v - 1) * chapters_per_volume + 1
  183. end = min(v * chapters_per_volume, target_chapters)
  184. rows.append(f"| {v} | | 第{start}-{end}章 | | |")
  185. # 避免重复插入(若模板已有数据行)
  186. existing = {line.strip() for line in lines}
  187. rows = [r for r in rows if r.strip() not in existing]
  188. return "\n".join(lines[:insert_idx] + rows + lines[insert_idx:])
  189. def init_project(
  190. project_dir: str,
  191. title: str,
  192. genre: str,
  193. *,
  194. protagonist_name: str = "",
  195. target_words: int = 2_000_000,
  196. target_chapters: int = 600,
  197. golden_finger_name: str = "",
  198. golden_finger_type: str = "",
  199. golden_finger_style: str = "",
  200. core_selling_points: str = "",
  201. protagonist_structure: str = "",
  202. heroine_config: str = "",
  203. heroine_names: str = "",
  204. heroine_role: str = "",
  205. co_protagonists: str = "",
  206. co_protagonist_roles: str = "",
  207. antagonist_tiers: str = "",
  208. world_scale: str = "",
  209. factions: str = "",
  210. power_system_type: str = "",
  211. social_class: str = "",
  212. resource_distribution: str = "",
  213. gf_visibility: str = "",
  214. gf_irreversible_cost: str = "",
  215. protagonist_desire: str = "",
  216. protagonist_flaw: str = "",
  217. protagonist_archetype: str = "",
  218. antagonist_level: str = "",
  219. target_reader: str = "",
  220. platform: str = "",
  221. currency_system: str = "",
  222. currency_exchange: str = "",
  223. sect_hierarchy: str = "",
  224. cultivation_chain: str = "",
  225. cultivation_subtiers: str = "",
  226. ) -> None:
  227. project_path = Path(project_dir).expanduser().resolve()
  228. if ".claude" in project_path.parts:
  229. raise SystemExit("Refusing to initialize a project inside .claude. Choose a different directory.")
  230. project_path.mkdir(parents=True, exist_ok=True)
  231. # 目录结构(同时兼容“卷目录”与后续扩展)
  232. directories = [
  233. ".webnovel/backups",
  234. ".webnovel/archive",
  235. ".webnovel/summaries",
  236. "设定集/角色库/主要角色",
  237. "设定集/角色库/次要角色",
  238. "设定集/角色库/反派角色",
  239. "设定集/物品库",
  240. "设定集/其他设定",
  241. "大纲",
  242. "正文/第1卷",
  243. "审查报告",
  244. ]
  245. for dir_path in directories:
  246. (project_path / dir_path).mkdir(parents=True, exist_ok=True)
  247. # state.json(创建或增量补齐)
  248. state_path = project_path / ".webnovel" / "state.json"
  249. if state_path.exists():
  250. try:
  251. state: Dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
  252. except json.JSONDecodeError:
  253. state = {}
  254. else:
  255. state = {}
  256. state = _ensure_state_schema(state)
  257. created_at = state.get("project_info", {}).get("created_at") or datetime.now().strftime("%Y-%m-%d")
  258. state["project_info"].update(
  259. {
  260. "title": title,
  261. "genre": genre,
  262. "created_at": created_at,
  263. "target_words": int(target_words),
  264. "target_chapters": int(target_chapters),
  265. # 下面字段属于“初始化元信息”,不影响运行时脚本
  266. "golden_finger_name": golden_finger_name,
  267. "golden_finger_type": golden_finger_type,
  268. "golden_finger_style": golden_finger_style,
  269. "core_selling_points": core_selling_points,
  270. "protagonist_structure": protagonist_structure,
  271. "heroine_config": heroine_config,
  272. "heroine_names": heroine_names,
  273. "heroine_role": heroine_role,
  274. "co_protagonists": co_protagonists,
  275. "co_protagonist_roles": co_protagonist_roles,
  276. "antagonist_tiers": antagonist_tiers,
  277. "world_scale": world_scale,
  278. "factions": factions,
  279. "power_system_type": power_system_type,
  280. "social_class": social_class,
  281. "resource_distribution": resource_distribution,
  282. "gf_visibility": gf_visibility,
  283. "gf_irreversible_cost": gf_irreversible_cost,
  284. "target_reader": target_reader,
  285. "platform": platform,
  286. "currency_system": currency_system,
  287. "currency_exchange": currency_exchange,
  288. "sect_hierarchy": sect_hierarchy,
  289. "cultivation_chain": cultivation_chain,
  290. "cultivation_subtiers": cultivation_subtiers,
  291. }
  292. )
  293. if protagonist_name:
  294. state["protagonist_state"]["name"] = protagonist_name
  295. gf_type_norm = (golden_finger_type or "").strip()
  296. if gf_type_norm in {"无", "无金手指", "none"}:
  297. state["protagonist_state"]["golden_finger"]["name"] = "无金手指"
  298. state["protagonist_state"]["golden_finger"]["level"] = 0
  299. state["protagonist_state"]["golden_finger"]["cooldown"] = 0
  300. elif golden_finger_name:
  301. state["protagonist_state"]["golden_finger"]["name"] = golden_finger_name
  302. # 确保 golden_finger 字段存在且可编辑
  303. if not state["protagonist_state"]["golden_finger"].get("name"):
  304. state["protagonist_state"]["golden_finger"]["name"] = "未命名金手指"
  305. state["progress"]["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  306. state_path.parent.mkdir(parents=True, exist_ok=True)
  307. # 使用原子化写入(初始化不需要备份旧文件)
  308. atomic_write_json(state_path, state, use_lock=True, backup=False)
  309. # 读取内置模板(可选)
  310. script_dir = Path(__file__).resolve().parent
  311. templates_dir = script_dir.parent / "templates"
  312. output_templates_dir = templates_dir / "output"
  313. genre_key = (genre or "").strip()
  314. genre_keys = [_normalize_genre_key(k) for k in _split_genre_keys(genre_key)]
  315. genre_templates = []
  316. seen = set()
  317. for key in genre_keys:
  318. if not key or key in seen:
  319. continue
  320. seen.add(key)
  321. template_text = _read_text_if_exists(templates_dir / "genres" / f"{key}.md")
  322. if template_text:
  323. genre_templates.append(template_text.strip())
  324. genre_template = "\n\n---\n\n".join(genre_templates)
  325. golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
  326. output_worldview = _read_text_if_exists(output_templates_dir / "设定集-世界观.md")
  327. output_power = _read_text_if_exists(output_templates_dir / "设定集-力量体系.md")
  328. output_protagonist = _read_text_if_exists(output_templates_dir / "设定集-主角卡.md")
  329. output_heroine = _read_text_if_exists(output_templates_dir / "设定集-女主卡.md")
  330. output_team = _read_text_if_exists(output_templates_dir / "设定集-主角组.md")
  331. output_golden_finger = _read_text_if_exists(output_templates_dir / "设定集-金手指.md")
  332. output_outline = _read_text_if_exists(output_templates_dir / "大纲-总纲.md")
  333. output_fusion = _read_text_if_exists(output_templates_dir / "复合题材-融合逻辑.md")
  334. output_antagonist = _read_text_if_exists(output_templates_dir / "设定集-反派设计.md")
  335. # 基础文件(只在缺失时生成,避免覆盖已有内容)
  336. now = datetime.now().strftime("%Y-%m-%d")
  337. worldview_content = output_worldview.strip() if output_worldview else ""
  338. if not worldview_content:
  339. worldview_content = "\n".join(
  340. [
  341. "# 世界观",
  342. "",
  343. f"> 项目:{title}|题材:{genre}|创建:{now}",
  344. "",
  345. "## 一句话世界观",
  346. "- (用一句话说明世界的核心规则与卖点)",
  347. "",
  348. "## 核心规则(设定即物理)",
  349. "- 规则1:",
  350. "- 规则2:",
  351. "- 规则3:",
  352. "",
  353. "## 势力与地理(简版)",
  354. "- 主要势力:",
  355. "- 关键地点:",
  356. "",
  357. "## 参考题材模板(可删/可改)",
  358. "",
  359. (genre_template.strip() + "\n") if genre_template else "(未找到对应题材模板,可自行补充)\n",
  360. ]
  361. ).rstrip() + "\n"
  362. else:
  363. worldview_content = _apply_label_replacements(
  364. worldview_content,
  365. {
  366. "大陆/位面数量": world_scale,
  367. "核心势力": factions,
  368. "社会阶层": social_class,
  369. "资源分配规则": resource_distribution,
  370. "宗门/组织层级": sect_hierarchy,
  371. "货币体系": currency_system,
  372. "兑换规则": currency_exchange,
  373. },
  374. )
  375. _write_text_if_missing(
  376. project_path / "设定集" / "世界观.md",
  377. worldview_content,
  378. )
  379. power_content = output_power.strip() if output_power else ""
  380. if not power_content:
  381. power_content = "\n".join(
  382. [
  383. "# 力量体系",
  384. "",
  385. f"> 项目:{title}|题材:{genre}|创建:{now}",
  386. "",
  387. "## 等级/境界划分",
  388. "- (列出从弱到强的等级,含突破条件与代价)",
  389. "",
  390. "## 技能/招式规则",
  391. "- 获得方式:",
  392. "- 成本与副作用:",
  393. "- 进阶与组合:",
  394. "",
  395. "## 禁止事项(防崩坏)",
  396. "- 未达等级不得使用高阶能力(设定即物理)",
  397. "- 新增能力必须申报并入库(发明需申报)",
  398. "",
  399. ]
  400. ).rstrip() + "\n"
  401. else:
  402. power_content = _apply_label_replacements(
  403. power_content,
  404. {
  405. "体系类型": power_system_type,
  406. "典型境界链(可选)": cultivation_chain,
  407. "小境界划分": cultivation_subtiers,
  408. },
  409. )
  410. _write_text_if_missing(
  411. project_path / "设定集" / "力量体系.md",
  412. power_content,
  413. )
  414. protagonist_content = output_protagonist.strip() if output_protagonist else ""
  415. if not protagonist_content:
  416. protagonist_content = "\n".join(
  417. [
  418. "# 主角卡",
  419. "",
  420. f"> 主角:{protagonist_name or '(待填写)'}|项目:{title}|创建:{now}",
  421. "",
  422. "## 三要素",
  423. f"- 欲望:{protagonist_desire or '(待填写)'}",
  424. f"- 弱点:{protagonist_flaw or '(待填写)'}",
  425. f"- 人设类型:{protagonist_archetype or '(待填写)'}",
  426. "",
  427. "## 初始状态(开局)",
  428. "- 身份:",
  429. "- 资源:",
  430. "- 约束:",
  431. "",
  432. "## 金手指概览",
  433. f"- 称呼:{golden_finger_name or '(待填写)'}",
  434. f"- 类型:{golden_finger_type or '(待填写)'}",
  435. f"- 风格:{golden_finger_style or '(待填写)'}",
  436. "- 成长曲线:",
  437. "",
  438. ]
  439. ).rstrip() + "\n"
  440. else:
  441. protagonist_content = _apply_label_replacements(
  442. protagonist_content,
  443. {
  444. "姓名": protagonist_name,
  445. "真正渴望(可能不自知)": protagonist_desire,
  446. "性格缺陷": protagonist_flaw,
  447. },
  448. )
  449. _write_text_if_missing(
  450. project_path / "设定集" / "主角卡.md",
  451. protagonist_content,
  452. )
  453. heroine_content = output_heroine.strip() if output_heroine else ""
  454. if heroine_content:
  455. heroine_content = _apply_label_replacements(
  456. heroine_content,
  457. {
  458. "姓名": heroine_names,
  459. "与主角关系定位(对手/盟友/共谋/牵制)": heroine_role,
  460. },
  461. )
  462. _write_text_if_missing(project_path / "设定集" / "女主卡.md", heroine_content)
  463. team_content = output_team.strip() if output_team else ""
  464. if team_content:
  465. names = [n.strip() for n in co_protagonists.split(",") if n.strip()] if co_protagonists else []
  466. roles = [r.strip() for r in co_protagonist_roles.split(",") if r.strip()] if co_protagonist_roles else []
  467. if names:
  468. lines = team_content.splitlines()
  469. new_rows = _render_team_rows(names, roles)
  470. replaced = False
  471. out_lines: List[str] = []
  472. for line in lines:
  473. if line.strip().startswith("| 主角A"):
  474. out_lines.extend(new_rows)
  475. replaced = True
  476. continue
  477. if replaced and line.strip().startswith("| 主角"):
  478. continue
  479. out_lines.append(line)
  480. team_content = "\n".join(out_lines)
  481. _write_text_if_missing(
  482. project_path / "设定集" / "主角组.md",
  483. team_content,
  484. )
  485. golden_finger_content = output_golden_finger.strip() if output_golden_finger else ""
  486. if not golden_finger_content:
  487. golden_finger_content = "\n".join(
  488. [
  489. "# 金手指设计",
  490. "",
  491. f"> 项目:{title}|题材:{genre}|创建:{now}",
  492. "",
  493. "## 选型",
  494. f"- 称呼:{golden_finger_name or '(待填写)'}",
  495. f"- 类型:{golden_finger_type or '(待填写)'}",
  496. f"- 风格:{golden_finger_style or '(待填写)'}",
  497. "",
  498. "## 规则(必须写清)",
  499. "- 触发条件:",
  500. "- 冷却/代价:",
  501. "- 上限:",
  502. "- 反噬/风险:",
  503. "",
  504. "## 成长曲线(章节规划)",
  505. "- Lv1:",
  506. "- Lv2:",
  507. "- Lv3:",
  508. "",
  509. "## 模板参考(可删/可改)",
  510. "",
  511. (golden_finger_templates.strip() + "\n") if golden_finger_templates else "(未找到金手指模板库)\n",
  512. ]
  513. ).rstrip() + "\n"
  514. else:
  515. golden_finger_content = _apply_label_replacements(
  516. golden_finger_content,
  517. {
  518. "类型": golden_finger_type,
  519. "读者可见度": gf_visibility,
  520. "不可逆代价": gf_irreversible_cost,
  521. },
  522. )
  523. _write_text_if_missing(
  524. project_path / "设定集" / "金手指设计.md",
  525. golden_finger_content,
  526. )
  527. fusion_content = output_fusion.strip() if output_fusion else ""
  528. if fusion_content:
  529. _write_text_if_missing(
  530. project_path / "设定集" / "复合题材-融合逻辑.md",
  531. fusion_content,
  532. )
  533. antagonist_content = output_antagonist.strip() if output_antagonist else ""
  534. if not antagonist_content:
  535. antagonist_content = "\n".join(
  536. [
  537. "# 反派设计",
  538. "",
  539. f"> 项目:{title}|创建:{now}",
  540. "",
  541. f"- 反派等级:{antagonist_level or '(待填写)'}",
  542. "- 动机:",
  543. "- 资源/势力:",
  544. "- 与主角的镜像关系:",
  545. "- 终局:",
  546. "",
  547. ]
  548. ).rstrip() + "\n"
  549. else:
  550. tier_map = _parse_tier_map(antagonist_tiers)
  551. if tier_map:
  552. lines = antagonist_content.splitlines()
  553. out_lines = []
  554. for line in lines:
  555. if line.strip().startswith("| 小反派"):
  556. name = tier_map.get("小反派", "")
  557. out_lines.append(f"| 小反派 | {name} | 前期 | | |")
  558. continue
  559. if line.strip().startswith("| 中反派"):
  560. name = tier_map.get("中反派", "")
  561. out_lines.append(f"| 中反派 | {name} | 中期 | | |")
  562. continue
  563. if line.strip().startswith("| 大反派"):
  564. name = tier_map.get("大反派", "")
  565. out_lines.append(f"| 大反派 | {name} | 后期 | | |")
  566. continue
  567. out_lines.append(line)
  568. antagonist_content = "\n".join(out_lines)
  569. _write_text_if_missing(project_path / "设定集" / "反派设计.md", antagonist_content)
  570. outline_content = output_outline.strip() if output_outline else ""
  571. if outline_content:
  572. outline_content = _inject_volume_rows(outline_content, int(target_chapters)).rstrip() + "\n"
  573. else:
  574. outline_content = _build_master_outline(int(target_chapters))
  575. _write_text_if_missing(project_path / "大纲" / "总纲.md", outline_content)
  576. _write_text_if_missing(
  577. project_path / "大纲" / "爽点规划.md",
  578. "\n".join(
  579. [
  580. "# 爽点规划",
  581. "",
  582. f"> 项目:{title}|题材:{genre}|创建:{now}",
  583. "",
  584. "## 核心卖点(来自初始化输入)",
  585. f"- {core_selling_points or '(待填写,建议 1-3 条,用逗号分隔)'}",
  586. "",
  587. "## 密度目标(建议)",
  588. "- 每章至少 1 个小爽点",
  589. "- 每 5 章至少 1 个大爽点",
  590. "",
  591. "## 分布表(示例,可改)",
  592. "",
  593. "| 章节范围 | 主导爽点类型 | 备注 |",
  594. "|---|---|---|",
  595. "| 1-5 | 金手指/打脸/反转 | 开篇钩子 + 立人设 |",
  596. "| 6-10 | 升级/收获 | 进入主线节奏 |",
  597. "",
  598. ]
  599. ),
  600. )
  601. # 生成环境变量模板(不写入真实密钥)
  602. _write_text_if_missing(
  603. project_path / ".env.example",
  604. "\n".join(
  605. [
  606. "# Webnovel Writer 配置示例(复制为 .env 后填写)",
  607. "# 注意:请勿将包含真实 API_KEY 的 .env 提交到版本库。",
  608. "",
  609. "# Embedding",
  610. "EMBED_BASE_URL=https://api-inference.modelscope.cn/v1",
  611. "EMBED_MODEL=Qwen/Qwen3-Embedding-8B",
  612. "EMBED_API_KEY=",
  613. "",
  614. "# Rerank",
  615. "RERANK_BASE_URL=https://api.jina.ai/v1",
  616. "RERANK_MODEL=jina-reranker-v3",
  617. "RERANK_API_KEY=",
  618. "",
  619. ]
  620. )
  621. + "\n",
  622. )
  623. # Git 初始化(仅当项目目录内尚无 .git 且 Git 可用)
  624. git_dir = project_path / ".git"
  625. if not git_dir.exists():
  626. if not is_git_available():
  627. print("\n⚠️ Git 不可用,跳过版本控制初始化")
  628. print("💡 如需启用 Git 版本控制,请安装 Git: https://git-scm.com/")
  629. else:
  630. print("\nInitializing Git repository...")
  631. try:
  632. subprocess.run(["git", "init"], cwd=project_path, check=True, capture_output=True, text=True)
  633. gitignore_file = project_path / ".gitignore"
  634. if not gitignore_file.exists():
  635. gitignore_file.write_text(
  636. """# Python
  637. __pycache__/
  638. *.py[cod]
  639. *.so
  640. # Env (keep .env.example)
  641. .env
  642. .env.*
  643. !.env.example
  644. # Temporary files
  645. *.tmp
  646. *.bak
  647. .DS_Store
  648. # IDE
  649. .vscode/
  650. .idea/
  651. # Don't ignore .webnovel (we need to track state.json)
  652. # But ignore cache files
  653. .webnovel/context_cache.json
  654. .webnovel/*.lock
  655. .webnovel/*.bak
  656. """,
  657. encoding="utf-8",
  658. )
  659. subprocess.run(["git", "add", "."], cwd=project_path, check=True, capture_output=True)
  660. # 安全修复:清理 title 防止命令注入
  661. safe_title = sanitize_commit_message(title)
  662. subprocess.run(
  663. ["git", "commit", "-m", f"初始化网文项目:{safe_title}"],
  664. cwd=project_path,
  665. check=True,
  666. capture_output=True,
  667. )
  668. print("Git initialized.")
  669. except subprocess.CalledProcessError as e:
  670. print(f"Git init failed (non-fatal): {e}")
  671. # 记录工作区默认项目指针(非阻断)
  672. try:
  673. pointer_file = write_current_project_pointer(project_path)
  674. if pointer_file is not None:
  675. print(f"Default project pointer updated: {pointer_file}")
  676. except Exception as e:
  677. print(f"Default project pointer update failed (non-fatal): {e}")
  678. print(f"\nProject initialized at: {project_path}")
  679. print("Key files:")
  680. print(" - .webnovel/state.json")
  681. print(" - 设定集/世界观.md")
  682. print(" - 设定集/力量体系.md")
  683. print(" - 设定集/主角卡.md")
  684. print(" - 设定集/金手指设计.md")
  685. print(" - 大纲/总纲.md")
  686. print(" - 大纲/爽点规划.md")
  687. def main() -> None:
  688. parser = argparse.ArgumentParser(description="网文项目初始化脚本(生成项目结构 + state.json + 基础模板)")
  689. parser.add_argument("project_dir", help="项目目录(建议 ./webnovel-project)")
  690. parser.add_argument("title", help="小说标题")
  691. parser.add_argument(
  692. "genre",
  693. help="题材类型(可用“+”组合,如:都市脑洞+规则怪谈;示例:修仙/系统流/都市异能/古言/现实题材)",
  694. )
  695. parser.add_argument("--protagonist-name", default="", help="主角姓名")
  696. parser.add_argument("--target-words", type=int, default=2_000_000, help="目标总字数(默认 2000000)")
  697. parser.add_argument("--target-chapters", type=int, default=600, help="目标总章节数(默认 600)")
  698. parser.add_argument("--golden-finger-name", default="", help="金手指称呼/系统名(建议读者可见的代号)")
  699. parser.add_argument("--golden-finger-type", default="", help="金手指类型(如 系统流/鉴定流/签到流)")
  700. parser.add_argument("--golden-finger-style", default="", help="金手指风格(如 冷漠工具型/毒舌吐槽型)")
  701. parser.add_argument("--core-selling-points", default="", help="核心卖点(逗号分隔)")
  702. parser.add_argument("--protagonist-structure", default="", help="主角结构(单主角/多主角)")
  703. parser.add_argument("--heroine-config", default="", help="女主配置(无女主/单女主/多女主)")
  704. parser.add_argument("--heroine-names", default="", help="女主姓名(多个用逗号分隔)")
  705. parser.add_argument("--heroine-role", default="", help="女主定位(事业线/情感线/对抗线)")
  706. parser.add_argument("--co-protagonists", default="", help="多主角姓名(逗号分隔)")
  707. parser.add_argument("--co-protagonist-roles", default="", help="多主角定位(逗号分隔)")
  708. parser.add_argument("--antagonist-tiers", default="", help="反派分层(如 小反派:张三;中反派:李四;大反派:王五)")
  709. parser.add_argument("--world-scale", default="", help="世界规模")
  710. parser.add_argument("--factions", default="", help="势力格局/核心势力")
  711. parser.add_argument("--power-system-type", default="", help="力量体系类型")
  712. parser.add_argument("--social-class", default="", help="社会阶层")
  713. parser.add_argument("--resource-distribution", default="", help="资源分配")
  714. parser.add_argument("--gf-visibility", default="", help="金手指可见度(明牌/半明牌/暗牌)")
  715. parser.add_argument("--gf-irreversible-cost", default="", help="金手指不可逆代价")
  716. parser.add_argument("--currency-system", default="", help="货币体系")
  717. parser.add_argument("--currency-exchange", default="", help="货币兑换/面值规则")
  718. parser.add_argument("--sect-hierarchy", default="", help="宗门/组织层级")
  719. parser.add_argument("--cultivation-chain", default="", help="典型境界链")
  720. parser.add_argument("--cultivation-subtiers", default="", help="小境界划分(初/中/后/巅 等)")
  721. # 深度模式可选参数(用于预填模板)
  722. parser.add_argument("--protagonist-desire", default="", help="主角核心欲望(深度模式)")
  723. parser.add_argument("--protagonist-flaw", default="", help="主角性格弱点(深度模式)")
  724. parser.add_argument("--protagonist-archetype", default="", help="主角人设类型(深度模式)")
  725. parser.add_argument("--antagonist-level", default="", help="反派等级(深度模式)")
  726. parser.add_argument("--target-reader", default="", help="目标读者(深度模式)")
  727. parser.add_argument("--platform", default="", help="发布平台(深度模式)")
  728. args = parser.parse_args()
  729. init_project(
  730. args.project_dir,
  731. args.title,
  732. args.genre,
  733. protagonist_name=args.protagonist_name,
  734. target_words=args.target_words,
  735. target_chapters=args.target_chapters,
  736. golden_finger_name=args.golden_finger_name,
  737. golden_finger_type=args.golden_finger_type,
  738. golden_finger_style=args.golden_finger_style,
  739. core_selling_points=args.core_selling_points,
  740. protagonist_structure=args.protagonist_structure,
  741. heroine_config=args.heroine_config,
  742. heroine_names=args.heroine_names,
  743. heroine_role=args.heroine_role,
  744. co_protagonists=args.co_protagonists,
  745. co_protagonist_roles=args.co_protagonist_roles,
  746. antagonist_tiers=args.antagonist_tiers,
  747. world_scale=args.world_scale,
  748. factions=args.factions,
  749. power_system_type=args.power_system_type,
  750. social_class=args.social_class,
  751. resource_distribution=args.resource_distribution,
  752. gf_visibility=args.gf_visibility,
  753. gf_irreversible_cost=args.gf_irreversible_cost,
  754. protagonist_desire=args.protagonist_desire,
  755. protagonist_flaw=args.protagonist_flaw,
  756. protagonist_archetype=args.protagonist_archetype,
  757. antagonist_level=args.antagonist_level,
  758. target_reader=args.target_reader,
  759. platform=args.platform,
  760. currency_system=args.currency_system,
  761. currency_exchange=args.currency_exchange,
  762. sect_hierarchy=args.sect_hierarchy,
  763. cultivation_chain=args.cultivation_chain,
  764. cultivation_subtiers=args.cultivation_subtiers,
  765. )
  766. if __name__ == "__main__":
  767. main()