book rating and cover; search returns first result; author search

This commit is contained in:
lkw657 2024-09-01 22:24:28 +08:00 committed by dusk
parent e1fffa26a5
commit 8845942915
39 changed files with 4198 additions and 68 deletions

0
.gitignore vendored Normal file → Executable file
View File

0
.idea/.gitignore vendored Normal file → Executable file
View File

0
.idea/alexandria.iml Normal file → Executable file
View File

0
.idea/modules.xml Normal file → Executable file
View File

0
.idea/prettier.xml Normal file → Executable file
View File

0
.idea/vcs.xml Normal file → Executable file
View File

0
.swcrc Normal file → Executable file
View File

0
Dockerfile Normal file → Executable file
View File

0
package-lock.json generated Normal file → Executable file
View File

0
package.json Normal file → Executable file
View File

14
src/book-sources/BookInfo.ts Normal file → Executable file
View File

@ -5,6 +5,9 @@ export type BookInfo = {
description: string;
source: string;
url: string;
cover: string;
rating: string;
authorUrl: string;
// TODO isbn etc...
};
@ -14,3 +17,14 @@ export type BookInfoShort = {
id: string;
source: string;
};
export type AuthorInfo = {
url: string;
source: string;
name: string;
born: string;
birthday: string;
description: string;
photo: string;
books: BookInfoShort[];
};

3
src/book-sources/BookSource.ts Normal file → Executable file
View File

@ -1,6 +1,7 @@
import { BookInfo, BookInfoShort } from "./BookInfo";
import { AuthorInfo, BookInfo, BookInfoShort } from "./BookInfo";
export type BookSource = {
search(title: string): Promise<BookInfoShort[]>;
getBook(id: string): Promise<BookInfo>;
getAuthor(name: string): Promise<AuthorInfo>;
};

107
src/book-sources/goodreads/goodreads.test.ts Normal file → Executable file
View File

