IMDb Nudity Guide

Adds the Sex & Nudity section from the Parental Guide to the top of the IMDb metadata list

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         IMDb Nudity Guide
// @version      1.1.0
// @description  Adds the Sex & Nudity section from the Parental Guide to the top of the IMDb metadata list
// @match        https://www.imdb.com/title/tt*
// @exclude      https://www.imdb.com/title/tt*/parentalguide*
// @exclude      https://www.imdb.com/title/tt*/fullcredits*
// @exclude      https://www.imdb.com/title/tt*/reviews*
// @exclude      https://www.imdb.com/title/tt*/ratings*
// @exclude      https://www.imdb.com/title/tt*/episodes*
// @exclude      https://www.imdb.com/title/tt*/trivia*
// @exclude      https://www.imdb.com/title/tt*/goofs*
// @exclude      https://www.imdb.com/title/tt*/quotes*
// @exclude      https://www.imdb.com/title/tt*/faq*
// @exclude      https://www.imdb.com/title/tt*/awards*
// @exclude      https://www.imdb.com/title/tt*/technical*
// @exclude      https://www.imdb.com/title/tt*/mediaindex*
// @exclude      https://www.imdb.com/title/tt*/videogallery*
// @exclude      https://www.imdb.com/title/tt*/plotsummary*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      www.imdb.com
// @run-at       document-idle
// @namespace https://greasyfork.org/users/1615328
// ==/UserScript==

