☰

PMVHaven

Restores native context menus, hides known intrusive ad UI, consolidates chunked video grids.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         PMVHaven
// @namespace    https://pmvhaven.com/
// @version      1.3.0
// @description  Restores native context menus, hides known intrusive ad UI, consolidates chunked video grids.
// @match        https://pmvhaven.com/*
// @match        https://*.pmvhaven.com/*
// @run-at       document-start
// @sandbox      raw
// @noframes
// @author       Cat-Ling
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  const STYLE_ID = 'pmvhaven-optimizer-styles-v110';
  const BLOCKED_MENU_MARKER = 'data-pmvhaven-hidden-context-menu';

  /*
   * Evidence-backed menu fingerprint from PMVHaven's captured VideoCard chunk:
   *   div.fixed ... z-[100] min-w-[200px] overflow-hidden
   *     > a[target="_blank"][rel~="noopener"]
   *       > span "Open in new tab"
   *
   * Vue attaches its own `contextmenu` listener with a prevent modifier. The
   * window capture listener below stops that listener before it can cancel the
   * browser default. The DOM/CSS fallback catches the menu if another handler,
   * long-press path, or site update still manages to create it.
   */
  const VIDEO_CONTEXT_MENU_SELECTOR =
    'div.fixed[class~="z-[100]"][class~="min-w-[200px]"].overflow-hidden:has(> a[target="_blank"][rel~="noopener"] > span)';

  const AD_SELECTORS = [
    // Confirmed from the captured NativeAdCard / InlineScrollBanner chunks.
    'a.native-ad-card:not([data-video-id])',
    'a.ad-link:not([data-video-id])',
    'a[rel~="sponsored"]:not([data-video-id])',
    'a.inline-scroll-banner:not([data-video-id])',

    // Site-specific header/mobile ad variants retained from v1.0.0.
    'a.undress-ai-animated-btn:not([data-video-id])',
    'a[href*="happyleafmotion.com"]:not([data-video-id])',
    'a[href*="s.eunow4u.com"]:not([data-video-id])',
    'a[href*="rr.rndomnm.com"]:not([data-video-id])',
    'a[href*="fluxion-lab"]:not([data-video-id])',
    'a[href*="realxxx.com"]:not([data-video-id])',
    '.mobile-undress-ad-container',
    '.mobile-undress-ad-banner',
    '.mobile-header-ad',
    '.ads-container',
    '.video-player-banner',
    '.sidebar-ad-container',
    '.sidebar-ad-wrapper',
    '.sidebar-ad-iframe',
    '.ad-banner',
    '.ad-content-mobile',

    // Known ad/creative iframe hosts retained as defensive fallbacks.
    'iframe[src*="sadbaguette.com"]',
    'iframe[src*="clearsky-rhythm.com"]',
    'iframe[src*="exacdn.com"]',
    'iframe[src*="happyleafmotion.com"]',
    'iframe[src*="fluxion-lab"]',
    'iframe[src*="rr.rndomnm.com"]',
    'iframe[src*="s.eunow4u.com"]',
    'iframe[src*="realxxx.com"]'
  ];

  const AD_CLICK_SELECTOR = [
    'a.native-ad-card',
    'a.ad-link',
    'a[rel~="sponsored"]',
    'a.inline-scroll-banner',
    'a.undress-ai-animated-btn',
    'a[href*="happyleafmotion.com"]',
    'a[href*="s.eunow4u.com"]',
    'a[href*="rr.rndomnm.com"]',
    'a[href*="fluxion-lab"]',
    'a[href*="realxxx.com"]',
    '[class*="native-ad"]',
    '[class*="inline-scroll-banner"]'
  ].join(',');

  const GRID_WRAPPER_SELECTOR = [
    'div:has(> .grid[class~="cols-4-1500"])',
    'div:has(> .grid[class~="cols-3-1200"])'
  ].join(',\n');

  const GRID_CHUNK_SELECTOR = [
    'div:has(> .grid[class~="cols-4-1500"]) > .grid',
    'div:has(> .grid[class~="cols-3-1200"]) > .grid'
  ].join(',\n');

  const GRID_SPACER_SELECTOR = [
    'div:has(> .grid[class~="cols-4-1500"]) > div.my-6:not(:has([data-video-id]))',
    'div:has(> .grid[class~="cols-3-1200"]) > div.my-6:not(:has([data-video-id]))'
  ].join(',\n');

  const css = `
    /* Hide known ad UI without hiding real video cards. */
    ${AD_SELECTORS.join(',\n    ')} {
      display: none !important;
      visibility: hidden !important;
      pointer-events: none !important;
    }

    /* The site uses a custom Vue/Teleport menu, not a Radix/Reka menu. */
    ${VIDEO_CONTEXT_MENU_SELECTOR},
    [data-reka-context-menu-content],
    [data-radix-context-menu-content] {
      display: none !important;
      visibility: hidden !important;
      pointer-events: none !important;
    }

    /* Merge the split video-grid chunks into one responsive grid. */
    ${GRID_WRAPPER_SELECTOR} {
      display: grid !important;
      grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
      gap: 1rem !important;
      align-items: stretch !important;
    }

    @media (min-width: 640px) {
      ${GRID_WRAPPER_SELECTOR} {
        grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
      }
    }

    @media (min-width: 1200px) and (max-width: 1499.98px) {
      ${GRID_WRAPPER_SELECTOR} {
        grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
      }
    }

    @media (min-width: 1500px) {
      ${GRID_WRAPPER_SELECTOR} {
        grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
      }
    }

    /* Let individual video cards participate directly in the outer grid. */
    ${GRID_CHUNK_SELECTOR} {
      display: contents !important;
      contain: none !important;
    }

    /* Only remove the empty inter-chunk spacer inside the affected wrapper. */
    ${GRID_SPACER_SELECTOR} {
      display: none !important;
      margin: 0 !important;
      padding: 0 !important;
      height: 0 !important;
      min-height: 0 !important;
    }

    /* Reduce backdrop-filter work on video-card overlays only. Do not reset
       transforms or global transitions: the site uses those for card/preview UI. */
    a[data-video-id] [class*="backdrop-blur"] {
      backdrop-filter: none !important;
      -webkit-backdrop-filter: none !important;
    }
  `;

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;

    const target = document.head || document.documentElement;
    if (!target) return;

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    target.appendChild(style);
  }

  // Do not call preventDefault(): that would suppress the native browser menu.
  function restoreNativeContextMenu(event) {
    event.stopImmediatePropagation();
  }

  window.addEventListener('contextmenu', restoreNativeContextMenu, true);
  // Defensive fallback for synthetic/non-composed events that do not reach window.
  document.addEventListener('contextmenu', restoreNativeContextMenu, true);

  // Keep core menu restoration alive even if a browser/manager blocks API hooks.
  try {
    blockObservedPopunderRequest();
  } catch (error) {
    console.warn('[PMVHaven] Could not install the narrow popunder request guard:', error);
  }

  function normalizeText(value) {
    return String(value || '').replace(/\s+/g, ' ').trim();
  }

  function hideSiteVideoContextMenu(anchor) {
    if (!(anchor instanceof HTMLAnchorElement)) return;
    if (normalizeText(anchor.textContent) !== 'Open in new tab') return;
    if (anchor.target !== '_blank' || !anchor.relList.contains('noopener')) return;

    let url;
    try {
      url = new URL(anchor.href, location.href);
    } catch {
      return;
    }

    // VideoCard links in the captured site use /video/<slug>_<id> routes.
    if (url.origin !== location.origin || !url.pathname.startsWith('/video/')) return;

    let candidate = anchor.parentElement;
    for (let depth = 0; candidate && depth < 4; depth += 1, candidate = candidate.parentElement) {
      const classes = candidate.classList;
      if (
        classes.contains('fixed') &&
        classes.contains('overflow-hidden') &&
        classes.contains('z-[100]') &&
        classes.contains('min-w-[200px]')
      ) {
        if (!candidate.hasAttribute(BLOCKED_MENU_MARKER)) {
          candidate.setAttribute(BLOCKED_MENU_MARKER, '');
          candidate.setAttribute('aria-hidden', 'true');
          candidate.setAttribute('inert', '');
          candidate.style.setProperty('display', 'none', 'important');
          candidate.style.setProperty('visibility', 'hidden', 'important');
          candidate.style.setProperty('pointer-events', 'none', 'important');
        }
        return;
      }
    }
  }

  function inspectForMenus(root) {
    if (!(root instanceof Element || root instanceof Document)) return;

    if (root instanceof HTMLAnchorElement) hideSiteVideoContextMenu(root);

    if (typeof root.querySelectorAll === 'function') {
      root.querySelectorAll('a[target="_blank"][rel~="noopener"]').forEach(hideSiteVideoContextMenu);
    }
  }

  /*
   * The captured network trace records GET https://s.eunow4u.com/venor.php
   * from a preparePop initiator while the site's ad config enables an
   * inline-v6 popunder. Block only that exact observed endpoint, not the whole
   * host or ordinary fetch/XHR traffic. @sandbox raw makes these hooks run in
   * the page's JS world in Tampermonkey, where the site's requests originate.
   */
  function isObservedPopunderEndpoint(candidate) {
    try {
      const raw = candidate instanceof Request ? candidate.url : String(candidate);
      const url = new URL(raw, location.href);
      return url.hostname.toLowerCase() === 's.eunow4u.com' && url.pathname === '/venor.php';
    } catch {
      return false;
    }
  }

  function blockObservedPopunderRequest() {
    const nativeFetch = window.fetch;
    if (typeof nativeFetch === 'function') {
      window.fetch = function pmvhavenFetchGuard(input, init) {
        if (isObservedPopunderEndpoint(input)) {
          return Promise.reject(new DOMException('Blocked known PMVHaven popunder endpoint', 'AbortError'));
        }
        return Reflect.apply(nativeFetch, this, arguments);
      };
    }

    const xhrPrototype = XMLHttpRequest.prototype;
    const nativeOpen = xhrPrototype.open;
    const nativeSend = xhrPrototype.send;
    const blockedRequests = new WeakSet();

    xhrPrototype.open = function pmvhavenXHROpenGuard(method, url, ...rest) {
      if (isObservedPopunderEndpoint(url)) blockedRequests.add(this);
      else blockedRequests.delete(this);
      return Reflect.apply(nativeOpen, this, [method, url, ...rest]);
    };

    xhrPrototype.send = function pmvhavenXHRSendGuard(...args) {
      if (blockedRequests.has(this)) {
        blockedRequests.delete(this);
        // XHR.open does not send anything; returning here prevents the network
        // request entirely. Emit the failure events that an ad-specific network
        // error would normally produce, without inventing a successful response.
        const xhr = this;
        queueMicrotask(() => {
          try {
            xhr.dispatchEvent(new ProgressEvent('error', {
              lengthComputable: false,
              loaded: 0,
              total: 0
            }));
            xhr.dispatchEvent(new ProgressEvent('loadend', {
              lengthComputable: false,
              loaded: 0,
              total: 0
            }));
          } catch {
            // The request is already suppressed; event synthesis is best-effort.
          }
        });
        return undefined;
      }
      return Reflect.apply(nativeSend, this, args);
    };
  }

  function interceptAdClicks(event) {
    const target = event.target instanceof Element
      ? event.target
      : event.target instanceof Node
        ? event.target.parentElement
        : null;
    if (!target) return;

    // Explicitly protect legitimate video cards, including their nested controls.
    if (target.closest('a[data-video-id]')) return;

    const ad = target.closest(AD_CLICK_SELECTOR);
    if (!ad || ad.hasAttribute('data-video-id')) return;

    event.preventDefault();
    event.stopImmediatePropagation();
  }

  window.addEventListener('click', interceptAdClicks, true);

  // Handles Vue/Teleport menus and ads that appear after the initial page render.
  const observer = new MutationObserver((records) => {
    injectStyles();
    for (const record of records) {
      for (const node of record.addedNodes) inspectForMenus(node);
    }
  });
  observer.observe(document, { childList: true, subtree: true });

  injectStyles();
  inspectForMenus(document);
})();