☰

R34 Viewed Thumbs Border

Marks viewed thumbnails on rule34video.com, stores viewed video IDs, and highlights selected thumb titles

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         R34 Viewed Thumbs Border
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Marks viewed thumbnails on rule34video.com, stores viewed video IDs, and highlights selected thumb titles
// @author       You
// @match        https://rule34video.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_addValueChangeListener
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?sz=64&domain=rule34video.com
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'r34_viewed_thumbs_v1';
    const STORAGE_KEY_FAR = 'r34_viewed_thumbs_far_v1';
    const PROCESS_DELAY = 120;
    const MIN_PAGE_FOR_DATE_HIGHLIGHT = 400;
    const THUMB_SELECTOR = '.item.ma-v, .th, .item.thumb, .thumb:not(img)';
    const WRAP_SELECTOR = '.ma-thumb, .img.wrap_image';
    const VIDEO_LINK_SELECTOR = 'a[href*="/video/"]';
    const LINK_SELECTOR = `${THUMB_SELECTOR} ${VIDEO_LINK_SELECTOR}`;
    const TITLE_SELECTOR = '.ma-v__t, .thumb_title';
    const DATE_SELECTOR = '.video-card-meta__date, .ma-v__m span:nth-child(2)';
    const PAGINATION_ACTIVE_SELECTOR = '.pagination .item.active, .fs-pages .is-cur, .fs-pages [aria-current="page"]';
    const VIEWED_ATTR = 'data-r34-viewed-thumb';
    const FLAGGED_TITLE_ATTR = 'data-r34-flagged-title';
    const VIEWED_DATE_ATTR = 'data-r34-viewed-date';
    const TITLE_HIGHLIGHT_KEYWORDS = 'pmv / amv / hmv / edit / feet / test / hottest / clip / spanish / br / portuguese / brazilian  / qos / joi / thigh / gallery / game / legendado / goon / story / collection / compilation / comp / ai / best / rus / subtitles /  viet ';
    const TITLE_HIGHLIGHT_BLACKLIST = 'sound edit';
    const TITLE_HIGHLIGHT_MATCHER = createTitleHighlightMatcher(TITLE_HIGHLIGHT_KEYWORDS);
    const TITLE_HIGHLIGHT_BLACKLIST_MATCHER = createTitleHighlightMatcher(TITLE_HIGHLIGHT_BLACKLIST);
    const VIDEO_PATH_PATTERN = /\/video\/(\d+)(?:\/|$)/;
    const VIEWED_STYLE = `
        .wrap_image[${VIEWED_ATTR}="1"],
        .ma-thumb[${VIEWED_ATTR}="1"]{
            border: 4px solid rgb(141, 233, 182);
            box-sizing: border-box;
        }

        .thumb_title[${FLAGGED_TITLE_ATTR}="1"],
        .ma-v__t[${FLAGGED_TITLE_ATTR}="1"]{
            color: #d31717;
            text-decoration: 4px underline rgb(255 0 0 / 75%);
        }

        .video-card-meta__date[${VIEWED_DATE_ATTR}="1"],
        .ma-v__m span[${VIEWED_DATE_ATTR}="1"]{
            color: rgb(141, 233, 182);
        }

        .video-card-meta__date[${VIEWED_DATE_ATTR}="1"] svg,
        .ma-v__m span[${VIEWED_DATE_ATTR}="1"] svg{
            fill: rgb(141, 233, 182);
            color: rgb(141, 233, 182);
        }

        .r34-far-date-tooltip {
            position: fixed;
            z-index: 999999;
            pointer-events: none;
            background: rgba(18, 18, 24, 0.95);
            color: rgb(141, 233, 182);
            border: 1px solid rgb(141, 233, 182);
            border-radius: 4px;
            padding: 3px 8px;
            font-size: 12px;
            font-family: sans-serif;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
            white-space: nowrap;
        }
    `;

    let processTimer = null;
    let pendingRoots = new Set();
    let viewedMap = loadViewedMap();
    let viewedFarMap = loadViewedFarMap();
    let lastUrl = location.href;
    let domObserver = null;
    let observedBody = null;
    let ajaxHooksInstalled = false;
    let lazyloadHookInstalled = false;
    let tooltipElement = null;
    let lastPointerPos = { x: 0, y: 0 };
    let currentHoveredDate = null;

    // Нормализация объекта
    function isPlainObject(value) {
        return Object.prototype.toString.call(value) === '[object Object]';
    }

    // Экранирование спецсимволов
    function escapeRegExp(value) {
        return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    // Нормализация ключевых слов
    function normalizeTitleHighlightKeywords(source) {
        const rawItems = Array.isArray(source)
            ? source
            : String(source || '').split(/[\/\\|\n,;]+/);

        return Array.from(new Set(
            rawItems
                .map((keyword) => String(keyword || '').trim().toLowerCase())
                .filter(Boolean)
        ));
    }

    // Создание сопоставителя заголовков
    function createTitleHighlightMatcher(keywords) {
        const normalized = normalizeTitleHighlightKeywords(keywords);

        if (normalized.length === 0) return null;

        const matchers = normalized
            .sort((left, right) => right.length - left.length)
            .map(createTitleRuleMatcher)
            .filter(Boolean);

        if (matchers.length === 0) return null;

        return (text) => matchers.some((matcher) => matcher.test(text));
    }

    // Создание правила сопоставления
    function createTitleRuleMatcher(keyword) {
        const trimmed = String(keyword || '').trim().toLowerCase();
        if (!trimmed) return null;

        const isPartialMatch = trimmed.startsWith('*') && trimmed.endsWith('*') && trimmed.length > 2;
        const value = isPartialMatch ? trimmed.slice(1, -1).trim() : trimmed;
        if (!value) return null;

        const parts = value.split(/\s+/).filter(Boolean).map(escapeRegExp);
        if (parts.length === 0) return null;

        const pattern = parts.join('[\\s._-]+');

        if (isPartialMatch) {
            return new RegExp(pattern, 'iu');
        }

        return new RegExp(`(?:^|[^\\p{L}\\p{N}])(?:${pattern})(?=$|[^\\p{L}\\p{N}])`, 'iu');
    }

    // Нормализация карты просмотров
    function normalizeViewedMap(stored) {
        if (!isPlainObject(stored)) return {};
        const normalized = {};
        for (const [key, value] of Object.entries(stored)) {
            if (/^\d+$/.test(key) && typeof value === 'number' && value > 0) {
                normalized[key] = value;
            }
        }
        return normalized;
    }

    // Чтение резервной копии
    function readBackupMap(key) {
        try {
            const raw = localStorage.getItem(key);
            if (!raw) return {};
            return normalizeViewedMap(JSON.parse(raw));
        } catch (err) {
            return {};
        }
    }

    // Запись резервной копии
    function writeBackupMap(key, map) {
        try {
            localStorage.setItem(key, JSON.stringify(map));
        } catch (err) {}
    }

    // Чтение основного хранилища
    function readStoredViewedMap() {
        const gmData = normalizeViewedMap(GM_getValue(STORAGE_KEY, {}));
        const backupData = readBackupMap(STORAGE_KEY);
        return mergeViewedMaps(backupData, gmData);
    }

    // Чтение хранилища дальнего просмотра
    function readStoredViewedFarMap() {
        const gmData = normalizeViewedMap(GM_getValue(STORAGE_KEY_FAR, {}));
        const backupData = readBackupMap(STORAGE_KEY_FAR);
        return mergeViewedMaps(backupData, gmData);
    }

    // Загрузка основного хранилища
    function loadViewedMap() {
        return readStoredViewedMap();
    }

    // Загрузка хранилища дальнего просмотра
    function loadViewedFarMap() {
        return readStoredViewedFarMap();
    }

    // Объединение карт просмотров
    function mergeViewedMaps(...maps) {
        return Object.assign({}, ...maps.map(normalizeViewedMap));
    }

    // Синхронизация хранилищ просмотров
    function syncViewedMapFromStorage() {
        viewedMap = mergeViewedMaps(viewedMap, readStoredViewedMap());
        viewedFarMap = mergeViewedMaps(viewedFarMap, readStoredViewedFarMap());
        return viewedMap;
    }

    // Сохранение основного хранилища
    function saveViewedMap() {
        viewedMap = mergeViewedMaps(readStoredViewedMap(), viewedMap);
        GM_setValue(STORAGE_KEY, viewedMap);
        writeBackupMap(STORAGE_KEY, viewedMap);
    }

    // Сохранение хранилища дальнего просмотра
    function saveViewedFarMap() {
        viewedFarMap = mergeViewedMaps(readStoredViewedFarMap(), viewedFarMap);
        GM_setValue(STORAGE_KEY_FAR, viewedFarMap);
        writeBackupMap(STORAGE_KEY_FAR, viewedFarMap);
    }

    // Форматирование даты клика
    function formatFarDate(val) {
        if (!val) return '';
        const timestamp = typeof val === 'number' && val > 1 ? val : Date.now();
        const date = new Date(timestamp);
        const day = date.getDate();
        const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
        const month = months[date.getMonth()];
        const year = date.getFullYear();
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        return `${day} ${month} ${year} ${hours}:${minutes}`;
    }

    // Создание элемента тултипа
    function getOrCreateTooltip() {
        if (!tooltipElement) {
            tooltipElement = document.createElement('div');
            tooltipElement.className = 'r34-far-date-tooltip';
            tooltipElement.style.display = 'none';
            document.body.appendChild(tooltipElement);
        }
        return tooltipElement;
    }

    // Скрытие элемента тултипа
    function hideTooltip() {
        if (tooltipElement) {
            tooltipElement.style.display = 'none';
        }
    }

    // Обновление состояния тултипа
    function updateTooltipState(e) {
        if (e && typeof e.clientX === 'number') {
            lastPointerPos.x = e.clientX;
            lastPointerPos.y = e.clientY;
        }

        const dateEl = currentHoveredDate;
        if (!dateEl || !document.body.contains(dateEl)) {
            hideTooltip();
            return;
        }

        const thumb = dateEl.closest(THUMB_SELECTOR);
        const key = getThumbKey(thumb);
        const farVal = key ? viewedFarMap[key] : null;

        if (!farVal) {
            hideTooltip();
            return;
        }

        const tooltip = getOrCreateTooltip();
        tooltip.textContent = formatFarDate(farVal);
        tooltip.style.left = `${lastPointerPos.x + 12}px`;
        tooltip.style.top = `${lastPointerPos.y + 12}px`;
        tooltip.style.display = 'block';
    }

    // Получение ключа ссылки
    function getVideoKeyFromLink(link) {
        return extractVideoKeyFromUrl(link?.href || '');
    }

    // Получение текущего ключа видео
    function getCurrentVideoKey() {
        return extractVideoKeyFromUrl(location.href);
    }

    // Поиск ссылки миниатюры
    function getThumbLink(thumb) {
        if (!(thumb instanceof Element)) return null;

        if (thumb.matches(VIDEO_LINK_SELECTOR) && getVideoKeyFromLink(thumb)) {
            return thumb;
        }

        const links = thumb.querySelectorAll(VIDEO_LINK_SELECTOR);
        for (const link of links) {
            if (getVideoKeyFromLink(link)) return link;
        }

        return null;
    }

    // Поиск заголовка миниатюры
    function getThumbTitleElement(thumb) {
        return thumb?.querySelector(TITLE_SELECTOR) || null;
    }

    // Извлечение ключа из адреса
    function extractVideoKeyFromUrl(url) {
        try {
            const parsed = new URL(url, window.location.origin);
            const match = parsed.pathname.match(VIDEO_PATH_PATTERN);
            return match ? match[1] : '';
        } catch (err) {
            return '';
        }
    }

    // Получение ключа миниатюры
    function getThumbKey(thumb) {
        return getVideoKeyFromLink(getThumbLink(thumb));
    }

    // Извлечение номера страницы из адреса
    function extractPageFromUrl(url) {
        if (!url) return 0;
        try {
            const parsed = new URL(url, window.location.origin);
            if (!parsed.pathname.includes('/video/')) {
                const pathMatch = parsed.pathname.match(/\/(\d+)\/?$/);
                if (pathMatch) return parseInt(pathMatch[1], 10) || 0;
            }
            for (const [key, val] of parsed.searchParams) {
                if (key === 'from' || key.startsWith('from_')) {
                    const n = parseInt(val, 10);
                    if (Number.isFinite(n) && n > 0) return n;
                }
            }
        } catch (err) {}
        return 0;
    }

    // Определение номера страницы
    function getCurrentPageNumber() {
        const jumpAttr = document.querySelector('[data-rd-jump-page]')?.getAttribute('data-rd-jump-page');
        if (jumpAttr) {
            const num = parseInt(jumpAttr, 10);
            if (Number.isFinite(num) && num > 0) return num;
        }

        const activeItem = document.querySelector(PAGINATION_ACTIVE_SELECTOR);
        if (activeItem) {
            const num = parseInt(activeItem.textContent.trim(), 10);
            if (!Number.isNaN(num) && num > 0) return num;
            const link = activeItem.querySelector('a');
            const paramMatch = link?.getAttribute('data-parameters')?.match(/from(?:_[a-z]+)?:(\d+)/i);
            if (paramMatch) {
                const numParam = parseInt(paramMatch[1], 10);
                if (!Number.isNaN(numParam)) return numParam;
            }
        }

        const urlPage = extractPageFromUrl(location.href);
        if (urlPage > 0) return urlPage;

        return 1;
    }

    // Установка состояния просмотра
    function setThumbViewedState(thumb, isViewed) {
        const wrap = thumb?.querySelector(WRAP_SELECTOR) || (thumb?.matches(WRAP_SELECTOR) ? thumb : null);
        if (!wrap) return;

        if (isViewed) {
            wrap.setAttribute(VIEWED_ATTR, '1');
        } else {
            wrap.removeAttribute(VIEWED_ATTR);
        }
    }

    // Установка подсветки заголовка
    function setThumbTitleHighlightState(thumb, isHighlighted) {
        const title = getThumbTitleElement(thumb);
        if (!title) return;

        if (isHighlighted) {
            title.setAttribute(FLAGGED_TITLE_ATTR, '1');
        } else {
            title.removeAttribute(FLAGGED_TITLE_ATTR);
        }
    }

    // Поиск даты миниатюры
    function getThumbDateElement(thumb) {
        return thumb?.querySelector(DATE_SELECTOR) || null;
    }

    // Установка подсветки даты
    function setThumbDateHighlightState(thumb, isHighlighted) {
        const dateElement = getThumbDateElement(thumb);
        if (!dateElement) return;

        if (isHighlighted) {
            dateElement.setAttribute(VIEWED_DATE_ATTR, '1');
        } else {
            dateElement.removeAttribute(VIEWED_DATE_ATTR);
        }
    }

    // Проверка подсветки заголовка
    function shouldHighlightTitle(text) {
        if (!text) return false;

        const normalizedText = text.toLowerCase();

        if (TITLE_HIGHLIGHT_BLACKLIST_MATCHER?.(normalizedText)) {
            return false;
        }

        return TITLE_HIGHLIGHT_MATCHER?.(normalizedText) || false;
    }

    // Применение состояния миниатюры
    function applyThumbState(thumb) {
        if (!(thumb instanceof HTMLElement) || !thumb.matches(THUMB_SELECTOR)) return;

        const key = getThumbKey(thumb);
        const titleText = getThumbTitleElement(thumb)?.textContent?.trim() || '';
        const isViewed = Boolean(key && viewedMap[key]);
        const isViewedFar = Boolean(key && viewedFarMap[key]);
        const isAfterThreshold = getCurrentPageNumber() > MIN_PAGE_FOR_DATE_HIGHLIGHT;

        setThumbViewedState(thumb, isViewed);
        setThumbTitleHighlightState(thumb, shouldHighlightTitle(titleText));
        setThumbDateHighlightState(thumb, isViewedFar && isAfterThreshold);
    }

    // Обработка корневого элемента
    function processRoot(root) {
        if (!root) return;

        if (root instanceof Element && root.matches(THUMB_SELECTOR)) {
            applyThumbState(root);
        }

        if (root.querySelectorAll) {
            root.querySelectorAll(THUMB_SELECTOR).forEach(applyThumbState);
        }
    }

    // Сброс очереди обработки
    function flushPendingRoots() {
        const roots = Array.from(pendingRoots);
        pendingRoots.clear();
        roots.forEach(processRoot);
    }

    // Планирование обработки
    function scheduleProcess(root = document) {
        pendingRoots.add(root);
        if (processTimer) return;

        processTimer = window.setTimeout(() => {
            processTimer = null;
            flushPendingRoots();
        }, PROCESS_DELAY);
    }

    // Запись основного просмотра видео
    function markVideoAsViewed(key) {
        if (!key || viewedMap[key] === 1) return;

        viewedMap = mergeViewedMaps(readStoredViewedMap(), viewedMap);
        if (viewedMap[key] === 1) return;

        viewedMap[key] = 1;
        saveViewedMap();
    }

    // Запись дальнего просмотра видео
    function markVideoAsViewedFar(key) {
        if (!key) return;

        viewedFarMap = mergeViewedMaps(readStoredViewedFarMap(), viewedFarMap);
        if (!viewedFarMap[key] || viewedFarMap[key] === 1) {
            viewedFarMap[key] = Date.now();
            saveViewedFarMap();
        }
    }

    // Запись текущего видео
    function markCurrentVideoAsViewed() {
        const key = getCurrentVideoKey();
        if (!key) return;

        markVideoAsViewed(key);

        const refPage = extractPageFromUrl(document.referrer);
        if (refPage > MIN_PAGE_FOR_DATE_HIGHLIGHT) {
            markVideoAsViewedFar(key);
        }
    }

    // Обработка смены адреса
    function handleUrlChange() {
        if (location.href === lastUrl) return;
        lastUrl = location.href;
        syncViewedMapFromStorage();
        markCurrentVideoAsViewed();
        scheduleProcess(document);
        startObserver();
    }

    // Установка перехватчиков истории
    function installHistoryHooks() {
        const { pushState, replaceState } = history;

        history.pushState = function (...args) {
            const result = pushState.apply(this, args);
            handleUrlChange();
            return result;
        };

        history.replaceState = function (...args) {
            const result = replaceState.apply(this, args);
            handleUrlChange();
            return result;
        };

        window.addEventListener('popstate', handleUrlChange, true);
        window.addEventListener('hashchange', handleUrlChange, true);
    }

    // Обработка событий указателя
    function handlePointer(event) {
        if (event.button !== 0 && event.button !== 1) return;

        const target = event.target instanceof Element ? event.target : null;
        const link = target?.closest(VIDEO_LINK_SELECTOR);
        const key = getVideoKeyFromLink(link);
        if (!key) return;

        const isAfterThreshold = getCurrentPageNumber() > MIN_PAGE_FOR_DATE_HIGHLIGHT;
        const thumb = link?.closest(THUMB_SELECTOR);

        markVideoAsViewed(key);
        if (isAfterThreshold) {
            markVideoAsViewedFar(key);
        }

        if (thumb) {
            setThumbViewedState(thumb, true);
            if (isAfterThreshold) {
                setThumbDateHighlightState(thumb, true);
            }
        }
    }

    // Обработка наведения указателя
    function handleMouseOver(e) {
        const dateEl = e.target?.closest?.(DATE_SELECTOR);
        if (dateEl) {
            currentHoveredDate = dateEl;
            updateTooltipState(e);
        } else {
            currentHoveredDate = null;
            hideTooltip();
        }
    }

    // Обработка перемещения указателя
    function handleMouseMove(e) {
        if (currentHoveredDate) {
            updateTooltipState(e);
        }
    }

    // Обработка мутаций DOM
    function handleDomMutations(mutations) {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === 1) {
                    scheduleProcess(node);
                }
            }
        }
    }

    // Запуск наблюдателя DOM
    function startObserver() {
        if (!document.body) return;

        if (!domObserver) {
            domObserver = new MutationObserver(handleDomMutations);
        }

        if (observedBody !== document.body) {
            domObserver.disconnect();
            observedBody = document.body;
            domObserver.observe(observedBody, { childList: true, subtree: true });
        }

        if (!lazyloadHookInstalled) {
            document.addEventListener('lazyloaded', () => {
                scheduleProcess(document);
            }, true);
            lazyloadHookInstalled = true;
        }
    }

    // Обработка ответа AJAX
    function handleAjaxUpdate() {
        syncViewedMapFromStorage();
        markCurrentVideoAsViewed();
        scheduleProcess(document);
        startObserver();
    }

    // Установка перехватчиков AJAX
    function installAjaxHooks() {
        if (ajaxHooksInstalled) return;

        const jq = window.jQuery;
        if (!jq?.fn?.jquery) return;

        jq(document).on('ajaxComplete.r34ViewedThumbs ajaxSuccess.r34ViewedThumbs', handleAjaxUpdate);
        ajaxHooksInstalled = true;
    }

    // Установка слушателей хранилища
    function installStorageSync() {
        if (typeof GM_addValueChangeListener !== 'function') return;

        GM_addValueChangeListener(STORAGE_KEY, (name, oldValue, newValue, remote) => {
            if (!remote) return;

            viewedMap = mergeViewedMaps(viewedMap, newValue);
            scheduleProcess(document);
        });

        GM_addValueChangeListener(STORAGE_KEY_FAR, (name, oldValue, newValue, remote) => {
            if (!remote) return;

            viewedFarMap = mergeViewedMaps(viewedFarMap, newValue);
            scheduleProcess(document);
        });
    }

    // Инициализация скрипта
    function init() {
        syncViewedMapFromStorage();
        markCurrentVideoAsViewed();
        scheduleProcess(document);
        startObserver();
        installAjaxHooks();
    }

    GM_addStyle(VIEWED_STYLE);

    document.addEventListener('mousedown', handlePointer, true);
    document.addEventListener('auxclick', handlePointer, true);
    document.addEventListener('click', handlePointer, true);

    document.addEventListener('mouseover', handleMouseOver, true);
    document.addEventListener('mousemove', handleMouseMove, true);

    installHistoryHooks();
    installStorageSync();

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }

    window.addEventListener('load', () => {
        scheduleProcess(document);
        startObserver();
        installAjaxHooks();
    }, { once: true });
})();