#!/usr/bin/env python3
"""Карточка отдельной статьи: всё, что нужно оператору, чтобы её опубликовать.

Просьба руководителя 22.09.2026: во вкладке «Статьи» клик по черновику должен
открывать страницу материала, как у PR Factory (mybiusty.prfabrika.ru/articles/175),
с полной информацией и с тем, как публиковать.

Что оттуда взято: шапка «площадка · статус · тип», обложка, список картинок
с alt и подписью, краткое описание, ключевые слова, полный текст статьи,
блок отметки публикации и ряд действий внизу.

Чем отличаемся и почему:
  - у них кнопки «Перегенерировать», «Одобрить», «Удалить» работают на сервере,
    у нас панель статическая и ничего не запускает: рисуем только то, что
    действительно открывается - текст на витрине, документ для редакции,
    опубликованная страница;
  - вместо общей инструкции даём правила именно этой площадки: как публикуем,
    что с картинками, передаёт ли она вес ссылок, где ловушки. У них это
    лежит в карточке площадки отдельно, а оператору нужно в момент публикации;
  - добавлен чек-лист выпуска: у нас он живёт в проверках завода, и без него
    материал уходит с незаполненными местами.

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

import importlib.util
import json
import re
from pathlib import Path

ПАПКА = Path(__file__).resolve().parent
КОРЕНЬ = ПАПКА.parent.parent
СТАТЬИ = ПАПКА / "articles.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ПЛОЩАДКИ = КОРЕНЬ / "завод" / "площадки.json"
ВЫХОД = ПАПКА / "статьи"
# Ссылки на сами файлы картинок, чтобы карточка показывала превью и давала
# скачать. Папка «данные» панели видна и на витрине, поэтому адрес
# «данные/картинки/<имя>» открывается и там, и локально. Кладём ссылки
# только на картинки из карточек: витрина с 08.09 без пароля, и выставлять
# наружу всю папку публикаций с черновиками и docx не нужно (06.10.2026).
КАРТИНКИ = ПАПКА / "картинки"
ГДЕ_ИСКАТЬ = [КОРЕНЬ / "завод" / "публикации", КОРЕНЬ / "сайт" / "public" / "img"]

# Ключ в подписи кадра проверяем общим правилом «ключ вошёл в текст»,
# своё сравнение не заводим: иначе разойдётся с доской ключей.
_спец = importlib.util.spec_from_file_location("ключ_в_тексте", КОРЕНЬ / "семантика" / "ключ_в_тексте.py")
_правило = importlib.util.module_from_spec(_спец)
_спец.loader.exec_module(_правило)

# Норма кадров по образцу PR-завода Adonis (свод D3): кадр на 75-100 слов,
# считаем по верхней границе - ориентир, а не «десять картинок любой ценой».
СЛОВ_НА_КАДР = 100

# Служебная шапка в наших текстах: всё до этой черты в публикацию не идёт.
СЛУЖЕБНОЕ = re.compile(r"^>\s*\*\*Служебная шапка.*?$", re.M)


def карточки_площадок():
    if not ПЛОЩАДКИ.exists():
        return {}
    б = json.loads(ПЛОЩАДКИ.read_text(encoding="utf-8"))
    сп = б.get("площадки") if isinstance(б, dict) else б
    return {(п.get("имя") or "").split("(")[0].strip(): п for п in сп if isinstance(п, dict)}


def тело_текста(путь):
    """Текст статьи без frontmatter и без служебной шапки."""
    if not путь or not путь.exists():
        return ""
    т = путь.read_text(encoding="utf-8")
    if т.startswith("---"):
        части = т.split("---", 2)
        т = части[2] if len(части) > 2 else т
    м = СЛУЖЕБНОЕ.search(т)
    if м:
        т = т[м.end():]
    return т.strip()


# Строка-картинка в тексте: ![alt](файл). Ставим кадр на его место в тексте,
# а не только списком в «Иллюстрациях»: по тексту видно, к какому абзацу он
# относится (просьба Дарьи 08.10.2026).
КАРТИНКА = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)\)\s*$")


def картинка_html(alt, имя):
    экр = (lambda с: с.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
    адрес = выложить_файл(найти_картинку(имя))
    if not адрес:
        return (f'<figure class="статья-кадр статья-кадр--нет" data-файл="{экр(Path(имя).name)}">'
                f'<div>нет файла: <code>{экр(имя)}</code></div></figure>')
    return (f'<figure class="статья-кадр" data-файл="{экр(Path(имя).name)}">'
            f'<img src="{экр(адрес)}" alt="{экр(alt)}" loading="lazy">'
            f'<figcaption>{экр(alt) or "alt не задан"}</figcaption></figure>')


ГОЛЫЙ_ДОМЕН = re.compile(
    r"(?<![\w.@/-])((?:[\w-]+\.)+(?:ru|рф|com|net|org|io|su|info|pro|me|by|kz)(?:/[^\s<>\"'()]*)?)(?![\w-])",
    re.I)


def домен_ссылкой(м):
    адрес = м.group(1)
    хвост = re.search(r"[.,;:!?)»]+$", адрес)
    хвост = хвост.group(0) if хвост else ""
    адрес = адрес[:len(адрес) - len(хвост)]
    if "." not in адрес:
        return м.group(0)
    return (f'<a class="автоссылка" href="https://{адрес}" target="_blank" '
            f'rel="noopener">{адрес}</a>{хвост}')


def в_html(текст):
    """Markdown в простой HTML. Полный разбор не нужен: наши тексты - это
    заголовки, абзацы, списки, цитаты, ссылки и картинки."""
    if not текст:
        return ""
    экр = (lambda с: с.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
    куски, список, цитата, абзац = [], [], [], []

    def закрыть():
        # строки подряд без пустой между ними - один абзац, как в markdown:
        # иначе текст с ручными переносами рвётся на абзацы посреди фразы
        if абзац:
            куски.append(f"<p>{строчно(' '.join(абзац))}</p>")
            абзац.clear()
        if список:
            куски.append("<ul>" + "".join(f"<li>{строчно(э)}</li>" for э in список) + "</ul>")
            список.clear()
        if цитата:
            куски.append("<blockquote>" + " ".join(строчно(э) for э in цитата) + "</blockquote>")
            цитата.clear()

    def строчно(с):
        с = экр(с)
        с = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)",
                   r'<a href="\2" target="_blank" rel="noopener">\1</a>', с)
        с = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", с)
        с = re.sub(r"(?<!\w)\*([^*]+)\*(?!\w)", r"<i>\1</i>", с)
        с = re.sub(r"`([^`]+)`", r"<code>\1</code>", с)
        # голый адрес тоже делаем ссылкой: в постах каналов они идут текстом
        с = re.sub(r'(?<!["\'>=])(https?://[^\s<]+)', r'<a href="\1" target="_blank" rel="noopener">\1</a>', с)
        # голый домен (consultant.ru, vc.ru/rules) - ссылка только для панели:
        # класс «автоссылка» при копировании снимается, на площадку уходит текст
        return "".join(к if к.startswith("<") else ГОЛЫЙ_ДОМЕН.sub(домен_ссылкой, к)
                       for к in re.split(r"(<a\b.*?</a>|<[^>]+>)", с))

    for строка in текст.split("\n"):
        с = строка.rstrip()
        if not с.strip():
            закрыть()
            continue
        м = КАРТИНКА.match(с.strip())
        if м:
            закрыть()
            куски.append(картинка_html(м.group(1), м.group(2)))
            continue
        м = re.match(r"^(#{1,4})\s+(.*)$", с)
        if м:
            закрыть()
            ур = min(len(м.group(1)) + 1, 5)
            куски.append(f"<h{ур}>{строчно(м.group(2))}</h{ур}>")
            continue
        if re.match(r"^[-*•]\s+", с):
            if абзац:
                закрыть()
            цитата.clear()
            список.append(re.sub(r"^[-*•]\s+", "", с))
            continue
        if с.lstrip().startswith(">"):
            if абзац:
                закрыть()
            список.clear()
            цитата.append(с.lstrip().lstrip(">").strip())
            continue
        if список or цитата:
            закрыть()
        абзац.append(с.strip())
    закрыть()
    return "\n".join(куски)


def описание_и_ключи(путь):
    """Краткое описание и ключевые слова из самого текста.

    В эталоне PR Factory эти два блока стоят прямо над статьёй, и они правда
    нужны: description уходит в мету, а ключи показывают, под что материал
    написан. У нас description лежит в шапке текста или в SEO-таблице,
    ключевые слова - в той же таблице (22.09.2026).
    """
    if not путь or not путь.exists():
        return None, []
    т2 = путь.read_text(encoding="utf-8", errors="ignore")
    о = (re.search(r"\|\s*Description[^|]*\|([^|]+)\|", т2)
         or re.search(r"^description:\s*(.+)$", т2, re.M | re.I))
    к = (re.search(r"\|\s*Ключевые слова\s*\|([^|]+)\|", т2)
         or re.search(r"^ключевые_слова:\s*(.+)$", т2, re.M | re.I))
    описание = о.group(1).strip().strip('"«»') if о else None
    ключи = [ф.strip() for ф in re.split(r"[;,]", к.group(1))] if к else []
    return описание, [ф for ф in ключи if ф]


def письмо(путь):
    """Письмо в редакцию для площадок, которые публикуют сами (Audit-it
    с 07.10.2026). Путь к письму стоит в шапке статьи полем «письмо:».
    Из файла письма берём шаги «Как отправить» и цитату «Текст письма»:
    в панели их видно рядом со статьёй и текст можно скопировать."""
    if not путь or not путь.exists():
        return None
    т = путь.read_text(encoding="utf-8")
    м = re.match(r"^---\n(.*?)\n---\n", т, flags=re.S)
    п = re.search(r"^письмо:\s*(.+)$", м.group(1), re.M) if м else None
    if not п:
        return None
    файл = п.group(1).strip()
    источник = КОРЕНЬ / файл
    if not источник.exists():
        return {"файл": файл, "нет_файла": True}
    п2 = источник.read_text(encoding="utf-8")

    def раздел(имя):
        р = re.search(r"^## " + имя + r"\s*$(.*?)(?=^## |^---\s*$|\Z)", п2, re.M | re.S)
        return р.group(1).strip() if р else ""

    шаги = [re.sub(r"^\d+\.\s*", "", ш).strip()
            for ш in re.split(r"\n(?=\d+\.\s)", раздел("Как отправить")) if ш.strip()]
    цитата = "\n".join(re.sub(r"^>\s?", "", с) for с in раздел("Текст письма").splitlines()
                        if с.startswith(">"))
    цитата = цитата.replace("**", "").strip()
    # В файле строки перенесены по ширине, в почте такой перенос рвёт фразу.
    # Строки абзаца склеиваем; две пробела в конце строки - перенос оставить
    # (так записана подпись).
    цитата = "\n\n".join(
        re.sub(r"(?<! ) ?\n(?!$)", " ", абз).replace("  \n", "\n").strip()
        for абз in re.split(r"\n\s*\n", цитата))
    отправлено = "НЕ ОТПРАВЛЕНО" not in п2.split("---")[0]
    return {"файл": файл, "шаги": шаги, "текст": цитата, "отправлено": отправлено}


# Куда ставить материал на площадке: раздел и теги из шапки черновика.
# Просьба 07.10.2026: при выкладке на Sostav раздел и теги пришлось искать
# в файле, а оператору они нужны в карточке рядом с текстом. Поля в шапках
# называются по-разному - у каждой площадки своё слово, - поэтому список.
ПОЛЯ_РАЗМЕЩЕНИЯ = [
    ("раздел", "Раздел"), ("разделы", "Разделы"), ("раздел_выхода", "Раздел выхода"),
    ("рубрика", "Рубрика"), ("рубрики", "Рубрики"), ("сообщество", "Сообщество"),
    ("хабы", "Хабы"), ("поток", "Поток"), ("блог", "Блог"),
    # Тематика из списка в форме площадки (TenChat «темы», mt «тема_выпуска»,
    # Habr «направление»): просьба Дарьи 09.10.2026 видеть её в карточке.
    ("темы", "Тематика"), ("тема_выпуска", "Тематика выпуска"), ("направление", "Направление"),
    ("теги", "Теги"), ("теги_запасные", "Запасные теги"), ("теги_как", "Как вводить теги"),
]


def шапка(путь):
    """Поля frontmatter как словарь; продолжения строк с отступом склеиваем."""
    if not путь or not путь.exists():
        return {}
    м = re.match(r"^---\n(.*?)\n---\n", путь.read_text(encoding="utf-8", errors="ignore"), re.S)
    if not м:
        return {}
    поля, имя = {}, None
    for с in м.group(1).splitlines():
        п = re.match(r"^([^\s:][^:]*):\s*(.*)$", с)
        if п and not с.startswith((" ", "\t")):
            имя = п.group(1).strip()
            поля[имя] = п.group(2).strip()
        elif имя and с.strip():
            поля[имя] = (поля[имя] + " " + с.strip()).strip()
    return поля


def размещение(путь):
    ш = шапка(путь)
    return [{"поле": подпись, "значение": ш[к].strip('"')}
            for к, подпись in ПОЛЯ_РАЗМЕЩЕНИЯ if ш.get(к, "").strip('"')]


# Анонс - отдельное поле формы площадки (Fishki, Wsem, Workspace...).
# Просьба Дарьи 08.10.2026: анонс к fishki-05 был в шапке, а в карточке
# его не было видно, и казалось, что его нет. Сам текст пишем в шапку полем
# «анонс_площадки:»; старые черновики держат его под другими именами.
# Поле «анонс:» и «лид:» без кавычек - это пояснение («до 300 знаков»),
# а не текст, поэтому лид берём, только если он в «ёлочках».
ПОЛЯ_АНОНСА = ("анонс_площадки", "анонс_для_ленты")


def анонс(путь, п):
    ш = шапка(путь)
    текст = next((ш[к] for к in ПОЛЯ_АНОНСА if ш.get(к, "").strip()), "")
    if not текст and ш.get("лид", "").strip().startswith("«"):
        текст = ш["лид"].strip()[1:].rsplit("»", 1)[0]
    текст = текст.strip().strip('"')
    нужен = (п or {}).get("анонс_поле") or {}
    if not текст and not нужен:
        return None
    return {"текст": текст or None, "знаков": len(текст),
            "поле": нужен.get("поле") or "Анонс", "лимит": нужен.get("лимит"),
            "пояснение": ш.get("анонс") or None}


def иллюстрации(запись, площадка):
    """Картинки материала: файл, alt, подпись. Разные записи ведут их
    по-разному - и списком, и числом, поэтому «не знаем» не выдаём за ноль."""
    поле = запись.get("иллюстрации")
    # alt по имени файла собирает реестр текстов (06.10.2026): из разметки
    # картинки в тексте, из полей alt_<файл> в шапке, у обложки портала -
    # заголовок статьи, как его ставит сайт
    alt = запись.get("alt") or {}
    if isinstance(поле, list):
        ряд = []
        for и in поле:
            if isinstance(и, dict):
                ряд.append({"файл": и.get("файл") or и.get("имя"),
                            "alt": и.get("alt"), "подпись": и.get("подпись")})
            elif isinstance(и, str):
                ряд.append({"файл": и, "alt": alt.get(и), "подпись": None})
        return ряд, None
    if isinstance(поле, int):
        return [], f"в карточке записано число: {поле}, разбивки по картинкам нет"
    if isinstance(поле, str) and поле.strip():
        return [], поле
    return [], None


def найти_картинку(имя):
    """Путь к файлу картинки или None. В карточках имя бывает и голым, и с папкой."""
    if not имя:
        return None
    for кандидат in [КОРЕНЬ / имя] + [п / Path(имя).name for п in ГДЕ_ИСКАТЬ]:
        if кандидат.is_file():
            return кандидат
    return None


def выложить_файл(путь):
    """Ставит ссылку на файл в данные/картинки/ и возвращает адрес для панели."""
    if not путь:
        return None
    ссылка = КАРТИНКИ / путь.name
    if ссылка.is_symlink() and ссылка.resolve() != путь.resolve():
        ссылка.unlink()
    if not ссылка.is_symlink():
        ссылка.symlink_to(путь)
    return f"данные/картинки/{путь.name}"


def выложить_картинки(картинки):
    """Пишет в каждую запись адрес файла для панели."""
    for и in картинки:
        и["адрес"] = выложить_файл(найти_картинку(и.get("файл")))
    return картинки


def ключи_в_подписи(подпись, ключи):
    """Ключи статьи, которые стоят в подписи кадра. Вложенные схлопываем:
    подпись с «купить франшизу кофейни» содержит и «купить франшизу»,
    но несёт один ключ, а не два (тот же приём, что у Adonis)."""
    if not подпись:
        return []
    есть = [к for к in ключи if _правило.ключ_в_тексте(к, подпись)]
    основы = {к: set(_правило.основы(к)) for к in есть}
    return [к for к in есть
            if not any(д != к and основы[к] < основы[д] for д in есть)]


def иллюстрации_сводка(картинки, текст, ключи, заголовок):
    """Блок «Иллюстрации» по образцу PR-завода Adonis (07.10.2026): где кадр
    стоит в тексте, какой ключ несёт его подпись, сколько кадров просит
    объём текста и какие ключи ещё не разобраны по кадрам.

    Позицию берём из текста, а не из шапки: черновик правят руками, и
    записанное место устаревает первым же переносом абзаца."""
    блоки = [б for б in re.split(r"\n\s*\n", текст or "") if б.strip()]
    for и in картинки:
        имя = Path(и.get("файл") or "").name
        место = next((н for н, б in enumerate(блоки, 1)
                      if имя and re.search(r"\]\([^)]*" + re.escape(имя) + r"\)", б)), None)
        обложка = "oblozhka" in имя or "cover" in имя or (
            заголовок and (и.get("alt") or "").strip() == заголовок.strip())
        и["блок"] = место
        и["обложка"] = bool(обложка) and место is None
        и["ключи"] = ключи_в_подписи(и.get("alt") or и.get("подпись"), ключи)
    без_картинок = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", текст or "")
    слов = len(re.findall(r"[\w-]+", без_картинок))
    # Обложку в повторы не считаем: её alt сайт ставит из заголовка, и главный
    # ключ там стоит всегда. Иначе каждый кадр с главным ключом звался бы переспамом.
    занято, в_кадрах = {}, {}
    for и in картинки:
        for к in и["ключи"]:
            занято[к] = занято.get(к, 0) + 1
            if not и["обложка"]:
                в_кадрах[к] = в_кадрах.get(к, 0) + 1
    return {
        "слов": слов,
        "норма": max(1, round(слов / СЛОВ_НА_КАДР)) if слов else 0,
        "в_тексте": sum(1 for и in картинки if и["блок"]),
        "ключи": [{"фраза": к, "в_подписи": к in занято} for к in ключи],
        "повторы": sorted(к for к, н in в_кадрах.items() if н > 1),
    }


def правила(п):
    """Что оператору надо знать про площадку в момент публикации."""
    if not п:
        return None
    огр = п.get("ограничения")
    if isinstance(огр, str):
        огр = [огр]
    return {
        "имя": п.get("имя"),
        "адрес": п.get("url"),
        "как_публикуем": п.get("как_публикуем"),
        "ссылки_вес": п.get("ссылки_вес"),
        "ссылки_правило": п.get("ссылки") or п.get("ссылки_seo"),
        "картинки": п.get("картинки") or п.get("иллюстрации"),
        "объём": п.get("объём") or п.get("объем"),
        "регистрация": п.get("спрашивает_при_регистрации"),
        "телефон": п.get("телефон_обязателен"),
        "аккаунт": п.get("аккаунт"),
        "статус_площадки": п.get("статус"),
        "ограничения": [с for с in (огр or []) if с][:8],
    }


def чеклист(запись, п, текст):
    """Что проверить перед отправкой. Пункт появляется только если его есть
    на чём проверить: пустые галочки оператор перестаёт читать."""
    ряд = []
    есть_ссылка = "frantlas.ru" in (текст or "")
    вес = str((п or {}).get("ссылки_вес") or "").lower()
    if п:
        if вес == "ссылок нет" or "нет" in str((п or {}).get("ссылки") or "").lower():
            ряд.append({"что": "Ссылок на портал в тексте быть не должно - площадка их запрещает",
                        "готово": not есть_ссылка})
        else:
            ряд.append({"что": "Ссылка на портал в тексте", "готово": есть_ссылка})
    # Анонс нужен там, где у формы площадки есть под него поле (анонс_поле
    # в карточке площадки, 08.10.2026)
    нужен = (п or {}).get("анонс_поле")
    if нужен:
        а = запись.get("_анонс") or {}
        лимит = нужен.get("лимит")
        if not а.get("текст"):
            ряд.append({"что": f"Анонс для поля «{нужен.get('поле') or 'Анонс'}» - не написан"
                               " (шапка, поле анонс_площадки)", "готово": False})
        else:
            длиннее = лимит and а["знаков"] > лимит
            ряд.append({"что": f"Анонс для поля «{нужен.get('поле') or 'Анонс'}»: {а['знаков']} знаков"
                               + (f" из {лимит}" if лимит else "")
                               + (" - длиннее лимита" if длиннее else ""),
                        "готово": not длиннее})
    ряд.append({"что": "Заголовок совпадает с тем, что в плане", "готово": bool(запись.get("заголовок"))})
    ряд.append({"что": "Текст написан и лежит в заводе", "готово": bool(текст)})
    if запись.get("документ_для_редакции"):
        ряд.append({"что": "Документ для редакции собран", "готово": True})
    if запись.get("на_витрине"):
        ряд.append({"что": "Черновик выложен на витрину - можно показать до публикации",
                    "готово": True})
    # alt обязателен у каждой картинки (просьба Дарьи 06.10.2026): по нему
    # материал попадает в поиск по картинкам. Записывается в разметке
    # картинки в тексте или полем «alt_<файл>» в шапке.
    картинки = запись.get("иллюстрации") or []
    if isinstance(картинки, list) and картинки:
        без_alt = [и for и in картинки
                   if isinstance(и, str) and not (запись.get("alt") or {}).get(и)]
        ряд.append({"что": "alt у каждой картинки" + (
                        f" - нет у {len(без_alt)} из {len(картинки)}" if без_alt else ""),
                    "готово": not без_alt})
    # Ключ статьи в alt кадра (с 07.10.2026, по образцу PR-завода Adonis):
    # хотя бы один кадр в тексте несёт ключ, один ключ не стоит в двух подписях.
    # Сводку собирает иллюстрации_сводка() до вызова чек-листа.
    илл = запись.get("_иллюстрации") or {}
    кадры = [и for и in (запись.get("_картинки") or []) if not и.get("обложка")]
    if илл.get("ключи") and кадры:
        с_ключом = sum(1 for и in кадры if и.get("ключи"))
        повторы = илл.get("повторы") or []
        ряд.append({"что": "Ключ статьи в подписи кадра" + (
                        " - ни в одной подписи" if not с_ключом else
                        f" - повтор: {', '.join(повторы)}" if повторы else ""),
                    "готово": bool(с_ключом) and not повторы})
    ряд.append({"что": "Дисклеймер про доход и окупаемость, если в тексте есть такие цифры",
                "готово": "дисклеймер" in (текст or "").lower()
                          or not re.search(r"окупаем|доход|прибыл", текст or "", re.I)})
    return ряд


def главная():
    строки = json.loads(СТАТЬИ.read_text(encoding="utf-8"))["строки"]
    реестр = json.loads(РЕЕСТР.read_text(encoding="utf-8")).get("тексты", [])
    базы = карточки_площадок()

    # Реестр сопоставляем со строками панели по заголовку И площадке: номера
    # живут только в панели, а тексты и файлы - только в реестре. По одному
    # заголовку сопоставлять нельзя: один и тот же разбор ушёл и на Habr, и на
    # iXBT Live, и карточка Habr показывала текст iXBT (22.09.2026).
    def короткое(имя):
        return re.sub(r"\s*\([^)]*\)\s*$", "", str(имя or "")).split(",")[0].strip().lower()

    по_паре, по_заголовку = {}, {}
    for т2 in реестр:
        з = (т2.get("заголовок") or "").strip().lower()
        по_паре[(з, короткое(т2.get("площадка")))] = т2
        по_заголовку.setdefault(з, т2)

    ВЫХОД.mkdir(exist_ok=True)
    КАРТИНКИ.mkdir(exist_ok=True)
    указатель, собрано, без_текста = {}, 0, []
    for с in строки:
        номер = с.get("№")
        з = (с.get("заголовок") or "").strip().lower()
        запись = по_паре.get((з, короткое(с.get("площадка")))) or по_заголовку.get(з, {})
        файл = запись.get("файл")
        путь = (КОРЕНЬ / файл) if файл else None
        текст = тело_текста(путь)
        описание, ключи_текста = описание_и_ключи(путь)
        имя_площадки = (с.get("площадка") or "").split("(")[0].strip()
        п = базы.get(имя_площадки)
        картинки, нота_картинок = иллюстрации(запись, п)
        выложить_картинки(картинки)
        ключи_статьи = [к.get("фраза") for к in (с.get("ключи") or []) if к.get("фраза")] or ключи_текста
        сводка_картинок = иллюстрации_сводка(картинки, текст, ключи_статьи, с.get("заголовок"))
        карточка = {
            "№": номер,
            "заголовок": с.get("заголовок"),
            "площадка": с.get("площадка"),
            "статус": с.get("статус"),
            "тип": с.get("тип"),
            "контур": с.get("контур"),
            "рубрика": с.get("рубрика"),
            "кластер": с.get("кластер"),
            "волна": с.get("волна"),
            "приоритет": с.get("приоритет"),
            "создана": с.get("создана"),
            "выход": с.get("выход"),
            "просмотры": с.get("просмотры"),
            "ключи": с.get("ключи") or [],
            "ключи_нота": с.get("ключи_нота"),
            "ключи_кластер": с.get("ключи_кластер"),
            "ключи_замер": с.get("ключи_замер"),
            "описание": описание,
            "ключи_из_текста": ключи_текста,
            "знаков": запись.get("знаков"),
            "слов": запись.get("слов"),
            "файл": файл,
            "статус_текста": запись.get("статус"),
            "ссылки": {
                "опубликована": с.get("ссылка") or запись.get("ссылка"),
                "витрина": запись.get("на_витрине"),
                "документ": запись.get("документ_ссылка") or None,
                "документ_локальный": bool(запись.get("документ_для_редакции")),
                "страница": запись.get("страница_собрана"),
            },
            "картинки": картинки,
            "картинки_нота": нота_картинок,
            "иллюстрации": сводка_картинок,
            "площадка_правила": правила(п),
            "чеклист": чеклист({**запись, "_иллюстрации": сводка_картинок, "_картинки": картинки,
                                "_анонс": анонс(путь, п)}, п, текст),
            "письмо": письмо(путь),
            "размещение": размещение(путь),
            "анонс": анонс(путь, п),
            "текст_html": в_html(текст),
            "есть_текст": bool(текст),
        }
        (ВЫХОД / f"{номер}.json").write_text(
            json.dumps(карточка, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
        указатель[str(номер)] = {"заголовок": с.get("заголовок"), "есть_текст": bool(текст)}
        собрано += 1
        if not текст:
            без_текста.append(f"{номер} {с.get('заголовок','')[:40]}")
    (ВЫХОД / "указатель.json").write_text(
        json.dumps(указатель, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    print(f"карточек статей: {собрано}, из них без текста: {len(без_текста)}")
    if без_текста:
        print("  без текста (материал ещё не написан):", "; ".join(без_текста[:6]),
              "…" if len(без_текста) > 6 else "")
    return 0


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