322 lines
18 KiB
Python
322 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""Ф0.2 — МАТЕРИАЛ ЭКСП-22: невиданный zh-срез + ось переноса en. $0 (кроме проб контаминации).
|
||
|
||
Почему новая книга. Эксп-21 мерил главный zh-контраст на 蛊真人, которую собственная проба
|
||
оценила как узнанную моделью 4/5 — наравне с положительным контролем. Внутреннюю валидность
|
||
сравнения армов это не рушит (все армы видят один материал), но перенос ВЕЛИЧИН — рушит, и
|
||
строка 55 бэклога осталась открытой. Промт эксп-22 заказывает срез повторно и жёстко.
|
||
|
||
Метод поиска (объявляется здесь, потому что «нашёл в вебе» без метода не проверяемо):
|
||
1. Площадка выбрана по критерию «текст главы отдаётся сервером» — проверено curl'ом:
|
||
七猫/番茄 отдают Vue-каркас без текста, 纵横中文网 (zongheng.com, Baidu) отдаёт
|
||
`<div class="content">` с полной главой. Пиратские агрегаторы не рассматривались.
|
||
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'<div class="content"[^>]*>(.*?)</div>', html, re.S)
|
||
if not m:
|
||
return "", ""
|
||
body = re.sub(r"<br\s*/?>", "\n", m.group(1))
|
||
body = re.sub(r"</p>", "\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"<title>\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__)
|