UNPKG

audible-api

Version:

A Node.js API for searching the audible website

359 lines (314 loc) 9.56 kB
import { load as loadPage } from "cheerio"; import fetch from "node-fetch"; import { URL, URLSearchParams } from "url"; import { abridged, audibleOriginal, audiobookFormat, pageSizes, plusCatalog, releaseTimes, searchCategories, searchDurations, searchLanguages, siteCountries, unabridged, whispersync, } from "./data/audible-search-fields"; import getAudibleBook from "./get-audible-book"; import { Book, Category, Creator, Duration, SearchLanguage, Series, } from "./types"; import { parseLanguage } from "./utils/language"; import { cleanNarratorUrl, cleanTitle, cleanUrl } from "./utils/string"; import { getDurationFromStr } from "./utils/time"; /** * A set of parameters used to search the Audible website */ export interface SearchParams { /** * A general search string */ keywords?: string; /** * A title of a book to search for */ title?: string; /** * The name of an author to search for */ author?: string; /** * The name of a narrator to search for */ narrator?: string; /** * A publishing company to search for */ publisher?: string; /** * One of a predefined list of strings representing top level categories on Audible */ category?: Category; /** * Whether or not to limit the search to Audible Originals * * @defaultValue `false` */ isAudibleOriginal?: boolean; /** * Whether or not to limit the search to Audible's Plus Catalog * * @defaultValue `false` */ isPlusCatalog?: boolean; /** * A timeframe from a predefined list of when the book was released */ releaseTime?: "soon" | "last30" | "last90"; /** * An array of strings representing the length of time the book can fall into * * Options: `<1` | `1-3` | `3-6` | `6-10` | `10-20` | `>20`; */ durations?: Duration[]; /** * An array of ISO 639-1 langauges codes to limit the books to from a pre-defined list * * Options: `en` | `es` | `de` | `fr` | `it` | `pt` | `ja` | `ru` | `af` | `da` | `zh` | `nl` */ languages?: SearchLanguage[]; /** * Whether or not to limit the results to Whisper Sync compatible books */ isWhisperSync?: boolean; /** * Pass this as either true or false to limit the results to abridged or unabridged books respectively */ isAbridged?: boolean; /** * The page number of the search results to view */ pageNum?: number; /** * The number of results to return * * @defaultValue `20` */ pageSize?: 20 | 30 | 40 | 50; /** * Whether or not to parse all of the information about each book from the search result, this will take much longer * * @defaultValue `false` */ getFull?: boolean; /** * A ISO 3166-1 alpha-2 country code representing the Audible site to search * * @defaultValue `us` */ site?: "us" | "ca" | "gb" | "au" | "fr" | "de" | "it"; } type SearchResults = { totalResults: number; results: Book[]; }; /** * Search the Audible website for a list of books matching your search criteria * * @param options - An object containing your search criteria * @returns A promise returning an array of book objects from the search results, along with total results */ export default async function searchAudible({ keywords, title, author, narrator, publisher, category, isAudibleOriginal = false, isPlusCatalog = false, releaseTime, durations = [], languages = [], isWhisperSync = false, isAbridged, pageNum = 1, pageSize = 20, getFull = false, site = "us", }: SearchParams = {}): Promise<SearchResults> { const { url: baseUrl } = siteCountries[site] || siteCountries.us; const baseSearchUrl = `${baseUrl}/search`; const realPageSize = pageSizes.includes(pageSize) ? pageSize : pageSizes[0]; const paramsObj: Record<string, string> = { ...(title && { title }), ...(keywords && { keywords }), ...(author && { searchAuthor: author }), ...(narrator && { narrator }), ...(publisher && { publisher }), ...(isAbridged === true && abridged), ...(isAbridged === false && unabridged), ...(isAudibleOriginal && audibleOriginal), ...(isPlusCatalog && plusCatalog), ...(isWhisperSync && whispersync), page: pageNum.toString(), pageSize: realPageSize.toString(), ipRedirectOverride: "true", ...audiobookFormat, }; const params = new URLSearchParams(paramsObj); if (releaseTime && releaseTimes[releaseTime]) { params.append("publication_date", releaseTimes[releaseTime]); } if (category && searchCategories[category]) { params.append("node", searchCategories[category]); } durations.forEach((duration) => { if (searchDurations[duration]) { params.append("feature_seven_browse-bin", searchDurations[duration]); } }); languages.forEach((lang) => { const langCode = searchLanguages[lang.toLowerCase()]; if (langCode) { params.append("feature_six_browse-bin", langCode); } }); const searchUrl = `${baseSearchUrl}?${params.toString()}`; const res = await fetch(searchUrl); const body = await res.text(); const page = loadPage(body); let audibleItems: Book[] = []; const resultsStr = page(".resultsSummarySubheading").eq(0).text().trim(); const totalResultsMatch = resultsStr.match(/([0-9,]+) results$/); const totalResults = totalResultsMatch ? Number(totalResultsMatch[1]) : 0; page(".productListItem").each((i, elSel) => { const newBook: Book = { title: page("h3", elSel).text().trim(), coverUrl: page("img", elSel).attr("data-lazy"), authors: [], narrators: [], }; // Get book's language const newLanguage = parseLanguage( page(".languageLabel", elSel).text().replace("Language:", "").trim() ); if (newLanguage) { newBook.language = newLanguage; } // Get book's url const urlStr = page("h3 a", elSel).attr("href"); if (urlStr) { newBook.url = cleanUrl(new URL(urlStr, baseUrl).href); } // Get book's clean title if (newBook.title) { newBook.cleanTitle = cleanTitle(newBook.title); } // Get the book's duration const runtimeStr = page(".runtimeLabel", elSel).text().trim(); if (runtimeStr) { newBook.duration = getDurationFromStr(runtimeStr); } // Get the book's ASIN const asinMatch = newBook.url?.match(/\/([A-Z0-9]+)$/); if (asinMatch) { newBook.asin = asinMatch[1]; } // Get the aggregate rating const ratingValueStr = page(".ratingsLabel .bc-pub-offscreen", elSel) .text() .trim(); const ratingsValueMatch = ratingValueStr.match(/[0-9.]+/); const ratingValue = Number(ratingsValueMatch?.[0] || 0); const ratingCountStr = page(".ratingsLabel .bc-color-secondary", elSel) .text() .trim(); const ratingCountMatch = ratingCountStr.match(/[0-9,]+/); const ratingCount = Number(ratingCountMatch?.[0].replace(/,/g, "") || 0); newBook.rating = { value: ratingValue, count: ratingCount }; page(".authorLabel a", elSel).each((ii, authorSel) => { const authorEl = page(authorSel); const newAuthor: Creator = { name: authorEl.text().trim(), }; const authorUrlPath = authorEl.attr("href"); if (authorUrlPath) { newAuthor.url = cleanUrl(new URL(authorUrlPath, baseUrl).href); } newBook.authors.push(newAuthor); }); page(".authorLabel a", elSel).each((ii, narratorSel) => { const narratorEl = page(narratorSel); const newNarrator: Creator = { name: narratorEl.text().trim(), }; const narratorUrlPath = narratorEl.attr("href"); if (narratorUrlPath) { newNarrator.url = cleanNarratorUrl( new URL(narratorUrlPath, baseUrl).href ); } newBook.narrators.push(newNarrator); }); // Get series and series part try { const series: Series[] = []; page(".seriesLabel a", elSel).each((iii, seriesSel) => { const el = page(seriesSel); const newSeries: Series = { name: el.text().trim(), }; const seriesUrlPath = el.attr("href"); if (seriesUrlPath) { newSeries.url = cleanUrl(new URL(seriesUrlPath, baseUrl).href); } series.push(newSeries); }); const seriesArr = page(".seriesLabel", elSel) .text() .replace(/\n/g, "") .trim() // remove the beginning text from the series .replace("Series: ", "") .split(", ") .map((item) => item.trim()); seriesArr.forEach((seriesStr, seriesI) => { if (seriesStr.includes("Book")) { const seriesPart = Number(seriesStr.replace("Book ", "")); if (seriesPart) { const matchingBookIndex = series.findIndex( (item) => item.name === seriesArr[seriesI - 1] ); if (matchingBookIndex > -1) { series[matchingBookIndex].part = seriesPart; } } } }); newBook.series = series; } catch (err) { // console.warn(`ERROR PARSING AUDIBLE SERIES'\n${err.stack}`); } audibleItems.push(newBook); }); if (getFull) { audibleItems = await Promise.all( audibleItems.map((book) => book.asin ? getAudibleBook(book.asin, { site }) : book ) ); } // console.log(JSON.stringify(audibleItems, null, 2)); const searchResults: SearchResults = { totalResults, results: audibleItems, }; return searchResults; }