@oh-story/dsh 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +12 -12
  2. package/lib/client.js +204 -72
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +84 -74
  5. package/lib/drama/skills/short-drama/SKILL.md +2 -2
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +14 -0
  7. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
  8. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
  9. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +27 -29
  11. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +2 -2
  12. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +24 -67
  13. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +27 -56
  14. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +37 -79
  15. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +39 -92
  16. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +12 -16
  17. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +126 -11
  18. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +64 -0
  19. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
  20. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
  21. package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
  22. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
  23. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
  24. package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  25. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
  26. package/lib/drama/skills/short-drama-review/SKILL.md +2 -3
  27. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
  28. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +9 -35
  29. package/lib/drama/skills/short-drama-storyboard/SKILL.md +10 -6
  30. package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  31. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +58 -21
  32. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +1 -1
  33. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +10 -9
  34. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
  35. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +12 -38
  36. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +4 -7
  37. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +8 -22
  38. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -19
  39. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
  40. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
  41. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +2 -2
  42. package/lib/drama/skills/short-drama-write/SKILL.md +2 -4
  43. package/lib/index.js +289 -91
  44. package/lib/index.js.map +4 -4
  45. package/lib/novel-to-game/README.md +299 -59
  46. package/lib/novel-to-game/README_ZH.md +299 -56
  47. package/lib/novel-to-game/VERSION +1 -1
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
  57. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
  58. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
  59. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
  60. package/lib/novel-to-game/manifest.json +77 -87
  61. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
  62. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
  64. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
  65. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
  66. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
  67. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
  68. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
  69. package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
  70. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
  71. package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
  72. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
  73. package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
  74. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
  75. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
  76. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
  77. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
  78. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
  79. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
  80. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
  81. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
  82. package/lib/oh-story/manifest.json +230 -185
  83. package/lib/oh-story/roles/chapter-extractor.md +177 -237
  84. package/lib/oh-story/roles/consistency-checker.md +4 -4
  85. package/lib/oh-story/roles/narrative-writer.md +24 -25
  86. package/lib/oh-story/roles/story-architect.md +5 -5
  87. package/lib/oh-story/roles/story-explorer.md +11 -11
  88. package/lib/oh-story/skills/story/SKILL.md +13 -7
  89. package/lib/oh-story/skills/story/VERSION +1 -1
  90. package/lib/oh-story/skills/story/references/author-memory.md +54 -24
  91. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
  92. package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
  93. package/lib/oh-story/skills/story-deslop/SKILL.md +40 -53
  94. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +11 -10
  95. package/lib/oh-story/skills/story-deslop/references/author-memory.md +54 -24
  96. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +1 -1
  97. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
  98. package/lib/oh-story/skills/story-import/SKILL.md +77 -81
  99. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
  100. package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
  101. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
  102. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +8 -6
  103. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +34 -16
  104. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
  105. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
  106. package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
  107. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
  108. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
  109. package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
  110. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
  111. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
  112. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
  113. package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
  114. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
  115. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
  116. package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
  117. package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
  118. package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
  119. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
  120. package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
  121. package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
  122. package/lib/oh-story/skills/story-long-write/SKILL.md +38 -29
  123. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +11 -10
  124. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +25 -20
  125. package/lib/oh-story/skills/story-long-write/references/author-memory.md +54 -24
  126. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +3 -3
  127. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
  128. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
  129. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
  130. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +4 -13
  131. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +1 -1
  132. package/lib/oh-story/skills/story-long-write/references/project-files.md +9 -7
  133. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
  134. package/lib/oh-story/skills/story-long-write/references/reference-index.md +1 -2
  135. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +8 -6
  136. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +86 -115
  137. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -120
  138. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
  139. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +66 -68
  140. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +3 -3
  141. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +4 -12
  142. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
  143. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +139 -18
  144. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +34 -16
  145. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +5 -3
  146. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
  147. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
  148. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
  149. package/lib/oh-story/skills/story-review/SKILL.md +68 -85
  150. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +11 -10
  151. package/lib/oh-story/skills/story-review/references/author-memory.md +54 -24
  152. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
  153. package/lib/oh-story/skills/story-review/references/review-quality.md +4 -10
  154. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +8 -6
  155. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
  156. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
  157. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
  158. package/lib/oh-story/skills/story-setup/SKILL.md +50 -42
  159. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +11 -10
  160. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +1 -1
  161. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
  162. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
  163. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
  164. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +4 -12
  165. package/lib/oh-story/skills/story-setup/references/diagnostics.md +1 -1
  166. package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
  167. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
  168. package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
  169. package/lib/oh-story/skills/story-short-write/SKILL.md +6 -147
  170. package/lib/oh-story/skills/story-short-write/references/author-memory.md +54 -24
  171. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
  172. package/lib/oh-story/skills/story-short-write/references/reference-index.md +2 -0
  173. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +3 -2
  174. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
  175. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
  176. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
  177. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  178. package/lib/types/client/file-activity.d.ts +19 -1
  179. package/lib/types/client/file-activity.d.ts.map +1 -1
  180. package/lib/types/client/index.d.ts.map +1 -1
  181. package/lib/types/client/production-prompts.d.ts +7 -0
  182. package/lib/types/client/production-prompts.d.ts.map +1 -1
  183. package/lib/types/client/production-runtime.d.ts +16 -0
  184. package/lib/types/client/production-runtime.d.ts.map +1 -1
  185. package/lib/types/drama-adapters.d.ts +5 -3
  186. package/lib/types/drama-adapters.d.ts.map +1 -1
  187. package/lib/types/native-hooks.d.ts +52 -0
  188. package/lib/types/native-hooks.d.ts.map +1 -1
  189. package/lib/types/role-identity.d.ts +17 -0
  190. package/lib/types/role-identity.d.ts.map +1 -0
  191. package/lib/types/role-tool.d.ts.map +1 -1
  192. package/lib/types/skill-provider.d.ts.map +1 -1
  193. package/package.json +45 -45
  194. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
  195. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
  196. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
  197. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env python3