@ -9,6 +9,15 @@ fetchMocker.enableMocks();
// I don't want to hit the site every time the tests run
const searchPage = fs.readFileSync(`${__dirname}/test/search.html`, "utf8");
const bookPage = fs.readFileSync(`${__dirname}/test/book.html`, "utf8");
const authorsearchPage = fs.readFileSync(
`${__dirname}/test/author-search.html`,
"utf8",
);
const berserkPage = fs.readFileSync(`${__dirname}/test/berserk.html`, "utf8");
const authorPage = fs.readFileSync(
`${__dirname}/test/author-page.html`,
"utf8",
);
describe("goodreads", () => {
beforeEach(async () => {
@ -18,7 +27,20 @@ describe("goodreads", () => {
);
fetchMocker.mockIf("https://www.goodreads.com/book/show/980031", bookPage);
});
fetchMocker.mockIf(
"https://www.goodreads.com/search?q=Kentaro+Miura",
authorsearchPage,
);
fetchMocker.mockIf(
"https://www.goodreads.com/book/show/248871",
berserkPage,
);
fetchMocker.mockIf(
"https://www.goodreads.com/author/show/145435.Kentaro_Miura",
authorPage,
);
}, 50000);
it("searches for a book", async () => {
const bookResults = await goodreads.search("democracy the god that failed");
@ -57,11 +79,92 @@ describe("goodreads", () => {
expect(info).toMatchInlineSnapshot(`
{
"author": "Hans-Hermann Hoppe",
"authorUrl": "https://www.goodreads.com/author/show/98317.Hans_Hermann_Hoppe",
"cover": "https://images-na.ssl-images-amazon.com/images/S/compressed.photo.goodreads.com/books/1379379301i/980031.jpg",
"description": "The core of this book is a systematic treatment of the historic transformation of the West from monarchy to democracy. Revisionist in nature, it reaches the conclusion that monarchy is a lesser evil than democracy, but outlines deficiencies in both. Its methodology is axiomatic-deductive, allowing the writer to derive economic and sociological theorems, and then apply them to interpret historical events.A compelling chapter on time preference describes the progress of civilization as lowering time preferences as capital structure is built, and explains how the interaction between people can lower time all around, with interesting parallels to the Ricardian Law of Association. By focusing on this transformation, the author is able to interpret many historical phenomena, such as rising levels of crime, degeneration of standards of conduct and morality, and the growth of the mega-state. In underscoring the deficiencies of both monarchy and democracy, the author demonstrates how these systems are both inferior to a natural order based on private-property.Hoppe deconstructs the classical liberal belief in the possibility of limited government and calls for an alignment of conservatism and libertarianism as natural allies with common goals. He defends the proper role of the production of defense as undertaken by insurance companies on a free market, and describes the emergence of private law among competing insurers.Having established a natural order as superior on utilitarian grounds, the author goes on to assess the prospects for achieving a natural order. Informed by his analysis of the deficiencies of social democracy, and armed with the social theory of legitimation, he forsees secession as the likely future of the US and Europe, resulting in a multitude of region and city-states. This book complements the author's previous work defending the ethics of private property and natural order. Democracy - The God that Failed will be of interest to scholars and students of history, political economy, and political philosophy.",
"id": "980031",
"rating": "4.17/5",
"source": "goodreads",
"title": "Democracy: The God That Failed",
"url": "https://www.goodreads.com/book/show/980031",
}
`);
});
}, 50000);
it.only(`Gets an author's info`, async () => {
const info = await goodreads.getAuthor("Kentaro Miura");
expect(info).toMatchInlineSnapshot(`
{
"birthday": "July 11, 1966",
"books": [
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 1 (Berserk, #1)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 2 (Berserk, #2)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 3 (Berserk, #3)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 4 (Berserk, #4)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 5 (Berserk, #5)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 6 (Berserk, #6)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 7 (Berserk, #7)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk Deluxe Edition, Vol. 1",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 8 (Berserk, #8)",
},
{
"author": "Kentaro Miura",
"id": "todo",
"source": "goodreads",
"title": "Berserk, Vol. 9",
},
],
"born": "in Chiba City, Chiba Prefecture, Japan",
"description": "Kentarou Miura (三浦建太郎) was born in Chiba City, Chiba Prefecture, Japan, in 1966. He is left-handed. In 1976, at the early age of 10, Miura made his first Manga, entitled "Miuranger", that was published for his classmates in a school publication; the manga ended up spanning 40 volumes. In 1977, Miura created his second manga called Ken e no michi (剣への道 The Way to the Sword), using Indian ink for the first time. When he was in middle school in 1979, Miura's drawing techniques improved greatly as he started using professional drawing techniques. His first dōjinshi was published, with the help of friends, in a magazine in 1982.That same year, in 1982, Miura enrolled in an artistic curriculum in high school, where he and his classmates started pKentarou Miura (三浦建太郎) was born in Chiba City, Chiba Prefecture, Japan, in 1966. He is left-handed. In 1976, at the early age of 10, Miura made his first Manga, entitled "Miuranger", that was published for his classmates in a school publication; the manga ended up spanning 40 volumes. In 1977, Miura created his second manga called Ken e no michi (剣への道 The Way to the Sword), using Indian ink for the first time. When he was in middle school in 1979, Miura's drawing techniques improved greatly as he started using professional drawing techniques. His first dōjinshi was published, with the help of friends, in a magazine in 1982.That same year, in 1982, Miura enrolled in an artistic curriculum in high school, where he and his classmates started publishing their works in school booklets, as well as having his first dōjinshi published in a fan-produced magazine. In 1985, Miura applied for the entrance examination of an art college in Nihon University. He submitted Futanabi for examination and was granted admission. This project was later nominated Best New Author work in Weekly Shōnen Magazine. Another Miura manga Noa was published in Weekly Shōnen Magazine the very same year. Due to a disagreement with one of the editors, the manga was stalled and eventually dropped altogether. This is approximately where Miura's career hit a slump.In 1988, Miura bounced back with a 48-page manga known as Berserk Prototype, as an introduction to the current Berserk fantasy world. It went on to win Miura a prize from the Comi Manga School. In 1989, after receiving a doctorate degree, Kentarou started a project titled King of Wolves (王狼, ōrō?) based on a script by Buronson, writer of Hokuto no Ken. It was published in the monthly Japanese Animal House magazine in issues 5 and 7 of that year.In 1990, a sequel is made to Ourou entitled Ourou Den (王狼伝 ōrō den, The Legend of the Wolf King) that was published as a prequel to the original in Young Animal Magazine. In the same year, the 10th issue of Animal House witnesses the first volume of the solo project Berserk was released with a relatively limited success. Miura again collaborated with Buronson on manga titled Japan, that was published in Young Animal House from the 1st issue to the 8th of 1992, and was later released as a stand-alone tankōbon. Miura's fame grew after Berserk was serialized in Young Animal in 1992 with the release of "The Golden Age" story arc and the huge success of his masterpiece made of him one of the most prominent contemporary mangakas. At this time Miura dedicates himself solely to be working on Berserk. He has indicated, however, that he intends to publish more manga in the future.In 1997, Miura supervised the production of 25 anime episodes of Berserk that aired in the same year on NTV. Various art books and supplemental materials by Miura based on Berserk are also released. In 1999, Miura made minor contributions to the Dreamcast video game Sword of the Berserk: Guts' Rage. 2004 saw the release of yet another video game adaptation entitled Berserk Millennium Falcon Arc: Chapter of the Record of the Holy Demon War.Since that time, the Berserk manga has spanned 34 tankōbon with no end in sight. The series has also spawned a whole host of merchandise, both official and fan-made, ranging from statues, action figures to key rings, video games, and a trading card game. In 2002, Kentarou Miura received the second place in the Osamu Tezuka Culture Award of Excellence for Berserk.[1]Miura provided the design for the Vocaloid Kamui Gakupo, whose voice is taken from the Japanese singer and actor, Gackt.Miura passed away on May 6, 2021 at 2:48 p.m. due to acute aortic dissection.",
"name": "Kentaro Miura",
"photo": "https://images.gr-assets.com/authors/1275384473p5/145435.jpg",
"source": "goodreads",
"url": "https://www.goodreads.com/author/show/145435.Kentaro_Miura",
}
`);
}, 50000);
});

