#!/usr/bin/env python3
"""Расход семантического ядра: что написано, что дошло до поиска, что пусто.

Задача руководителя из видео 24.09.2026: «хочется понимать, в какой части
семантического ядра мы находимся, что уже написано, что не написано. По
ключевику инвестиций есть одна статья на SEO, она показывается, есть пять
статей в PR, из них две показываются. Супер, мы этот ключевик забрали,
отработали, идём дальше».

Два разреза:

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

Откуда данные:
  ключи и позиции   - Google Search Console (разрез «страница + запрос»)
                      и Яндекс Вебмастер (у него пока только общий срез,
                      связки «страница-запрос» он не отдаёт)
  статьи и кластеры - реестр текстов
  состав ядра       - семантика/ядро.json

Запуск: python3 панель/данные/обновить-расход-ядра.py
"""

import json
import re
from collections import defaultdict
from datetime import date
from pathlib import Path

ПАПКА = Path(__file__).resolve().parent
КОРЕНЬ = ПАПКА.parent.parent
ОТЧЁТ = ПАПКА / "report.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ЯДРО = КОРЕНЬ / "семантика" / "ядро.json"
GSC = КОРЕНЬ / "завод" / "search-console" / "история"
ВЕБМАСТЕР = КОРЕНЬ / "завод" / "вебмастер" / "история"


# Сколько дней ступень считается нормальной. Дольше - статья застряла,
# и её надо толкать: переобход, ссылки, переделка заголовка. Сроки взяты
# из наблюдений сентября: Яндекс берёт свежую статью за 1-2 недели,
# Google быстрее, но показы после индексации идут не сразу.
# Значки ступеней - те же, что в эталоне PR Factory: по ним ступень узнаётся
# с одного взгляда, не читая подпись.
ЗНАЧКИ = {
    "Опубликована, но не в индексе Яндекса": "📄",
    "В индексе Яндекса, но не в Google": "🔍",
    "В индексе обоих, показов ещё нет": "🌐",
    "Есть показы, позиции ещё нет": "👁",
    "Есть позиция, но дальше третьей страницы": "📈",
    "Есть позиция в первых тридцати": "🔥",
}

НОРМА_ДНЕЙ = {
    "не в индексе Яндекса": 14,
    "не в Google": 10,
    "нет показов": 14,
    "нет позиции": 21,
}


def склонение(н, один, два, много):
    о, е = abs(н) % 100, abs(н) % 10
    if 11 <= о <= 14:
        return много
    return один if е == 1 else два if 2 <= е <= 4 else много


def свежий_gsc():
    """Последний снимок Search Console. None - выгрузки ещё не было."""
    снимки = sorted(GSC.glob("*.json")) if GSC.exists() else []
    if not снимки:
        return None
    return json.loads(снимки[-1].read_text(encoding="utf-8"))


def по_страницам_gsc(снимок):
    """{слаг: [{запрос, показы, клики, позиция}]} из разреза «страница+запрос»."""
    итог = defaultdict(list)
    for р in (снимок or {}).get("страница_запрос") or []:
        слаг = str(р.get("страница", "")).rstrip("/").rsplit("/", 1)[-1]
        if слаг:
            итог[слаг].append(р)
    for сп in итог.values():
        сп.sort(key=lambda з: -(з.get("показы") or 0))
    return итог


