#!/usr/bin/env python3
"""Единый экран семантики: строка на каждый одобренный ключ ядра.

Черновик 30.09.2026, написан без запуска (оболочка сессии мертва: исчерпана
квота на /tmp, разбор в материалы/разбор-видео-2026-09-30/план-сборки.md).
Перед первым применением - сухой прогон:
    python3 панель/данные/обновить-ключи.py --сухой

Зачем. Задача руководителя из видео 30.09.2026: выработка семантики должна
быть видна в одном месте - «ключ → статья → позиция», а не «отдельно ядро,
отдельно вышедшие статьи». Этот скрипт собирает единственный источник для
всего экрана: и для таблицы ключей в SEO → Аналитика, и для плиток
выработки, и для среза «Порядок работы» во вкладке ядра. Раньше каждый
из них считал бы ключи сам, и они разошлись бы, как разошлись витрина
и панель в сентябре.

Главное правило, из-за которого всё это и делается (решение по ревью плана
30.09.2026): **ключ считается закрытым только если его фраза реально вошла
в текст** - статьи портала или опубликованного материала площадки. Иначе
одна статья кластера А1 «закрыла» бы 59 ключей разом, и плитка врала бы
ровно так, как руководитель жаловался на «265 ключевиков». Ключ, который
в текст не вошёл, попадает в один из двух рабочих списков - «дописать
в статью» или «нужна своя статья», - и это разные задачи, см. функцию
«состояние».

Что считается по каждому ключу:
  тема словами, код кластера, приоритет, показы Wordstat, интент;
  статья портала кластера и вошёл ли ключ в её текст (и куда именно);
  индекс Яндекса и Google по этой странице;
  позиция Google - по парам «страница-запрос» из Search Console;
  позиция Яндекса - из популярных запросов хоста, с пометкой «по хосту»:
    связку «страница-запрос» Вебмастер не отдаёт;
  площадки - сколько опубликованных материалов взяли ключ в шапку
    и лучшая позиция площадки в Яндексе (первая сотня, Google не меряем);
  раскрытие статьи - запросов всего по данным Google, из них ключей ядра.

Чего здесь намеренно нет: своего сравнения фразы с текстом (оно одно на всех,
в семантика/ключ_в_тексте.py), своего определения индексации и своего счёта
материалов площадок - эти функции берутся из обновить-расход-ядра.py через
importlib, чтобы два экрана не считали по-разному.

Зависимость от шага 3 плана: поле «ключи» в реестре текстов. Пока реестр его
не читает, скрипт берёт ключи прямо из шапки материала - поэтому его можно
прогнать и проверить до правки реестра, а после правки он сам начнёт брать
готовое поле.

Запуск:
  python3 панель/данные/обновить-ключи.py --сухой   # показать сводку и сверки
  python3 панель/данные/обновить-ключи.py           # записать keys.json
Место в панель/сборщики.sh: после обновить-расход-ядра.py и обновить-позиции.py,
до обновить-карточки-статей.py и обновить-фолбэк.py (фолбэк вшивает копию
данных в страницу, поэтому он последний).
"""

import importlib.util
import json
import re
import sys
from datetime import date
from pathlib import Path

ДАННЫЕ = Path(__file__).resolve().parent
КОРЕНЬ = ДАННЫЕ.parent.parent
КЛЮЧИ = ДАННЫЕ / "keys.json"
СЕМАНТИКА = ДАННЫЕ / "semantics.json"
ПОЗИЦИИ = ДАННЫЕ / "positions.json"
ЯДРО = КОРЕНЬ / "семантика" / "ядро.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ВЕБМАСТЕР = КОРЕНЬ / "завод" / "вебмастер" / "история"
СЕГОДНЯ = date.today().isoformat()

# Сколько пар «страница-запрос» показывать в раскрытии строки. Все не кладём:
# keys.json вшивается в страницу фолбэком, и полный список её раздул бы.
СПИСОК = 10


def _модуль(путь, имя):
    спец = importlib.util.spec_from_file_location(имя, путь)
    м = importlib.util.module_from_spec(спец)
    спец.loader.exec_module(м)
    return м


_правило = _модуль(КОРЕНЬ / "семантика" / "ключ_в_тексте.py", "ключ_в_тексте")
_расход = _модуль(ДАННЫЕ / "обновить-расход-ядра.py", "расход_ядра")


