☰

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