def по_страницам_яндекса():
    """{слаг: {показы, клики, позиция}} из последнего снимка Вебмастера.

    Связки «страница - запрос» у Яндекса нет, поэтому список ключей по статье
    он дать не может - только сколько её показали и на каком месте в среднем.
    Руководитель предупредил об этом голосовым 24.09.2026: «один и тот же
    запрос Гугл выведет, а Яндекс другие, перемешать не получится».
    """
    снимки = sorted(ВЕБМАСТЕР.glob("*.json")) if ВЕБМАСТЕР.exists() else []
    for путь in reversed(снимки):
        try:
            д = json.loads(путь.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            continue
        стр = д.get("по_страницам")
        if стр is not None:
            итог = {}
            for с in стр:
                слаг = str(с.get("страница", "")).rstrip("/").rsplit("/", 1)[-1]
                if слаг:
                    итог[слаг] = {"показы": с.get("показы"), "клики": с.get("клики"),
                                  "позиция": с.get("позиция")}
            return итог
    return {}


def статьи_портала():
    """Опубликованные статьи портала с датой выхода - от неё считается норма."""
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))["тексты"]
    итог = []
    for т in р:
        if т.get("вид") == "внешняя площадка":
            continue
        статус = str(т.get("статус", ""))
        if "опубликован" not in статус.lower():
            continue
        м = re.search(r"(\d{4})-(\d{2})-(\d{2})", статус)
        вышла = date(*map(int, м.groups())) if м else None
        итог.append({
            "слаг": (т.get("слаг") or "").strip("/"),
            "заголовок": т.get("заголовок", ""),
            "кластер": т.get("кластер", ""),
            "вышла": вышла.isoformat() if вышла else None,
            "дней": (date.today() - вышла).days if вышла else None,
        })
    return итог


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


def в_индексе_google(снимок):
    """Слаги страниц, которые показывает Google."""
    итог = set()
    for с in (снимок or {}).get("страницы") or []:
        слаг = str(с.get("страница", "")).rstrip("/").rsplit("/", 1)[-1]
        if слаг:
            итог.add(слаг)
    return итог


def ступень(с, в_я, в_г):
    """На какой ступени застряла статья и просрочена ли она.

    Ступени идут по дороге статьи: вышла - попала в индекс - получила показы -
    получила позицию - растёт. Каждая статья стоит на самой дальней достигнутой.
    Норма считается от даты выхода, а не от даты последней проверки.
    """
    слаг = с["слаг"]
    дней = с.get("дней")
    я, г = слаг in в_я, слаг in в_г
    показы = (с.get("показов") or 0) + ((с.get("яндекс") or {}).get("показы") or 0)
    позиция = с.get("лучшая_позиция")
    if (с.get("яндекс") or {}).get("позиция") is not None:
        позиция = min(позиция or 999, с["яндекс"]["позиция"])

    if not я:
        имя, норма = "Опубликована, но не в индексе Яндекса", НОРМА_ДНЕЙ["не в индексе Яндекса"]
    elif not г:
        имя, норма = "В индексе Яндекса, но не в Google", НОРМА_ДНЕЙ["не в Google"]
    elif not показы:
        имя, норма = "В индексе обоих, показов ещё нет", НОРМА_ДНЕЙ["нет показов"]
    elif позиция is None:
        имя, норма = "Есть показы, позиции ещё нет", НОРМА_ДНЕЙ["нет позиции"]
    elif позиция > 30:
        имя, норма = "Есть позиция, но дальше третьей страницы", None
    else:
        имя, норма = "Есть позиция в первых тридцати", None
    просрочено = bool(норма and дней is not None and дней > норма)
    return имя, просрочено, норма


def статьи_площадок():
    """{кластер: сколько материалов вышло на чужих площадках}."""
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))["тексты"]
    счёт = defaultdict(int)
    for т in р:
        if т.get("вид") != "внешняя площадка":
            continue
        с = str(т.get("статус", "")).lower()
        if "опубликован" not in с or "не видно" in с:
            continue
        к = т.get("кластер") or ""
        if к and "нет" not in к[:4].lower():
            счёт[к] += 1
    return счёт


def k_кластер(к):
    """Название из поля «кластер», если оно человеческое, а не код."""
    зн = str(к.get("кластер") or "").strip()
    return зн if зн and зн != к.get("код") else ""


def приоритеты_ключей(к):
    """Приоритеты, расставленные руководителем у одобренных ключей кластера."""
    return sorted({str(ф.get("приоритет")) for ф in (к.get("пул") or [])
                   if ф.get("решение") == "approved" and ф.get("приоритет")})


