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

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

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

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

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

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"]

# Служебная шапка в наших текстах: всё до этой черты в публикацию не идёт.
СЛУЖЕБНОЕ = 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()


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

    def закрыть():
        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>', с)
        return с

    for строка in текст.split("\n"):
        с = строка.rstrip()
        if not с.strip():
            закрыть()
            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+", с):
            цитата.clear()
            список.append(re.sub(r"^[-*•]\s+", "", с))
            continue
        if с.lstrip().startswith(">"):
            список.clear()
            цитата.append(с.lstrip().lstrip(">").strip())
            continue
        закрыть()
        куски.append(f"<p>{строчно(с.strip())}</p>")
    закрыть()
    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 иллюстрации(запись, площадка):
    """Картинки материала: файл, 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 выложить_картинки(картинки):
    """Ставит ссылку на файл в данные/картинки/ и пишет в запись адрес для панели."""
    for и in картинки:
        путь = найти_картинку(и.get("файл"))
        if not путь:
            и["адрес"] = None
            continue
        ссылка = КАРТИНКИ / путь.name
        if ссылка.is_symlink() and ссылка.resolve() != путь.resolve():
            ссылка.unlink()
        if not ссылка.is_symlink():
            ссылка.symlink_to(путь)
        и["адрес"] = f"данные/картинки/{путь.name}"
    return картинки


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({"что": "Ссылка на портал в тексте", "готово": есть_ссылка})
    ряд.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})
    ряд.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("заголовок"),
            "площадка": с.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(главная())
