#!/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__)