Boosty Title Modifier

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);
    }

})();