IMDb Nudity Guide

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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