2
+ """Build the mechanical, content-addressed chapter index.
3
+
4
+ The CSV is the only source for chapter boundaries. This script never writes
5
+ analysis progress and never interprets story semantics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import os
16
+ import re
17
+ import statistics
18
+ import sys
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
22
+
23
+ from inspect_existing_assets import legacy_mapping_check
24
+
25
+
26
+ PARSER_VERSION = "3"
27
+ CSV_COLUMNS = (
28
+ "chapter", "source_chapter", "volume", "title", "start_line", "end_line",
29
+ "char_count", "source_locator", "status", "chapter_sha256", "source_sha256",
30
+ "parser_version",
31
+ )
32
+ NUMBER = r"〇零一二三四五六七八九十百千万两0-9"
33
+ VOLUME_RE = re.compile(rf"^\s*第(?P<number>[{NUMBER}]+)卷(?P<title>.*)$")
34
+ COMBINED_RE = re.compile(rf"^\s*第(?P<volume>[{NUMBER}]+)卷\s*第(?P<chapter>[{NUMBER}]+)章(?P<title>.*)$")
35
+ CHAPTER_RE = re.compile(rf"^\s*第(?P<number>[{NUMBER}]+)章(?P<title>.*)$")
36
+ ENGLISH_RE = re.compile(r"^\s*Chapter\s+(?P<number>[0-9]+)\b(?P<title>.*)$", re.IGNORECASE)
37
+ NUMERIC_RE = re.compile(r"^\s*(?P<number>[0-9]+)[..、]\s*(?P<title>.*)$")
38
+ SPECIAL_RE = re.compile(
39
+ rf"^\s*(?P<label>楔子|序章|引子|前言|后记|尾声|番外(?:[{NUMBER}]+)?)"
40
+ r"(?:[\s::\-—]+(?P<title>.*))?\s*$"
41
+ )
42
+ TITLE_PREFIX_RE = re.compile(r"^[\s\-—::、..]+")
43
+ DIGITS = {"〇": 0, "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
44
+ "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
45
+ UNITS = {"十": 10, "百": 100, "千": 1000, "万": 10000}
46
+
47
+
48
+ def sha256(data: bytes) -> str:
49
+ return hashlib.sha256(data).hexdigest()
50
+
51
+
52
+ def atomic_write(path: Path, data: bytes) -> None:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
55
+ try:
56
+ with os.fdopen(fd, "wb") as handle:
57
+ handle.write(data)
58
+ handle.flush()
59
+ os.fsync(handle.fileno())
60
+ os.replace(temporary, str(path))
61
+ except BaseException:
62
+ try:
63
+ os.unlink(temporary)
64
+ except OSError:
65
+ pass
66
+ raise
67
+
68
+
69
+ def decode_source(raw: bytes) -> str:
70
+ for encoding in ("utf-8-sig", "utf-8", "gb18030"):
71
+ try:
72
+ return raw.decode(encoding)
73
+ except UnicodeDecodeError:
74
+ continue
75
+ raise ValueError("source_encoding_unsupported")
76
+
77
+
78
+ def physical_lines(text: str) -> List[str]:
79
+ """Split on LF only so CSV line numbers agree with grep -n and editors."""
80
+ lines = text.split("\n")
81
+ if lines and lines[-1] == "":
82
+ lines.pop()
83
+ return [line[:-1] if line.endswith("\r") else line for line in lines]
84
+
85
+
86
+ def parse_number(raw: str) -> int:
87
+ if raw.isdigit():
88
+ return int(raw)
89
+ if not any(character in UNITS for character in raw):
90
+ try:
91
+ return int("".join(str(DIGITS[character]) for character in raw))
92
+ except (KeyError, ValueError) as exc:
93
+ raise ValueError("chapter_number_invalid:%s" % raw) from exc
94
+ total = 0
95
+ section = 0
96
+ number = 0
97
+ for character in raw:
98
+ if character in DIGITS:
99
+ number = DIGITS[character]
100
+ elif character in UNITS:
101
+ unit = UNITS[character]
102
+ if unit == 10000:
103
+ total += (section + number) * unit
104
+ section = 0
105
+ else:
106
+ section += (number or 1) * unit
107
+ number = 0
108
+ else:
109
+ raise ValueError("chapter_number_invalid:%s" % raw)
110
+ return total + section + number
111
+
112
+
113
+ def clean_title(raw: str, fallback: str) -> str:
114
+ title = TITLE_PREFIX_RE.sub("", raw or "").strip()
115
+ return title or fallback
116
+
117
+
118
+ def heading_candidates(lines: Sequence[str]) -> List[Dict[str, Any]]:
119
+ explicit = [] # type: List[Dict[str, Any]]
120
+ numeric_candidates = [] # type: List[Dict[str, Any]]
121
+ volume_number = None # type: Optional[int]
122
+ volume_title = ""
123
+ for line_number, line in enumerate(lines, start=1):
124
+ combined = COMBINED_RE.match(line)
125
+ if combined:
126
+ raw_volume = combined.group("volume")
127
+ raw_chapter = combined.group("chapter")
128
+ volume_number = parse_number(raw_volume)
129
+ volume_title = "第%s卷" % raw_volume
130
+ explicit.append({
131
+ "line": line_number, "heading_kind": "combined",
132
+ "source_chapter": str(parse_number(raw_chapter)),
133
+ "number_value": parse_number(raw_chapter), "volume_number": volume_number,
134
+ "volume": volume_title,
135
+ "title": clean_title(combined.group("title"), "第%s章" % raw_chapter),
136
+ })
137
+ continue
138
+ volume = VOLUME_RE.match(line)
139
+ if volume and not CHAPTER_RE.match(line):
140
+ raw_volume = volume.group("number")
141
+ volume_number = parse_number(raw_volume)
142
+ volume_title = clean_title(volume.group("title"), "第%s卷" % raw_volume)
143
+ continue
144
+ chapter = CHAPTER_RE.match(line)
145
+ english = ENGLISH_RE.match(line)
146
+ match = chapter or english
147
+ if match:
148
+ raw_chapter = match.group("number")
149
+ value = parse_number(raw_chapter)
150
+ explicit.append({
151
+ "line": line_number, "heading_kind": "chapter" if chapter else "english",
152
+ "source_chapter": str(value), "number_value": value,
153
+ "volume_number": volume_number, "volume": volume_title,
154
+ "title": clean_title(match.group("title"), "第%s章" % raw_chapter),
155
+ })
156
+ continue
157
+ special = SPECIAL_RE.match(line)
158
+ if special:
159
+ label = special.group("label")
160
+ explicit.append({
161
+ "line": line_number, "heading_kind": "special", "source_chapter": label,
162
+ "number_value": None, "volume_number": volume_number, "volume": volume_title,
163
+ "title": clean_title(special.group("title") or "", label),
164
+ })
165
+ continue
166
+ numeric = NUMERIC_RE.match(line)
167
+ if numeric:
168
+ raw_chapter = numeric.group("number")
169
+ value = parse_number(raw_chapter)
170
+ numeric_candidates.append({
171
+ "line": line_number, "heading_kind": "numeric", "source_chapter": str(value),
172
+ "number_value": value, "volume_number": volume_number, "volume": volume_title,
173
+ "title": clean_title(numeric.group("title"), "第%s章" % raw_chapter),
174
+ })
175
+ return explicit if explicit else numeric_candidates
176
+
177
+
178
+ def identity(candidate: Dict[str, Any]) -> Tuple[Optional[int], str]:
179
+ return candidate.get("volume_number"), str(candidate["source_chapter"])
180
+
181
+
182
+ def drop_leading_toc(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
183
+ if len(candidates) < 6:
184
+ return candidates
185
+ max_prefix = min(len(candidates) // 2, 500)
186
+ for prefix_count in range(max_prefix, 2, -1):
187
+ prefix = candidates[:prefix_count]
188
+ if prefix[0]["line"] > 200:
189
+ continue
190
+ gaps = [prefix[index]["line"] - prefix[index - 1]["line"] for index in range(1, len(prefix))]
191
+ if not gaps or statistics.median(gaps) > 3:
192
+ continue
193
+ signature_length = min(8, prefix_count)
194
+ signature = [identity(item) for item in prefix[:signature_length]]
195
+ for body_start in range(prefix_count, len(candidates) - signature_length + 1):
196
+ body_signature = [identity(item) for item in candidates[body_start:body_start + signature_length]]
197
+ if signature == body_signature and candidates[body_start]["line"] - prefix[-1]["line"] > 3:
198
+ return candidates[body_start:]
199
+ return candidates
200
+
201
+
202
+ def duplicate_title_key(title: str) -> str:
203
+ return re.sub(r"\s*[((][^))]*[))]\s*$", "", title).strip()
204
+
205
+
206
+ def drop_adjacent_duplicate_headings(candidates: List[Dict[str, Any]], lines: Sequence[str]) -> List[Dict[str, Any]]:
207
+ deduplicated = [] # type: List[Dict[str, Any]]
208
+ for candidate in candidates:
209
+ if deduplicated:
210
+ previous = deduplicated[-1]
211
+ gap = candidate["line"] - previous["line"]
212
+ between = lines[previous["line"]:candidate["line"] - 1]
213
+ if (previous.get("heading_kind") == "numeric"
214
+ and candidate.get("heading_kind") == "numeric"
215
+ and identity(candidate) == identity(previous) and 1 <= gap <= 3
216
+ and all(not line.strip() for line in between)
217
+ and duplicate_title_key(candidate["title"]) == duplicate_title_key(previous["title"])):
218
+ continue
219
+ deduplicated.append(candidate)
220
+ return deduplicated
221
+
222
+
223
+ def validate_numbering(candidates: Sequence[Dict[str, Any]]) -> None:
224
+ previous = None # type: Optional[Dict[str, Any]]
225
+ for position, candidate in enumerate(candidates, start=1):
226
+ number = candidate.get("number_value")
227
+ # 第0章 behaves like a prologue. The first positive chapter may start at
228
+ # any number so excerpts such as 第五章 can be indexed.
229
+ if number is None or number == 0:
230
+ continue
231
+ if previous is None:
232
+ previous = candidate
233
+ continue
234
+ if candidate["volume_number"] == previous["volume_number"]:
235
+ expected = previous["number_value"] + 1
236
+ if number != expected:
237
+ kind = "chapter_number_duplicate" if number <= previous["number_value"] else "chapter_number_gap"
238
+ raise ValueError("%s:expected=%s:actual=%s:position=%s" % (kind, expected, number, position))
239
+ else:
240
+ expected = previous["number_value"] + 1
241
+ if number not in (1, expected):
242
+ raise ValueError("volume_chapter_number_invalid:expected=1_or_%s:actual=%s:position=%s" % (expected, number, position))
243
+ previous = candidate
244
+
245
+
246
+ def normalized_chapter_text(lines: Sequence[str], start_line: int, end_line: int) -> str:
247
+ selected = list(lines[start_line - 1:end_line])
248
+ while selected and not selected[-1].strip():
249
+ selected.pop()
250
+ return "\n".join(selected)
251
+
252
+
253
+ def fold_leading_specials(candidates: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]:
254
+ """Merge 楔子/序章/第0章… before the first numbered chapter into that chapter.
255
+
256
+ Only on request: it reproduces the numbering of older runs that did not
257
+ count a prologue, so their 第N章 files keep pointing at the same chapter.
258
+ """
259
+ first = next((index for index, item in enumerate(candidates)
260
+ if item.get("number_value") is not None and item["number_value"] >= 1), None)
261
+ if not first:
262
+ return candidates, []
263
+ folded = [str(item["source_chapter"]) if item["number_value"] is None else "第0章"
264
+ for item in candidates[:first]]
265
+ body = dict(candidates[first])
266
+ body["line"] = candidates[0]["line"]
267
+ return [body] + candidates[first + 1:], folded
268
+
269
+
270
+ def build_boundaries(text: str, locator_path: str, source_hash: str,
271
+ fold_prologue: bool = False) -> Tuple[List[Dict[str, Any]], List[str]]:
272
+ lines = physical_lines(text)
273
+ candidates = drop_adjacent_duplicate_headings(drop_leading_toc(heading_candidates(lines)), lines)
274
+ if not candidates:
275
+ raise ValueError("chapter_heading_not_found")
276
+ validate_numbering(candidates)
277
+ folded = [] # type: List[str]
278
+ if fold_prologue:
279
+ candidates, folded = fold_leading_specials(candidates)
280
+ rows = [] # type: List[Dict[str, Any]]
281
+ for index, candidate in enumerate(candidates):
282
+ start_line = candidate["line"]
283
+ end_line = candidates[index + 1]["line"] - 1 if index + 1 < len(candidates) else len(lines)
284
+ if end_line < start_line:
285
+ raise ValueError("chapter_boundary_invalid:position=%s" % (index + 1))
286
+ body = "\n".join(lines[start_line:end_line])
287
+ char_count = len(re.sub(r"\s+", "", body))
288
+ chapter_text = normalized_chapter_text(lines, start_line, end_line)
289
+ rows.append({
290
+ "chapter": index + 1, "source_chapter": candidate["source_chapter"],
291
+ "volume": candidate["volume"], "title": candidate["title"],
292
+ "start_line": start_line, "end_line": end_line, "char_count": char_count,
293
+ "source_locator": "%s:L%s-L%s" % (locator_path, start_line, end_line),
294
+ "status": "ok" if char_count else "empty",
295
+ "chapter_sha256": sha256(normalized_chapter_text(lines, start_line, end_line).encode("utf-8")),
296
+ "source_sha256": source_hash, "parser_version": PARSER_VERSION,
297
+ })
298
+ return rows, folded
299
+
300
+
301
+ def csv_payload(rows: Sequence[Dict[str, Any]]) -> bytes:
302
+ buffer = io.StringIO(newline="")
303
+ writer = csv.DictWriter(buffer, fieldnames=CSV_COLUMNS, lineterminator="\n")
304
+ writer.writeheader()
305
+ for row in rows:
306
+ writer.writerow({column: row[column] for column in CSV_COLUMNS})
307
+ return buffer.getvalue().encode("utf-8-sig")
308
+
309
+
310
+ def read_existing(path: Path) -> Tuple[bytes, List[Dict[str, str]]]:
311
+ data = path.read_bytes()
312
+ with io.StringIO(data.decode("utf-8-sig"), newline="") as handle:
313
+ reader = csv.DictReader(handle)
314
+ rows = list(reader)
315
+ fields = tuple(reader.fieldnames or ())
316
+ if not rows or "chapter" not in fields or "source_chapter" not in fields:
317
+ raise ValueError("existing_index_invalid")
318
+ return data, rows
319
+
320
+
321
+ def reusable_index(path: Path, source_hash: str, locator_path: str) -> Optional[Tuple[bytes, List[Dict[str, str]]]]:
322
+ try:
323
+ data, rows = read_existing(path)
324
+ with io.StringIO(data.decode("utf-8-sig"), newline="") as handle:
325
+ fields = tuple(csv.DictReader(handle).fieldnames or ())
326
+ except (OSError, UnicodeError, csv.Error, ValueError):
327
+ return None
328
+ if fields != CSV_COLUMNS:
329
+ return None
330
+ for expected, row in enumerate(rows, start=1):
331
+ try:
332
+ valid_numbers = (int(row["chapter"]) == expected and int(row["start_line"]) >= 1
333
+ and int(row["end_line"]) >= int(row["start_line"])
334
+ and int(row["char_count"]) >= 0)
335
+ except (KeyError, TypeError, ValueError):
336
+ return None
337
+ if not valid_numbers or not re.fullmatch(r"[0-9a-f]{64}", row.get("chapter_sha256", "")):
338
+ return None
339
+ if row.get("source_sha256") != source_hash or row.get("parser_version") != PARSER_VERSION:
340
+ return None
341
+ if row.get("status") not in {"ok", "empty"}:
342
+ return None
343
+ if not str(row.get("source_locator", "")).startswith(locator_path + ":L"):
344
+ return None
345
+ return data, rows
346
+
347
+
348
+ def mapping_signature(row: Dict[str, Any]) -> Tuple[str, str]:
349
+ return str(row.get("volume", "")), str(row.get("source_chapter", ""))
350
+
351
+
352
+ class RebuildMismatch(ValueError):
353
+ def __init__(self, code: str, author_message: str) -> None:
354
+ super().__init__(code)
355
+ self.author_message = author_message
356
+
357
+
358
+ REBUILD_MISMATCH_MESSAGE = (
359
+ "重建章节表时发现,%s。照这样继续,已拆好的章节会和原文错开,所以章节表没有改。请选一种:"
360
+ "① 换回上次拆文时用的那份原文再继续(推荐);② 换一个新目录,整本重新拆。"
361
+ )
362
+
363
+
364
+ def index_was_folded(old_rows: Sequence[Dict[str, Any]], new_rows: Sequence[Dict[str, Any]]) -> bool:
365
+ """True when the existing index merged a prologue into chapter one.
366
+
367
+ A folded index starts with a numbered chapter although the text opens with
368
+ 楔子/序章/第0章; a rebuild keeps that numbering instead of shifting it.
369
+ """
370
+ old_first = str(old_rows[0].get("source_chapter", "")) if old_rows else ""
371
+ new_first = str(new_rows[0].get("source_chapter", "")) if new_rows else ""
372
+ return (old_first.isdigit() and int(old_first) >= 1
373
+ and not (new_first.isdigit() and int(new_first) >= 1))
374
+
375
+
376
+ def compare_rebuild(old_rows: Sequence[Dict[str, Any]], new_rows: Sequence[Dict[str, Any]]) -> List[int]:
377
+ for position, old_row in enumerate(old_rows[:len(new_rows)]):
378
+ if mapping_signature(old_row) != mapping_signature(new_rows[position]):
379
+ raise RebuildMismatch(
380
+ "chapter_mapping_ambiguous:position=%s" % (position + 1),
381
+ REBUILD_MISMATCH_MESSAGE % ("第%s章和上次的章节表对不上(上次是「%s」,这次是「%s」)" % (
382
+ position + 1, old_row.get("title", ""), new_rows[position]["title"])))
383
+ if len(new_rows) < len(old_rows):
384
+ raise RebuildMismatch("chapter_mapping_ambiguous:index_would_shrink",
385
+ REBUILD_MISMATCH_MESSAGE % "这次认出的章节比上次少")
386
+ return [position for position, row in enumerate(new_rows, start=1)
387
+ if position > len(old_rows) or old_rows[position - 1].get("chapter_sha256") != row["chapter_sha256"]]
388
+
389
+
390
+ def parse_args() -> argparse.Namespace:
391
+ parser = argparse.ArgumentParser(description=__doc__)
392
+ parser.add_argument("--source", required=True, type=Path)
393
+ parser.add_argument("--output", required=True, type=Path)
394
+ parser.add_argument("--locator-path")
395
+ parser.add_argument("--rebuild", action="store_true")
396
+ parser.add_argument("--fold-prologue", action="store_true",
397
+ help="merge 楔子/序章/第0章 before the first numbered chapter into it")
398
+ return parser.parse_args()
399
+
400
+
401
+ def fail(error: str, author_message: Optional[str] = None) -> int:
402
+ payload = {"ok": False, "error": error} # type: Dict[str, Any]
403
+ if author_message:
404
+ payload["author_message"] = author_message
405
+ print(json.dumps(payload, ensure_ascii=False))
406
+ return 2
407
+
408
+
409
+ def main() -> int:
410
+ for stream in (sys.stdout, sys.stderr):
411
+ if hasattr(stream, "reconfigure"):
412
+ stream.reconfigure(encoding="utf-8")
413
+ args = parse_args()
414
+ try:
415
+ if not args.source.is_file():
416
+ raise ValueError("source_not_found:%s" % args.source)
417
+ raw = args.source.read_bytes()
418
+ source_hash = sha256(raw)
419
+ locator_path = (args.locator_path or "原文/%s" % args.source.name).replace("\\", "/")
420
+ if not locator_path.startswith("原文/") or ".." in locator_path.split("/"):
421
+ raise ValueError("locator_path_must_stay_under_original")
422
+ if args.output.exists() and not args.output.is_file():
423
+ raise ValueError("output_is_not_file:%s" % args.output)
424
+ if args.output.is_file() and not args.rebuild:
425
+ reusable = reusable_index(args.output, source_hash, locator_path)
426
+ if reusable is None:
427
+ return fail("existing_index_incompatible",
428
+ "原文和上次建章节表时不一样了(内容、文件名或识别规则变了)。确认原文就是要拆的版本后,再重建章节表。")
429
+ _, existing_rows = reusable
430
+ print(json.dumps({"ok": True, "reused": True, "parsed_source": False,
431
+ "chapters": len(existing_rows), "pending_chapters": []}, ensure_ascii=False))
432
+ return 0
433
+ text = decode_source(raw)
434
+ rows, folded = build_boundaries(text, locator_path, source_hash, args.fold_prologue)
435
+ pending = list(range(1, len(rows) + 1))
436
+ old_count = 0
437
+ if args.output.is_file():
438
+ _, old_rows = read_existing(args.output)
439
+ old_count = len(old_rows)
440
+ if not args.fold_prologue and index_was_folded(old_rows, rows):
441
+ rows, folded = build_boundaries(text, locator_path, source_hash, True)
442
+ pending = compare_rebuild(old_rows, rows)
443
+ else:
444
+ mapping = legacy_mapping_check(args.output.parent, rows)
445
+ if mapping:
446
+ return fail("chapter_mapping_ambiguous:%s:legacy_chapters=%s" % (
447
+ mapping["code"], ",".join(map(str, mapping["legacy_chapters"]))), str(mapping["author_message"]))
448
+ data = csv_payload(rows)
449
+ if not args.output.is_file() or args.output.read_bytes() != data:
450
+ atomic_write(args.output, data)
451
+ print(json.dumps({
452
+ "ok": True, "reused": False, "parsed_source": True, "rebuilt": bool(old_count),
453
+ "chapters": len(rows), "empty_chapters": sum(row["status"] == "empty" for row in rows),
454
+ "pending_chapters": pending, "unchanged_chapters": len(rows) - len(pending),
455
+ "source_sha256": source_hash, "parser_version": PARSER_VERSION,
456
+ "folded_into_first_chapter": folded,
457
+ }, ensure_ascii=False))
458
+ return 0
459
+ except RebuildMismatch as exc:
460
+ return fail(str(exc), exc.author_message)
461
+ except (OSError, UnicodeError, ValueError, csv.Error) as exc:
462
+ return fail(str(exc))
463
+
464
+
465
+ if __name__ == "__main__":
466
+ raise SystemExit(main())