#!/usr/bin/env python3
"""Ф0.2 — МАТЕРИАЛ ЭКСП-22: невиданный zh-срез + ось переноса en. $0 (кроме проб контаминации).
Почему новая книга. Эксп-21 мерил главный zh-контраст на 蛊真人, которую собственная проба
оценила как узнанную моделью 4/5 — наравне с положительным контролем. Внутреннюю валидность
сравнения армов это не рушит (все армы видят один материал), но перенос ВЕЛИЧИН — рушит, и
строка 55 бэклога осталась открытой. Промт эксп-22 заказывает срез повторно и жёстко.
Метод поиска (объявляется здесь, потому что «нашёл в вебе» без метода не проверяемо):
1. Площадка выбрана по критерию «текст главы отдаётся сервером» — проверено curl'ом:
七猫/番茄 отдают Vue-каркас без текста, 纵横中文网 (zongheng.com, Baidu) отдаёт
`
` с полной главой. Пиратские агрегаторы не рассматривались.
2. Из блока новинок главной страницы взяты 17 книг, у каждой снято поле `首发时间`
(дата ПЕРВОЙ публикации, печатается самой площадкой на странице главы).
3. Отобрана книга с `首发时间` в июле 2026 — то есть заведомо ПОСЛЕ любого правдоподобного
среза обучения — и с жанром, совпадающим с продуктовым (东方玄幻 = то же семейство, что
蛊真人), и с объёмом, которого хватает на 16 единиц с запасом на дедуп.
4. Непереведённость проверяется поиском по ru-площадкам (`--check-ru`), контаминация —
пробой узнавания/клоуза (`contam.py`), порог эксп-21: узнавание ≥3/5 = материал непригоден.
⚠ Текст книги в репозиторий и в отчёт НЕ попадает: здесь только метрики и хеши.
"""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
import sys
import time
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
OUT = Path.home() / "books" / "tenant-panel"
SRC = OUT / "zh-source"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
# Книга среза. `first_chapter` снят со страницы книги, дальше идём по ссылке «следующая глава» —
# нумерация id не гарантирована последовательной, поэтому обход по ссылкам, а не по арифметике.
BOOK = dict(
site="zongheng.com", book_id=1569059, first_chapter=94741845,
title="阴阳天帝诀", author="", genre="玄幻奇幻 / 东方玄幻",
first_pub="2026-07-13", # поле 首发时间 со страницы главы
url="https://www.zongheng.com/detail/1569059",
)
# Резерв на случай провала пробы контаминации у основной книги (объявляется ДО замера, чтобы
# выбор не оказался подгонкой под результат).
BACKUP = dict(
site="zongheng.com", book_id=1573203, first_chapter=0,
title="吞仙葫", genre="玄幻奇幻 / 东方玄幻", first_pub="2026-07-23",
url="https://www.zongheng.com/detail/1573203",
)
TARGET_CHARS = 1700 # медиана источника единицы эксп-21 — 1679 знаков
MIN_CHARS, MAX_CHARS = 1100, 2600
N_ZH, N_EN = 16, 6
# ⚠ ГЕЙТ ToS, объявлен ДО отбора единиц. Срез — взрослая вебновелла: слой насилия плотный (это
# рабочий случай продукта и его меряют все прежние паки), но у ДВУХ якорных провайдеров панели
# лейбл `sexually-explicit` в договоре стоит FORBIDDEN (quirks 00, таблица прав: deepseek §3.4(5),
# zai п. g) «Submitting … sexually explicit»). Отправлять им эротически плотную главу — нарушение
# ПРАВА, а не вопрос поведения модели. Поэтому из пула единиц снимаются ДВЕ главы с наибольшей
# плотностью эротических маркеров (верхний дециль по 18 главам); остаток и есть материал.
# Честная граница: после снятия остаётся ровно 16 глав на 16 единиц, то есть выбор «из середины
# длин» вырождается в «все оставшиеся». Это ограничение, а не замысел.
SE_MARKERS = ("炉鼎", "双修", "凌辱", "玉体", "娇躯", "呻吟", "裸", "私处", "交合",
"情欲", "欲望", "媚", "胸")
SE_DROP_TOP = 2
def _get(url: str) -> str:
r = subprocess.run(["curl", "-s", "--compressed", "-m", "60", "-A", UA, url], # noqa: S603, S607
capture_output=True, text=True)
return r.stdout
def _get_polite(url: str, tries: int = 4) -> str:
"""Площадка ставит капчу на быстрый обход. Отступаем и ждём, а не долбим: это чужой сервер.
Поймано исполнением: при паузе 0.4 с обход умер на 19-й главе редиректом на `/captcha`.
"""
for k in range(tries):
html = _get(url)
if "captcha" not in html[:200].lower():
return html
wait = 20 * (k + 1)
print(f" капча — пауза {wait} с")
time.sleep(wait)
return ""
def _chapter_text(html: str) -> tuple[str, str]:
"""(заголовок, текст) главы. Пусто — страница не глава."""
m = re.search(r'
]*>(.*?)
', html, re.S)
if not m:
return "", ""
body = re.sub(r"
", "\n", m.group(1))
body = re.sub(r"", "\n", body)
body = re.sub(r"<[^>]+>", "", body)
body = re.sub(r" ", " ", body)
lines = [ln.strip() for ln in body.split("\n")]
t = re.sub(r"\n{2,}", "\n", "\n".join(ln for ln in lines if ln)).strip()
h = re.search(r"
\s*([^<_]+)", html)
return (h.group(1).strip() if h else ""), t
def crawl(limit: int = 200) -> None:
"""Скачать главы по цепочке «следующая глава». Идемпотентно: скачанное не перекачивается."""
SRC.mkdir(parents=True, exist_ok=True)
cid, n = str(BOOK["first_chapter"]), 0
while cid and cid != "0" and n < limit:
f = SRC / f"{n + 1:04d}-{cid}.json"
if f.exists():
nxt = json.loads(f.read_text(encoding="utf-8"))["next"]
else:
html = _get_polite(f"https://read.zongheng.com/chapter/{BOOK['book_id']}/{cid}.html")
title, text = _chapter_text(html)
if not text:
print(f" глава {cid}: текста нет — стоп")
break
m = re.search(rf'href="/chapter/{BOOK["book_id"]}/(\d+)\.html\?"[^>]*>\s*下一章', html)
nxt = m.group(1) if m else "0"
f.write_text(json.dumps(dict(idx=n + 1, cid=cid, title=title, next=nxt, text=text),
ensure_ascii=False), encoding="utf-8")
time.sleep(3.0)
n += 1
cid = nxt
tot = sum(len(json.loads(p.read_text(encoding="utf-8"))["text"]) for p in SRC.glob("*.json"))
print(f"глав на диске {len(list(SRC.glob('*.json')))} · знаков {tot:,}")
def chapters(all_: bool = False) -> list[dict]:
"""Главы среза. По умолчанию — БЕЗ снятых гейтом ToS (см. SE_DROP_TOP)."""
chs = [json.loads(p.read_text(encoding="utf-8")) for p in sorted(SRC.glob("*.json"))]
if all_ or not chs:
return chs
return [c for c in chs if c["idx"] not in se_dropped(chs)]
def se_density(text: str) -> int:
return sum(text.count(k) for k in SE_MARKERS)
def se_dropped(chs: list[dict] | None = None) -> set[int]:
"""Индексы глав, снятых гейтом прав: верхние SE_DROP_TOP по плотности маркеров."""
chs = chs if chs is not None else chapters(all_=True)
order = sorted(chs, key=lambda c: (-se_density(c["text"]) / max(1, len(c["text"])), c["idx"]))
return {c["idx"] for c in order[:SE_DROP_TOP]}
def uid_of(source: str) -> str:
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10] # noqa: S324
_RE_CHAPTER = re.compile(r"^\s*第[零一二两三四五六七八九十百千\d]+[节章回]\s*[::]?\s*")
def _dedup_sig(source: str) -> str:
"""Подпись близких дублей — как в эксп-21: снимаются заголовок главы и все пробелы."""
s = _RE_CHAPTER.sub("", source.strip())
return hashlib.sha1("".join(s.split()).encode("utf-8")).hexdigest() # noqa: S324
def chunks() -> list[dict]:
"""Абзацные чанки ~1700 знаков. Абзац не рвётся: боевая сегментация тоже держит абзац.
⚠ Хвост главы КОРОЧЕ порога не выбрасывается, а прирастает к предыдущему чанку той же главы.
Первая версия его роняла и теряла ~30% текста книги — то есть меняла материал молча.
"""
out = []
for ch in chapters():
made: list[dict] = []
buf: list[str] = []
size = 0
for para in ch["text"].split("\n"):
para = para.strip()
if not para:
continue
buf.append(para)
size += len(para)
if size >= TARGET_CHARS:
made.append(dict(text="\n".join(buf), chapter=ch["idx"]))
buf, size = [], 0
if size:
tail = "\n".join(buf)
if size >= MIN_CHARS or not made:
made.append(dict(text=tail, chapter=ch["idx"]))
elif len(made[-1]["text"]) + size <= MAX_CHARS:
made[-1]["text"] += "\n" + tail
else:
made.append(dict(text=tail, chapter=ch["idx"]))
out += made
return [c for c in out if MIN_CHARS <= len(c["text"]) <= MAX_CHARS]
def _pinned_uids() -> list[str]:
"""uid единиц, ЗАФИКСИРОВАННЫЕ манифестом пака при отборе."""
f = OUT / "manifest.json"
if not f.exists():
return []
try:
return list(json.loads(f.read_text(encoding="utf-8")).get("zh", {}).get("uids", []))
except (ValueError, AttributeError):
return []
def units_zh(n: int = N_ZH) -> list[dict]:
"""N единиц: дедуп по подписи, ключ — хеш источника, отбор из СЕРЕДИНЫ длин (эксп-21 §0).
Из середины — чтобы единицы были боевого размера и при этом не выбросами; ключ сортировки
(длина, uid), а не одна длина: при равной длине порядок иначе решает обход множества.
⚠ ПРИКОЛОТО К МАНИФЕСТУ. Отбор «из середины длин» считается от того, что лежит на диске, и
поймано это исполнением: доскачка глав для следующего пака СДВИНУЛА единицы уже сданного
эксп-22 (главы 3–18 превратились в 2,5,9…41), то есть его гейты стали считать по другому
материалу, чем куплен. Детерминизм прогона — цель 6 канона владельца, и «воспроизводимо, пока
никто не трогал каталог» ей не отвечает. Поэтому если манифест отбора существует, единицы
берутся ИМЕННО ЕГО uid; правило отбора применяется только когда манифеста ещё нет.
"""
pins = _pinned_uids()
if pins:
byid = {}
for c in chunks():
byid.setdefault(uid_of(c["text"]),
dict(source=c["text"], uid=uid_of(c["text"]), chapter=c["chapter"]))
miss = [u for u in pins if u not in byid]
if miss:
raise SystemExit(f"единицы манифеста не находятся в материале: {miss}")
return [byid[u] for u in pins]
uniq: dict[str, dict] = {}
seen: set[str] = set()
for c in chunks():
sig = _dedup_sig(c["text"])
if sig in seen:
continue
seen.add(sig)
uniq.setdefault(uid_of(c["text"]), dict(source=c["text"], uid=uid_of(c["text"]),
chapter=c["chapter"]))
keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k))
lo = (len(keys) - n) // 2
return [uniq[k] for k in keys[lo:lo + n]]
# ⚠ Единица `129b73ad5a` выброшена по §8 эксп-21: это выходные данные и библиография, а не проза.
EN_DROP = {"129b73ad5a"}
def units_en(n: int = N_EN) -> list[dict]:
"""Ось переноса: единицы пары en из эксп-21, минус выброшенная библиография.
Берутся ПЕРВЫЕ n по стабильному порядку (страта, длина, uid) — набор объявлен здесь, чтобы
«≥6» не превратилось в выбор после замера.
"""
import pair_en as PE # noqa: PLC0415
us = [u for u in PE.units() if u["uid"] not in EN_DROP]
us.sort(key=lambda u: (u["stratum"], len(u["source"]), u["uid"]))
fr = [u for u in us if u["stratum"] == "fr"][: n // 2]
pl = [u for u in us if u["stratum"] == "plain"][: n - n // 2]
return fr + pl
def manifest() -> dict:
zs, es = units_zh(), units_en()
return dict(
book={k: v for k, v in BOOK.items() if k != "first_chapter"},
zh=dict(n=len(zs), chars=[len(u["source"]) for u in zs],
uids=[u["uid"] for u in zs], chapters=[u["chapter"] for u in zs],
sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in zs]),
en=dict(n=len(es), uids=[u["uid"] for u in es],
strata=[u["stratum"] for u in es],
sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in es]),
)
def _pairwise_max_sim(us: list[dict]) -> float:
"""Максимальная попарная близость отобранных единиц — контроль дедупа (эксп-21 дал 0.060).
⚠ `ratio`, а не `quick_ratio`: последний игнорирует порядок и на CJK даёт 0.6+ у заведомо
разных глав (общие служебные иероглифы), то есть с числом эксп-21 несравним.
"""
import difflib # noqa: PLC0415
best = 0.0
for i, a in enumerate(us):
for b in us[i + 1:]:
best = max(best, difflib.SequenceMatcher(None, a["source"], b["source"]).ratio())
return best
def cmd_units() -> None:
zs, es = units_zh(), units_en()
allch = chapters(all_=True)
drop = se_dropped(allch)
print(f"КНИГА {BOOK['title']} · {BOOK['genre']} · 首发 {BOOK['first_pub']} · {BOOK['url']}")
print(f"глав скачано {len(allch)} · снято гейтом прав {sorted(drop)} "
f"(плотность эротических маркеров: "
f"{', '.join(f'гл.{c["idx"]}={se_density(c["text"])}' for c in allch if c['idx'] in drop)})")
print(f"главы в пуле {len(chapters())} · чанков {len(chunks())}")
print(f"\nzh-единиц {len(zs)} · знаков "
f"{min(len(u['source']) for u in zs)}…{max(len(u['source']) for u in zs)} "
f"(медиана {sorted(len(u['source']) for u in zs)[len(zs) // 2]})")
print(f"глав-источников {len(set(u['chapter'] for u in zs))} "
f"· макс. попарная близость {_pairwise_max_sim(zs):.3f}")
for u in zs:
print(f" {u['uid']} гл.{u['chapter']:3d} {len(u['source']):5d} зн")
print(f"\nen-единиц {len(es)}: " + " · ".join(f"{u['uid']}({u['stratum']})" for u in es))
(OUT / "manifest.json").write_text(json.dumps(manifest(), ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"\nманифест → {OUT / 'manifest.json'} (хеши единиц — во фриз пре-рега)")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
a = sys.argv[1:] or ["--units"]
if a[0] == "--crawl":
crawl()
elif a[0] == "--units":
cmd_units()
else:
print(__doc__)