JavBus Filter

Add a single-cast filter for JavBus list pages.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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