Skip to content

Instantly share code, notes, and snippets.

@csandman
Last active October 29, 2024 00:07
Show Gist options
  • Save csandman/0615901502012976122a10b3b2db161f to your computer and use it in GitHub Desktop.
Save csandman/0615901502012976122a10b3b2db161f to your computer and use it in GitHub Desktop.
Grab the response from the iTunes API for a book search and get the hi-res cover url in the process
import { URLSearchParams } from "url";
/**
* Check whether a URL string is in a proper format
*
* @param {string} str A URL string to check for the correct structure
* @returns
*/
const isUrl = (str) => {
if (typeof str !== "string") {
throw new TypeError("Expected a string");
}
const trimmedStr = str.trim();
if (trimmedStr.includes(" ")) {
return false;
}
try {
// eslint-disable-next-line no-new
new URL(str);
return true;
} catch {
return false;
}
};
/**
* Check whether a URL is a valid endpoint
*
* @param {string} url A url to check the existence of
* @returns
*/
const urlExists = async (url) => {
if (typeof url !== "string") {
throw new TypeError(`Expected a string, got ${typeof url}`);
}
if (!isUrl(url)) {
return false;
}
try {
const resp = await fetch(url, { method: "HEAD" });
return resp.ok;
} catch (err) {
return false;
}
};
export const stripDiacretics = (str) =>
str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
export const removeNonWordChars = (str) => str.replace(/\W+/g, " ");
export const removeSpaces = (str) => str.replace(/\s/g, "");
export const simplify = (str) =>
removeSpaces(removeNonWordChars(stripDiacretics(str).toLowerCase()));
/**
* Compare two strings for similarity, by first simplifying them and then
* checking for equality
*
* @param {string} str1 The first string to compare
* @param {string} str2 The second string to compare
* @param {boolean} checkIncludes If `true` is passed, the matching will be
* looser and will check to see if one string contains the other as a substring
* @returns A boolean representing whether or not the two strings are similar
*/
export const fuzzyMatch = (str1, str2, checkIncludes = false) => {
const simpleStr1 = simplify(str1);
const simpleStr2 = simplify(str2);
return (
simpleStr1 === simpleStr2 ||
(checkIncludes &&
(simpleStr1.includes(simpleStr2) || simpleStr2.includes(simpleStr1)))
);
};
/**
* Compare two arrays of authors with a `{ name: "name" }` structure for a match
*
* @param {Author[]} authors1
* @param {Author[]} authors2
* @returns boolean for whether or not the authors match
*/
export const checkAuthorOverlap = (authors1, authors2) => {
for (let i = 0; i < authors1.length; i += 1) {
for (let j = 0; j < authors2.length; j += 1) {
if (fuzzyMatch(authors1[i].name, authors2[j].name)) {
return true;
}
}
}
return false;
};
const findCorrectResult = (title, authors, itunesData) => {
for (let i = 0; i < itunesData.results.length; i += 1) {
const item = itunesData.results[i];
const titlesMatch = fuzzyMatch(title, item.collectionName, true);
const itunesAuthors = item.artistName
.split("&")
.map((author) => ({ name: author.trim() }));
const authorsMatch = checkAuthorOverlap(itunesAuthors, authors);
if (titlesMatch && authorsMatch) {
return item;
}
}
return null;
};
const ITUNES_BASE_SEARCH_URL = "https://itunes.apple.com/search";
const fetchItunesData = async (title, country = "us") => {
const params = new URLSearchParams({
term: removeNonWordChars(title),
country,
entity: "audiobook",
limit: 10,
});
const searchUrl = `${ITUNES_BASE_SEARCH_URL}?${params.toString()}`;
console.info(`${title}: ITUNES URL: ${searchUrl}`);
const resp = await fetch(searchUrl);
const itunesData = await resp.json();
console.info(
`${title}: ITUNES RESPONSE:\n${JSON.stringify(itunesData, null, 2)}`
);
return itunesData;
};
/**
* Finds a book from the iTunes API which matches the current book. The important part being the `artworkUrlHiRes` which usually provides an image between 1500x1500px and 3000x3000px.
*
* @param {Book} book A book object (from my own project) which includes a title, and an authors object array in `{ name: "name" }` format
* @returns An object containing the response from the iTunes API with modified cover URLs for higher resolution versions
*
* {
* wrapperType: "audiobook",
* artistId: 79595314,
* collectionId: 1442759222,
* artistName: "J.K. Rowling",
* collectionName: "Harry Potter and the Order of the Phoenix",
* collectionCensoredName: "Harry Potter and the Order of the Phoenix",
* artistViewUrl:
* "https://books.apple.com/us/author/j-k-rowling/id79595314?uo=4",
* collectionViewUrl:
* "https://books.apple.com/us/audiobook/harry-potter-and-the-order-of-the-phoenix/id1442759222?uo=4",
* artworkUrl60:
* "https://is4-ssl.mzstatic.com/image/thumb/Music113/v4/7e/ba/89/7eba898e-131c-8112-63a2-11f8073f39a7/9781781102671.jpg/60x60bb.jpg",
* artworkUrl100:
* "https://is4-ssl.mzstatic.com/image/thumb/Music113/v4/7e/ba/89/7eba898e-131c-8112-63a2-11f8073f39a7/9781781102671.jpg/100x100bb.jpg",
* collectionPrice: 39.99,
* collectionExplicitness: "notExplicit",
* trackCount: 1,
* country: "USA",
* currency: "USD",
* releaseDate: "2015-11-20T08:00:00Z",
* primaryGenreName: "Fiction",
* previewUrl:
* "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/c0/be/14/c0be141d-0570-24a6-b82c-d7c92e3269a1/mzaf_14540166729641165902.std.aac.p.m4a",
* description:
* "<i>'You are sharing the Dark Lord's thoughts and emotions. The Headmaster thinks it inadvisable for this to continue. He wishes me to teach you how to close your mind to the Dark Lord.'</i><br /><br />Dark times have come to Hogwarts. After the Dementors' attack on his cousin Dudley, Harry Potter knows that Voldemort will stop at nothing to find him. There are many who deny the Dark Lord's return, but Harry is not alone: a secret order gathers at Grimmauld Place to fight against the Dark forces. Harry must allow Professor Snape to teach him how to protect himself from Voldemort's savage assaults on his mind. But they are growing stronger by the day and Harry is running out of time...<br /><br /><i>Having now become classics of our time, the Harry Potter audiobooks never fail to bring comfort and escapism to listeners of all ages. With its message of hope, belonging and the enduring power of truth and love, the story of the Boy Who Lived continues to delight generations of new listeners.</i>",
* artworkUrl600:
* "https://is4-ssl.mzstatic.com/image/thumb/Music113/v4/7e/ba/89/7eba898e-131c-8112-63a2-11f8073f39a7/9781781102671.jpg/600x600bb.jpg",
* artworkUrlHiRes:
* "https://is4-ssl.mzstatic.com/image/thumb/Music113/v4/7e/ba/89/7eba898e-131c-8112-63a2-11f8073f39a7/9781781102671.jpg/100000x100000-999.jpg",
* }
*/
const getItunesData = async (book) => {
try {
console.info(`${book.title}: PARSE ITUNES`);
let itunesData = await fetchItunesData(book.title);
let itunesBook = findCorrectResult(book.title, book.authors, itunesData);
if (!itunesBook) {
itunesData = await fetchItunesData(book.originalTitle);
itunesBook = findCorrectResult(
book.originalTitle,
book.authors,
itunesData
);
}
if (itunesBook) {
console.info(
`${book.title}: ITUNES BOOK FOUND:\n${JSON.stringify(
itunesBook,
null,
2
)}`
);
} else {
console.warn("No iTunes Book Found");
return null;
}
// This is where the magic happens, the `artworkUrl600` and `artworkUrlHiRes` links are what
// provide the hi-res versions of the book's cover
itunesBook.artworkUrl600 = itunesBook.artworkUrl100.replace(
"100x100",
"600x600"
);
const artworkUrlHiRes = itunesBook.artworkUrl100.replace(
"100x100bb",
"100000x100000-999"
);
const doesHiResExist = await urlExists(artworkUrlHiRes);
if (doesHiResExist) {
itunesBook.artworkUrlHiRes = artworkUrlHiRes;
}
return itunesBook;
} catch (err) {
console.warn("Error grabbing iTunes Info");
console.warn(err.stack);
return null;
}
};
export { getItunesData };
import { promises as fsPromises } from "fs";
import sharp from "sharp";
const resizeCover = async (cover, book) => {
const coverName = `${cover.source} - ${cover.path.split("/").pop()}`;
const image = sharp(cover.path);
const metadata = await image.metadata();
console.info(
`${book.title}: ${coverName} - COVER DIMENSIONS ${metadata.height} x ${metadata.width}`
);
// Should only resize if the cover is not a square
if (metadata.height !== metadata.width) {
const newDim = Math.min(metadata.width, metadata.height);
console.info(
`${book.title}: ${coverName} - NEW COVER DIMENSIONS ${newDim} x ${newDim}`
);
const outBuffer = await image
.resize(newDim, newDim, {
fit: cover.source === "cloud-library" ? "cover" : "fill",
})
.toBuffer();
await fsPromises.writeFile(cover.path, outBuffer);
console.info(`${book.title}: ${coverName} - DONE RESIZING`);
} else {
console.info(
`${book.title}: ${coverName} - COVER IS ALREADY PROPERLY SIZED, SKIPPING`
);
}
};
const resizeCovers = async (book) => {
if (book.covers?.length) {
console.info(`${book.title}: RESIZING COVERS`);
await Promise.all(book.covers.map((cover) => resizeCover(cover, book)));
console.info(`${book.title}: DONE RESIZING COVERS`);
} else {
console.info(`${book.title}: NO COVERS TO RESIZE`);
}
};
export default resizeCovers;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment