133 lines
7.7 KiB
Python
133 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""narezka.py — вырезать диапазон глав книги в формат, который принимает движок.
|
||
|
||
Заведён 02.09 по находке проверки исполнимости: пре-рег требовал «срез исходника (диапазон глав,
|
||
файл, его sha256)», а СПОСОБА получить этот файл в проекте не было ни одного. Резалки не
|
||
существовало, и свежая сессия обязана была бы её изобрести — вместе с форматом.
|
||
|
||
ФОРМАТ, СНЯТЫЙ С ЭТАЛОНА (`~/books/gu-zhenren/coldrun-v16/guzhenren-ch1-10.gb18030.txt`,
|
||
проверено `file` и `od`): кодировка **GB18030**, переводы строки **CRLF**, первая строка файла —
|
||
ОБЯЗАТЕЛЬНО заголовок главы. Нарушение любого из трёх молча сдвигает нумерацию глав у движка.
|
||
|
||
⚠ **МИНА НУМЕРАЦИИ, названная до запуска.** Движок не умеет смещение глав: срез всегда нумеруется
|
||
с единицы. Вырезав главы 26–42, в базе и отчётах увидишь «главы 1–17». Соответствие держится
|
||
ТОЛЬКО этим файлом и строкой в пре-реге. Поэтому скрипт печатает обе нумерации и пишет их в
|
||
манифест рядом с sha256.
|
||
|
||
⚠ **ТОМА.** В книге нумерация глав СБРАСЫВАЕТСЯ по томам — `第二十六节` встречается несколько раз.
|
||
Поэтому режем по ПОРЯДКОВОМУ НОМЕРУ вхождения заголовка от начала файла, а не по тексту заголовка.
|
||
|
||
Использование:
|
||
python3 eval/dovodka/narezka.py --from 26 --to 42 --out <путь>.gb18030.txt
|
||
python3 eval/dovodka/narezka.py --selftest
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
SRC = Path.home() / "books/gu-zhenren/guzhenren-utf8.txt"
|
||
HEAD_RE = re.compile(r"^第[^\n]{1,20}节")
|
||
|
||
|
||
def split_chapters(text):
|
||
"""[(порядковый номер от 1, заголовок, тело с заголовком)] — по порядку вхождения, не по тексту."""
|
||
lines = text.split("\n")
|
||
starts = [i for i, ln in enumerate(lines) if HEAD_RE.match(ln)]
|
||
out = []
|
||
for k, i in enumerate(starts):
|
||
j = starts[k + 1] if k + 1 < len(starts) else len(lines)
|
||
out.append((k + 1, lines[i], "\n".join(lines[i:j])))
|
||
return out
|
||
|
||
|
||
def cut(src_path, first, last, out_path):
|
||
text = src_path.read_text(encoding="utf-8")
|
||
chapters = split_chapters(text)
|
||
if not chapters:
|
||
print("⛔ ОТКАЗ: в исходнике не найдено ни одного заголовка главы — проверь путь и кодировку")
|
||
return 2
|
||
if last > len(chapters):
|
||
print(f"⛔ ОТКАЗ: запрошена глава {last}, а в исходнике их {len(chapters)}")
|
||
return 2
|
||
picked = chapters[first - 1 : last]
|
||
body = "\n".join(ch[2] for ch in picked)
|
||
if not HEAD_RE.match(body.split("\n")[0]):
|
||
print("⛔ ОТКАЗ: первая строка среза не заголовок — движок сдвинет нумерацию")
|
||
return 2
|
||
data = body.replace("\r\n", "\n").replace("\n", "\r\n").encode("gb18030", errors="strict")
|
||
out_path.write_bytes(data)
|
||
sha = hashlib.sha256(data).hexdigest()
|
||
|
||
print(f"срез: главы книги {first}–{last} → в файле пронумеруются 1–{len(picked)}")
|
||
print(f" файл : {out_path}")
|
||
print(f" байт : {len(data)}")
|
||
print(f" sha256 : {sha}")
|
||
print(f" заголовков в срезе: {sum(1 for ch in picked if HEAD_RE.match(ch[1]))}")
|
||
print(f" первая строка: {picked[0][1][:60]}")
|
||
print(f" последняя глава: {picked[-1][1][:60]}")
|
||
print()
|
||
print("⚠ В пре-рег занести ИМЕННО ЭТУ пару: диапазон книги и sha256 файла.")
|
||
print(f" главы книги {first}–{last} ≡ главы среза 1–{len(picked)}, sha256 {sha}")
|
||
return 0
|
||
|
||
|
||
def selftest():
|
||
"""Гейты на самом скрипте: без них резалка может молча отдать не тот формат."""
|
||
ok = True
|
||
|
||
def check(name, cond):
|
||
nonlocal ok
|
||
print((" ✓ " if cond else " ✗ ") + name)
|
||
ok = ok and cond
|
||
|
||
print("СЕЛФТЕСТ narezka.py")
|
||
check("исходник на месте", SRC.exists())
|
||
if SRC.exists():
|
||
chapters = split_chapters(SRC.read_text(encoding="utf-8"))
|
||
check(f"глав в исходнике > 2000 (найдено {len(chapters)})", len(chapters) > 2000)
|
||
# ⚠ Тест первой редакции проверял повтор ЦЕЛОЙ строки заголовка и был красным: строки
|
||
# уникальны, потому что несут НАЗВАНИЕ главы («第一节:纵身亡魔心仍不悔»). Повторяется
|
||
# НОМЕР — вот его и проверяем, ради этого и режем по порядку вхождения.
|
||
nums = [re.match(r"^第[^::]{1,12}节", c[1]).group(0) for c in chapters
|
||
if re.match(r"^第[^::]{1,12}节", c[1])]
|
||
check(f"НОМЕР главы в исходнике повторяется (тома): {len(nums)} заголовков, "
|
||
f"{len(set(nums))} различных номеров", len(set(nums)) < len(nums))
|
||
body = "\n".join(c[2] for c in chapters[25:42])
|
||
check("первая строка среза 26–42 — заголовок", bool(HEAD_RE.match(body.split("\n")[0])))
|
||
try:
|
||
enc = body.replace("\n", "\r\n").encode("gb18030")
|
||
check("срез кодируется в GB18030 без потерь", True)
|
||
check("CRLF на месте", b"\r\n" in enc)
|
||
except UnicodeEncodeError as e:
|
||
check(f"срез кодируется в GB18030 без потерь — ОШИБКА {e}", False)
|
||
ref = Path.home() / "books/gu-zhenren/coldrun-v16/guzhenren-ch1-10.gb18030.txt"
|
||
if ref.exists():
|
||
raw = ref.read_bytes()
|
||
check("эталон холодного прогона тоже CRLF", b"\r\n" in raw)
|
||
head = raw[:60].decode("gb18030", errors="replace")
|
||
check("эталон начинается с заголовка", bool(HEAD_RE.match(head)))
|
||
print("\n" + ("РЕЗАЛКА ГОДНА" if ok else "⛔ РЕЗАЛКА НЕ ГОДНА"))
|
||
return 0 if ok else 1
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="вырезать диапазон глав в формат движка")
|
||
ap.add_argument("--from", dest="first", type=int, help="первая глава книги (сквозной номер)")
|
||
ap.add_argument("--to", dest="last", type=int, help="последняя глава книги включительно")
|
||
ap.add_argument("--out", type=Path, help="кудаписать срез")
|
||
ap.add_argument("--src", type=Path, default=SRC)
|
||
ap.add_argument("--selftest", action="store_true")
|
||
a = ap.parse_args()
|
||
if a.selftest:
|
||
return selftest()
|
||
if not (a.first and a.last and a.out):
|
||
ap.error("нужны --from, --to и --out (или --selftest)")
|
||
return cut(a.src, a.first, a.last, a.out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|