def приоритет_кластера(к):
    """Приоритет кластера. Своего поля у кластера нет: руководитель ставит
    приоритет кнопками 1-4 у каждого ключа, и до 24.09.2026 панель показывала
    «не задан» у всех 72 кластеров, хотя из 299 одобренных ключей размечены
    295. Берём самый срочный приоритет среди одобренных ключей: если хотя бы
    один ключ помечен единицей, кластер нужен в первую очередь."""
    если_поле = к.get("приоритет")
    if если_поле:
        return str(если_поле)
    п = приоритеты_ключей(к)
    return п[0] if п else None


def кластеры_ядра():
    я = json.loads(ЯДРО.read_text(encoding="utf-8"))
    итог = []
    for к in я.get("кластеры", []):
        частота = sum((ф.get("показы") or 0) for ф in (к.get("фразы") or []))
        # Имя кластера пишут в трёх разных полях, а у девяти его нет вовсе -
        # там берём первую фразу: «Б33» ничего не говорит, «инвестиции»
        # говорит всё (24.09.2026).
        фразы = [ф.get("фраза") for ф in (к.get("фразы") or []) if ф.get("фраза")]
        имя = (к.get("имя") or k_кластер(к) or (фразы[0] if фразы else "")
               or к.get("код") or "")
        if имя == к.get("код") and фразы:
            имя = фразы[0]
        итог.append({
            "код": к.get("код") or "",
            "имя": имя,
            "решение": к.get("решение") or "",
            "приоритет": приоритет_кластера(к),
            "приоритеты_ключей": приоритеты_ключей(к),
            "частота": частота,
            "фраз": len(к.get("фразы") or []),
        })
    return итог


