JavBus Filter

Add a single-cast filter for JavBus list pages.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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