(function () {
  "use strict";

  // ---------------------------------------------------------------------------
  // Styles — injected once, uses IMDb's own CSS variables & class conventions
  // ---------------------------------------------------------------------------

  GM_addStyle(`
    .nudity-guide-accordion {
      overflow: hidden;
      padding-top: 0 !important;
      padding-bottom: 0 !important;
    }

    .nudity-guide-accordion__header {
      cursor: pointer;
      user-select: none;
      -webkit-user-select: none;
      transition: padding 0.2s ease;
    }

    .nudity-guide-accordion--open .nudity-guide-accordion__header {
      padding-top: 12px;
    }

    .nudity-guide-accordion__chevron {
      transition: transform 0.2s ease;
    }

    .nudity-guide-accordion__chevron--open {
      transform: rotate(90deg);
    }

    .nudity-guide-accordion__body {
      max-height: 0;
      overflow: hidden;
      transition: max-height 0.3s ease, padding 0.3s ease;
      padding: 0 0 0 0;
    }

    .nudity-guide-accordion__body--open {
      max-height: 2000px;
      padding: 8px 0 4px 0;
    }

    .nudity-guide-accordion__item {
      font-size: 14px;
      line-height: 1.5;
      padding: 6px 0;
      color: rgba(255, 255, 255, 0.7);
      border-top: 1px solid rgba(255, 255, 255, 0.08);
    }

    .nudity-guide-accordion__item:first-child {
      border-top: none;
    }

    .nudity-guide-severity-text--none     { color: #4caf50 !important; }
    .nudity-guide-severity-text--mild     { color: #cddc39 !important; }
    .nudity-guide-severity-text--moderate { color: #ff9800 !important; }
    .nudity-guide-severity-text--severe   { color: #f44336 !important; }

    .nudity-guide-empty {
      font-size: 13px;
      color: rgba(255, 255, 255, 0.4);
      padding: 6px 0;
    }
  `);

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  function getTitleId() {
    const match = location.pathname.match(/\/title\/(tt\d+)/);
    return match ? match[1] : null;
  }

  function waitForElement(selector, timeout = 10000) {
    return new Promise((resolve, reject) => {
      const el = document.querySelector(selector);
      if (el) return resolve(el);

      const observer = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) {
          observer.disconnect();
          resolve(el);
        }
      });

      observer.observe(document.body, { childList: true, subtree: true });

      setTimeout(() => {
        observer.disconnect();
        reject(new Error(`Timeout waiting for "${selector}"`));
      }, timeout);
    });
  }

  function gmFetch(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        headers: {
          "User-Agent": navigator.userAgent,
          Accept: "text/html",
        },
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) {
            resolve(res.responseText);
          } else {
            reject(new Error(`HTTP ${res.status} for ${url}`));
          }
        },
        onerror: (err) => reject(err),
      });
    });
  }

  // ---------------------------------------------------------------------------
  // Parsing
  // ---------------------------------------------------------------------------

  function findNuditySection(doc) {
    // Try the anchor ID first (highly standard/robust for section pages)
    const anchor = doc.getElementById("nudity");
    if (anchor) {
      const parent = anchor.closest("section") || anchor.closest(".ipc-page-section") || anchor.closest("div");
      if (parent) return parent;
    }

    let section = doc.querySelector("section.section-advisory-nudity");
    if (section) return section;

    section = doc.querySelector('[class*="advisory-nudity"]');
    if (section) return section;

    section = doc.querySelector('[id*="advisory-nudity"]');
    if (section) return section;

    // Use closest section for any element matching data-testid="sub-section-nudity" or similar to avoid returning the inner list only
    const dataTestIdEl = doc.querySelector('[data-testid*="nudity"]');
    if (dataTestIdEl) {
      const parent = dataTestIdEl.closest("section") || dataTestIdEl.closest(".ipc-page-section") || dataTestIdEl;
      return parent;
    }

    const allElements = doc.querySelectorAll("h2, h3, h4, span, a, dt");
    for (const el of allElements) {
      const text = el.textContent.trim();
      if (/^sex\s*[&]\s*nudity$/i.test(text) || text === "Sex & Nudity") {
        const parent = el.closest("section") || el.closest("div[class]");
        if (parent) return parent;
      }
    }

    return null;
  }

  function parseNuditySection(html) {
    const doc = new DOMParser().parseFromString(html, "text/html");
    const items = [];
    const nuditySection = findNuditySection(doc);
    if (!nuditySection) return items;

    let contentDivs = nuditySection.querySelectorAll(".ipc-html-content-inner-div");
    if (contentDivs.length === 0) {
      contentDivs = nuditySection.querySelectorAll(".ipl-zebra-list__item");
    }
    if (contentDivs.length === 0) {
      contentDivs = nuditySection.querySelectorAll("li");
    }

    contentDivs.forEach((el) => {
      const text = cleanText(el);
      if (text) items.push(text);
    });

    return items;
  }

  function parseSeverity(html) {
    const doc = new DOMParser().parseFromString(html, "text/html");
    const nuditySection = findNuditySection(doc);
    if (!nuditySection) return null;

    // Query inner elements first, so textContent is clean (e.g. "Moderate" instead of "Moderate11 of 36 found...")
    const severityContainer =
      nuditySection.querySelector('.ipc-signpost__text') ||
      nuditySection.querySelector('[class*="ipl-status-pill"]') ||
      nuditySection.querySelector('[data-testid="severity_component"] .ipc-signpost__text') ||
      nuditySection.querySelector('[data-testid="severity_component"]') ||
      nuditySection.querySelector('[class*="advisory-severity-vote"]') ||
      nuditySection.querySelector('[class*="severity"]') ||
      nuditySection.querySelector('[class*="signpost"]');

    if (severityContainer) {
      const text = severityContainer.textContent.toLowerCase();
      const match = text.match(/(?:^|[^a-zA-Z])(severe|moderate|mild|none)(?:$|[^a-zA-Z])/i);
      if (match) return match[1].toLowerCase();
    }

    const sectionText = nuditySection.textContent.toLowerCase();
    const topText = sectionText.slice(0, 300);
    const matchTop = topText.match(/(?:^|[^a-zA-Z])(severe|moderate|mild|none)(?:$|[^a-zA-Z])/i);
    if (matchTop) return matchTop[1].toLowerCase();

    return null;
  }

  function cleanText(el) {
    const clone = el.cloneNode(true);
    clone
      .querySelectorAll(
        'button, [class*="vote"], [class*="edit"], [class*="btn"], [class*="severity"], [class*="spoiler-warning"]'
      )
      .forEach((child) => child.remove());

    let text = clone.textContent.replace(/\s+/g, " ").trim();
    text = text.replace(/\s*Edit\s*$/i, "").trim();

    if (!text || text.length < 5) return "";
    if (/^(none|mild|moderate|severe)$/i.test(text)) return "";
    if (/^(sex\s*&\s*nudity|violence\s*&\s*gore|profanity|alcohol|frightening)/i.test(text)) return "";

    return text;
  }

  // ---------------------------------------------------------------------------
  // UI — Build a native IMDb metadata list item with accordion
  // ---------------------------------------------------------------------------

  /**
   * Builds an <li> that matches the Stars row structure:
   *
   *   <li class="ipc-metadata-list__item ...">
   *     <span class="ipc-metadata-list-item__label">Nudity · Moderate</span>
   *     <div class="ipc-metadata-list-item__content-container">
   *       <ul class="ipc-inline-list ...">  ← summary text (first item truncated)
   *     </div>
   *     <span> ← chevron toggle (down/up for accordion, not a link)
   *   </li>
   *
   * Clicking the row toggles the accordion body that reveals full items.
   */
  function buildWidget(items, severity, titleId) {
    // --- Outer <li> mimicking ipc-metadata-list__item ---
    const li = document.createElement("li");
    li.setAttribute("role", "presentation");
    li.className =
      "ipc-metadata-list__item ipc-metadata-list__item--align-end nudity-guide-accordion";

    // --- Header row (clickable) ---
    const header = document.createElement("div");
    header.className = "nudity-guide-accordion__header";
    header.style.cssText =
      "display:flex; align-items:center; width:100%;";

    // Label: "Explicitness"
    const label = document.createElement("span");
    label.className = "ipc-metadata-list-item__label ipc-btn--not-interactable";
    label.setAttribute("aria-disabled", "false");
    label.textContent = "Explicitness";

    header.appendChild(label);

    // Content preview: show just the severity level
    const contentContainer = document.createElement("div");
    contentContainer.className = "ipc-metadata-list-item__content-container";

    {
      const previewList = document.createElement("ul");
      previewList.className =
        "ipc-inline-list ipc-inline-list--show-dividers ipc-inline-list--inline ipc-metadata-list-item__list-content baseAlt";
      previewList.setAttribute("role", "presentation");

      const previewLi = document.createElement("li");
      previewLi.setAttribute("role", "presentation");
      previewLi.className = "ipc-inline-list__item";

      const previewText = document.createElement("span");
      previewText.className = "ipc-metadata-list-item__list-content-item";
      if (severity) {
        previewText.textContent = severity.charAt(0).toUpperCase() + severity.slice(1);
        previewText.classList.add(`nudity-guide-severity-text--${severity}`);
      } else if (items.length > 0) {
        previewText.textContent = `${items.length} item${items.length > 1 ? "s" : ""}`;
      } else {
        previewText.textContent = "No advisory information";
        previewText.style.opacity = "0.5";
      }
      previewLi.appendChild(previewText);
      previewList.appendChild(previewLi);

      contentContainer.appendChild(previewList);
    }

    header.appendChild(contentContainer);

    // Chevron toggle (down arrow, rotates on open)
    const chevron = document.createElement("span");
    chevron.className = "nudity-guide-accordion__chevron";
    chevron.style.cssText =
      "display:flex; align-items:center; justify-content:center; margin-left:auto; flex-shrink:0;";
    chevron.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" class="ipc-icon ipc-icon--chevron-right" viewBox="0 0 24 24" fill="currentColor" role="presentation"><path fill="none" d="M0 0h24v24H0V0z"></path><path d="M9.29 6.71a.996.996 0 0 0 0 1.41L13.17 12l-3.88 3.88a.996.996 0 1 0 1.41 1.41l4.59-4.59a.996.996 0 0 0 0-1.41L10.7 6.7c-.38-.38-1.02-.38-1.41.01z"></path></svg>`;

    header.appendChild(chevron);

    li.appendChild(header);

    // --- Accordion body (hidden by default) ---
    const body = document.createElement("div");
    body.className = "nudity-guide-accordion__body";

    if (items.length > 0) {
      items.forEach((text) => {
        const itemDiv = document.createElement("div");
        itemDiv.className = "nudity-guide-accordion__item";
        itemDiv.textContent = text;
        body.appendChild(itemDiv);
      });
    } else {
      const empty = document.createElement("div");
      empty.className = "nudity-guide-empty";
      empty.textContent = "No advisory information available for this title.";
      body.appendChild(empty);
    }

    // Link to full parental guide
    const linkDiv = document.createElement("div");
    linkDiv.className = "nudity-guide-accordion__item";

    const link = document.createElement("a");
    link.href = `/title/${titleId}/parentalguide#nudity`;
    link.className = "ipc-link ipc-link--baseAlt";
    link.style.cssText = "display: inline-flex; align-items: center; font-size: 13px; text-decoration: none;";
    link.innerHTML = `View full Parental Guide <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="ipc-icon ipc-icon--launch-inline ipc-icon--inline ipc-link__launch-icon" viewBox="0 0 24 24" fill="currentColor" style="margin-left: 4px; vertical-align: middle;"><path d="M21.6 21.6H2.4V2.4h7.2V0H0v24h24v-9.6h-2.4v7.2zM14.4 0v2.4h4.8L7.195 14.49l2.4 2.4L21.6 4.8v4.8H24V0h-9.6z"></path></svg>`;

    linkDiv.appendChild(link);
    body.appendChild(linkDiv);

    li.appendChild(body);

    // --- Toggle behavior ---
    let isOpen = false;
    header.addEventListener("click", () => {
      isOpen = !isOpen;
      li.classList.toggle("nudity-guide-accordion--open", isOpen);
      body.classList.toggle("nudity-guide-accordion__body--open", isOpen);
      chevron.classList.toggle("nudity-guide-accordion__chevron--open", isOpen);
    });

    return li;
  }

  // ---------------------------------------------------------------------------
  // Main
  // ---------------------------------------------------------------------------

  async function main() {
    const titleId = getTitleId();
    if (!titleId) return;

    const guideUrl = `https://www.imdb.com/title/${titleId}/parentalguide`;

    try {
      const html = await gmFetch(guideUrl);
      const items = parseNuditySection(html);
      const severity = parseSeverity(html);

      console.log("[IMDb Nudity Guide] Parsed items:", items);
      console.log("[IMDb Nudity Guide] Severity:", severity);

      // Find the metadata list
      let list;
      try {
        list = await waitForElement(".ipc-metadata-list", 8000);
      } catch {
        list = document.querySelector(".ipc-metadata-list");
      }

      if (!list) {
        console.log("[IMDb Nudity Guide] No .ipc-metadata-list element found on page");
        return;
      }

      const widget = buildWidget(items, severity, titleId);
      list.prepend(widget);

      console.log(
        `[IMDb Nudity Guide] Added nudity guide widget to top of .ipc-metadata-list with ${items.length} items (severity: ${severity || "unknown"})`
      );
    } catch (err) {
      console.error("[IMDb Nudity Guide] Error:", err);
    }
  }

  main();
})();