Boost Link Generator

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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