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

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

Два разреза:

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

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

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

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

ПАПКА = Path(__file__).resolve().parent
КОРЕНЬ = ПАПКА.parent.parent
ОТЧЁТ = ПАПКА / "report.json"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ЯДРО = КОРЕНЬ / "семантика" / "ядро.json"
GSC = КОРЕНЬ / "завод" / "search-console" / "история"
ВЕБМАСТЕР = КОРЕНЬ / "завод" / "вебмастер" / "история"
ПРОСМОТРЫ = КОРЕНЬ / "сайт" / "данные" / "просмотры.json"
# Сколько запросов на статью везём в панель: список вшивается в страницу,
# и сотня строк на каждую статью раздула бы её (тот же довод, что у
# СПИСОК = 10 в обновить-ключи.py). Сортировка по показам, хвост не нужен.
ЗАПРОСОВ_НА_СТАТЬЮ = 50


# Сколько дней ступень считается нормальной. Дольше - статья застряла,
# и её надо толкать: переобход, ссылки, переделка заголовка. Сроки взяты
# из наблюдений сентября: Яндекс берёт свежую статью за 1-2 недели,
# Google быстрее, но показы после индексации идут не сразу.
# Значки ступеней - те же, что в эталоне PR Factory: по ним ступень узнаётся
# с одного взгляда, не читая подпись.
ЗНАЧКИ = {
    "Опубликована, но не в индексе Яндекса": "📄",
    "В индексе Яндекса, но не в Google": "🔍",
    "В индексе 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 - выгрузки ещё не было."""
    # Только снимки с датой в имени: с 07.10.2026 индекс.py кладёт в ту же
    # папку «индекс-ГГГГ-ММ-ДД.json», по алфавиту он идёт после дат, и панель
    # полдня читала его вместо выгрузки - показы и позиции Google обнулились.
    снимки = sorted(GSC.glob("20*.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 запросы_яндекса():
    """{слаг: [запросы]} из свежего снимка Вебмастера, где у запроса есть страница.

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


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


def ссылки_с_площадок():
    """{слаг: сколько опубликованных материалов площадок ссылаются на статью}.

    Считаем материалы, а не вхождения: в одном тексте ссылка бывает дважды.
    Это наш текст, а не то, что вышло: модерация могла ссылку вырезать.
    """
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))["тексты"]
    счёт = defaultdict(int)
    for т in р:
        if т.get("вид") != "внешняя площадка" or т.get("видно_читателю") == "нет":
            continue
        if "опубликован" not in str(т.get("статус", "")).lower():
            continue
        try:
            текст = (КОРЕНЬ / т.get("файл", "")).read_text(encoding="utf-8")
        except (OSError, IsADirectoryError):
            continue
        for слаг in set(re.findall(r"frantlas\.ru/statya/([a-z0-9-]+)", текст)):
            счёт[слаг] += 1
    return счёт


def просмотры_за_неделю():
    """{слаг: просмотров за 7 дней} по своему счётчику из логов nginx.
    Это не визиты Метрики: логи видят и тех, у кого не работает JavaScript."""
    try:
        д = json.loads(ПРОСМОТРЫ.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}
    с = (date.today() - timedelta(days=6)).isoformat()
    return {слаг: sum(н for день, н in (з.get("по_дням") or {}).items() if день >= с)
            for слаг, з in (д.get("статьи") or {}).items()}


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()


ИНДЕКС_GOOGLE = КОРЕНЬ / "завод" / "search-console" / "индекс-состояние.json"


def в_индексе_google(снимок):
    """Слаги страниц в индексе Google.

    Главный источник - URL Inspection: завод/search-console/индекс.py каждое
    утро проверяет новые и не взятые статьи, по понедельникам все, и пишет
    индекс-состояние.json. До 07.10.2026 индексом считали только страницы
    с показами в Search Console, и панель занижала индекс: статья в индексе,
    но без показов, стояла на ступени «не в Google» (06.10 так стояли 4 из 6).
    Страницы с показами добавляем как раньше: показы бывают только у
    проиндексированной страницы, а проверка могла не дойти до свежей.
    """
    итог = set()
    for с in (снимок or {}).get("страницы") or []:
        слаг = str(с.get("страница", "")).rstrip("/").rsplit("/", 1)[-1]
        if слаг:
            итог.add(слаг)
    try:
        сост = json.loads(ИНДЕКС_GOOGLE.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return итог
    for адрес, з in (сост.get("страницы") or {}).items():
        if з.get("в_индексе"):
            итог.add(str(адрес).rstrip("/").rsplit("/", 1)[-1])
    return итог


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

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

    # Проверяем с дальнего конца, как эталон Adonis. До 06.10.2026 проверка
    # шла с начала, и статья с 84 показами и местом в Google стояла на первой
    # ступени только потому, что её не было в выборке индекса Яндекса.
    if позиция is not None and позиция <= 30:
        имя, норма = "Есть позиция в первых тридцати", None
    elif позиция is not None:
        имя, норма = "Есть позиция, но дальше третьей страницы", None
    elif показы:
        имя, норма = "Есть показы, позиции ещё нет", НОРМА_ДНЕЙ["нет позиции"]
    elif я and г:
        имя, норма = "В индексе обоих, показов ещё нет", НОРМА_ДНЕЙ["нет показов"]
    elif г:
        имя, норма = "В индексе Google, но не в Яндексе", НОРМА_ДНЕЙ["не в индексе Яндекса"]
    elif я:
        имя, норма = "В индексе Яндекса, но не в Google", НОРМА_ДНЕЙ["не в Google"]
    else:
        имя, норма = "Опубликована, но не в индексе Яндекса", НОРМА_ДНЕЙ["не в индексе Яндекса"]
    просрочено = 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(с["слаг"]),
        })
        # Колонки блока «как у Adonis» (видео руководителя 01.10.2026).
        стр = строки_статей[-1]
        я = стр["яндекс"] or {}
        стр["адрес"] = f"https://frantlas.ru/statya/{с['слаг']}/"
        стр["показы_я"] = int(я.get("показы") or 0)
        стр["показы_g"] = стр["показов"]
        # Позиция одна, как у эталона: лучшая из двух поисковиков, с буквой
        # того, чья она. У Яндекса это средняя позиция страницы по запросам.
        кандидаты = [(п, б) for п, б in ((стр["лучшая_позиция"], "G"), (я.get("позиция"), "Я"))
                     if п is not None]
        if кандидаты:
            п, б = min(кандидаты)
            стр["позиция"], стр["позиция_поиск"] = round(п, 1), б
        else:
            стр["позиция"], стр["позиция_поиск"] = None, None
        день = индекс_я.get(с["слаг"])
        стр["дней_до_индекса"], стр["индекс_оценка"] = None, False
        if день and с.get("вышла"):
            стр["дней_до_индекса"] = max((date.fromisoformat(день)
                                        - date.fromisoformat(с["вышла"])).days, 0)
            стр["индекс_оценка"] = день == начало_списка and с["вышла"] < начало_списка
        стр["ссылки_с_площадок"] = ссылки.get(с["слаг"], 0)
        стр["просмотры_7"] = просмотры.get(с["слаг"], 0)
        # Полный список запросов живёт отдельно от строк статей: строки едут
        # и в «по_статьям», и в воронку, список вшился бы в страницу дважды.
        все_запросы = запросы_я.get(с["слаг"], []) + [
            {"поиск": "Google", "запрос": з.get("запрос", ""), "позиция": з.get("позиция"),
             "показы": з.get("показы") or 0, "клики": з.get("клики") or 0} for з in ключи]
        все_запросы.sort(key=lambda з: -(з["показы"] or 0))
        стр["запросов"] = len(все_запросы)
        if все_запросы:
            запросы_статей[с["слаг"]] = все_запросы[:ЗАПРОСОВ_НА_СТАТЬЮ]
    for с in строки_статей:
        имя, просрочено, норма = ступень(с, в_я, в_г)
        с["ступень"] = имя
        с["просрочено"] = просрочено
        с["норма_дней"] = норма
    строки_статей.sort(key=lambda с: (-(с["показов"] or 0), -(с["ключей"] or 0)))

    # Воронка: сколько статей стоит на каждой ступени и сколько из них
    # держится дольше нормы. Порядок ступеней - порядок дороги статьи.
    порядок = ["Опубликована, но не в индексе Яндекса",
               "В индексе Яндекса, но не в Google",
               "В индексе 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."),
        "оговорка": ("Яндекс относит каждый запрос к одной, самой популярной "
                     "странице, а цифры запроса считает по всему сайту. Поэтому "
                     "показы статьи в Яндексе взяты из среза по страницам и "
                     "могут не совпасть с суммой её запросов."),
    }
    ОТЧЁТ.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(главная())
