// ==UserScript==
// @name Audible Metadata to MAM JSON script (with ChatGPT)
// @namespace https://greasyfork.org/en/scripts/511491
// @version 1.5.2
// @license MIT
// @description Copies audiobook metadata to JSON and opens MAM upload page. Uses ChatGPT for category and series detection.
// @author SnowmanNurse (Modified from original by Dr.Blank)
// @include https://www.audible.*/pd/*
// @include https://www.audible.*/ac/*
// @grant none
// ==/UserScript==
const RIPPER = "MusicFab"; // yours can be Libation, OpenAudible, MusicFab, InAudible etc. or Blank if Encoded
const CHAPTERIZED = true; // yours will be false if not properly ripped
// Category selection method: 1 for direct mapping, 2 for scoring method, 3 for ChatGPT
const CATEGORY_SELECTION_METHOD = 3;
// API key for ChatGPT
const CHATGPT_API_KEY = "OPENAI Code goes here."; // Insert your ChatGPT API key here
const AVAILABLE_CATEGORIES = [
"Audiobooks - Art", "Audiobooks - Biographical", "Audiobooks - Business", "Audiobooks - Crafts",
"Audiobooks - Fantasy", "Audiobooks - Food", "Audiobooks - History", "Audiobooks - Horror",
"Audiobooks - Humor", "Audiobooks - Instructional", "Audiobooks - Juvenile", "Audiobooks - Language",
"Audiobooks - Medical", "Audiobooks - Mystery", "Audiobooks - Nature", "Audiobooks - Philosophy",
"Audiobooks - Recreation", "Audiobooks - Romance", "Audiobooks - Self-Help", "Audiobooks - Western",
"Audiobooks - Young Adult", "Audiobooks - Historical Fiction", "Audiobooks - Literary Classics",
"Audiobooks - Science Fiction", "Audiobooks - True Crime", "Audiobooks - Urban Fantasy",
"Audiobooks - Action/Adventure", "Audiobooks - Computer/Internet", "Audiobooks - Crime/Thriller",
"Audiobooks - Home/Garden", "Audiobooks - Math/Science/Tech", "Audiobooks - Travel/Adventure",
"Audiobooks - Pol/Soc/Relig", "Audiobooks - General Fiction", "Audiobooks - General Non-Fic"
];
// Direct mapping from Audible to MAM categories
const AUDIBLE_TO_MAM_CATEGORY_MAP = {
"Arts & Entertainment": "Audiobooks - Art",
"Biographies & Memoirs": "Audiobooks - Biographical",
"Business & Careers": "Audiobooks - Business",
"Children's Audiobooks": "Audiobooks - Juvenile",
"Comedy & Humor": "Audiobooks - Humor",
"Computers & Technology": "Audiobooks - Computer/Internet",
"Education & Learning": "Audiobooks - Instructional",
"Erotica": "Audiobooks - Romance",
"Health & Wellness": "Audiobooks - Medical",
"History": "Audiobooks - History",
"Home & Garden": "Audiobooks - Home/Garden",
"LGBTQ+": "Audiobooks - General Fiction",
"Literature & Fiction": "Audiobooks - General Fiction",
"Money & Finance": "Audiobooks - Business",
"Mystery, Thriller & Suspense": "Audiobooks - Mystery",
"Politics & Social Sciences": "Audiobooks - Pol/Soc/Relig",
"Relationships, Parenting & Personal Development": "Audiobooks - Self-Help",
"Religion & Spirituality": "Audiobooks - Pol/Soc/Relig",
"Romance": "Audiobooks - Romance",
"Science & Engineering": "Audiobooks - Math/Science/Tech",
"Science Fiction & Fantasy": "Audiobooks - Science Fiction",
"Sports & Outdoors": "Audiobooks - Recreation",
"Teen & Young Adult": "Audiobooks - Young Adult",
"Travel & Tourism": "Audiobooks - Travel/Adventure"
};
// Keyword mapping for smart category matching
const KEYWORD_TO_MAM_CATEGORY_MAP = {
"science fiction": "Audiobooks - Science Fiction",
"sci-fi": "Audiobooks - Science Fiction",
"fantasy": "Audiobooks - Fantasy",
"magic": "Audiobooks - Fantasy",
"mystery": "Audiobooks - Mystery",
"detective": "Audiobooks - Mystery",
"crime": "Audiobooks - Crime/Thriller",
"thriller": "Audiobooks - Crime/Thriller",
"suspense": "Audiobooks - Crime/Thriller",
"horror": "Audiobooks - Horror",
"romance": "Audiobooks - Romance",
"love story": "Audiobooks - Romance",
"historical": "Audiobooks - Historical Fiction",
"history": "Audiobooks - History",
"biography": "Audiobooks - Biographical",
"memoir": "Audiobooks - Biographical",
"business": "Audiobooks - Business",
"finance": "Audiobooks - Business",
"self-help": "Audiobooks - Self-Help",
"personal development": "Audiobooks - Self-Help",
"science": "Audiobooks - Math/Science/Tech",
"technology": "Audiobooks - Math/Science/Tech",
"computer": "Audiobooks - Computer/Internet",
"programming": "Audiobooks - Computer/Internet",
"travel": "Audiobooks - Travel/Adventure",
"adventure": "Audiobooks - Travel/Adventure",
"cooking": "Audiobooks - Food",
"recipe": "Audiobooks - Food",
"health": "Audiobooks - Medical",
"wellness": "Audiobooks - Medical",
"fitness": "Audiobooks - Medical",
"sports": "Audiobooks - Recreation",
"outdoor": "Audiobooks - Recreation",
"philosophy": "Audiobooks - Philosophy",
"religion": "Audiobooks - Pol/Soc/Relig",
"spirituality": "Audiobooks - Pol/Soc/Relig",
"politics": "Audiobooks - Pol/Soc/Relig",
"social science": "Audiobooks - Pol/Soc/Relig"
};
// Part of main script: Replace the existing extractEditionInfo function
function extractEditionInfo(title) {
// Comprehensive patterns for edition detection
const editionPatterns = [
// Parenthetical editions
/\(([^)]*?\b(?:edition|ed\.|edn\.)[^)]*?)\)/i,
// Editions after punctuation
/(?:[-–—:,]\s*)([^-–—:,]*?\b(?:edition|ed\.|edn\.)[^-–—:,]*?)(?=\s*[-–—:,]|\s*$)/i,
// Standalone edition phrases
/\b(\w+(?:\s+\w+)*\s+(?:edition|ed\.|edn\.)(?:\s*[-–—:,][^-–—:,]*)?)\b/i,
// Numbered editions
/\b(\d+(?:st|nd|rd|th)?\s+(?:edition|ed\.|edn\.)\b[^,]*)/i
];
let cleanedTitle = title;
let editionInfo = null;
// Try each pattern until we find a match
for (const pattern of editionPatterns) {
const match = title.match(pattern);
if (match) {
editionInfo = match[1] ? match[1].trim() : match[0].trim();
// Remove the matched edition text from the title
cleanedTitle = title.replace(match[0], '');
break;
}
}
// Clean up the remaining title
if (cleanedTitle) {
cleanedTitle = cleanedTitle
// Remove trailing punctuation
.replace(/\s*[-–—:,]\s*$/, '')
// Remove empty parentheses
.replace(/\(\s*\)/g, '')
// Remove trailing parenthetical content
.replace(/\s*\([^)]*\)\s*$/, '')
// Clean up multiple commas
.replace(/,\s*,/g, ',')
// Remove trailing comma
.replace(/\s*,\s*$/, '')
// Remove trailing colon
.replace(/:\s*$/g, '')
// Normalize spaces
.replace(/\s+/g, ' ')
.trim();
}
return {
cleanedTitle: cleanedTitle || title,
editionInfo: editionInfo
};
}
function cleanName(name) {
const titlesToRemove = [
"PhD", "MD", "JD", "MBA", "MA", "MS", "MSc", "MFA", "MEd", "ScD", "DrPH", "MPH", "LLM",
"DDS", "DVM", "EdD", "PsyD", "ThD", "DO", "PharmD", "DSc", "DBA", "RN", "CPA", "Esq.",
"LCSW", "PE", "AIA", "FAIA", "CSP", "CFP", "Jr.", "Sr.", "I", "II", "III", "IV",
"Dr.", "Rev.", "Msgr.", "STD", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.",
"Capt.", "Col.", "Gen.", "Lt.", "Cmdr.", "Adm.", "Sir", "Dame", "Hon.", "Amb.", "Gov.", "Sen.", "Rep.", "BSN", "MSN"
];
// Generate variations of the titles that might have or not have periods
const titlesWithVariants = titlesToRemove.reduce((acc, title) => {
// Remove the period if it exists, and add it as an additional variant
const cleanTitle = title.replace('.', '');
acc.push(title, cleanTitle);
return acc;
}, []);
let cleanedName = name.trim();
// Remove everything after a hyphen with space
cleanedName = cleanedName.replace(/\s+-\s+.*$/, '');
// Remove specific role patterns
const rolesToRemove = [
"foreword",
"afterword",
"translator",
"editor",
"introduction",
"preface",
"contributor",
"with",
"featuring",
"feat[.]?",
"illustrated by",
"read by",
"narrated by",
"performed by",
"presented by"
];
// Create pattern for removing roles with various separators
const rolePattern = new RegExp(`\\s*[-–—:,]\\s*(${rolesToRemove.join('|')}).*$`, 'i');
cleanedName = cleanedName.replace(rolePattern, '');
// Remove parenthetical information
cleanedName = cleanedName.replace(/\s*\(.*?\)\s*$/g, '');
// Sort titles by length in descending order to handle longer titles first
const sortedTitles = titlesWithVariants.sort((a, b) => b.length - a.length);
// Remove titles using a more comprehensive approach
for (const title of sortedTitles) {
// Escape special regex characters in the title
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match the title at start, end, or middle of string, with word boundaries and optional spaces
const regex = new RegExp(`(^|\\s+)${escapedTitle}(\\s+|$)`, 'gi');
cleanedName = cleanedName.replace(regex, ' ');
}
// Clean up any remaining artifacts
cleanedName = cleanedName
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.replace(/\s*,\s*$/, '') // Remove trailing comma
.replace(/^\s*,\s*/, '') // Remove leading comma
.trim();
return cleanedName;
}
function cleanSeriesName(seriesName) {
const wordsToRemove = ["series", "an", "the", "novel"];
let cleanedName = seriesName.toLowerCase();
wordsToRemove.forEach(word => {
const regex = new RegExp(`\\b${word}\\b`, 'gi');
cleanedName = cleanedName.replace(regex, '');
});
cleanedName = cleanedName.replace(/\s+/g, ' ').trim();
return cleanedName.replace(/\b\w/g, l => l.toUpperCase());
}
async function detectSeriesWithChatGPT(title, author, description) {
if (!CHATGPT_API_KEY) return { name: "", number: "" };
const prompt = `Given the following book information, determine if it's part of a series. Only respond with series information if you are HIGHLY confident (90%+ certain) it's part of a series. Many books are standalone and not part of any series.
Title: ${title}
Author: ${author}
Description: ${description}
If the book is part of a series, respond in exactly this JSON format:
{"name": "Series Name", "number": "X"}
where X is the book number if known, or empty string if unknown.
If the book is not part of a series, or if you're not highly confident, respond with exactly:
{"name": "", "number": ""}
Response should be ONLY the JSON, nothing else.`;
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${CHATGPT_API_KEY}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
max_tokens: 100
})
});
const data = await response.json();
const result = JSON.parse(data.choices[0].message.content.trim());
return {
name: result.name || "",
number: result.number || ""
};
} catch (error) {
console.error("Error detecting series with ChatGPT:", error);
return { name: "", number: "" };
}
}
async function getCategoryFromChatGPT(title, description, audibleCategory) {
if (!CHATGPT_API_KEY) return null;
const prompt = `Given the following audiobook information, select the most appropriate category from this list: ${AVAILABLE_CATEGORIES.join(", ")}
Title: ${title}
Description: ${description}
Audible Category: ${audibleCategory}
Please respond with only the category name, nothing else.`;
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${CHATGPT_API_KEY}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: 50
})
});
const data = await response.json();
return data.choices[0].message.content.trim();
} catch (error) {
console.error("Error getting category from ChatGPT:", error);
return null;
}
}
function smartCategoryMatcher(audibleCategory, title, description) {
let possibleCategories = {};
if (AUDIBLE_TO_MAM_CATEGORY_MAP[audibleCategory]) {
possibleCategories[AUDIBLE_TO_MAM_CATEGORY_MAP[audibleCategory]] = 5;
}
const addScoreForKeywords = (text, weight) => {
text = text.toLowerCase();
for (let [keyword, category] of Object.entries(KEYWORD_TO_MAM_CATEGORY_MAP)) {
if (text.includes(keyword)) {
possibleCategories[category] = (possibleCategories[category] || 0) + weight;
if (category === "Audiobooks - Romance") {
possibleCategories[category] += 2;
}
}
}
};
addScoreForKeywords(title, 3);
addScoreForKeywords(description, 2);
let bestMatch = Object.entries(possibleCategories).reduce((a, b) => a[1] > b[1] ? a : b)[0];
return bestMatch || (audibleCategory.includes("Fiction") ? "Audiobooks - General Fiction" : "Audiobooks - General Non-Fic");
}
async function getMAMCategory() {
let audibleCategory = getAudibleCategory();
let title = getTitle();
let description = document.querySelector(".productPublisherSummary>div>div>span") ?
document.querySelector(".productPublisherSummary>div>div>span").textContent :
"No description available";
switch (CATEGORY_SELECTION_METHOD) {
case 1: // Direct mapping
return AUDIBLE_TO_MAM_CATEGORY_MAP[audibleCategory] || "";
case 2: // Scoring method
return smartCategoryMatcher(audibleCategory, title, description);
case 3: // ChatGPT
const chatGPTCategory = await getCategoryFromChatGPT(title, description, audibleCategory);
if (chatGPTCategory && AVAILABLE_CATEGORIES.includes(chatGPTCategory)) {
return chatGPTCategory;
}
// If ChatGPT fails or no API key, fall back to direct mapping
return AUDIBLE_TO_MAM_CATEGORY_MAP[audibleCategory] || "";
default:
return "";
}
}
function getLanguage() {
let languageElement = document.querySelector(".languageLabel");
let patt = /\s*(\w+)$/g;
let matches = patt.exec(languageElement.innerHTML.trim());
return matches[1];
}
function getSeriesInfo() {
let seriesElements = document.querySelectorAll(".seriesLabel");
let seriesInfo = [];
// First check for series information on the Audible page
seriesElements.forEach(element => {
let seriesLink = element.querySelector("a");
if (seriesLink) {
let seriesName = cleanSeriesName(seriesLink.textContent);
let bookNumberMatch = element.textContent.match(/Book\s*?(\d+\.?\d*-?\d*\.?\d*)/);
let bookNumber = bookNumberMatch ? bookNumberMatch[1] : "";
seriesInfo.push({ name: seriesName, number: bookNumber });
}
});
// If no series found on page and ChatGPT is enabled, try AI detection
if (seriesInfo.length === 0 && CATEGORY_SELECTION_METHOD === 3) {
window.detectingSeriesWithAI = true;
}
return seriesInfo;
}
function getTitle() {
let title = document.getElementsByTagName("h1")[0].innerText;
const { cleanedTitle, editionInfo } = extractEditionInfo(title);
// Store edition info for later use in tags
if (editionInfo) {
window.editionInfo = editionInfo;
}
return cleanedTitle;
}
function getSubtitle() {
let sLoggedIn = document.querySelector(".subtitle");
let sLoggedOut = document.querySelector("span.bc-size-medium");
let subtitle = "";
if (sLoggedIn) {
subtitle = sLoggedIn.innerText;
} else if (sLoggedOut) {
subtitle = sLoggedOut.innerText;
}
if (!subtitle) return "";
if (subtitle.trim() === "+More") return "";
let seriesInfo = getSeriesInfo();
let isSubtitleSeries = seriesInfo.some(series =>
subtitle.toLowerCase().includes(series.name.toLowerCase())
);
if (isSubtitleSeries) return "";
return subtitle;
}
function getTitleAndSubtitle() {
let subtitle = getSubtitle();
if (subtitle) {
return `${getTitle()}: ${subtitle}`;
}
return getTitle();
}
function getReleaseDate() {
let element = document.querySelector(".releaseDateLabel");
let patt = /\d{2}-\d{2}-\d{2}/;
let matches = patt.exec(element.innerText);
return matches ? matches[0] : "";
}
function getPublisher() {
let publisherElement = document.querySelector(".publisherLabel>a");
return publisherElement ? publisherElement.innerText : "Unknown Publisher";
}
function getRunTime() {
let runtimeElement = document.querySelector(".runtimeLabel");
let runtime = runtimeElement ? runtimeElement.textContent : "Unknown";
let patt = new RegExp("Length:\\n\\s+(\\d[^\n]+)");
let matches = patt.exec(runtime);
return matches ? matches[1] : "Unknown";
}
function getAudibleCategory() {
let categoryElement = document.querySelector(".categoriesLabel");
if (categoryElement) return categoryElement.innerText;
categoryElement = document.querySelector("nav.bc-breadcrumb");
if (categoryElement) return categoryElement.innerText;
return "";
}
function getAdditionalTags() {
let tags = [];
tags.push(`Duration: ${getRunTime()}`);
if (CHAPTERIZED) tags.push("Chapterized");
if (RIPPER) tags.push(RIPPER);
tags.push(`Audible Release: ${getReleaseDate()}`);
tags.push(`Publisher: ${getPublisher()}`);
tags.push(getAudibleCategory());
// Add edition info to tags if it exists
if (window.editionInfo) {
tags.push(`Edition: ${window.editionInfo}`);
}
return tags.join(" | ");
}
function getASIN() {
const urlMatch = window.location.pathname.match(/\/([A-Z0-9]{10})/);
if (urlMatch && urlMatch[1]) {
return "ASIN:" + urlMatch[1];
}
const productDetails = document.querySelector('#detailsproductInfoSection');
if (productDetails) {
const asinMatch = productDetails.textContent.match(/ASIN:\s*([A-Z0-9]{10})/);
if (asinMatch && asinMatch[1]) {
return "ASIN:" + asinMatch[1];
}
}
return "";
}
function getAuthors() {
var authorElements = document.querySelectorAll(".authorLabel a");
var authors = [];
for (let element of authorElements) {
if (element) {
let authorName = element.textContent.trim();
authorName = cleanName(authorName);
if (authorName &&
!authors.includes(authorName) &&
authorName !== "Prologue Projects" &&
authorName.toLowerCase() !== "title" &&
authorName.toLowerCase() !== "author + title") {
authors.push(authorName);
}
}
}
return authors;
}
function getNarrators() {
var narratorElements = document.querySelectorAll(".narratorLabel a");
var narrators = [];
for (let element of narratorElements) {
if (element) {
let narratorName = element.textContent.trim();
narratorName = cleanName(narratorName);
if (narratorName && !narrators.includes(narratorName) && narratorName.toLowerCase() !== "full cast") {
narrators.push(narratorName);
}
}
}
return narrators;
}
async function generateJson() {
var imgElement = document.querySelector(".bc-image-inset-border");
var imageSrc = imgElement ? imgElement.src : "No image available";
var descriptionElement = document.querySelector(".productPublisherSummary>div>div>span") ||
document.querySelector("div.bc-col-6 span.bc-text") ||
document.querySelector("div.bc-text.bc-color-secondary");
var description = descriptionElement ? descriptionElement.innerHTML : "No description available";
description = description.replace(/\s+/g, " ").replace(/"/g, '\\"').replace(/<p><\/p>/g, "").replace(/<\/p>/g, "</p><br>").replace(/<\/ul>/g, "</ul><br>");
let authors = getAuthors();
let seriesInfo = getSeriesInfo();
// If AI series detection is needed, do it here
if (window.detectingSeriesWithAI) {
try {
const aiSeriesInfo = await detectSeriesWithChatGPT(getTitle(), authors[0] || "", description);
if (aiSeriesInfo.name) {
seriesInfo = [aiSeriesInfo];
}
} catch (error) {
console.error("Error in AI series detection:", error);
}
delete window.detectingSeriesWithAI;
}
var json = {
"authors": authors,
"description": description,
"narrators": getNarrators(),
"tags": getAdditionalTags(),
"thumbnail": imageSrc,
"title": getTitleAndSubtitle(),
"language": getLanguage(),
"series": seriesInfo,
"category": await getMAMCategory(),
"isbn": getASIN()
};
var strJson = JSON.stringify(json);
await copyToClipboard(strJson);
}
function addOverlayAndButton() {
const overlayHtml = `
<div id="mam-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(5px); z-index: 9999;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 5px; text-align: center;">
<p style="font-size: 18px; margin: 0;">Processing JSON data...</p>
<p style="font-size: 16px; margin: 10px 0 0;">Please wait...</p>
<div id="ai-gif" style="display: none; margin-top: 20px;">
<img src="https://c.tenor.com/JDV9WN1QC3kAAAAC/tenor.gif" alt="AI Processing" style="max-width: 200px;">
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', overlayHtml);
const buyBoxElement = document.querySelector("#adbl-buy-box");
if (!buyBoxElement) return;
const buttonText = CATEGORY_SELECTION_METHOD === 3 ? "Copy Book info to JSON with ChatGPT" : "Copy Book info to JSON";
const buttonHtml = `
<div id="mam-metadata-button" class="bc-row bc-spacing-top-s1">
<div class="bc-row">
<div class="bc-trigger bc-pub-block">
<span class="bc-button bc-button-primary">
<button id="copyMetadataButton" class="bc-button-text" type="button" tabindex="0" title="Copy book details as JSON">
<span class="bc-text bc-button-text-inner bc-size-action-large">
${buttonText}
</span>
</button>
</span>
</div>
</div>
</div>`;
buyBoxElement.insertAdjacentHTML('beforeend', buttonHtml);
document.getElementById("copyMetadataButton").addEventListener("click", async function (event) {
event.preventDefault();
showOverlay();
await generateJson();
hideOverlay();
});
}
function showOverlay() {
document.getElementById("mam-overlay").style.display = "block";
if (CATEGORY_SELECTION_METHOD === 3) {
document.getElementById("ai-gif").style.display = "block";
}
}
function hideOverlay() {
document.getElementById("mam-overlay").style.display = "none";
document.getElementById("ai-gif").style.display = "none";
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log('Copied to clipboard successfully!');
window.open("https://www.myanonamouse.net/tor/upload.php", "_blank");
} catch (err) {
console.error('Could not copy text: ', err);
alert('Failed to copy metadata. Please check the console for errors.');
}
}
window.onload = function () {
addOverlayAndButton();
};