Boosty Title Modifier

Добавляет время поста в заголовок *вкладки*

// ==UserScript==
// @name         Boosty Title Modifier
// @version      0.45
// @description  Добавляет время поста в заголовок *вкладки*
// @match        https://boosty.to/*
// @grant        none
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==

(function() {
    'use strict';

    // --- КАТЕГОРИЯ: ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ И СОСТОЯНИЯ ---
    // Этот раздел содержит переменные, управляющие состоянием скрипта.
    // - currentFormattedTitle: Хранит текущий, нами установленный, заголовок вкладки (например, "2023.10.26 15:30 - Название поста").
    //                         Используется "Защитником" для восстановления заголовка, если Boosty попытается его сбросить.
    // - currentPostId: ID поста (data-post-id), который в данный момент отображается и заголовок которого мы модифицировали.
    //                  Помогает избежать повторной обработки того же поста при обновлениях DOM и сбросить заголовок при уходе со страницы.
    // - titleObserver: Экземпляр MutationObserver ("Защитник"). Следит за элементом <title> и предотвращает его изменение
    //                  другими скриптами Boosty, пока мы находимся на странице поста.
    // - bodyObserver: Экземпляр MutationObserver ("Искатель"). Следит за <body> на предмет появления/исчезновения
    //                 контента поста. Он отвечает за первоначальное обнаружение данных поста и сброс состояния при уходе.
    let currentFormattedTitle = null; // Текущий отформатированный заголовок
    let currentPostId = null; // ID поста, который мы сейчас отслеживаем
    let titleObserver = null; // "Защитник" заголовка вкладки
    let bodyObserver = null; // "Искатель" контента

    // --- КАТЕГОРИЯ: ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ И КОНСТАНТЫ ---
    // Вспомогательные утилиты для парсинга и поиска данных.

    // - monthsRu: Словарь для преобразования сокращенных русских названий месяцев в числовой формат (MM).
    const monthsRu = { 'янв': '01', 'фев': '02', 'мар': '03', 'апр': '04', 'мая': '05', 'июн': '06', 'июл': '07', 'авг': '08', 'сент': '09', 'окт': '10', 'нояб': '11', 'дек': '12' };
    
    // - monthsEn: Словарь для преобразования сокращенных английских названий месяцев в числовой формат (MM).
    const monthsEn = { 'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04', 'may': '05', 'jun': '06', 'jul': '07', 'aug': '08', 'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12' };


    /**
     * Парсит строку даты Boosty (например, "26 окт 2023 в 15:30" или "Oct 27 12:01") в стандартизированный формат "YYYY.MM.DD HH MM".
     * @param {string} dateStr - Исходная строка даты/времени из DOM.
     * @returns {string} - Отформатированная строка или исходная строка, если парсинг не удался.
     */
    function parseBoostyDate(dateStr) {
        try {
            // Приводим к нижнему регистру и убираем запятые для унификации
            const lowerDateStr = dateStr.toLowerCase().replace(/,/g, ''); 
            const parts = lowerDateStr.split(' '); // ["oct", "27", "2023", "at", "12:01"] или ["27", "окт", "2023", "в", "12:01"] или ["27", "oct", "12:01"]

            let day, monthStr, year, time, month;

            // 1. Найти время (XX:XX)
            time = parts.find(p => p.includes(':'));
            if (!time) {
                // Если нет времени (например, "только что"), выходим
                return dateStr;
            }
            
            // 2. Найти месяц
            monthStr = parts.find(p => monthsRu[p] || monthsEn[p]);
            if (!monthStr) {
                // Если нет месяца (странный формат), выходим
                return dateStr;
            }
            
            // 3. Определить словарь месяцев
            let monthMap = monthsRu[monthStr] ? monthsRu : monthsEn;
            month = monthMap[monthStr];

            // 4. Найти день (число, которое не является годом и не содержит ":")
            day = parts.find(p => !isNaN(parseInt(p, 10)) && p.length <= 2 && !p.includes(':'));
            if (!day) {
                return dateStr; // Не нашли день
            }

            // 5. Найти год (число из 4 цифр)
            year = parts.find(p => !isNaN(parseInt(p, 10)) && p.length === 4);
            if (!year) {
                year = new Date().getFullYear(); // Если год не указан, берем текущий
            }
            
            // 6. Форматирование
            time = time.replace(/:/g, '').padStart(4, '0');
            day = day.padStart(2, '0');

            return `${year}.${month}.${day} ${time.slice(0, 2)} ${time.slice(2)}`;

        } catch (e) {
            console.error("[Title Modifier] Parse Error:", e, dateStr);
            return dateStr; // Возвращаем исходную строку в случае ошибки
        }
    }


    /**
     * Ищет элемент с датой создания поста внутри контейнера поста.
     * @param {HTMLElement} postElement - DOM-элемент поста (с [data-test-id="COMMON_POST:ROOT"]).
     * @returns {string} - Текстовое содержимое элемента времени или пустая строка.
     */
    function getPostTime(postElement) {
        // Ищем по селекторам. Сначала по data-test-id (более надежный),
        // затем по части имени класса (менее надежный, но запасной).
        const timeElement = postElement.querySelector('[data-test-id="COMMON_CREATEDAT:ROOT"]') || postElement.querySelector('[class*="CreatedAt"]');
        return timeElement?.textContent?.trim() || '';
    }

    // --- КАТЕГОРИЯ: НАБЛЮДАТЕЛИ (MUTATION OBSERVERS) ---

    /**
     * "Защитник" (Наблюдатель 1)
     * Активируется *после* того, как "Искатель" найдет пост и установит заголовок.
     * Его задача - следить за тегом <title> и немедленно отменять любые изменения,
     * которые пытаются внести скрипты Boosty (например, при скролле или других событиях SPA).
     */
    function startTitleObserver() {
        // Если старый "Защитник" активен, отключаем его.
        if (titleObserver) titleObserver.disconnect();

        const titleElement = document.querySelector('title');
        if (!titleElement) return; // Не можем найти title (маловероятно)

        titleObserver = new MutationObserver(() => {
            // Если мы сохранили наш формат заголовка И текущий заголовок в DOM *не* равен нашему
            if (currentFormattedTitle && document.title !== currentFormattedTitle) {
                // Boosty попытался сбросить заголовок!
                // 1. Немедленно отключаем "Защитника", чтобы наш следующий вызов не вызвал рекурсию.
                titleObserver.disconnect();
                // 2. Возвращаем наш правильный заголовок.
                document.title = currentFormattedTitle;
                // 3. Подключаем "Защитника" снова, чтобы продолжать следить за <title>.
                titleObserver.observe(titleElement, { childList: true, characterData: true, subtree: true });
            }
        });

        // Начинаем наблюдение за <title> и его дочерними узлами (текстом внутри).
        titleObserver.observe(titleElement, { childList: true, characterData: true, subtree: true });
    }

    /**
     * "Искатель" (Наблюдатель 2)
     * Это основной наблюдатель, который постоянно следит за <body>.
     * Его задача - обнаружить, что пользователь перешел на страницу поста (или она загрузилась),
     * извлечь данные (ID, заголовок, время) и запустить "Защитника".
     * Он также отвечает за сброс состояния (отключение "Защитника"), когда пользователь уходит
     * со страницы поста (например, обратно в ленту).
     */
    function startBodyObserver() {
        if (bodyObserver) bodyObserver.disconnect();

        bodyObserver = new MutationObserver(() => {
            // Ищем *первый* пост в главной колонке (column-1).
            // Это гарантирует, что мы смотрим на основной контент, а не на посты в боковой панели.
            const postElement = document.querySelector('#column-1 [data-test-id="COMMON_POST:ROOT"]');

            if (!postElement) {
                // --- Сценарий 1: Пост не найден ---
                // Мы не на странице поста или ленты (например, в настройках, сообщениях).
                // Или страница еще не загрузилась.

                // Если у нас был ID отслеживаемого поста (т.е. мы *были* на странице поста)
                if (currentPostId) {
                    // Значит, мы ушли со страницы поста.
                    if (titleObserver) titleObserver.disconnect(); // Отключаем "Защитника".
                    currentPostId = null; // Сбрасываем состояние.
                    currentFormattedTitle = null;
                    // Boosty вернет свой заголовок по умолчанию (например, "Блог автора").
                }
                return; // Делать нечего.
            }

            // --- Сценарий 2: Пост найден (postElement существует) ---

            // Теперь нам нужно понять, это страница *одного* поста или лента.
            // Признак страницы поста - наличие заголовка H1.
            // (В ленте заголовки постов - H2).
            const h1Title = postElement.querySelector('h1[data-test-id="COMMON_POST_POSTCONTENT:TITLE"], h1[class*="PostSubscriptionBlock-scss--module_title"], h1[class*="Post-scss--module_title"]');
            const postId = postElement.dataset.postId; // Получаем ID поста

            if (h1Title && postId) {
                // --- Сценарий 2а: Мы на странице поста (H1 найден) ---

                // Если ID поста совпадает с тем, что мы уже обрабатывали,
                // значит, это просто внутреннее обновление DOM (например, комменты подгрузились).
                if (postId === currentPostId) {
                    return; // Ничего не делаем, "Защитник" уже работает.
                }

                // --- Это новый пост! (переход на другой пост или первая загрузка) ---
                if (titleObserver) titleObserver.disconnect(); // Отключаем старого "Защитника" (если был).

                currentPostId = postId; // Запоминаем ID нового поста.
                const rawTime = getPostTime(postElement);
                const rawTitle = h1Title.textContent.trim();

                if (rawTime && rawTitle) {
                    // Форматируем данные
                    const formattedTime = parseBoostyDate(rawTime);
                    const newTitle = `${formattedTime} - ${rawTitle}`;

                    // Устанавливаем наш заголовок
                    document.title = newTitle;
                    currentFormattedTitle = newTitle; // Сохраняем его для "Защитника"

                    // Запускаем "Защитника", чтобы этот заголовок не сбросился.
                    startTitleObserver();
                }

            } else {
                // --- Сценарий 2б: Мы в ленте (H1 не найден) ---

                // Если у нас был ID отслеживаемого поста (т.е. мы *только что* ушли со страницы поста
                // обратно в ленту, нажав "назад" или на логотип)
                if (currentPostId) {
                    // Мы ушли со страницы поста в ленту.
                    if (titleObserver) titleObserver.disconnect(); // Отключаем "Защитника".
                    currentPostId = null; // Сбрасываем состояние.
                    currentFormattedTitle = null;
                    // Boosty вернет свой заголовок (например, "Лента").
                }
                // (Если currentPostId уже null, значит мы просто скроллим ленту, ничего не делаем)
            }
        });

        // Начинаем наблюдение за всем <body> и его дочерними элементами.
        bodyObserver.observe(document.body, { childList: true, subtree: true });
    }

    // --- КАТЕГОРИЯ: ПЕРВЫЙ ЗАПУСК ---
    // Ждем, пока <body> будет доступен, прежде чем запускать "Искателя".
    if (document.body) {
        startBodyObserver();
    } else {
        window.addEventListener('DOMContentLoaded', startBodyObserver);
    }

})();