71
src/book-sources/goodreads/index.ts Normal file → Executable file
View File

@ -1,5 +1,5 @@
import { BookSource } from "../BookSource";
import { BookInfo, BookInfoShort } from "../BookInfo";
import { AuthorInfo, BookInfo, BookInfoShort } from "../BookInfo";
import * as cheerio from "cheerio";
import { UserError } from "../../core/UserError";
@ -32,7 +32,6 @@ const search = async (title: string): Promise<BookInfoShort[]> => {
}));
} catch (error) {
console.log(error);
throw new UserError("Failed to search goodreads for book");
}
};
@ -43,33 +42,93 @@ const getBook = async (id: string): Promise<BookInfo> => {
if (!response.ok) {
console.log(
`Failed to search goodreads: ${response.statusText} (${response.status})`,
`Failed to get book from goodreads: ${response.statusText} (${response.status})`,
);
console.log(`url: ${url}`);
throw new Error("Http request failed");
}
const $ = cheerio.load(await response.text());
const text = await response.text();
const $ = cheerio.load(text);
const data = JSON.parse($(`script[type="application/ld+json"]`).text());
const jsonMetadata = $(`script[type="application/ld+json"]`).text();
const data = JSON.parse(jsonMetadata);
return {
title: data.name.trim(),
author: data.author[0].name.trim(),
description: $(".BookPageMetadataSection__description").text().trim(),
cover: $(".ResponsiveImage").attr("src"),
rating:
$(".BookPageMetadataSection .RatingStatistics__rating").text().trim() +
"/5",
id,
source: "goodreads",
url,
authorUrl: data.author[0].url.trim(),
};
} catch (error) {
console.log(error);
}
};
const getAuthor = async (name: string): Promise<AuthorInfo> => {
const results = await search(name);
if (results.length == 0) return undefined;
const authorUrl = (await getBook(results[0].id)).authorUrl;
try {
const response = await fetch(authorUrl);
if (!response.ok) {
console.log(
`Failed to get author from goodreads: ${response.statusText} (${response.status})`,
);
console.log(`url: ${authorUrl}`);
throw new Error("Http request failed");
}
const $ = cheerio.load(await response.text());
// TODO should be name, and rename argument
const foundName = $($(".rightContainer .authorName").toArray()[0])
.text()
.trim();
const books = $(
// `div[itemtype="https://schema.org/Collection"] tr[itemtype="http://schema.org/Book"]`,
`div[itemtype="https://schema.org/Collection"] tbody`,
)
.children()
.toArray()
.map(
(element): BookInfoShort => ({
id: "todo",
source: "goodreads",
author: foundName,
title: $(element).find(`span[role="heading"]`).text().trim(),
}),
);
return {
url: authorUrl,
source: "goodreads",
born: $($(".rightContainer").contents().toArray()[8]).text().trim(),
birthday: $(`div[itemprop="birthDate"]`).text().trim(),
name: foundName,
description: $(".aboutAuthorInfo span").text().trim(),
photo: $(".authorLeftContainer img").attr("src"),
books,
};
} catch (error) {
console.log(error);
throw new UserError("Failed to search goodreads for book");
}
};
const goodreads: BookSource = {
search,
getBook,
getAuthor,
};
export default goodreads;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