def главная():
    снимок = свежий_gsc()
    по_стр = по_страницам_gsc(снимок)
    яндекс = по_страницам_яндекса()
    в_я, в_г = в_индексе_яндекса(), в_индексе_google(снимок)
    портал = статьи_портала()
    площадки = статьи_площадок()
    ядро = кластеры_ядра()

    # --- разрез по статьям ---
    строки_статей = []
    for с in портал:
        ключи = по_стр.get(с["слаг"], [])
        в_топ10 = [з for з in ключи if (з.get("позиция") or 999) <= 10]
        строки_статей.append({
            "слаг": с["слаг"],
            "заголовок": с["заголовок"],
            "кластер": с["кластер"],
            # дату выхода несём дальше: от неё считается норма ступени
            "вышла": с.get("вышла"),
            "дней": с.get("дней"),
            "ключей": len(ключи),
            "показов": sum((з.get("показы") or 0) for з in ключи),
            "в_топ10": len(в_топ10),
            "лучшая_позиция": min((з.get("позиция") for з in ключи
                                   if з.get("позиция") is not None), default=None),
            "ключи": ключи[:10],
            # Яндекс: только общие числа по странице, ключей он не даёт
            "яндекс": яндекс.get(с["слаг"]),
        })
    for с in строки_статей:
        имя, просрочено, норма = ступень(с, в_я, в_г)
        с["ступень"] = имя
        с["просрочено"] = просрочено
        с["норма_дней"] = норма
    строки_статей.sort(key=lambda с: (-(с["показов"] or 0), -(с["ключей"] or 0)))

    # Воронка: сколько статей стоит на каждой ступени и сколько из них
    # держится дольше нормы. Порядок ступеней - порядок дороги статьи.
    порядок = ["Опубликована, но не в индексе Яндекса",
               "В индексе Яндекса, но не в Google",
               "В индексе обоих, показов ещё нет",
               "Есть показы, позиции ещё нет",
               "Есть позиция, но дальше третьей страницы",
               "Есть позиция в первых тридцати"]
    воронка_ступеней = []
    for имя in порядок:
        свои = [с for с in строки_статей if с["ступень"] == имя]
        if not свои:
            continue
        воронка_ступеней.append({
            "ступень": имя,
            "значок": ЗНАЧКИ.get(имя, ""),
            "сколько": len(свои),
            "просрочено": sum(1 for с in свои if с["просрочено"]),
            "норма_дней": свои[0]["норма_дней"],
            "статьи": sorted(свои, key=lambda с: -(с.get("дней") or 0)),
        })

    # --- разрез по ядру ---
    по_кластерам = defaultdict(lambda: {"портал": 0, "площадки": 0, "показов": 0, "ключей": 0})
    for с in строки_статей:
        к = с["кластер"] or "без кластера"
        по_кластерам[к]["портал"] += 1
        по_кластерам[к]["показов"] += с["показов"]
        по_кластерам[к]["ключей"] += с["ключей"]
    for к, н in площадки.items():
        по_кластерам[к]["площадки"] += н

    строки_ядра = []
    for к in ядро:
        имя = к["имя"]
        д = по_кластерам.get(имя) or по_кластерам.get(к["код"]) or {}
        портал_n = д.get("портал", 0)
        площадки_n = д.get("площадки", 0)
        показов = д.get("показов", 0)
        # Состояние кластера словами: по нему и видно, что делать дальше.
        if not портал_n and not площадки_n:
            состояние = "не тронут"
        elif портал_n and показов:
            состояние = "работает"
        elif портал_n:
            состояние = "написано, но не показывается"
        else:
            состояние = "только на площадках"
        строки_ядра.append({
            "кластер": имя,
            "решение": к["решение"],
            "приоритет": к["приоритет"],
            "приоритеты_ключей": к["приоритеты_ключей"],
            "частота": к["частота"],
            "портал": портал_n,
            "площадки": площадки_n,
            "показов": показов,
            "ключей": д.get("ключей", 0),
            "состояние": состояние,
        })
    строки_ядра.sort(key=lambda с: (-(с["частота"] or 0), с["кластер"]))

    отчёт = json.loads(ОТЧЁТ.read_text(encoding="utf-8"))
    отчёт.setdefault("seo", {})["расход_ядра"] = {
        "заголовок": "Расход семантического ядра",
        "подпись": ("По каждому кластеру: сколько статей написано на портале "
                    "и на чужих площадках и показывается ли хоть одна в поиске. "
                    "Отсюда видно, какие темы отработаны, а какие стоят пустыми."),
        "снято": (снимок or {}).get("снято"),
        "кластеров": len(строки_ядра),
        "не_тронуто": sum(1 for с in строки_ядра if с["состояние"] == "не тронут"),
        "работает": sum(1 for с in строки_ядра if с["состояние"] == "работает"),
        "молчит": sum(1 for с in строки_ядра if с["состояние"] == "написано, но не показывается"),
        "строки": строки_ядра,
        "по_статьям": строки_статей,
        "воронка_статей": воронка_ступеней,
        "как_считаем_просрочку": (
            "Каждая статья стоит на самой дальней ступени, которой достигла. "
            "Красным - те, кто держится на ступени дольше нормы; норма "
            "считается от даты выхода статьи, а не от даты последней проверки. "
            "Нормы: не в индексе Яндекса - 14 дней, не в Google - 10, "
            "нет показов - 14, нет позиции - 21."),
        "оговорка": ("Список ключей по статье даёт только Google: у Яндекса "
                     "связки «страница - запрос» в API нет, он отдаёт показы "
                     "и позицию страницы целиком. Поэтому у каждой статьи два "
                     "ряда цифр, и они не совпадают - поисковики выводят её "
                     "по разным запросам и на разных местах."),
    }
    ОТЧЁТ.write_text(json.dumps(отчёт, ensure_ascii=False, indent=1) + "\n",
                     encoding="utf-8")
    print(f"расход ядра: {len(строки_ядра)} кластеров, "
          f"работает {отчёт['seo']['расход_ядра']['работает']}, "
          f"молчит {отчёт['seo']['расход_ядра']['молчит']}, "
          f"не тронуто {отчёт['seo']['расход_ядра']['не_тронуто']}")
    print(f"по статьям: {len(строки_статей)}, "
          f"с ключами в Google: {sum(1 for с in строки_статей if с['ключей'])}")
    return 0


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