#!/usr/bin/env python3 """Полигон, пакет-7 (Z4-добор): словарные статьи БКРС для списка терминов. Зачем. В фазе A «что говорит БКРС» осталось UNCLEAR по ВСЕМ терминам: страница считалась JS-рендеримой. На деле bkrs.info ставит одноразовый куки-челлендж (`ca=`) и перезагружает страницу; воспроизведя куку, получаем СЕРВЕРНЫЙ html со статьёй. Словарная улика — сильнейший класс из доступных нам (сильнее площадочной и сильнее аннотации), поэтому она закрывает больше строк §A4.3, чем весь остальной добор. Лицензия источника (со страницы «Скачать словарь», дамп 2026.06.10): «Базы можно использовать свободно в любых целях. Можно указать данный сайт как источник.» — то есть провенанс-класс «серый→зелёный при указании источника», и мы источник указываем. $0: сетевой GET, ни одной модели. Вежливость: пауза между запросами, один проход по списку. """ from __future__ import annotations import argparse import html import json import re import sys import time import urllib.parse import urllib.request from pathlib import Path UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36") BASE = "https://bkrs.info/slovo.php?ch=" def get(url: str, cookie: str | None = None, timeout: int = 30) -> str: req = urllib.request.Request(url, headers={"User-Agent": UA}) if cookie: req.add_header("Cookie", cookie) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode("utf-8", "replace") def challenge_cookie(sample_html: str) -> str | None: m = re.search(r'"(ca=[0-9a-f]+)', sample_html) return m.group(1) if m else None def extract_entry(page: str, term: str) -> str: """Тело словарной статьи: от заголовка-термина до служебного хвоста страницы.""" txt = html.unescape(re.sub(r"<(script|style)[^>]*>.*?", " ", page, flags=re.S)) txt = re.sub(r"<[^>]+>", "\n", txt) lines = [x.strip() for x in txt.split("\n") if x.strip()] # Статья начинается ПОСЛЕ последнего вхождения служебного пункта меню «Контакты»/«Войти» start = 0 for i, l in enumerate(lines): if l in ("Войти", "Контакты", "Тёмная тема"): start = i + 1 body = lines[start:] # и заканчивается перед подвалом stop = len(body) for i, l in enumerate(body): if l.startswith(("В начало", "Обсуждение", "Комментарии", "Ошибки", "Пожаловаться", "Добавить", "Правка", "Примеры", "Похожие")): stop = i break return " ".join(body[:stop]).strip() def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--terms", required=True, type=Path, help="файл: по термину в строке") ap.add_argument("--out", required=True, type=Path) ap.add_argument("--sleep", type=float, default=1.2) a = ap.parse_args() terms = [t.strip() for t in a.terms.read_text(encoding="utf-8").splitlines() if t.strip() and not t.startswith("#")] first = get(BASE + urllib.parse.quote(terms[0])) cookie = challenge_cookie(first) if not cookie: print("челлендж-кука не найдена — страница отдалась сразу", file=sys.stderr) rows = [] for i, t in enumerate(terms, 1): try: page = get(BASE + urllib.parse.quote(t), cookie) entry = extract_entry(page, t) except Exception as e: # сеть — не повод останавливать проход entry = f"__ОШИБКА__ {e}" found = bool(entry) and "__ОШИБКА__" not in entry and "Не найдено" not in entry rows.append({"src": t, "found": found, "entry": entry}) print(f"[{i}/{len(terms)}] {t}\t{'OK' if found else 'НЕТ'}\t{entry[:110]}", file=sys.stderr) time.sleep(a.sleep) a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") ok = sum(r["found"] for r in rows) print(f"\nитого: {ok}/{len(rows)} статей найдено → {a.out}", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())