// ==UserScript==
// @name Javdb 增强脚本
// @name:zh Javdb 增强脚本
// @name:en Javdb Enhanced Script
// @namespace http://tampermonkey.net/
// @version 2.2.0
// @icon https://javdb.com/favicon-32x32.png
// @description 增强 Javdb 浏览体验,提供多种可配置功能:热门影片热力图高亮、热度排序、以及在列表页直接管理"已看"/"想看"状态。兼容自动翻页脚本。
// @description:zh 增强 Javdb 浏览体验,提供多种可配置功能:热门影片热力图高亮、热度排序、以及在列表页直接管理"已看"/"想看"状态。兼容自动翻页脚本。
// @description:en Enhances Javdb with configurable features: heatmap highlighting for popular videos, sorting by heat, and direct status management (Watched/Want) on list pages. Compatible with auto-paging scripts.
// @author JHT, 黄页大嫖客 (Modified by Gemini)
// @match https://javdb*.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect javdb*.com
// @license GPLv3
// @homeURL https://sleazyfork.org/zh-CN/scripts/539525
// @supportURL https://sleazyfork.org/zh-CN/scripts/539525/feedback
// ==/UserScript==
(function() {
'use strict';
// --- I18N 国际化模块 ---
const I18N = {
zh: {
settings: '设置',
highlightFeature: '启用高亮功能',
sortFeature: '启用排序功能',
statusFeature: '启用状态标记功能',
sort: '排序',
unmarked: '暂未标记',
want: '想看',
watched: '已看',
modify: '修改',
delete: '删除',
confirmDelete: '确认删除标记?',
confirm: '确认',
cancel: '取消'
},
en: {
settings: 'Settings',
highlightFeature: 'Enable Highlight Feature',
sortFeature: 'Enable Sort Feature',
statusFeature: 'Enable Status Tag Feature',
sort: 'Sort',
unmarked: 'Unmarked',
want: 'Want',
watched: 'Watched',
modify: 'Modify',
delete: 'Delete',
confirmDelete: 'Confirm Delete?',
confirm: 'Confirm',
cancel: 'Cancel'
}
};
const getBrowserLanguage = () => {
const browserLang = navigator.language || navigator.userLanguage || 'zh';
return browserLang.toLowerCase().startsWith('zh') ? 'zh' : 'en';
};
const lang = getBrowserLanguage();
const T = (key) => I18N[lang][key] || I18N['zh'][key];
// --- 配置与全局变量 ---
const SETTINGS_KEY = 'JavdbEnhanced_Settings';
let settings = {
highlight: true,
sort: true,
status: true,
highlightThreshold: 3.75,
ratedByThreshold: 200,
fetchDelay: 300
};
let authenticityToken = null;
const scoreRegex = /([\d.]+)[^\d]+(\d+)/;
// --- 样式注入 ---
GM_addStyle(`
:root {
--rem-base: 16px;
--color-sort-button: #fa6699;
--color-unmarked: rgba(100, 100, 100, 0.9);
--color-watched: #3273dc;
--color-wanted: #e83e8c;
--color-delete: #ff3860;
--color-modify: #ffc107;
--color-modify-hover-text: #212529;
--color-star: #ccc;
--color-star-filled: #ffdd44;
}
#sort-by-heat-btn-container { position: fixed; bottom: 1.7rem; right: 0; z-index: 9998; }
#sort-by-heat-btn-container .button { width: 2.45rem; height: 1.7rem; font-size: 0.8rem; background-color: var(--color-sort-button); color: white; border: none; padding: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.item .cover { position: relative; overflow: hidden; }
.item .cover img.cover-img-blurred { filter: blur(0.25rem) brightness(0.8); transform: scale(1.05); transition: all 0.3s ease; }
.cover-status-buttons { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; display: flex; flex-direction: column; align-items: center; width: 100%; }
.cover-status-buttons .state { display: none; flex-direction: column; align-items: center; width: 100%; gap: 0.5rem; }
.cover-status-buttons.show-unmarked .state-unmarked, .cover-status-buttons.show-watched .state-watched, .cover-status-buttons.show-wanted .state-wanted { display: flex; }
.status-tag-main { width: 5rem; height: 2rem; border-radius: 0.3125rem; display: flex; justify-content: center; align-items: center; color: white; font-weight: bold; font-size: 0.9rem; cursor: pointer; }
.state-unmarked .status-tag-main { background-color: var(--color-unmarked); }
.state-watched .status-tag-main { background-color: var(--color-watched); }
.state-wanted .status-tag-main { background-color: var(--color-wanted); }
.action-buttons { display: flex; gap: 0.5rem; }
.action-buttons .button { padding: 0.25rem 0.625rem; font-size: 0.8rem; border: none; border-radius: 0.25rem; color: white; cursor: pointer; transition: background-color 0.2s ease; }
.btn-set-wanted { background-color: var(--color-wanted); }
.btn-set-watched { background-color: var(--color-watched); }
.btn-modify { background-color: var(--color-modify); }
.btn-delete { background-color: var(--color-delete); }
.btn-set-wanted:hover { background-color: var(--color-wanted); }
.btn-set-watched:hover { background-color: var(--color-watched); }
.btn-modify:hover { background-color: var(--color-modify); color: var(--color-modify-hover-text); }
.btn-delete:hover { background-color: var(--color-delete); }
.action-buttons-hover { display: none; }
.state-watched:hover .action-buttons-hover { display: flex; }
.state-watched:hover .user-rating-display { display: none; }
.user-rating-display { display: none; }
.user-rating-display.is-visible { display: flex; }
.user-rating-display span { font-size: 1rem; color: var(--color-star); padding: 0 0.0625rem; text-shadow: 0 0 0.1875rem black; }
.user-rating-display span.is-filled { color: var(--color-star-filled); }
.jdbe-modal, .cover-modal-base { display: flex; justify-content: center; align-items: center; }
.jdbe-modal { position: fixed; z-index: 10000; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
.jdbe-modal-content { background: #fff; padding: 1.25rem; border-radius: 0.5rem; box-shadow: 0 0.3125rem 1rem rgba(0,0,0,0.3); }
.jdbe-modal-close { float: right; cursor: pointer; border: none; background: none; font-size: 1.5rem; }
.jdbe-modal-body { margin-top: 1.25rem; }
.jdbe-modal-body .setting-row { margin-bottom: 0.625rem; display: flex; align-items: center; }
.jdbe-modal-body label { margin-left: 0.5rem; }
.cover-modal-base { position: absolute; z-index: 10; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); backdrop-filter: blur(0.125rem); flex-direction: column; gap: 0.625rem; }
.rating-modal-container { z-index: 15; }
.rating-stars { display: flex; flex-direction: row-reverse; }
.rating-stars input[type="radio"] { display: none; }
.rating-stars label { font-size: 1.5rem; color: var(--color-star); cursor: pointer; transition: color 0.2s; }
.rating-stars label:hover, .rating-stars label:hover ~ label, .rating-stars input[type="radio"]:checked ~ label { color: var(--color-star-filled); }
.rating-modal-container .btn-cancel-rating { background-color: #f5f5f5; padding: 0.25rem 0.5rem; font-size: 0.7rem; border-radius: 0.25rem; border: none; cursor: pointer; margin-top: 0.3125rem;}
.confirm-delete-modal p { color: white; font-weight: bold; }
.confirm-delete-modal .buttons { display: flex; gap: 0.625rem; }
.confirm-delete-modal .button { padding: 0.3125rem 0.75rem; border-radius: 0.25rem; border: none; cursor: pointer; }
.confirm-delete-modal .is-danger { background-color: var(--color-delete); color: white; }
.confirm-delete-modal .is-light { background-color: #f5f5f5; }
body.jdbe-highlight-disabled .item a.box {
background-color: transparent !important;
}
body.jdbe-status-disabled .cover-status-buttons {
display: none !important;
}
body.jdbe-status-disabled .cover img.cover-img-blurred {
filter: none !important;
transform: none !important;
}
`);
// --- 设置与 UI 更新模块 ---
function loadSettings() {
const savedSettings = GM_getValue(SETTINGS_KEY, settings);
settings = { ...settings, ...savedSettings };
}
function saveSettings() {
GM_setValue(SETTINGS_KEY, settings);
}
function updateUIVisibility() {
document.body.classList.toggle('jdbe-highlight-disabled', !settings.highlight);
document.body.classList.toggle('jdbe-status-disabled', !settings.status);
const sortButton = document.getElementById('sort-by-heat-btn-container');
if (sortButton) {
sortButton.style.display = (settings.sort && settings.highlight) ? 'block' : 'none';
}
document.querySelectorAll('.item').forEach(item => {
const anchorElement = item.querySelector('a.box');
if (anchorElement) {
anchorElement.style.backgroundColor = (settings.highlight && item.dataset.heatColor) ? item.dataset.heatColor : '';
}
});
}
function openSettingsModal() {
let modal = document.getElementById('jdbe-settings-modal');
if (modal) {
modal.style.display = 'flex';
return;
}
modal = document.createElement('div');
modal.id = 'jdbe-settings-modal';
modal.className = 'jdbe-modal';
modal.innerHTML = `
<div class="jdbe-modal-content">
<button class="jdbe-modal-close">×</button>
<h2>${T('settings')}</h2>
<div class="jdbe-modal-body">
<div class="setting-row">
<input type="checkbox" id="setting-highlight">
<label for="setting-highlight">${T('highlightFeature')}</label>
</div>
<div class="setting-row">
<input type="checkbox" id="setting-sort">
<label for="setting-sort">${T('sortFeature')}</label>
</div>
<div class="setting-row">
<input type="checkbox" id="setting-status">
<label for="setting-status">${T('statusFeature')}</label>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const closeModal = () => modal.style.display = 'none';
modal.querySelector('.jdbe-modal-close').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
const highlightCheck = document.getElementById('setting-highlight');
const sortCheck = document.getElementById('setting-sort');
const statusCheck = document.getElementById('setting-status');
highlightCheck.checked = settings.highlight;
sortCheck.checked = settings.sort;
statusCheck.checked = settings.status;
const updateSortDependency = () => {
sortCheck.disabled = !highlightCheck.checked;
if (!highlightCheck.checked) {
sortCheck.checked = false;
}
};
updateSortDependency();
highlightCheck.addEventListener('change', (e) => {
settings.highlight = e.target.checked;
updateSortDependency();
if (!settings.highlight) {
settings.sort = false;
}
updateUIVisibility();
saveSettings();
});
sortCheck.addEventListener('change', (e) => {
settings.sort = e.target.checked;
updateUIVisibility();
saveSettings();
});
statusCheck.addEventListener('change', (e) => {
settings.status = e.target.checked;
updateUIVisibility();
saveSettings();
});
}
// --- 核心功能 ---
const STATUS_BUTTONS_TEMPLATE = `
<div class="state state-unmarked">
<div class="status-tag-main">${T('unmarked')}</div>
<div class="action-buttons">
<button class="button btn-set-wanted">${T('want')}</button>
<button class="button btn-set-watched js-set-watched">${T('watched')}</button>
</div>
</div>
<div class="state state-watched">
<div class="status-tag-main">${T('watched')}</div>
<div class="bottom-content-area">
<div class="user-rating-display">
<span>★</span><span>★</span><span>★</span><span>★</span><span>★</span>
</div>
<div class="action-buttons-hover action-buttons">
<button class="button btn-modify">${T('modify')}</button>
<button class="button btn-delete js-delete">${T('delete')}</button>
</div>
</div>
</div>
<div class="state state-wanted">
<div class="status-tag-main">${T('want')}</div>
<div class="action-buttons">
<button class="button btn-set-watched js-set-watched">${T('watched')}</button>
<button class="button btn-delete js-delete">${T('delete')}</button>
</div>
</div>
`;
function getCsrfToken() {
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
if (tokenMeta) { authenticityToken = tokenMeta.content; }
}
function isLoggedIn() {
return document.querySelector('a[href="/logout"]') !== null;
}
function updateReviewStatus(action, videoId, item, rating = null) {
if (!authenticityToken) { return; }
let urlPath, method, body;
const reviewId = item.dataset.reviewId;
switch (action) {
case 'watched':
if (rating === null) { return; }
urlPath = reviewId ? `/v/${videoId}/reviews/${reviewId}` : `/v/${videoId}/reviews`;
method = 'POST';
const baseBody = `authenticity_token=${encodeURIComponent(authenticityToken)}&video_review[status]=watched&video_review[score]=${rating}&video_review[content]=`;
body = reviewId ? `_method=patch&${baseBody}` : baseBody;
break;
case 'wanted':
urlPath = `/v/${videoId}/reviews/want_to_watch`;
method = 'POST';
body = `authenticity_token=${encodeURIComponent(authenticityToken)}`;
break;
case 'delete':
if (!reviewId) { return; }
urlPath = `/v/${videoId}/reviews/${reviewId}`;
method = 'POST';
body = `_method=delete&authenticity_token=${encodeURIComponent(authenticityToken)}`;
break;
default: return;
}
const url = window.location.origin + urlPath;
GM_xmlhttpRequest({
method: method,
url: url,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: body,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
fetchItemStatus(item, true);
} else { console.error("Failed to update status:", response.status, response.responseText); }
},
onerror: (error) => { console.error("Error during API request:", error); }
});
}
function fetchItemStatus(item, forceUpdate = false) {
if (!settings.status) return;
if (item.dataset.statusChecked === 'true' && !forceUpdate) return;
const anchor = item.querySelector('a.box');
if (!anchor) return;
if (item.dataset.statusFetching === 'true') return;
item.dataset.statusFetching = 'true';
GM_xmlhttpRequest({
method: "GET",
url: anchor.href, // anchor.href 已经是完整的绝对路径,所以这里不需要修改
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
item.dataset.statusChecked = 'true';
const doc = new DOMParser().parseFromString(response.responseText, "text/html");
setupStatusButtonsUI(doc, item);
}
},
onabort: () => { item.dataset.statusFetching = 'false'; },
onerror: () => { item.dataset.statusFetching = 'false'; },
ontimeout: () => { item.dataset.statusFetching = 'false'; },
onloadend: () => { item.dataset.statusFetching = 'false'; }
});
}
function setupStatusButtonsUI(doc, item) {
const buttonsContainer = item.querySelector('.cover-status-buttons');
const coverImage = item.querySelector('.cover img');
if (!buttonsContainer) return;
let status = 'unmarked';
buttonsContainer.className = 'cover-status-buttons';
item.dataset.reviewId = '';
const watchedTag = doc.querySelector('.review-title .tag.is-success.is-light');
const wantedTag = doc.querySelector('.review-title .tag.is-info.is-light');
const deleteLink = doc.querySelector('a[data-method="delete"][href*="/reviews/"]');
const ratingDisplay = buttonsContainer.querySelector('.user-rating-display');
if(ratingDisplay) ratingDisplay.classList.remove('is-visible');
if (watchedTag) {
status = 'watched';
const ratingInput = doc.querySelector('.rating-star .control input[checked="checked"]');
if (ratingInput && ratingDisplay) {
const score = parseInt(ratingInput.value, 10);
const stars = ratingDisplay.querySelectorAll('span');
stars.forEach((star, index) => star.classList.toggle('is-filled', index < score));
ratingDisplay.classList.add('is-visible');
}
} else if (wantedTag) {
status = 'wanted';
}
if (deleteLink) {
const match = deleteLink.href.match(/\/reviews\/(\d+)/);
if (match) item.dataset.reviewId = match[1];
}
buttonsContainer.classList.add(`show-${status}`);
if (coverImage) {
coverImage.classList.add('cover-img-blurred');
}
}
function showRatingModal(item, videoId) {
const cover = item.querySelector('.cover');
if (!cover || cover.querySelector('.rating-modal-container')) return;
const modal = document.createElement('div');
modal.className = 'rating-modal-container cover-modal-base';
modal.innerHTML = `
<div class="rating-stars">
<input type="radio" id="star5-${videoId}" name="rating-${videoId}" value="5"><label for="star5-${videoId}">★</label>
<input type="radio" id="star4-${videoId}" name="rating-${videoId}" value="4"><label for="star4-${videoId}">★</label>
<input type="radio" id="star3-${videoId}" name="rating-${videoId}" value="3"><label for="star3-${videoId}">★</label>
<input type="radio" id="star2-${videoId}" name="rating-${videoId}" value="2"><label for="star2-${videoId}">★</label>
<input type="radio" id="star1-${videoId}" name="rating-${videoId}" value="1"><label for="star1-${videoId}">★</label>
</div>
<button class="btn-cancel-rating">${T('cancel')}</button>
`;
cover.appendChild(modal);
modal.addEventListener('click', (e) => e.stopPropagation());
modal.querySelectorAll('input[type="radio"]').forEach(radio => {
radio.addEventListener('change', (e) => {
updateReviewStatus('watched', videoId, item, e.target.value);
cover.removeChild(modal);
});
});
modal.querySelector('.btn-cancel-rating').addEventListener('click', () => {
cover.removeChild(modal);
});
}
function processItem(item) {
if (item.dataset.enhanced) return;
item.dataset.enhanced = 'true';
const anchorElement = item.querySelector('a.box');
const scoreElement = item.querySelector('.score .value');
const coverElement = item.querySelector('.cover');
if (!item.dataset.highlightProcessed) {
item.dataset.highlightProcessed = 'true';
item.dataset.heat = '0';
if (scoreElement) {
const scoreMatch = scoreElement.textContent.trim().match(scoreRegex);
if (scoreMatch) {
const score = parseFloat(scoreMatch[1]);
if (score >= settings.highlightThreshold) {
const ratedBy = parseInt(scoreMatch[2], 10);
const heat = calculateHeat(score, ratedBy);
item.dataset.heat = heat;
item.dataset.heatColor = getHeatmapColor(heat);
}
}
}
}
if (anchorElement) {
anchorElement.style.backgroundColor = (settings.highlight && item.dataset.heatColor) ? item.dataset.heatColor : '';
}
if (!item.querySelector('.cover-status-buttons')) {
if (!coverElement || !anchorElement) return;
const videoId = anchorElement.href.split('/').pop();
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'cover-status-buttons';
buttonsContainer.innerHTML = STATUS_BUTTONS_TEMPLATE;
coverElement.appendChild(buttonsContainer);
const handleDeleteClick = (e) => {
e.preventDefault(); e.stopPropagation();
if (coverElement.querySelector('.confirm-delete-modal')) return;
const modal = document.createElement('div');
modal.className = 'confirm-delete-modal cover-modal-base';
modal.innerHTML = `<p>${T('confirmDelete')}</p><div class="buttons"><button class="button is-danger btn-confirm-delete">${T('confirm')}</button><button class="button is-light btn-cancel-delete">${T('cancel')}</button></div>`;
coverElement.appendChild(modal);
modal.querySelector('.btn-confirm-delete').addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); updateReviewStatus('delete', videoId, item); coverElement.removeChild(modal); });
modal.querySelector('.btn-cancel-delete').addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); coverElement.removeChild(modal); });
};
const handleModifyToWatched = (e) => {
e.preventDefault(); e.stopPropagation();
showRatingModal(item, videoId);
};
buttonsContainer.querySelector('.btn-set-wanted').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); updateReviewStatus('wanted', videoId, item); });
buttonsContainer.querySelectorAll('.js-set-watched, .btn-modify').forEach(btn => btn.addEventListener('click', handleModifyToWatched));
buttonsContainer.querySelectorAll('.js-delete').forEach(btn => btn.addEventListener('click', handleDeleteClick));
}
if (isLoggedIn() && !item.dataset.hoverInit) {
item.dataset.hoverInit = 'true';
let hoverTimer;
item.addEventListener('mouseenter', () => {
if (settings.status) {
hoverTimer = setTimeout(() => fetchItemStatus(item), settings.fetchDelay);
}
});
item.addEventListener('mouseleave', () => {
clearTimeout(hoverTimer);
});
}
}
function getHeatmapColor(heat) {
let r = 0, g = 0, b = 0;
const h = Math.min(Math.max(heat, 0), 1);
if (h < 0.5) {
g = Math.round(255 * (h * 2));
b = Math.round(255 * (1 - h * 2));
} else {
r = Math.round(255 * ((h - 0.5) * 2));
g = Math.round(255 * (1 - (h - 0.5) * 2));
}
return `rgba(${r}, ${g}, ${b}, 0.5)`;
}
function calculateHeat(score, ratedBy) {
const scoreNormalized = score / 5;
const scoreTransformed = Math.pow(scoreNormalized, 2);
const ratedByNormalized = Math.min(ratedBy / settings.ratedByThreshold, 1);
return scoreTransformed * ratedByNormalized;
}
function createIndependentSortButton() {
if (!document.querySelector('.movie-list') || document.getElementById('sort-by-heat-btn-container')) return;
const container = document.createElement('div');
container.id = 'sort-by-heat-btn-container';
const sortButton = document.createElement('a');
sortButton.textContent = T('sort');
sortButton.className = 'button';
sortButton.addEventListener('click', (e) => { e.preventDefault(); sortItemsByHeat(); });
container.appendChild(sortButton);
document.body.appendChild(container);
updateUIVisibility();
}
function sortItemsByHeat() {
const containers = document.querySelectorAll('.movie-list');
if (containers.length === 0) return;
let allItems = [];
containers.forEach(container => { allItems.push(...container.querySelectorAll('.item')); });
allItems.sort((a, b) => (parseFloat(b.dataset.heat || 0) - parseFloat(a.dataset.heat || 0)));
const primaryContainer = containers[0];
primaryContainer.innerHTML = '';
allItems.forEach(item => primaryContainer.appendChild(item));
for (let i = 1; i < containers.length; i++) containers[i].remove();
const navBar = document.querySelector('.navbar.is-fixed-top');
const offset = navBar ? navBar.getBoundingClientRect().height : 53.45;
window.scrollTo({ top: primaryContainer.getBoundingClientRect().top + window.pageYOffset - offset, behavior: 'smooth' });
}
function main() {
loadSettings();
GM_registerMenuCommand(T('settings'), openSettingsModal);
getCsrfToken();
updateUIVisibility();
try {
document.querySelectorAll('.item').forEach(processItem);
createIndependentSortButton();
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('.item')) {
processItem(node);
} else {
node.querySelectorAll('.item').forEach(processItem);
}
}
});
createIndependentSortButton();
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
} catch (error) {
console.error("[Javdb Enhanced] Script execution error:", error);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();