Custom Goodreads Bookshelf Rendering in Astro
TLDR: You can get all of your Goodreads bookshelf data by using their RSS feed. In this article I will show you how to fetch this data, parse it using xml2js, and render it in an Astro component.
Data fetching
On goodreads.com, navigate to your bookshelves page (found somewhere on your profile). At the bottom of this page there is an RSS link, this will be our datasource. Copy it’s value and save it as a const
in an Astro component. I call mine reading.astro
. We then send a fetch request to the url, and parse the response using the xml2js library.
---
const GOODREADS_URL =
"https://www.goodreads.com/review/list_rss/USER_ID?key=YOUR_KEY&shelf=%23ALL%23";
const response = await fetch(GOODREADS_URL);
const data = await response.text();
const result = await xml2js.parseStringPromise(data);
---
Parsing the results
Take a look at your newly created result
array. This contains a lot of data from Goodreads which we do not need.
I start by filtering out the books from my to read shelf, as I don’t indent to show these on my website:
const filteredBooks = result.rss.channel[0].item.filter((item) => {
return !item.user_shelves.includes("to-read");
});
Next, I categorize the books by creating an object with the books respective shelves (currently-reading, 2023, 2022, ..., earlier), like so:
const categorizedBooks = filteredBooks.reduce((acc, item) => {
const book = {
title: item.title[0],
shelves: item.user_shelves,
date_read: item.user_read_at,
rating: item.user_rating[0],
author_name: item.author_name,
book_image_url: item.book_image_url[0],
book_id: item.book_id[0],
};
if (book.shelves.includes("currently-reading")) {
if (!acc["Currently Reading"]) {
acc["Currently Reading"] = [];
}
acc["Currently Reading"].push(book);
return acc;
}
const yearShelf = book.shelves.find((shelf) => /^\d{4}$/.test(shelf));
if (yearShelf) {
if (!acc[yearShelf]) {
acc[yearShelf] = [];
}
acc[yearShelf].push(book);
} else {
if (!acc["Earlier"]) {
acc["Earlier"] = [];
}
acc["Earlier"].push(book);
}
return acc;
}, {});
At the end, I sort the keys in the order I want, with currently-reading first and earlier last.
const sortedKeys = Object.keys(categorizedBooks).sort((a, b) => {
if (a === "Currently Reading") return -1;
if (b === "Currently Reading") return 1;
if (a === "Earlier") return 1;
if (b === "Earlier") return -1;
return b - a;
});
In your astro component, you can loop through the sorted keys like so, and render them the way you want:
---
// imports, data fetching and parsing
// ...
---
<main>
{
sortedKeys.map((key) => {
return (
<div>
<h2 class="text-lg font-bold mt-4 mb-2 border-b">{key}</h2>
<ul class="flex flex-col gap-2">
{categorizedBooks[key].map((book) => (
<li class="flex gap-2">
<a
href={`https://www.goodreads.com/book/show/${book.book_id}`}
class="w-64 truncate"
>
{book.title}
</a>
<span class="text-gray-700 w-32 truncate" class="block">
{book.author_name}
</span>
{book.date_read[0] !== "" && (
<span class="text-gray-700 w-32">
{formatDate(book.date_read)}
</span>
)}
</li>
))}
</ul>
</div>
);
})
}
</main>
The formatDate
function looks like so:
function formatDate(dateString) {
const monthNames = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
]
const date = new Date(dateString)
const day = date.getUTCDate()
const month = monthNames[date.getUTCMonth()]
const year = date.getUTCFullYear()
return `${day} ${month} ${year}`
}
Final component
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { SEO } from "astro-seo";
import xml2js from "xml2js";
const GOODREADS_URL =
"https://www.goodreads.com/review/list_rss/USER_ID?key=YOUR_KEY&shelf=%23ALL%23";
const response = await fetch(GOODREADS_URL);
const data = await response.text();
const result = await xml2js.parseStringPromise(data);
const filteredBooks = result.rss.channel[0].item.filter((item) => {
return !item.user_shelves.includes("to-read");
});
const categorizedBooks = filteredBooks.reduce((acc, item) => {
const book = {
title: item.title[0],
shelves: item.user_shelves,
date_read: item.user_read_at,
rating: item.user_rating[0],
author_name: item.author_name,
book_image_url: item.book_image_url[0],
book_id: item.book_id[0],
};
if (book.shelves.includes("currently-reading")) {
if (!acc["Currently Reading"]) {
acc["Currently Reading"] = [];
}
acc["Currently Reading"].push(book);
return acc;
}
const yearShelf = book.shelves.find((shelf) => /^\d{4}$/.test(shelf));
if (yearShelf) {
if (!acc[yearShelf]) {
acc[yearShelf] = [];
}
acc[yearShelf].push(book);
} else {
if (!acc["Earlier"]) {
acc["Earlier"] = [];
}
acc["Earlier"].push(book);
}
return acc;
}, {});
const sortedKeys = Object.keys(categorizedBooks).sort((a, b) => {
if (a === "Currently Reading") return -1;
if (b === "Currently Reading") return 1;
if (a === "Earlier") return 1;
if (b === "Earlier") return -1;
return b - a;
});
function formatDate(dateString) {
const monthNames = [
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
];
const date = new Date(dateString);
const day = date.getUTCDate();
const month = monthNames[date.getUTCMonth()];
const year = date.getUTCFullYear();
return `${day} ${month} ${year}`;
}
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<SEO title="reading" description="My virtual bookshelf!" />
</head>
<body>
<BaseLayout title="reading" pathname="/reading">
{
sortedKeys.map((key) => {
return (
<div>
<h2 class="text-lg font-bold mt-4 mb-2 border-b">{key}</h2>
<ul class="flex flex-col gap-2">
{categorizedBooks[key].map((book) => (
<li class="flex gap-2">
<a
href={`https://www.goodreads.com/book/show/${book.book_id}`}
class="w-64 truncate"
>
{book.title}
</a>
<span class="text-gray-700 w-32 truncate" class="block">
{book.author_name}
</span>
{book.date_read[0] !== "" && (
<span class="text-gray-700 w-32">
{formatDate(book.date_read)}
</span>
)}
</li>
))}
</ul>
</div>
);
})
}
</BaseLayout>
</body>
</html>