JavBus Filter

Add a single-cast filter for JavBus list pages.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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;
  }
})();