0
src/book-sources/goodreads/test/book.html Normal file → Executable file
View File

0
src/book-sources/goodreads/test/search.html Normal file → Executable file
View File

0
src/core/Bot.ts Normal file → Executable file
View File

0
src/core/Command.ts Normal file → Executable file
View File

0
src/core/CommandHandler.ts Normal file → Executable file
View File

0
src/core/Module.ts Normal file → Executable file
View File

0
src/core/UserError.ts Normal file → Executable file
View File

0
src/core/create-bot.ts Normal file → Executable file
View File

0
src/core/create-command-handler.ts Normal file → Executable file
View File

0
src/core/resolve-envars.test.js Normal file → Executable file
View File

0
src/core/resolve-envars.ts Normal file → Executable file
View File

0
src/index.js Normal file → Executable file
View File

0
src/index.ts Normal file → Executable file
View File

View File

@ -0,0 +1,51 @@
import { Command } from "../../core/Command";
import { EmbedBuilder, SlashCommandBuilder } from "discord.js";
import truncate from "../../utils/truncate";
// TODO inject these
import goodreads from "../../book-sources/goodreads";
import take from "../../utils/take";
const authorsearch: Command = {
slashCommand: new SlashCommandBuilder()
.setName("authorsearch")
.setDescription("Search for author")
.addStringOption((option) =>
option.setName("name").setDescription("Author name").setRequired(true),
),
async run(interaction) {
await interaction.deferReply();
// TODO this should use a particular source
const name = interaction.options.getString("name");
const author = await goodreads.getAuthor(name);
if (!author) {
await interaction.reply("No results found");
return;
}
const embed = new EmbedBuilder()
.setTitle(author.name)
.setDescription(truncate(1000, author.description))
.setImage(author.photo)
.addFields(
{ name: "Born", value: author.born, inline: true },
{ name: "Birthday", value: author.birthday, inline: true },
{ name: " ", value: " ", inline: true },
)
.addFields(
take(3, author.books).map((book, i) => ({
name: `Book ${i + 1}`,
value: book.title,
inline: true,
})),
)
.setURL(author.url);
await interaction.editReply({ embeds: [embed] });
},
};
export default authorsearch;

38
src/modules/books/booksearch.ts Normal file → Executable file
View File

