Adds the Sex & Nudity section from the Parental Guide to the top of the IMDb metadata list
// ==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();
})();