def норма(фраза):
    """Одна форма записи запроса: регистр и «ё» не считаются различием."""
    т = (фраза or "").strip().lower().replace("ё", "е")
    return re.sub(r"\s+", " ", т)


def адрес_норма(адрес):
    а = re.sub(r"^https?://", "", (адрес or "").strip().lower())
    а = re.sub(r"^www\.", "", а)
    return а.rstrip("/").split("?")[0]


def слаг_из(адрес):
    return str(адрес or "").rstrip("/").rsplit("/", 1)[-1]


# --- источники --------------------------------------------------------------

def свежий_вебмастер():
    """Последний снимок Вебмастера: запросы хоста с позицией (до 500 за 14 дней)."""
    снимки = sorted(ВЕБМАСТЕР.glob("*.json")) if ВЕБМАСТЕР.exists() else []
    for путь in reversed(снимки):
        try:
            д = json.loads(путь.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            continue
        if д.get("строки") is not None:
            return д
    return {}


def запросы_хоста(снимок):
    """{запрос: {позиция, показы, клики}} из снимка Вебмастера."""
    итог = {}
    for с in (снимок or {}).get("строки") or []:
        имя = норма(с.get("запрос"))
        если = итог.get(имя)
        поз = с.get("позиция")
        # одна и та же фраза может прийти дважды: берём лучшую позицию
        if если is None or (поз is not None and (если["позиция"] is None or поз < если["позиция"])):
            итог[имя] = {"позиция": поз, "показы": с.get("показы"), "клики": с.get("клики")}
    return итог


def тексты_реестра():
    """Статьи портала и материалы площадок из реестра, с ключами из шапок."""
    д = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    портал, площадки = {}, []
    for т in д.get("тексты") or []:
        статус = str(т.get("статус") or "")
        нижний = статус.lower()
        if т.get("вид") == "внешняя площадка":
            if "опубликован" not in нижний or "не видно" in нижний:
                continue
            if str(т.get("видно_читателю") or "да").strip().lower() == "нет":
                continue
            площадки.append({
                "файл": т.get("файл") or "",
                "кластер": (т.get("кластер") or "").strip(),
                "площадка": т.get("площадка") or "",
                "ссылка": т.get("ссылка") or "",
                "заголовок": т.get("заголовок") or "",
                "ключи": ключи_текста(т),
            })
            continue
        if "опубликовано на портале" not in нижний:
            continue
        м = re.search(r"(\d{4}-\d{2}-\d{2})", статус)
        кластер = (т.get("кластер") or "").strip()
        запись = {
            "файл": т.get("файл") or "",
            "слаг": (т.get("слаг") or "").strip("/"),
            "заголовок": т.get("заголовок") or "",
            # В реестре адрес статьи портала часто пуст, а слаг есть всегда:
            # адрес собираем по схеме WordPress-портала (проверено 30.09.2026,
            # все статьи под ключами отвечают 200). Нужен меткам доски
            # «Порядок работы», по которым руководитель переходит на статью.
            "ссылка": т.get("ссылка") or (
                f"https://frantlas.ru/statya/{(т.get('слаг') or '').strip('/')}/"
                if (т.get("слаг") or "").strip("/") else ""),
            "вышла": м.group(1) if м else None,
            "кластер": кластер,
        }
        # у кластера может быть несколько статей: держим первую по дате выхода
        если = портал.get(кластер)
        if если is None or (запись["вышла"] or "9999") < (если["вышла"] or "9999"):
            портал[кластер] = запись
    return портал, площадки


def ключи_текста(т):
    """Ключи материала: из реестра, если он их уже читает (шаг 3 плана),
    иначе прямо из шапки файла - тогда скрипт работает и до правки реестра."""
    зн = т.get("ключи")
    if isinstance(зн, list):
        return [str(к).strip() for к in зн if str(к).strip()]
    if isinstance(зн, str) and зн.strip():
        return [к.strip() for к in зн.split(",") if к.strip()]
    путь = КОРЕНЬ / (т.get("файл") or "")
    if not путь.is_file():
        return []
    шапка = re.match(r"^---\n(.*?)\n---\n", путь.read_text(encoding="utf-8"), flags=re.S)
    if not шапка:
        return []
    for строка in шапка.group(1).splitlines():
        if строка.startswith("ключи:"):
            return [к.strip() for к in строка.split(":", 1)[1].split(",") if к.strip()]
    return []


_тела = {}


def тело(файл):
    """Текст без frontmatter и служебной шапки «> ...», с кэшем."""
    if файл not in _тела:
        путь = КОРЕНЬ / файл
        сырое = путь.read_text(encoding="utf-8") if путь.is_file() else ""
        _тела[файл] = {"полный": сырое, "тело": _правило.тело_без_шапки(сырое)}
    return _тела[файл]


def позиции_площадок():
    """{запрос: [{позиция, адрес, площадка, замер}]} из замеров контура PR."""
    if not ПОЗИЦИИ.exists():
        return {}
    д = json.loads(ПОЗИЦИИ.read_text(encoding="utf-8"))
    итог = {}
    for с in д.get("строки") or []:
        if с.get("демо") or с.get("контур") != "PR":
            continue
        адрес = с.get("страница")
        if not адрес or адрес == "—":
            continue
        итог.setdefault(норма(с.get("запрос")), []).append({
            "позиция": с.get("позиция"),
            "адрес": адрес_норма(адрес),
            "площадка": с.get("площадка") or "",
            "замер": с.get("замер"),
        })
    return итог


# --- ядро: одобренные ключи и схлопывание ----------------------------------

def одобренные_ключи(ядро):
    """Одна строка на фразу, даже если она лежит в пулах нескольких кластеров.

    Правило схлопывания (решение по ревью плана 30.09.2026) живёт только здесь,
    панель своего больше не делает: первый приоритет - кластер, где фраза
    входит в ядро; второй - кластер, у которого есть страница портала; дальше
    более частотный, при равенстве - по коду, чтобы порядок не плавал
    между прогонами.
    """
    кандидаты = {}
    for к in ядро.get("кластеры") or []:
        есть_страница = str(к.get("страница") or "").startswith("http")
        for ф in (к.get("пул") or []):
            if ф.get("решение") != "approved":
                continue
            имя = (ф.get("фраза") or "").strip()
            if not имя:
                continue
            кандидаты.setdefault(норма(имя), []).append({
                "фраза": имя,
                "код": к.get("код") or "",
                "в_ядре": bool(ф.get("в_ядре")),
                "есть_страница": есть_страница,
                "показы": ф.get("показы"),
                "приоритет": str(ф.get("приоритет") or ""),
                "интент": ф.get("интент") or "",
                "воронка": ф.get("воронка") or "",
                "разметка": ф.get("разметка") or "",
            })
    итог = []
    for записи in кандидаты.values():
        записи.sort(key=lambda з: (not з["в_ядре"], not з["есть_страница"],
                                   -(з["показы"] or 0), з["код"]))
        лучшая = dict(записи[0])
        лучшая["ещё_в_кластерах"] = [з["код"] for з in записи[1:]]
        итог.append(лучшая)
    return итог


def approved_в_панели():
    """Сколько уникальных одобренных фраз видит вкладка ядра. Для сверки:
    экран и вкладка обязаны показывать одно и то же число ключей."""
    if not СЕМАНТИКА.exists():
        return None
    д = json.loads(СЕМАНТИКА.read_text(encoding="utf-8"))
    фразы = set()
    for к in д.get("строки") or []:
        for ф in (к.get("пул") or []):
            if ф.get("решение") == "approved" and (ф.get("фраза") or "").strip():
                фразы.add(норма(ф["фраза"]))
    return len(фразы)


# --- сборка строки ----------------------------------------------------------

def google_по_ключу(ключ, пары_страницы):
    """Позиция и показы Google по ключу.

    Сначала точное совпадение запроса с ключом. Вложенный запрос берём только
    если точного нет, и только среди пар этой же страницы, с пометкой:
    «франшиза кофейни цена» это не то же самое, что «франшиза кофейни»,
    и показы таких пар не складываем.
    """
    н = норма(ключ)
    for п in пары_страницы:
        if норма(п.get("запрос")) == н:
            return {"позиция": п.get("позиция"), "показы": п.get("показы"),
                    "клики": п.get("клики"), "запрос": п.get("запрос"),
                    "по_вложенному": False}
    вложенные = [п for п in пары_страницы if н and н in норма(п.get("запрос"))]
    if вложенные:
        лучшая = min(вложенные, key=lambda п: (п.get("позиция") is None, п.get("позиция") or 999))
        return {"позиция": лучшая.get("позиция"), "показы": лучшая.get("показы"),
                "клики": лучшая.get("клики"), "запрос": лучшая.get("запрос"),
                "по_вложенному": True}
    return {"позиция": None, "показы": None, "клики": None, "запрос": None,
            "по_вложенному": False}


# Порядок - это дорога ключа: нет статьи, статья есть но ключ не взят,
# взят, показывается, вышел в топ. Состояния взаимоисключающие: их сумма
# обязана давать число ключей, это проверяет проверка-панели.py.
СОСТОЯНИЯ = ["без статьи", "нужна своя статья", "дописать в статью",
             "закрыт, не показывается", "показывается", "в топ-10"]


def состояние(есть_статья, вошёл, лучшая, в_ядре_кластера):
    """Состояние ключа.

    Два состояния вместо одного «статья есть, ключ не вошёл» - решение
    руководителя 30.09.2026 «развести». Разница в том, что делать дальше,
    и валить это в один список нельзя:

      «дописать в статью»  - ключ входит в ядро темы, статья писалась под неё,
                             и фразы в тексте просто нет. Дописать абзац.
      «нужна своя статья»  - ключ лежит только в пуле темы, а статья досталась
                             ему потому, что у темы она есть. «что такое
                             франшиза» (16 201 показ) так оказался приписан
                             статье про заказные отзывы: дописывать туда
                             определение франшизы бессмысленно, под такой
                             запрос нужен свой текст.
    """
    if вошёл:
        if лучшая is None:
            return "закрыт, не показывается"
        return "в топ-10" if лучшая <= 10 else "показывается"
    if not есть_статья:
        return "без статьи"
    return "дописать в статью" if в_ядре_кластера else "нужна своя статья"


def главная():
    сухой = "--сухой" in sys.argv
    ядро = json.loads(ЯДРО.read_text(encoding="utf-8"))
    имена = {к["код"]: к["имя"] for к in _расход.кластеры_ядра()}
    # Ветка нужна экрану фильтром: «Франшизы», «Бизнес», «Мосты» -
    # три ядра портала, и смотреть их надо порознь.
    ветки = {к.get("код") or "": к.get("ветка") or "" for к in ядро.get("кластеры") or []}
    снимок_gsc = _расход.свежий_gsc()
    пары_gsc = _расход.по_страницам_gsc(снимок_gsc)
    в_я, в_г = _расход.в_индексе_яндекса(), _расход.в_индексе_google(снимок_gsc)
    снимок_я = свежий_вебмастер()
    хост = запросы_хоста(снимок_я)
    портал, площадки = тексты_реестра()
    замеры = позиции_площадок()
    ключи = одобренные_ключи(ядро)
    все_ключи = {норма(к["фраза"]) for к in ключи}

    строки = []
    for к in ключи:
        код = к["код"]
        статья = портал.get(код)
        слаг = (статья or {}).get("слаг") or ""
        пары = пары_gsc.get(слаг, []) if слаг else []

        вошёл_портал = False
        где = {}
        if статья and статья["файл"]:
            т = тело(статья["файл"])
            вошёл_портал = _правило.ключ_в_тексте(к["фраза"], т["тело"])
            if вошёл_портал:
                где = _правило.где_ключ(к["фраза"], т["полный"])

        # Материалы площадок, взявшие ключ. Фильтра по кластеру здесь нет
        # намеренно: материал берёт ключ в шапку, и его кластер может не
        # совпадать с тем, куда ключ схлопнулся при сборке ядра. Пока фильтр
        # стоял, колонка «материалов на площадках» показывала ноль у ключей,
        # которые материалы реально взяли (30.09.2026). Вхождение в текст
        # проверяем только у материалов своего кластера: у чужой темы
        # случайное совпадение слов ничего не закрывает.
        свои_площадки, вошёл_площадка = [], False
        for м in площадки:
            взял = норма(к["фраза"]) in {норма(х) for х in м["ключи"]}
            вошло = (_правило.ключ_в_тексте(к["фраза"], тело(м["файл"])["тело"])
                     if м["файл"] and (взял or м["кластер"] == код) else False)
            if not взял and not вошло:
                continue
            вошёл_площадка = вошёл_площадка or вошло
            свои_площадки.append({"площадка": м["площадка"], "адрес": м["ссылка"],
                                  "заголовок": м["заголовок"], "в_шапке": взял,
                                  "ключ_в_тексте": вошло})

        # лучшая позиция площадки в Яндексе - только по своим адресам
        мои_адреса = {адрес_норма(п["адрес"]) for п in свои_площадки if п["адрес"]}
        площадочные = [з for з in замеры.get(норма(к["фраза"]), [])
                       if з["адрес"] in мои_адреса and з["позиция"]]
        лучшая_площадка = min((з["позиция"] for з in площадочные), default=None)

        г = google_по_ключу(к["фраза"], пары)
        я = хост.get(норма(к["фраза"])) or {"позиция": None, "показы": None, "клики": None}

        свои = [п for п in (г["позиция"], я["позиция"], лучшая_площадка) if п is not None]
        лучшая = min(свои) if свои else None
        вошёл = вошёл_портал or вошёл_площадка
        есть_статья = bool(статья) or bool(свои_площадки)

        ключей_ядра_в_парах = sum(1 for п in пары if норма(п.get("запрос")) in все_ключи)
        строки.append({
            "фраза": к["фраза"],
            "тема": имена.get(код) or код,
            "код": код,
            "ветка": ветки.get(код) or "",
            "приоритет": к["приоритет"],
            "показы": к["показы"],
            "интент": к["интент"],
            "воронка": к["воронка"],
            "разметка_интента": к["разметка"],
            "ещё_в_кластерах": к["ещё_в_кластерах"],
            # Входит ли фраза в ядро кластера или лежит только в его пуле.
            # Без этой пометки экран врёт: 17 из 34 ключей в состоянии «статья
            # есть, ключ не вошёл» - это фразы из пула, и статья им досталась
            # только потому, что у кластера она есть. «что такое франшиза»
            # (16201 показ) оказался приписан статье про заказные отзывы.
            # Дописывать такую статью или писать свою - решает руководитель,
            # но он должен видеть, что связь случайная (30.09.2026).
            "в_ядре_кластера": к["в_ядре"],
            "связь": "ядро кластера" if к["в_ядре"] else "только пул кластера",
            "статья": ({"слаг": слаг, "заголовок": статья["заголовок"],
                        "ссылка": статья["ссылка"], "вышла": статья["вышла"]}
                       if статья else None),
            "ключ_в_тексте": вошёл_портал,
            "где_ключ": где,
            "в_индексе_яндекса": bool(слаг) and слаг in в_я,
            "в_индексе_google": bool(слаг) and слаг in в_г,
            "google": г,
            "яндекс": dict(я, по_хосту=True),
            "площадки": {
                "материалов": len(свои_площадки),
                "лучшая_позиция": лучшая_площадка,
                "список": свои_площадки[:СПИСОК],
            },
            "лучшая_позиция": лучшая,
            "раскрытие": {
                "запросов": len(пары),
                "из_них_ключей_ядра": ключей_ядра_в_парах,
                "список": [{"запрос": п.get("запрос"), "позиция": п.get("позиция"),
                            "показы": п.get("показы"), "клики": п.get("клики"),
                            "ключ_ядра": норма(п.get("запрос")) in все_ключи}
                           for п in пары[:СПИСОК]],
            },
            "состояние": состояние(есть_статья, вошёл, лучшая, к["в_ядре"]),
        })

    # приоритет важнее частотности: руководитель расставил его кнопками,
    # и экран должен открываться на том, что нужно в первую очередь
    строки.sort(key=lambda с: (с["приоритет"] or "9", -(с["показы"] or 0), с["фраза"]))

    состояния = {}
    for с in строки:
        состояния[с["состояние"]] = состояния.get(с["состояние"], 0) + 1
    закрыто = sum(v for к, v in состояния.items()
                  if к in ("закрыт, не показывается", "показывается", "в топ-10"))
    показываются = состояния.get("показывается", 0) + состояния.get("в топ-10", 0)
    плитки = {
        "ключей_в_ядре": len(строки),
        "закрыто": закрыто,
        "показываются": показываются,
        "в_топ10": состояния.get("в топ-10", 0),
        # два рабочих списка, разведённые по решению 30.09.2026: в первом
        # дописывают абзац, во втором пишут новый текст
        "дописать_в_статью": состояния.get("дописать в статью", 0),
        "нужна_своя_статья": состояния.get("нужна своя статья", 0),
    }

    # Фразы из полей «ключи», которых в ядре нет: опечатка, устаревшая
    # формулировка или мусор, попавший в поле (30.09.2026 в одну шапку
    # заехал комментарий в скобках, и поле дало две несуществующие фразы).
    # По таким фразам меряются позиции площадок, поэтому их видно сразу.
    чужие_ключи = sorted({х for м in площадки for х in м["ключи"]
                          if норма(х) not in все_ключи})

    в_панели = approved_в_панели()
    расхождения = []
    if в_панели is not None and в_панели != len(строки):
        расхождения.append(f"ключей в keys.json {len(строки)}, "
                           f"а одобренных фраз во вкладке ядра {в_панели}")
    if sum(состояния.values()) != len(строки):
        расхождения.append("сумма состояний не равна числу ключей")
    if плитки["в_топ10"] > плитки["показываются"]:
        расхождения.append("«в топ-10» больше, чем «показываются»")
    if плитки["закрыто"] > len(строки):
        расхождения.append("«закрыто» больше числа ключей")

    итог = {
        "мета": {
            "раздел": "Выработка семантики",
            "демо": False,
            "собрано": СЕГОДНЯ,
            "собрано_из": ("семантика/ядро.json, завод/реестр-текстов.json, снимки "
                           "Search Console и Вебмастера, панель/данные/positions.json; "
                           "скриптом панель/данные/обновить-ключи.py - руками не править"),
            "снято": {
                "search_console": (снимок_gsc or {}).get("снято"),
                "вебмастер": (снимок_я or {}).get("снято"),
                # Сколько запросов вообще в снимке Вебмастера. 30.09.2026 их
                # был ОДИН, поэтому колонка позиции Яндекса почти пустая -
                # это состояние данных, а не сбой сборки.
                "запросов_в_снимке_вебмастера": len((снимок_я or {}).get("строки") or []),
                "замер_площадок": max((з["замер"] for сп in замеры.values() for з in сп
                                       if з.get("замер")), default=None),
            },
            "правило_закрытия": (
                "Ключ закрыт, только если его фраза вошла в текст - статьи портала "
                "или опубликованного материала площадки. Сравниваем по словам: все "
                "слова ключа без стоп-слов стоят в одном предложении, порядок не "
                "важен (семантика/ключ_в_тексте.py). «Статья есть, ключ не вошёл» - "
                "отдельное состояние, а не «закрыто»: это список на дописать."),
            "оговорки": {
                "яндекс": "Яндекс: позиция сайта целиком за 14 дней, не страницы.",
                "google": "Google: пары «страница-запрос» за 28 дней.",
                "площадки": "Площадки: только Яндекс, топ-100. Google не меряем.",
            },
            "состояния": СОСТОЯНИЯ,
            "плитки": плитки,
            "сводка": dict(состояния, ключей=len(строки)),
            "сверки": {"одобренных_во_вкладке_ядра": в_панели, "расхождения": расхождения,
                       "фразы_из_шапок_не_из_ядра": чужие_ключи},
        },
        "строки": строки,
    }

    print(f"ключей: {len(строки)} | закрыто {плитки['закрыто']}, "
          f"показываются {плитки['показываются']}, в топ-10 {плитки['в_топ10']}")
    print("состояния:", json.dumps(состояния, ensure_ascii=False))
    print(f"статей портала под ключами: {len({с['статья']['слаг'] for с in строки if с['статья']})}, "
          f"ключей с материалами площадок: {sum(1 for с in строки if с['площадки']['материалов'])}")
    for р in расхождения:
        print("  СВЕРКА НЕ СОШЛАСЬ:", р)
    if чужие_ключи:
        print(f"  в шапках материалов {len(чужие_ключи)} фраз, которых нет в ядре:",
              "; ".join(f"«{ф}»" for ф in чужие_ключи[:5]))
    if сухой:
        print("сухой прогон: keys.json не тронут")
        return 1 if расхождения else 0
    if КЛЮЧИ.exists():
        Path(str(КЛЮЧИ) + f".до-сборки-{СЕГОДНЯ}").write_text(
            КЛЮЧИ.read_text(encoding="utf-8"), encoding="utf-8")
    КЛЮЧИ.write_text(json.dumps(итог, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    print("записано:", КЛЮЧИ.relative_to(КОРЕНЬ))
    return 1 if расхождения else 0


if __name__ == "__main__":
    raise SystemExit(главная())
