#!/usr/bin/env python3
"""Собрать когорты статей портала из реестра текстов и своего счётчика.

Когорта - статьи, вышедшие на одной неделе. Смотрим не суммарный трафик,
а жизнь статьи по месяцам после публикации.

Зачем скрипт. 14.09.2026 руководитель сверяла панель с эталоном PR Factory
и заметила, что раздел «Когортный анализ» пуст. Он писал «пока не вышла
ни одна статья», хотя на портале было шестнадцать статей с 03.09. Блок
показывал только демо-данные, а боевых не собирал никто.

Про разбивку. Раньше здесь стояло «счётчик хранит только текущую сумму,
истории по месяцам нет». Это была неправда: в сайт/данные/просмотры.json
у каждой статьи лежит поле «по_дням» с 04.09.2026 (найдено 15.09.2026).
Поэтому считаем не одну сумму, а жизнь когорты по неделям после выхода -
месяцы для статей возрастом в пару недель всё равно бессмысленны.

Чего счёт правда не умеет: до 11.09 просмотры собирала своя служба портала,
после переезда на WordPress она умерла, и с 11.09 счёт идёт разбором логов
nginx. Шов виден в данных, и про него сказано в оговорке.

Порядок: после обновить-статьи.py, до обновить-фолбэк.py.

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

import json
import re
import sys
from datetime import date, datetime, timedelta
from pathlib import Path

КОРЕНЬ = Path(__file__).resolve().parent.parent.parent
ДАННЫЕ = КОРЕНЬ / "панель" / "данные"
РЕЕСТР = КОРЕНЬ / "завод" / "реестр-текстов.json"
ПРОСМОТРЫ = КОРЕНЬ / "сайт" / "данные" / "просмотры.json"
sys.path.insert(0, str(КОРЕНЬ / "сайт"))
import просмотры as счёт  # noqa: E402

МЕСЯЦЫ = ("января", "февраля", "марта", "апреля", "мая", "июня",
          "июля", "августа", "сентября", "октября", "ноября", "декабря")


def дата_публикации(статус):
    м = re.search(r"(\d{4})-(\d{2})-(\d{2})", статус or "")
    if м:
        return date(int(м.group(1)), int(м.group(2)), int(м.group(3)))
    м = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", статус or "")
    if м:
        return date(int(м.group(3)), int(м.group(2)), int(м.group(1)))
    return None


def жизнь_по_неделям(список, сегодня):
    """Просмотры когорты по неделям жизни: 1-я неделя после выхода, 2-я и так далее.

    Считаем от даты выхода каждой статьи, а не от календаря: в одной когорте
    статьи вышли в разные дни, и календарная неделя смешала бы первый день
    жизни одной статьи с шестым днём другой.

    Неделя, которую прожили не все статьи когорты, в итог не идёт: иначе
    последний столбик всегда проседает просто потому, что он неполный.
    """
    # Полных недель жизни у самой молодой статьи когорты. Ноль значит, что
    # когорта не прожила и недели: показывать ей «первую неделю» нельзя -
    # два дня и семь дней это разные числа.
    прожито = min(((сегодня - с["дата"]).days + 1) // 7 for с in список)
    недели = [0] * прожито
    for с in список:
        for день, сколько in (с["по_дням"] or {}).items():
            try:
                д = date.fromisoformat(день)
            except ValueError:
                continue
            и = (д - с["дата"]).days // 7
            if 0 <= и < прожито:
                недели[и] += сколько or 0
    return недели, прожито


def кратко(заголовок, предел=38):
    """Название до первого двоеточия или до предела - по границе слова.

    Раньше заголовок резали ровно на 18 знаков, и в панели стояло
    «Бизнес-план: струк, Договор франшизы: , Защита прав предпр» (15.09.2026).
    """
    голова = заголовок.split(":")[0].strip() or заголовок
    if len(голова) <= предел:
        return голова
    обрез = голова[:предел].rsplit(" ", 1)[0]
    return (обрез or голова[:предел]) + "…"


def перечень(заголовки, сколько=3):
    """Названия статей когорты, коротко и без обрубков посреди слова."""
    видно = [кратко(з) for з in заголовки[:сколько]]
    хвост = len(заголовки) - len(видно)
    return ", ".join(видно) + (f" и ещё {хвост}" if хвост > 0 else "")


def собрать():
    р = json.loads(РЕЕСТР.read_text(encoding="utf-8"))
    счёт.загрузить()
    по_слагам = счёт.сводка()["статьи"]
    # сводка() схлопывает «по_дням» до «всего» и «сегодня», а для когорт
    # нужна именно поденная разбивка - берём её из самого файла счётчика.
    сырые = json.loads(ПРОСМОТРЫ.read_text(encoding="utf-8"))["статьи"] \
        if ПРОСМОТРЫ.exists() else {}

    статьи = []
    for т in р["тексты"]:
        если_портал = "опубликовано на портале" in str(т.get("статус") or "").lower()
        если_нет = дата_публикации(т.get("статус"))
        if если_портал and если_нет:
            слаг = (т.get("слаг") or "").strip("/")
            запись = по_слагам.get(слаг) or {}
            запись = dict(запись, по_дням=(сырые.get(слаг) or {}).get("по_дням") or {})
            # «Не мерили» и «ноль» - разные вещи. Статья, слага которой нет
            # в счётчике, раньше получала ноль, неотличимый от честного: две
            # такие составляли всю когорту недели, и на экране стояло
            # «2 статьи · 0 просмотров» с полосой нулевой длины (22.09.2026).
            есть_замер = слаг in по_слагам
            статьи.append({
                "дата": если_нет,
                "заголовок": т.get("заголовок") or "",
                "просмотры": запись.get("всего", 0) if есть_замер else None,
                "мерили": есть_замер,
                "слаг": слаг,
                "по_дням": запись.get("по_дням") or {},
            })
    if not статьи:
        return []

    # неделя начинается с понедельника
    группы = {}
    for с in статьи:
        начало = с["дата"] - timedelta(days=с["дата"].weekday())
        группы.setdefault(начало, []).append(с)

    сегодня = date.today()
    итог = []
    for н, (начало, список) in enumerate(sorted(группы.items()), 1):
        конец = начало + timedelta(days=6)
        мерили = [с for с in список if с["мерили"]]
        не_мерили = [с for с in список if not с["мерили"]]
        # Сумму считаем только по тем статьям, где замер есть. Если не мерили
        # ни одну, всего = None, и панель покажет прочерк, а не ноль.
        всего = sum(с["просмотры"] for с in мерили) if мерили else None
        возраст = (сегодня - начало).days
        подпись = (f"{начало.day}-{конец.day} {МЕСЯЦЫ[конец.month - 1]} {конец.year}"
                   if начало.month == конец.month else
                   f"{начало.day} {МЕСЯЦЫ[начало.month - 1]} - {конец.day} {МЕСЯЦЫ[конец.month - 1]}")
        по_неделям, прожито = жизнь_по_неделям(список, сегодня)
        заголовки = sorted(с["заголовок"] for с in список)
        итог.append({
            "когорта": f"Неделя {н} · {подпись}",
            "статей": len(список),
            "кластеры": перечень(заголовки),
            "по_месяцам": [всего] if возраст <= 31 else [None, всего],
            "по_неделям": по_неделям,
            "недель_прожито": прожито,
            "всего": всего,
            "в_топ10": None,
            "на_статью": (round(всего / len(мерили)) if мерили and всего is not None
                          else None),
            "мерили_статей": len(мерили),
            "без_замера": [с["заголовок"] for с in не_мерили],
            "нота_замера": ("" if not не_мерили else
                            (f"замера нет ни у одной из {len(список)} статей когорты"
                             if not мерили else
                             f"замер есть у {len(мерили)} из {len(список)}: "
                             f"у остальных статья ещё не попадала в логи")),
            "возраст_дней": возраст,
            "демо": False,
        })
    return итог


def главная():
    когорты = собрать()
    файл = ДАННЫЕ / "report.json"
    отчёт = json.loads(файл.read_text(encoding="utf-8"))
    seo = отчёт["seo"]
    if когорты:
        seo["когорты"] = когорты
        seo["когорты_пусто"] = ""
        недель = max((к["недель_прожито"] for к in когорты), default=0)
        seo["когорты_недели"] = [f"{и + 1}-я неделя" for и in range(недель)]
        seo["когорты_подпись"] = (
            "Когорта это статьи, вышедшие на одной неделе. Смотрим не суммарный "
            "трафик, а жизнь статьи после выхода: первые дни уходят на индексацию, "
            "дальше статья набирает вес или встаёт. Пока всем статьям меньше месяца, "
            "поэтому шаг недельный; на месяцы перейдём, когда первой когорте "
            "исполнится два.")
        seo["когорты_оговорка"] = (
            "Просмотры считает свой счётчик портала с 04.09.2026: до 11.09 "
            "их собирала служба портала, после переезда на WordPress она перестала "
            "работать, и с 11.09 счёт идёт разбором логов nginx. Столбцы - недели "
            "жизни статьи, а не календарь: отсчёт от дня выхода каждой статьи. "
            "Неполная неделя в счёт не идёт, поэтому у свежей когорты столбцов "
            "меньше. Судить по первым неделям рано - они уходят на индексацию."
        )
    else:
        seo["когорты_пусто"] = "Пока не вышла ни одна статья портала."
    файл.write_text(json.dumps(отчёт, ensure_ascii=False, indent=1), encoding="utf-8")
    print(f"когорт собрано: {len(когорты)}")
    # «всего» бывает None: когорта, ни одна статья которой ещё не попала
    # в логи (свежая, вышла сегодня). В report.json это законный прочерк,
    # а вот f-строка с {None:4} падала, и после-публикации.sh считал шаг
    # проваленным, хотя файл уже был записан (поймано 29.09.2026).
    for к in когорты:
        всего = "-" if к["всего"] is None else к["всего"]
        на_статью = "-" if к["на_статью"] is None else к["на_статью"]
        print(f"  {к['когорта']:34} статей {к['статей']:2}  просмотров {всего:>4}"
              f"  ({на_статью}/ст.)")


главная()