@ -1,24 +1,13 @@
import { Command } from "../../core/Command";
import {
ActionRow,
ActionRowBuilder,
Embed,
EmbedBuilder,
SlashCommandBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
User,
} from "discord.js";
import { EmbedBuilder, SlashCommandBuilder } from "discord.js";
// TODO inject these
import goodreads from "../../book-sources/goodreads";
import { BookInfo } from "../../book-sources/BookInfo";
import { UserError } from "../../core/UserError";
const booksearch: Command = {
slashCommand: new SlashCommandBuilder()
.setName("booksearch")
.setDescription("Search for a book")
.setDescription("Lookup a book")
.addStringOption((option) =>
option.setName("title").setDescription("Book title").setRequired(true),
),
@ -26,23 +15,26 @@ const booksearch: Command = {
async run(interaction) {
await interaction.deferReply();
const title = interaction.options.getString("title");
const results = await goodreads.search(title);
// TODO limit number of results
// TODO this should use a particular source
const title = interaction.options.getString("title")
const results = await goodreads.search(title)
if (results.length == 0) {
await interaction.reply("No results found");
return;
}
const message = results
.map(
(book) =>
`**${book.title}** - *${book.author}* (${book.source}#${book.id})`,
)
.join("\n");
const book = await goodreads.getBook(results[0].id);
await interaction.editReply(message);
const embed = new EmbedBuilder()
.setTitle(book.title)
.setDescription(book.description)
.setAuthor({ name: book.author })
.setImage(book.cover)
.addFields({name: 'Rating', value: book.rating})
.setURL(book.url);
await interaction.editReply({ embeds: [embed] });
},
};

View File

@ -0,0 +1,49 @@
import { Command } from "../../core/Command";
import {
ActionRow,
ActionRowBuilder,
Embed,
EmbedBuilder,
SlashCommandBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
User,
} from "discord.js";
// TODO inject these
import goodreads from "../../book-sources/goodreads";
import { BookInfo } from "../../book-sources/BookInfo";
import { UserError } from "../../core/UserError";
const booksearch: Command = {
slashCommand: new SlashCommandBuilder()
.setName("booksearchall")
.setDescription("Search for a book")
.addStringOption((option) =>
option.setName("title").setDescription("Book title").setRequired(true),
),
async run(interaction) {
await interaction.deferReply();
const title = interaction.options.getString("title");
const results = await goodreads.search(title);
// TODO limit number of results
if (results.length == 0) {
await interaction.reply("No results found");
return;
}
const message = results
.map(
(book) =>
`**${book.title}** - *${book.author}* (${book.source}#${book.id})`,
)
.join("\n");
await interaction.editReply(message);
},
};
export default booksearch;

View File

@ -1,34 +0,0 @@
import { Command } from "../../core/Command";
import { EmbedBuilder, SlashCommandBuilder } from "discord.js";
// TODO inject these
import goodreads from "../../book-sources/goodreads";
const getbook: Command = {
slashCommand: new SlashCommandBuilder()
.setName("getbook")
.setDescription("Lookup a book")
.addStringOption((option) =>
option.setName("id").setDescription("Book id").setRequired(true),
),
async run(interaction) {
await interaction.deferReply();
// TODO this should use a particular source
const id = interaction.options.getString("id").split("#")[1];
console.log(id);
const book = await goodreads.getBook(id);
console.log("got book");
const embed = new EmbedBuilder()
.setTitle(book.title)
.setDescription(book.description)
.setAuthor({ name: book.author })
.setURL(book.url);
await interaction.editReply({ embeds: [embed] });
},
};
export default getbook;

4
src/modules/books/index.ts Normal file → Executable file
View File

@ -1,11 +1,11 @@
import booksearch from "./booksearch";
import { Module } from "../../core/Module";
import getbook from "./getbook";
import authorsearch from "./authorsearch";
const Books: Module = {
async init({ commandHandler }) {
await commandHandler.registerCommand(booksearch);
await commandHandler.registerCommand(getbook);
await commandHandler.registerCommand(authorsearch);
},
};

35
src/utils/take.test.ts Executable file
View File

@ -0,0 +1,35 @@
import { describe, it, expect, vi } from "vitest";
import take from "./take";
describe(`take`, () => {
describe(`Given an array shorter than the length`, () => {
it(`Returns all the array items`, () => {
expect(take(3, [1, 2])).toEqual([1, 2]);
});
});
describe(`Given an array with length equal to the given length`, () => {
it(`Returns all the array items`, () => {
expect(take(3, [1, 2, 3])).toEqual([1, 2, 3]);
});
});
describe(`Given an array longer than the length`, () => {
it(`Returns only the first amount of items`, () => {
expect(take(3, [1, 2, 3, 4])).toEqual([1, 2, 3]);
});
});
it(`Does not modify the original array`, () => {
const arr = [1, 2, 3, 4, 5];
take(3, arr);
expect(arr).toEqual([1, 2, 3, 4, 5]);
});
it(`Returns a new copy when unmodified`, () => {
const arr = [1, 2];
const result = take(3, arr);
arr.push(3);
expect(result).toEqual([1, 2]);
});
});

4
src/utils/take.ts Executable file
View File

@ -0,0 +1,4 @@
const take = <T>(amount: number, array: T[]): T[] =>
array.length <= amount ? [...array] : array.slice(0, amount);
export default take;

22
src/utils/truncate.test.ts Executable file
View File

@ -0,0 +1,22 @@
import { describe, it, expect, vi } from "vitest";
import truncate from "./truncate";
describe(`truncate`, () => {
describe(`Given text shorter than the length`, () => {
it(`Returns the text unchanged`, () => {
expect(truncate(5, "aa")).toEqual("aa");
});
});
describe(`Given text with length equal to the given length`, () => {
it(`Returns the text unchanged`, () => {
expect(truncate(5, "aaaaa")).toEqual("aaaaa");
});
});
describe(`Given text longer than the length`, () => {
it(`Returns the text truncated the the length with ellipsis`, () => {
expect(truncate(5, "aaaaaaaaaa")).toEqual("aa...");
});
});
});

8
src/utils/truncate.ts Executable file
View File

@ -0,0 +1,8 @@
const truncate = (length: number, text: string): string => {
const ellipsis = "...";
return text.length <= length
? text
: text.slice(0, length - ellipsis.length) + ellipsis;
};
export default truncate;