Audible HQ Cover

Full-res cover images on Audible, with 'open' and 'download' actions.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Audible HQ Cover
// @namespace    https://greasyfork.org/en/users/1370205
// @version      0.2.3
// @description  Full-res cover images on Audible, with 'open' and 'download' actions.
// @license      MIT
// @match        https://*.audible.*/pd/*
// @match        https://*.audible.*/ac/*
// ==/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' },
	jp: { tld: 'co.jp', flag: '🇯🇵', name: 'Japan' },
	br: { tld: 'com.br', flag: '🇧🇷', name: 'Brazil' },
}

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 getTld(region) {
	return MARKETPLACE[region].tld || 'com'
}

function getAudibleApiUrl(region) {
	const tld = getTld(region)
	return new URL(`https://api.audible.${tld}/1.0`).href
}

const fetchProductData = async (region, asin, query) => {
	const baseUrl = getAudibleApiUrl(region)
	const url = new URL(`${baseUrl}/catalog/products/${asin}`)

	url.searchParams.append('num_results', '1')
	Object.entries(query).forEach(([key, value]) => {
		url.searchParams.append(key, value)
	})

	const response = await fetch(url.href, {
		method: 'GET',
		headers: {
			'Content-Type': 'application/json',
		},
	})

	if (!response.ok) {
		throw new Error(`HTTP error! status: ${response.status}`)
	}

	return await response.json()
}

async function fetchAudibleProductImages(region, asin) {
	const data = await fetchProductData(region, asin, {
		response_groups: 'media',
		image_sizes: 2400,
		num_results: '1',
	})
	return data.product.product_images
}

function extractParams() {
	const url = window.location
	const region = extractRegionFromUrl(url)
	const asin = extractAsinFromUrl(url)
	return { region, asin }
}

function downloadImage(imageUrl, fileName) {
	fetch(imageUrl)
		.then((response) => response.blob())
		.then((blob) => {
			const url = window.URL.createObjectURL(blob)
			const link = document.createElement('a')
			link.href = url
			link.download = fileName
			link.style.display = 'none'
			document.body.appendChild(link)
			link.click()
			window.URL.revokeObjectURL(url)
			document.body.removeChild(link)
		})
		.catch((error) => console.error('Error downloading image:', error))
}

function createActionButton(iconUrl, title, onClick) {
	const button = document.createElement('button')
	button.title = title
	button.setAttribute('data-hidden', 'true')

	const icon = document.createElement('img')
	icon.src = iconUrl
	icon.alt = title

	button.appendChild(icon)
	button.addEventListener('click', (e) => {
		e.stopPropagation()
		e.preventDefault()
		onClick()
	})

	return button
}

function wrapCoverWithLink(imageElement, coverUrl) {
	const linkElement = document.createElement('a')
	linkElement.href = coverUrl
	linkElement.className = 'cover-link'
	linkElement.title = 'Open cover'
	linkElement.target = '_blank'
	imageElement.parentNode.insertBefore(linkElement, imageElement)
	linkElement.appendChild(imageElement)
}

function enhanceCoverImage(coverUrl, asin) {
	const imageElement = document.querySelector(
		'img.bc-pub-block.bc-image-inset-border.js-only-element'
	)

	if (imageElement) {
		const containerElement = document.createElement('div')
		containerElement.className = 'container'

		imageElement.parentNode.insertBefore(containerElement, imageElement)
		containerElement.appendChild(imageElement)

		wrapCoverWithLink(imageElement, coverUrl)

		const coverActionsContainer = document.createElement('div')
		coverActionsContainer.className = 'cover-actions-container'

		const hqButton = createActionButton(
			'https://api.iconify.design/solar:high-quality-bold.svg',
			'Load HQ cover',
			() => {
				imageElement.src = coverUrl
				hqButton.classList.add('action-btn-disabled')
				hqButton.title = 'HQ cover loaded'
			}
		)

		const downloadButton = createActionButton(
			'https://api.iconify.design/solar:download-square-bold.svg',
			'Download cover',
			() => downloadImage(coverUrl, `${asin}.jpg`)
		)

		coverActionsContainer.appendChild(hqButton)
		coverActionsContainer.appendChild(downloadButton)

		containerElement.appendChild(coverActionsContainer)

		containerElement.addEventListener('mouseenter', () => {
			downloadButton.setAttribute('data-hidden', false)
			hqButton.setAttribute('data-hidden', false)
		})
		containerElement.addEventListener('mouseleave', () => {
			downloadButton.setAttribute('data-hidden', true)
			hqButton.setAttribute('data-hidden', true)
		})
	} else {
		console.warn('Cover image element not found.')
	}
}

const COVER_SIZE = 2400

const main = async () => {
	try {
		const { region, asin } = extractParams()

		if (!region || !asin) {
			throw new Error('Unable to extract region or ASIN from URL')
		}

		const images = await fetchAudibleProductImages(region, asin)

		if (!images || !images[COVER_SIZE]) {
			throw new Error('No images found')
		}

		const cover = images[COVER_SIZE]

		enhanceCoverImage(cover, asin)
		addStyles()
	} catch (error) {
		console.error('Error in main function:', error.message)
	}
}

main()

function addStyles() {
	const style = document.createElement('style')
	style.textContent = `
		.cover-actions-container {
			position: absolute;
			bottom: 10px;
			right: 10px;
			display: flex;
			gap: 6px;
		}
		.cover-actions-container button {
			background-color: rgba(0, 0, 0, 0.85);
			border: none;
			border-radius: 12px;
			padding: 6px;	
			cursor: pointer;
			transition: opacity 200ms ease-in-out;
		}
		.cover-actions-container button img {
			width: 20px;
			height: 20px;
			filter: invert(1);
			display: block;
		}
		.cover-actions-container .action-btn-disabled {
			opacity: 0.5;
			cursor: not-allowed;
		}
		.container {
			position: relative;
			display: flex;
			align-items: center;
			justify-content: center;
		}
		[data-hidden="true"] {
			opacity: 0;
			visibility: hidden;
		}
		[data-hidden="false"] {
			opacity: 1;
			visibility: visible;
		}
	`
	document.head.appendChild(style)
}