Boost Link Generator

Генерация YouTube ссылки

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Boost Link Generator
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  Генерация YouTube ссылки
// @match        https://www.youtube.com/watch*
// @match        https://m.youtube.com/watch*
// @grant        GM_setClipboard
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(() => {
    "use strict";

    const CONFIG = {
        searchBase: "https://www.youtube.com/results?search_query=",
        panelId: "tm-boost-link-panel",
        styleId: "tm-boost-link-style",
        pageCheckInterval: 700,
        initDelay: 600
    };
    const CHANNEL_URL_SELECTORS = [
        'ytd-video-owner-renderer a[href^="/@"]',
        'ytd-video-owner-renderer a[href^="/channel/"]',
        'ytd-channel-name a[href]',
        '#owner #channel-name a[href]'
    ];
    const CHANNEL_NAME_SELECTORS = [
        "ytd-video-owner-renderer ytd-channel-name a",
        "ytd-channel-name a",
        "#owner #channel-name a"
    ];
    const TITLE_SELECTORS = [
        "h1.ytd-watch-metadata yt-formatted-string",
        "h1.title yt-formatted-string",
        "yt-formatted-string.style-scope.ytd-watch-metadata"
    ];
    const PANEL_TARGET_SELECTORS = [
        "#above-the-fold #top-row",
        "#above-the-fold",
        "ytd-watch-metadata"
    ];
    const TEXTS = {
        refresh: "Обновите страницу если ненаход / Refresh the page if not found",
        copyOk: "Скопировано",
        copyError: "Ошибка копирования",
        dataError: "Не удалось получить данные",
        unknownChannel: "Unknown Channel",
        channelNotFound: "Channel not found",
        previewNotFound: "Not found"
    };

    let currentVideoId = null;
    let initTimer = null;

    function escapeHtml(str) {
        return String(str).replace(/[&<>"']/g, ch => ({
            "&": "&amp;",
            "<": "&lt;",
            ">": "&gt;",
            '"': "&quot;",
            "'": "&#039;"
        })[ch]);
    }

    function getVideoIdFromUrl() {
        const url = new URL(location.href);
        return url.searchParams.get("v") || "";
    }

    function cleanText(text) {
        return (text || "").replace(/\s+/g, " ").trim();
    }

    function queryFirst(selectors, mapper) {
        for (const selector of selectors) {
            const value = mapper(document.querySelector(selector));
            if (value) return value;
        }

        return "";
    }

    function removeFreeTags(title) {
        if (!title) return "";

        const first19 = title.slice(0, 19).replace(/\b(FREE\s+FOR\s+PROFIT|FREE)\b/gi, "");
        return first19 + title.slice(19);
    }

    function normalizeTitle(title) {
        let result = cleanText(title);
        result = removeFreeTags(result);

        result = result.replace(/[^a-zA-Zа-яёА-ЯЁ0-9\s&$"'’\-]/g, "");
        result = result.replace(/\s+/g, " ").trim();

        result = result.replace(/\bprod.*$/i, "").trim();

        return result;
    }

    function encodeQuery(query) {
        return encodeURIComponent(query).replace(/%20/g, "+");
    }

    function getSuffixByAge(datePublished) {
        if (!datePublished) return "CE";

        const published = new Date(datePublished);
        if (Number.isNaN(published.getTime())) return "CE";

        const now = new Date();
        const diffMs = now - published;
        const diffDays = Math.floor(diffMs / 86400000);

        if (diffDays > 6) return "EE";
        if (diffDays > 0) return "DE";
        return "CE";
    }

    function generateSearchUrl({ title, channelName, datePublished, noChannel, sortByDate }) {
        const normalizedTitle = normalizeTitle(title);
        const query = noChannel ? normalizedTitle : `${normalizedTitle} ${channelName}`.trim();
        const formattedQuery = encodeQuery(query);

        if (!sortByDate) {
            return `${CONFIG.searchBase}${formattedQuery}`;
        }

        const char = "I";
        const suffix = getSuffixByAge(datePublished);

        return `${CONFIG.searchBase}${formattedQuery}&sp=CA${char}SBAg${suffix}AE%253D`;
    }

    function getMetaContent(selector) {
        return document.querySelector(selector)?.content?.trim() || "";
    }

    function getChannelUrl() {
        return queryFirst(CHANNEL_URL_SELECTORS, el => el?.href) || getMetaContent('link[itemprop="name"]');
    }

    function getChannelName() {
        return queryFirst(CHANNEL_NAME_SELECTORS, el => cleanText(el?.textContent))
            || cleanText(getMetaContent('meta[itemprop="author"]'))
            || TEXTS.unknownChannel;
    }

    function getTitle() {
        return queryFirst(TITLE_SELECTORS, el => cleanText(el?.textContent))
            || cleanText(getMetaContent('meta[property="og:title"]'))
            || document.title.replace(/\s*-\s*YouTube$/i, "");
    }

    function getThumbnailUrl() {
        return getMetaContent('meta[property="og:image"]');
    }

    function getDatePublished() {
        return getMetaContent('meta[itemprop="datePublished"]');
    }

    function getVideoData() {
        const title = getTitle();
        const channelName = getChannelName();
        const channelUrl = getChannelUrl();
        const thumbnailUrl = getThumbnailUrl();
        const datePublished = getDatePublished();

        return {
            title,
            channelName,
            channelUrl,
            thumbnailUrl,
            datePublished
        };
    }

    async function copyToClipboard(text, html = "") {
        try {
            if (navigator.clipboard && window.ClipboardItem && html) {
                const item = new ClipboardItem({
                    "text/plain": new Blob([text], { type: "text/plain" }),
                    "text/html": new Blob([html], { type: "text/html" })
                });
                await navigator.clipboard.write([item]);
                return true;
            }

            if (navigator.clipboard?.writeText) {
                await navigator.clipboard.writeText(text);
                return true;
            }
        } catch (_) {}

        try {
            if (typeof GM_setClipboard === "function") {
                GM_setClipboard(html || text, { type: html ? "html" : "text", mimetype: html ? "text/html" : "text/plain" });
                return true;
            }
        } catch (_) {}

        try {
            const ta = document.createElement("textarea");
            ta.value = text;
            ta.style.position = "fixed";
            ta.style.opacity = "0";
            document.body.appendChild(ta);
            ta.focus();
            ta.select();
            document.execCommand("copy");
            ta.remove();
            return true;
        } catch (_) {
            return false;
        }
    }

    function showStatus(message, ok = true) {
        const status = document.querySelector(`#${CONFIG.panelId} .tm-status`);
        if (!status) return;

        status.textContent = message;
        status.style.color = ok ? "#22c55e" : "#ef4444";

        clearTimeout(status._timer);
        status._timer = setTimeout(() => {
            status.textContent = "";
        }, 2200);
    }

    function buildMessage(data, options) {
        const searchUrl = generateSearchUrl({
            title: data.title,
            channelName: data.channelName,
            datePublished: data.datePublished,
            noChannel: options.noChannel,
            sortByDate: options.sortByDate
        });

        const channelVideosUrl = data.channelUrl
            ? `${data.channelUrl.replace(/\/$/, "")}/videos`
            : TEXTS.channelNotFound;

        const notFoundText = `Ненаход / Not found: ${channelVideosUrl}`;
        const previewText = `Превью / Preview: ${data.thumbnailUrl || TEXTS.previewNotFound}`;
        const lines = [
            searchUrl,
            "",
            TEXTS.refresh,
            "",
            notFoundText,
            "",
            previewText
        ];

        const safeThumb = escapeHtml(data.thumbnailUrl || "");

        const htmlMessage = [
            `<div>`,
            ...lines.flatMap(line => line ? [`<div>${escapeHtml(line)}</div>`] : ["<br>"]),
            safeThumb ? `<br><a href="${safeThumb}">⠀⠀⠀⠀⠀</a>` : "",
            `</div>`
        ].join("");

        return { textMessage: lines.join("\n"), htmlMessage };
    }

    function injectStyles() {
        if (document.getElementById(CONFIG.styleId)) return;

        const style = document.createElement("style");
        style.id = CONFIG.styleId;
        style.textContent = `
            #${CONFIG.panelId} {
                display: flex;
                flex-wrap: wrap;
                align-items: center;
                gap: 10px;
                margin: 12px 0;
                padding: 12px 14px;
                background: rgba(255,255,255,0.06);
                border: 1px solid rgba(255,255,255,0.12);
                border-radius: 14px;
                font-family: Arial, sans-serif;
            }

            #${CONFIG.panelId} .tm-title {
                font-size: 15px;
                font-weight: 700;
                color: var(--yt-spec-text-primary, #fff);
                margin-right: 4px;
            }

            #${CONFIG.panelId} .tm-option {
                display: inline-flex;
                align-items: center;
                gap: 6px;
                color: var(--yt-spec-text-primary, #fff);
                font-size: 15px;
                user-select: none;
                cursor: pointer;
            }

            #${CONFIG.panelId} input[type="checkbox"] {
                cursor: pointer;
            }

            #${CONFIG.panelId} .tm-btn {
                border: 0;
                border-radius: 10px;
                padding: 8px 12px;
                cursor: pointer;
                font-size: 15px;
                font-weight: 700;
                transition: transform .12s ease, opacity .12s ease;
            }

            #${CONFIG.panelId} .tm-btn:hover {
                transform: translateY(-1px);
                opacity: .95;
            }

            #${CONFIG.panelId} .tm-btn-copy {
                background: #3ea6ff;
                color: #111;
            }

            #${CONFIG.panelId} .tm-status {
                min-width: 110px;
                font-size: 14px;
                font-weight: 700;
            }

            #${CONFIG.panelId} .tm-output {
                width: 100%;
                margin-top: 8px;
                padding: 10px 12px;
                border-radius: 10px;
                background: rgba(0,0,0,0.18);
                color: var(--yt-spec-text-primary, #fff);
                font-size: 14px;
                line-height: 1.45;
                white-space: pre-wrap;
                word-break: break-word;
                display: block;
            }
        `;
        document.head.appendChild(style);
    }

    function createPanel() {
        if (document.getElementById(CONFIG.panelId)) return;

        const target = queryFirst(PANEL_TARGET_SELECTORS, el => el);

        if (!target) return;

        const panel = document.createElement("div");
        panel.id = CONFIG.panelId;
        panel.innerHTML = `
            <div class="tm-title">Boost Link</div>

            <label class="tm-option">
                <input type="checkbox" class="tm-no-channel">
                Без канала / Without a channel
            </label>

            <label class="tm-option">
                <input type="checkbox" class="tm-sort-date" checked>
                По дате / By date
            </label>

            <button class="tm-btn tm-btn-copy" type="button">Скопировать</button>
            <div class="tm-status"></div>
            <div class="tm-output"></div>
        `;

        target.parentNode.insertBefore(panel, target.nextSibling);

        const btnCopy = panel.querySelector(".tm-btn-copy");
        const cbNoChannel = panel.querySelector(".tm-no-channel");
        const cbSortDate = panel.querySelector(".tm-sort-date");
        const output = panel.querySelector(".tm-output");

        function getMessage() {
            const data = getVideoData();

            if (!data.title || !data.channelName) {
                output.textContent = TEXTS.dataError;
                return null;
            }

            return buildMessage(data, {
                noChannel: cbNoChannel.checked,
                sortByDate: cbSortDate.checked
            });
        }

        function updateOutput() {
            const message = getMessage();
            if (!message) return null;

            const { textMessage } = message;
            output.textContent = textMessage;
            return message;
        }

        btnCopy.addEventListener("click", async () => {
            const message = updateOutput();

            if (!message) {
                showStatus(TEXTS.dataError, false);
                return;
            }

            const ok = await copyToClipboard(message.textMessage, message.htmlMessage);
            showStatus(ok ? TEXTS.copyOk : TEXTS.copyError, ok);
        });

        cbNoChannel.addEventListener("change", updateOutput);
        cbSortDate.addEventListener("change", updateOutput);

        updateOutput();
    }

    function removeOldPanel() {
        document.getElementById(CONFIG.panelId)?.remove();
    }

    function init() {
        if (!/\/watch/.test(location.pathname)) return;

        const videoId = getVideoIdFromUrl();
        if (!videoId) return;

        if (videoId === currentVideoId && document.getElementById(CONFIG.panelId)) return;
        currentVideoId = videoId;

        injectStyles();
        removeOldPanel();

        clearTimeout(initTimer);
        initTimer = setTimeout(() => {
            createPanel();
        }, CONFIG.initDelay);
    }

    function setupObservers() {
        document.addEventListener("yt-navigate-finish", init, true);
        window.addEventListener("load", init, { once: true });

        let lastHref = location.href;
        setInterval(() => {
            if (location.href !== lastHref) {
                lastHref = location.href;
                init();
            }
        }, CONFIG.pageCheckInterval);

        const observer = new MutationObserver(() => {
            if (!document.getElementById(CONFIG.panelId) && /\/watch/.test(location.pathname)) {
                init();
            }
        });

        observer.observe(document.documentElement, {
            childList: true,
            subtree: true
        });
    }

    setupObservers();
    init();
})();