IMDb Nudity Guide

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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