Marks viewed thumbnails on rule34video.com, stores viewed video IDs, and highlights selected thumb titles
// ==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 });
})();