JavBus Filter

Add a single-cast filter for JavBus list pages.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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;
  }
})();