Add various search links to Audible (MyAnonaMouse, AudioBookBay, Mobilism, Goodreads, Anna's Archive, Z-Library & more)
// ==UserScript== // @name Audible Search Hub // @namespace https://greasyfork.org/en/users/1370284 // @version 0.2.4 // @license MIT // @description Add various search links to Audible (MyAnonaMouse, AudioBookBay, Mobilism, Goodreads, Anna's Archive, Z-Library & more) // @match https://*.audible.*/pd/* // @match https://*.audible.*/ac/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // ==/UserScript== const sites = { mam: { label: '🐭 MAM', name: 'MyAnonaMouse', url: 'https://www.myanonamouse.net', searchBy: { title: true, titleAuthor: true, titleAuthorNarrator: true, }, getLink: (search, opts = {}) => { const baseUrl = GM_config.get('url_mam') const url = new URL(`${baseUrl}/tor/browse.php`) url.searchParams.set('tor[text]', search) url.searchParams.set('tor[searchType]', 'active') url.searchParams.set('tor[main_cat]', 13) // Audiobooks - not working tho... url.searchParams.set('tor[srchIn][title]', true) url.searchParams.set('tor[srchIn][author]', true) if (opts?.narrator) { url.searchParams.set('tor[srchIn][narrator]', true) } return url.href }, }, abb: { label: '🎧 ABB', name: 'AudioBookBay', url: 'https://audiobookbay.lu', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_abb') const url = new URL(baseUrl) url.searchParams.set('s', search.toLowerCase()) return url.href }, }, mobilism: { label: '📱 Mobilism', name: 'Mobilism', url: 'https://forum.mobilism.org', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_mobilism') const url = new URL(`${baseUrl}/search.php`) url.searchParams.set('keywords', search) url.searchParams.set('sr', 'topics') url.searchParams.set('sf', 'titleonly') return url.href }, }, goodreads: { label: '🔖 Goodreads', name: 'Goodreads', url: 'https://www.goodreads.com', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_goodreads') const url = new URL(`${baseUrl}/search`) url.searchParams.set('q', search) return url.href }, }, anna: { label: '📚 Anna', name: "Anna's Archive", url: 'https://annas-archive.org', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_anna') const url = new URL(`${baseUrl}/search`) url.searchParams.set('q', search) url.searchParams.set('lang', 'en') return url.href }, }, zlib: { label: '📕 zLib', name: 'Z-Library', url: 'https://z-lib.gs', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_zlib') const url = new URL(`${baseUrl}/s/${search}`) return url.href }, }, libgen: { label: '📗 Libgen', name: 'Libgen', url: 'https://libgen.rs', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_libgen') const url = new URL(`${baseUrl}/search`) url.searchParams.set('req', search) return url.href }, }, tgx: { label: '🌌 TGX', name: 'TorrentGalaxy', url: 'https://tgx.rs/torrents.php', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_tgx') const url = new URL(baseUrl) url.searchParams.set('search', search) return url.href }, }, btdig: { label: '⛏️ BTDig', name: 'BTDig', url: 'https://btdig.com', searchBy: { title: false, titleAuthor: true }, getLink: (search) => { const baseUrl = GM_config.get('url_btdig') const url = new URL(`${baseUrl}/search`) url.searchParams.set('q', search) return url.href }, }, // TODO: add libby, pointing to your library } const sitesKeys = Object.keys(sites) const searchByFields = { title: { label: 't', description: 'title', }, titleAuthor: { label: 't+a', description: 'title + author', }, titleAuthorNarrator: { label: 't+a+n', description: 'title + author + narrator', }, } function addSiteConfig(site) { return { [`section_${site}`]: { label: `-------------- ${sites[site].name} 👇 --------------`, type: 'hidden', }, [`enable_${site}`]: { label: 'Enable', type: 'checkbox', default: true, }, [`url_${site}`]: { label: 'URL', type: 'text', default: sites[site].url, }, [`enable_search_title_${site}`]: { label: 'Enable Search by Title', type: 'checkbox', default: sites[site].searchBy?.title || false, }, [`enable_search_titleAuthor_${site}`]: { label: 'Enable Search by Title + Author', type: 'checkbox', default: sites[site].searchBy?.titleAuthor || false, }, [`enable_search_titleAuthorNarrator_${site}`]: { label: 'Enable Search by Title + Author + Narrator', type: 'checkbox', default: sites[site].searchBy?.titleAuthorNarrator || false, }, } } const perSiteFields = sitesKeys.reduce((acc, siteKey) => { return { ...acc, ...addSiteConfig(siteKey, sites[siteKey]), } }, {}) GM_config.init({ id: 'audible-search-sites', title: 'Search Sites', fields: { open_in_new_tab: { label: 'Open Links in New Tab', type: 'checkbox', default: true, }, ...perSiteFields, }, }) GM_registerMenuCommand('Open Settings', () => { GM_config.open() }) function createLink(text, href, title) { const link = document.createElement('a') link.href = href link.textContent = text link.target = GM_config.get('open_in_new_tab') ? '_blank' : '_self' link.classList.add( 'bc-tag', 'bc-size-footnote', 'bc-tag-outline', 'bc-badge-tag', 'bc-badge', 'custom-bc-tag' ) link.title = title || text return link } function createLinksContainer() { const container = document.createElement('div') container.style.marginTop = '8px' container.style.display = 'flex' container.style.alignItems = 'center' container.style.flexWrap = 'wrap' container.style.gap = '4px' container.style.maxWidth = '340px' return container } const parser = new DOMParser() function decodeHtmlEntities(str) { if (str == null) return '' const domParser = parser || new DOMParser() const doc = domParser.parseFromString(str, 'text/html') return doc.documentElement.textContent } function cleanSeriesName(seriesName) { if (!seriesName) return '' const wordsToRemove = new Set(['series', 'an', 'the', 'novel']) return seriesName .toLowerCase() .split(' ') .filter((word) => !wordsToRemove.has(word)) .join(' ') .trim() } function cleanQuery(str) { const decoded = decodeHtmlEntities(str) // Remove dashes only when surrounded by spaces const noSurroundingDashes = decoded.replace(/(?<=\s)-(?=\s)/g, '') // Remove other unwanted characters return noSurroundingDashes.replace(/[?!:+~]/g, '') } function removePersonTitles(str) { return str ?.replace( /\b(Dr\.?|Mr\.?|Mrs\.?|Ms\.?|Prof\.?|M\.?D\.?|Ph\.?D\.?|D\.?O\.?|D\.?C\.?|D\.?D\.?S\.?|D\.?M\.?D\.?|D\.?Sc\.?|Ed\.?D\.?|LLB|JD|Esq\.?)\b\.?/gi, '' ) // Remove common author-related titles .replace(/\b\w{1,2}\.\s*/g, '') // Remove any 1 or 2 letter abbreviations followed by a dot .replace(/\s+/g, ' ') // Condense multiple spaces into one .trim() // Trim any extra spaces at the start or end } function extractBookInfo(data) { return { title: cleanQuery(data?.name), author: removePersonTitles(cleanQuery(data?.author?.at(0)?.name)), narrator: removePersonTitles(cleanQuery(data?.readBy?.at(0)?.name)), } } async function injectSearchLinks(data) { const { title, author, narrator } = extractBookInfo(data) const titleAuthor = `${title} ${author} ` const titleAuthorNarrator = `${title} ${author} ${narrator}` const authorLabelEl = document.querySelector('.authorLabel') const infoParentEl = authorLabelEl?.parentElement if (!infoParentEl) { console.warn("Can't find the parent element to inject links.") return } const linksContainer = createLinksContainer() const fragment = document.createDocumentFragment() // Use a DocumentFragment sitesKeys.forEach((siteKey) => { if (GM_config.get(`enable_${siteKey}`)) { const { label, name, getLink } = sites[siteKey] const enabledSearchFields = Object.keys(searchByFields).filter((field) => GM_config.get(`enable_search_${field}_${siteKey}`) ) const isMultipleEnabled = enabledSearchFields.length > 1 enabledSearchFields.forEach((field) => { const { label: searchLabel, description } = searchByFields[field] const finalLabel = isMultipleEnabled ? `${label} (${searchLabel})` : label let searchValue if (field === 'titleAuthorNarrator') { searchValue = titleAuthorNarrator } else if (field === 'titleAuthor') { searchValue = titleAuthor } else { searchValue = title } const opts = narrator ? { narrator } : {} const link = createLink( finalLabel, getLink(searchValue, opts), `Search ${name} by ${description}` ) fragment.appendChild(link) }) } }) linksContainer.appendChild(fragment) infoParentEl.parentElement.appendChild(linksContainer) } function injectStyles() { const style = document.createElement('style') style.textContent = ` .custom-bc-tag { text-decoration: none; transition: background-color 0.2s ease; white-space: nowrap; } .custom-bc-tag:hover { background-color: #f0f0f0; text-decoration: none; } ` document.head.appendChild(style) } function extractBookData(doc) { try { const acceptedType = 'Audiobook' const ldJsonScripts = doc.querySelectorAll( 'script[type="application/ld+json"]' ) for (const script of ldJsonScripts) { try { const jsonLdData = JSON.parse(script.textContent?.trim() || '') const items = Array.isArray(jsonLdData) ? jsonLdData : [jsonLdData] for (const item of items) { if (item['@type'] === acceptedType) { return item } } } catch (error) { console.error('Error parsing JSON-LD:', error) } } return null } catch (error) { console.error(`Error parsing data: `, error) return null } } function waitForBookDataScripts() { return new Promise((resolve, reject) => { const data = extractBookData(document) if (data) return resolve(data) const observer = new MutationObserver(() => { const data = extractBookData(document) if (data) { observer.disconnect() resolve(data) } }) observer.observe(document, { childList: true, subtree: true }) setTimeout(() => { observer.disconnect() reject(new Error('Timeout: ld+json script not found')) }, 2000) }) } injectStyles() waitForBookDataScripts() .then(injectSearchLinks) .catch((error) => console.error('Error:', error.message))