Add a single-cast filter for JavBus list pages.
// ==UserScript==
// @name JavBus Filter
// @namespace urn:javbus-single-cast-filter
// @version 0.1.1
// @description Add a single-cast filter for JavBus list pages.
// @author zzz
// @match https://www.javbus.com/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const STORAGE_PREFIX = 'javbus-single-cast-filter';
const ENABLED_KEY = `${STORAGE_PREFIX}:enabled`;
const CACHE_PREFIX = `${STORAGE_PREFIX}:cache:`;
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const CONCURRENCY = 10;
const TOOLBAR_ID = 'jb-single-cast-toolbar';
const HIDDEN_CLASS = 'jb-single-cast-hidden';
const MOVIE_ITEM_CLASS = 'jb-single-cast-movie-item';
const AVATAR_ITEM_CLASS = 'jb-single-cast-avatar-item';
const STATUS_UPDATE_INTERVAL_MS = 120;
let currentRunToken = 0;
if (isDetailPage()) {
return;
}
classifyPageItems();
protectAvatarBlocks();
const movieEntries = getMovieEntries();
if (movieEntries.length === 0) {
return;
}
injectStyles();
const state = {
enabled: loadEnabled(),
total: movieEntries.length,
checked: 0,
hidden: 0,
pending: 0,
lastStatusRenderAt: 0,
};
const toolbar = createToolbar(state);
const elements = {
toolbar,
toggle: toolbar.querySelector('input[type="checkbox"]'),
status: toolbar.querySelector('[data-role="status"]'),
clearCache: toolbar.querySelector('[data-role="clear-cache"]'),
};
elements.toggle.checked = state.enabled;
elements.toggle.addEventListener('change', () => {
currentRunToken += 1;
state.enabled = elements.toggle.checked;
saveEnabled(state.enabled);
resetCounts(state);
if (!state.enabled) {
showAll(movieEntries);
renderStatus(elements.status, state, '过滤已关闭');
return;
}
renderStatus(elements.status, state, '开始分析当前页影片');
runFilter(movieEntries, state, elements.status, currentRunToken).catch((error) => {
console.error('[JavBus Single Cast Filter] filter failed', error);
renderStatus(elements.status, state, '过滤过程中出现错误,已保留未识别影片');
});
});
elements.clearCache.addEventListener('click', () => {
currentRunToken += 1;
clearCache();
resetCounts(state);
showAll(movieEntries);
renderStatus(elements.status, state, '缓存已清空');
if (state.enabled) {
runFilter(movieEntries, state, elements.status, currentRunToken).catch((error) => {
console.error('[JavBus Single Cast Filter] refilter failed', error);
renderStatus(elements.status, state, '重跑过滤失败');
});
}
});
renderStatus(elements.status, state, state.enabled ? '开始分析当前页影片' : '过滤已关闭');
if (state.enabled) {
currentRunToken += 1;
runFilter(movieEntries, state, elements.status, currentRunToken).catch((error) => {
console.error('[JavBus Single Cast Filter] initial run failed', error);
renderStatus(elements.status, state, '初始化过滤失败');
});
}
function isDetailPage() {
return Boolean(
document.querySelector('#sample-waterfall, #magnet-table, #star-div') &&
document.querySelector('.info')
);
}
function getMovieEntries() {
classifyPageItems();
return Array.from(document.querySelectorAll(`.item.${MOVIE_ITEM_CLASS}`)).map((item) => {
const box = item.querySelector(':scope > a.movie-box[href]');
if (!box) {
return null;
}
const href = box.getAttribute('href') || '';
if (!/^https:\/\/www\.javbus\.com\/.+/.test(href) && !/^\/.+/.test(href)) {
return null;
}
return { box, item };
}).filter((entry) => entry !== null);
}
function classifyPageItems() {
document.querySelectorAll('.item').forEach((item) => {
if (!(item instanceof HTMLElement)) {
return;
}
if (item.querySelector(':scope > .avatar-box')) {
item.classList.add(AVATAR_ITEM_CLASS);
item.classList.remove(MOVIE_ITEM_CLASS);
return;
}
if (item.querySelector(':scope > a.movie-box[href]')) {
item.classList.add(MOVIE_ITEM_CLASS);
item.classList.remove(AVATAR_ITEM_CLASS);
}
});
}
function createToolbar(currentState) {
const container = document.createElement('section');
container.id = TOOLBAR_ID;
container.innerHTML = `
<div class="jb-single-cast-toolbar__title">自定义过滤</div>
<label class="jb-single-cast-toolbar__toggle">
<input type="checkbox">
<span>仅看单主角</span>
</label>
<button type="button" class="jb-single-cast-toolbar__button" data-role="clear-cache">清空缓存</button>
<span class="jb-single-cast-toolbar__status" data-role="status"></span>
`;
const anchor = findToolbarAnchor();
anchor.parentNode.insertBefore(container, anchor.nextSibling);
renderStatus(container.querySelector('[data-role="status"]'), currentState, '过滤已关闭');
return container;
}
function findToolbarAnchor() {
return (
document.querySelector('.alert-common:last-of-type') ||
document.querySelector('.container-fluid .ad-box') ||
document.querySelector('.container-fluid .row') ||
document.querySelector('nav.navbar')
);
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
#${TOOLBAR_ID} {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 12px 0 18px;
padding: 12px 14px;
border: 1px solid #e1d5b8;
border-radius: 8px;
background: linear-gradient(135deg, #fff8ea, #fffdf8);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
#${TOOLBAR_ID} .jb-single-cast-toolbar__title {
font-weight: 700;
color: #7b4f16;
}
#${TOOLBAR_ID} .jb-single-cast-toolbar__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-weight: 500;
cursor: pointer;
}
#${TOOLBAR_ID} .jb-single-cast-toolbar__button {
border: 1px solid #d6b57e;
border-radius: 6px;
background: #fff;
color: #6b481b;
padding: 4px 10px;
}
#${TOOLBAR_ID} .jb-single-cast-toolbar__status {
color: #6e6e6e;
font-size: 13px;
}
.${HIDDEN_CLASS} {
display: none !important;
}
.item.${MOVIE_ITEM_CLASS}.${HIDDEN_CLASS} {
display: none !important;
}
.item.${AVATAR_ITEM_CLASS},
.item.${AVATAR_ITEM_CLASS}.${HIDDEN_CLASS},
.item:has(> .avatar-box),
.item:has(> .avatar-box).${HIDDEN_CLASS} {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.container-fluid > .ad-box,
.container > .ad-box,
.ad-box,
.ad-item,
.ad-juicy {
display: none !important;
visibility: hidden !important;
height: 0 !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
#waterfall,
#related-waterfall {
margin-bottom: 20px !important;
}
.pagination,
.pagination.pagination-lg,
.text-center.hidden-xs,
.footer.hidden-xs {
position: relative !important;
z-index: 1 !important;
clear: both !important;
margin-top: 16px !important;
}
`;
document.head.appendChild(style);
}
async function runFilter(entries, currentState, statusElement, runToken) {
resetCounts(currentState);
showAll(entries, false);
const tasks = entries
.map((entry) => createTask(entry))
.filter((task) => task !== null);
currentState.total = tasks.length;
currentState.pending = tasks.length;
renderStatus(statusElement, currentState, '正在分析演员数量');
await runWithConcurrency(tasks, CONCURRENCY, async (task) => {
if (!state.enabled || runToken !== currentRunToken) {
return;
}
const actorCount = await resolveActorCount(task.code, task.url);
if (!state.enabled || runToken !== currentRunToken) {
return;
}
currentState.checked += 1;
currentState.pending -= 1;
if (actorCount > 1) {
hideEntry(task);
}
syncHiddenCount(currentState, entries);
protectAvatarBlocks();
renderStatus(
statusElement,
currentState,
currentState.pending > 0 ? '正在分析演员数量' : '过滤完成',
currentState.pending === 0
);
});
if (!state.enabled || runToken !== currentRunToken) {
showAll(entries);
return;
}
syncHiddenCount(currentState, entries);
protectAvatarBlocks();
relayout();
}
function createTask(entry) {
const url = new URL(entry.box.href, window.location.origin).toString();
const code = extractCodeFromUrl(url);
if (!code) {
return null;
}
return { ...entry, code, url };
}
async function resolveActorCount(code, url) {
const cached = readCache(code);
if (cached !== null) {
return cached;
}
let actorCount = 0;
try {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
actorCount = extractActorCount(html, url);
} catch (error) {
console.warn('[JavBus Single Cast Filter] failed to fetch detail page', url, error);
}
writeCache(code, actorCount);
return actorCount;
}
function extractActorCount(html, url) {
const starSectionMatch = html.match(/<div id="star-div">[\s\S]*?<\/div>\s*(?:<h4|<div class="clearfix"|<script)/i);
if (starSectionMatch) {
const avatarMatches = Array.from(
starSectionMatch[0].matchAll(/href="https:\/\/www\.javbus\.com\/star\/([^"]+)"/g),
(match) => match[1]
);
if (avatarMatches.length > 0) {
return new Set(avatarMatches).size;
}
}
const inlineActorsMatch = html.match(/<p class="star-show">[\s\S]*?<p>[\s\S]*?<\/p>/i);
if (inlineActorsMatch) {
const inlineActorMatches = Array.from(
inlineActorsMatch[0].matchAll(/href="https:\/\/www\.javbus\.com\/star\/([^"]+)"/g),
(match) => match[1]
);
if (inlineActorMatches.length > 0) {
return new Set(inlineActorMatches).size;
}
}
const fallbackMatches = Array.from(
html.matchAll(/href="https:\/\/www\.javbus\.com\/star\/([^"]+)"/g),
(match) => match[1]
);
if (fallbackMatches.length > 0) {
return new Set(fallbackMatches).size;
}
console.debug('[JavBus Single Cast Filter] actor info not found', url);
return 0;
}
async function runWithConcurrency(items, limit, worker) {
let index = 0;
async function next() {
if (index >= items.length) {
return;
}
const currentIndex = index;
index += 1;
await worker(items[currentIndex]);
await next();
}
const runners = Array.from({ length: Math.min(limit, items.length) }, () => next());
await Promise.all(runners);
}
function hideEntry(entry) {
if (!entry || !entry.item || !entry.item.classList.contains(MOVIE_ITEM_CLASS)) {
return;
}
entry.item.classList.add(HIDDEN_CLASS);
}
function showAll(entries, shouldRelayout = true) {
entries.forEach((entry) => {
if (!entry || !entry.item) {
return;
}
entry.item.classList.remove(HIDDEN_CLASS);
});
protectAvatarBlocks();
if (shouldRelayout) {
relayout();
}
}
function relayout() {
classifyPageItems();
protectAvatarBlocks();
const containers = getLayoutContainers();
if (containers.length === 0) {
return;
}
if (window.jQuery && typeof window.jQuery.fn.masonry === 'function') {
const $ = window.jQuery;
containers.forEach((container) => {
const element = $(container);
try {
element.masonry('destroy');
} catch (error) {
// Ignore pages where masonry is not initialized yet.
}
try {
element.masonry({
itemSelector: '.item.' + MOVIE_ITEM_CLASS + ':not(.' + HIDDEN_CLASS + ')',
isAnimated: false,
isFitWidth: true,
});
element.masonry('reloadItems');
element.masonry('layout');
} catch (error) {
// Ignore pages where masonry is not initialized yet.
}
});
}
}
function getLayoutContainers() {
const selectors = ['#waterfall', '#related-waterfall'];
const containers = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
return containers.filter((container, index, all) => {
if (all.indexOf(container) !== index) {
return false;
}
return Array.from(container.children).some((child) => {
return child instanceof HTMLElement &&
child.classList.contains('item') &&
child.classList.contains(MOVIE_ITEM_CLASS) &&
child.querySelector(':scope > .movie-box');
});
});
}
function protectAvatarBlocks() {
document.querySelectorAll('.item').forEach((item) => {
if (!(item instanceof HTMLElement) || !item.querySelector(':scope > .avatar-box')) {
return;
}
item.classList.add(AVATAR_ITEM_CLASS);
item.classList.remove(MOVIE_ITEM_CLASS);
item.classList.remove(HIDDEN_CLASS);
item.style.display = '';
item.style.visibility = '';
});
}
function renderStatus(element, currentState, prefix, force = false) {
const now = Date.now();
if (!force && now - currentState.lastStatusRenderAt < STATUS_UPDATE_INTERVAL_MS) {
return;
}
currentState.lastStatusRenderAt = now;
element.textContent = `${prefix} | 已检查 ${currentState.checked}/${currentState.total} | 已隐藏 ${currentState.hidden}`;
}
function syncHiddenCount(currentState, entries) {
currentState.hidden = entries.reduce((count, entry) => {
return count + (entry.item.classList.contains(HIDDEN_CLASS) ? 1 : 0);
}, 0);
}
function extractCodeFromUrl(url) {
try {
const pathname = new URL(url).pathname.replace(/\/+$/, '');
const parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return null;
}
const lastSegment = parts[parts.length - 1];
if (!lastSegment || lastSegment === 'uncensored') {
return null;
}
const root = parts[0];
const listingRoots = ['star', 'genre', 'director', 'studio', 'series', 'search', 'actresses', 'ajax', 'doc'];
if (listingRoots.includes(root)) {
return null;
}
if (root === 'uncensored') {
if (parts.length < 2 || listingRoots.includes(parts[1])) {
return null;
}
return lastSegment.toUpperCase();
}
if (parts.length > 1 && listingRoots.includes(parts[1])) {
return null;
}
return lastSegment.toUpperCase();
} catch (error) {
return null;
}
}
function loadEnabled() {
return window.localStorage.getItem(ENABLED_KEY) === '1';
}
function saveEnabled(enabled) {
window.localStorage.setItem(ENABLED_KEY, enabled ? '1' : '0');
}
function readCache(code) {
try {
const raw = window.localStorage.getItem(`${CACHE_PREFIX}${code}`);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed.count !== 'number' || typeof parsed.savedAt !== 'number') {
return null;
}
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
window.localStorage.removeItem(`${CACHE_PREFIX}${code}`);
return null;
}
return parsed.count;
} catch (error) {
return null;
}
}
function writeCache(code, count) {
try {
window.localStorage.setItem(
`${CACHE_PREFIX}${code}`,
JSON.stringify({
count,
savedAt: Date.now(),
})
);
} catch (error) {
// Ignore storage quota errors.
}
}
function clearCache() {
const keysToDelete = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key && key.startsWith(CACHE_PREFIX)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => window.localStorage.removeItem(key));
}
function resetCounts(currentState) {
currentState.total = getMovieEntries().length;
currentState.checked = 0;
currentState.hidden = 0;
currentState.pending = 0;
currentState.lastStatusRenderAt = 0;
}
})();