Add links to all Amazon marketplaces, highlighting free ones
// ==UserScript== // @name Audible Search Marketplaces // @namespace https://greasyfork.org/en/users/1370284 // @version 0.0.1 // @license MIT // @description Add links to all Amazon marketplaces, highlighting free ones // @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 MARKETPLACE = { us: { tld: 'com', flag: '🇺🇸', name: 'US' }, uk: { tld: 'co.uk', flag: '🇬🇧', name: 'UK' }, ca: { tld: 'ca', flag: '🇨🇦', name: 'Canada' }, au: { tld: 'com.au', flag: '🇦🇺', name: 'Australia' }, de: { tld: 'de', flag: '🇩🇪', name: 'Germany' }, fr: { tld: 'fr', flag: '🇫🇷', name: 'France' }, es: { tld: 'es', flag: '🇪🇸', name: 'Spain' }, it: { tld: 'it', flag: '🇮🇹', name: 'Italy' }, in: { tld: 'in', flag: '🇮🇳', name: 'India' }, // TODO // br: { tld: 'com.br', flag: '🇧🇷', name: 'Brazil' }, // jp: { tld: 'co.jp', flag: '🇯🇵', name: 'Japan' }, } function addMarketplaceConfig(marketplaceKey) { const { name, flag, tld } = MARKETPLACE[marketplaceKey] return { [`enable_${marketplaceKey}`]: { label: `Enable .${tld} ${flag} (${name})`, type: 'checkbox', default: true, }, } } const marketplaceConfigFields = Object.keys(MARKETPLACE).reduce( (acc, region) => ({ ...acc, ...addMarketplaceConfig(region) }), {} ) GM_config.init({ id: 'audible-marketplaces', title: 'Marketplace Settings', fields: { open_in_new_tab: { label: 'Open Links in New Tab', type: 'checkbox', default: true, }, ...marketplaceConfigFields, }, }) GM_registerMenuCommand('Open Settings', () => { GM_config.open() }) 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 audible(region) { const tld = MARKETPLACE[region].tld return { api: `https://api.audible.${tld}/1.0`, client: `https://www.audible.${tld}`, productPage: (asin) => `https://www.audible.${tld}/pd/${asin}`, } } function constructAudibleProductUrl(region, asin) { return audible(region).productPage(asin) } function extractRegionFromUrl(url) { try { const { hostname } = new URL(url) for (const [key, marketplace] of Object.entries(MARKETPLACE)) { if (hostname.endsWith(marketplace.tld)) { return key } } return null } catch (e) { console.error('Invalid URL:', e) return null } } function extractAsinFromUrl(url) { const asinMatch = url.pathname.match(/\/([A-Z0-9]{10})/) return asinMatch ? asinMatch[1] : null } function createLink(text, href, title, isFree) { const link = document.createElement('a') link.href = href link.textContent = text link.target = GM_config.get('open_in_new_tab') ? '_blank' : '_self' link.title = title || text link.classList.add( 'bc-tag', 'bc-size-footnote', 'bc-tag-outline', 'bc-badge-tag', 'bc-badge', 'bc-tag-custom-marketplace' ) isFree && link.classList.add('free-item') 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' container.classList.add('marketplace-links-container') return container } function extractBookInfo(data) { return { title: decodeHtmlEntities(data?.name), author: decodeHtmlEntities(data?.author?.at(0)?.name), narrator: decodeHtmlEntities(data?.readBy?.map((a) => a.name).join(', ')), language: data?.inLanguage ?? 'english', } } function mapR###lts(r###lts, title) { return r###lts .filter((r) => r.status === 'fulfilled') .map((r) => { const { region, products } = r.value return { region, products: products .filter((p) => { const matchesTitle = p.title.toLocaleLowerCase() === title.toLocaleLowerCase() const isCurrentBookInCurrentRegion = region === REGION && p.asin === ASIN return matchesTitle && !isCurrentBookInCurrentRegion }) .map((p) => mapProductFromCatalogSearch(p, region)), } }) } function createMarketplaceLink(region, product) { const { flag, name } = MARKETPLACE[region] const label = `${flag} ${region}` return createLink(label, product.url, `View in ${name}`, product.isFree) } function appendLinksToPage(mappedR###lts) { 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() mappedR###lts.forEach(({ region, products }) => { products.forEach((product) => { const link = createMarketplaceLink(region, product) fragment.appendChild(link) }) }) linksContainer.appendChild(fragment) if (infoParentEl) { infoParentEl.parentElement.appendChild(linksContainer) } } const REGION = extractRegionFromUrl(window.location) const ASIN = extractAsinFromUrl(window.location) async function processAndInjectLinks(data) { const regionsToSearch = [ REGION, ...Object.keys(MARKETPLACE).filter((m) => m !== REGION), ].filter((m) => GM_config.get(`enable_${m}`)) const bookInfo = extractBookInfo(data) const searchParams = { title: bookInfo.title, author: bookInfo.author, narrator: bookInfo.narrator, language: bookInfo.language, } const r###lts = await Promise.allSettled( regionsToSearch.map(async (region) => { const products = await searchCatalogProducts(region, searchParams) return { region, products } }) ) const mappedR###lts = mapR###lts(r###lts, bookInfo.title) appendLinksToPage(mappedR###lts) } function mapProductFromCatalogSearch(p, region) { return { asin: p.asin, title: p.title, author: p.authors.map((a) => a.name).join(', '), narrator: p.narrators?.map((a) => a.name).join(', '), publisher: p.publisher_name, language: p.language, url: constructAudibleProductUrl(region, p.asin), isFree: checkIfFree(p.plans), } } function checkIfFree(plans) { return plans.some((plan) => plan.plan_name.includes('AYCL')) } async function searchCatalogProducts(region, query) { const url = audible(region ?? 'us').api const searchParams = new URLSearchParams({ response_groups: [ 'contributors', 'product_extended_attrs', 'product_desc', 'product_plan_details', 'product_plans', ].join(','), num_r###lts: '5', products_sort_by: 'Relevance', ...query, }) const res = await fetch(`${url}/catalog/products?${searchParams}`) const data = await res.json() return data.products } function injectStyles() { const style = document.createElement('style') style.textContent = ` .bc-tag-custom-marketplace { white-space: nowrap; text-decoration: none !important; transition: background-color 0.2s ease; } .bc-tag-custom-marketplace:hover { background-color: #f0f0f0 !important; } .free-item { color: #14532d !important; border-color: #16a34a !important; background-color: #dcfce7 !important; } .free-item:hover { background-color: #cbeecf !important; } ` 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(processAndInjectLinks) .catch((error) => console.error('Error:', error.message))