@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.
- package/README.md +12 -12
- package/lib/client.js +204 -72
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +84 -74
- package/lib/drama/skills/short-drama/SKILL.md +2 -2
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +14 -0
- package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
- package/lib/drama/skills/short-drama-edit/SKILL.md +27 -29
- package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +2 -2
- package/lib/drama/skills/short-drama-edit/references/cut-craft.md +24 -67
- package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +27 -56
- package/lib/drama/skills/short-drama-edit/references/generated-footage.md +37 -79
- package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +39 -92
- package/lib/drama/skills/short-drama-edit/references/stage-contract.md +12 -16
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +126 -11
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +64 -0
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
- package/lib/drama/skills/short-drama-review/SKILL.md +2 -3
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
- package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +9 -35
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +10 -6
- package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +58 -21
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +10 -9
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
- package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +12 -38
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +4 -7
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +8 -22
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -19
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +2 -2
- package/lib/drama/skills/short-drama-write/SKILL.md +2 -4
- package/lib/index.js +289 -91
- package/lib/index.js.map +4 -4
- package/lib/novel-to-game/README.md +299 -59
- package/lib/novel-to-game/README_ZH.md +299 -56
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
- package/lib/novel-to-game/manifest.json +77 -87
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
- package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
- package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
- package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
- package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
- package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
- package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
- package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
- package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
- package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
- package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
- package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
- package/lib/oh-story/manifest.json +230 -185
- package/lib/oh-story/roles/chapter-extractor.md +177 -237
- package/lib/oh-story/roles/consistency-checker.md +4 -4
- package/lib/oh-story/roles/narrative-writer.md +24 -25
- package/lib/oh-story/roles/story-architect.md +5 -5
- package/lib/oh-story/roles/story-explorer.md +11 -11
- package/lib/oh-story/skills/story/SKILL.md +13 -7
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story/references/author-memory.md +54 -24
- package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
- package/lib/oh-story/skills/story-deslop/SKILL.md +40 -53
- package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +11 -10
- package/lib/oh-story/skills/story-deslop/references/author-memory.md +54 -24
- package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +1 -1
- package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-import/SKILL.md +77 -81
- package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
- package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
- package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
- package/lib/oh-story/skills/story-import/references/tracking-transaction.md +8 -6
- package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +34 -16
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
- package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
- package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
- package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
- package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
- package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
- package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
- package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
- package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
- package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
- package/lib/oh-story/skills/story-long-write/SKILL.md +38 -29
- package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +11 -10
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +25 -20
- package/lib/oh-story/skills/story-long-write/references/author-memory.md +54 -24
- package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
- package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +4 -13
- package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/project-files.md +9 -7
- package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
- package/lib/oh-story/skills/story-long-write/references/reference-index.md +1 -2
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +8 -6
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +86 -115
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -120
- package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +66 -68
- package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +4 -12
- package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +139 -18
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +34 -16
- package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +5 -3
- package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-review/SKILL.md +68 -85
- package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +11 -10
- package/lib/oh-story/skills/story-review/references/author-memory.md +54 -24
- package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-review/references/review-quality.md +4 -10
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +8 -6
- package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-setup/SKILL.md +50 -42
- package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +11 -10
- package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +4 -12
- package/lib/oh-story/skills/story-setup/references/diagnostics.md +1 -1
- package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
- package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
- package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
- package/lib/oh-story/skills/story-short-write/SKILL.md +6 -147
- package/lib/oh-story/skills/story-short-write/references/author-memory.md +54 -24
- package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-short-write/references/reference-index.md +2 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +3 -2
- package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
- package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +19 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -0
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +16 -0
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts +5 -3
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/native-hooks.d.ts +52 -0
- package/lib/types/native-hooks.d.ts.map +1 -1
- package/lib/types/role-identity.d.ts +17 -0
- package/lib/types/role-identity.d.ts.map +1 -0
- package/lib/types/role-tool.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/package.json +45 -45
- package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
- package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
- package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
- 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())
|