Skip to content

Commit 6ff7469

Browse files
authored
feat: added goodreads API (#17)
current workaround. to be moved to new package
1 parent 6db0dfe commit 6ff7469

File tree

9 files changed

+6852
-9
lines changed

9 files changed

+6852
-9
lines changed

goodreads.read.feed.json

+6,468
Large diffs are not rendered by default.

index.d.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export type RawFeed = {
22
rss: {
33
channel: RawFeedChannel[];
4-
[key: string]: any;
4+
[key: string]: unknown;
55
};
66
};
77

@@ -90,3 +90,22 @@ export function getSubstackFeed(
9090
): Promise<string | undefined>;
9191
export function getFeedByLink(rawFeed: unknown, link: string): RawFeedChannel[];
9292
export function getPosts(channels: RawFeedChannel[]): SubstackItem[];
93+
94+
// Goodreads RSS Feed Parser
95+
96+
// Goodreads Public Types
97+
export interface GoodreadsItem {
98+
title: string[];
99+
link: string[];
100+
book_image_url: string[];
101+
author_name: string[];
102+
book_description: string[];
103+
[key: string]: unknown;
104+
}
105+
106+
// Goodreads Public API
107+
export const getGoodreadsFeed: (
108+
feedUrl: string,
109+
callback?: (err: Error | null, result: unknown) => void,
110+
) => Promise<unknown>;
111+
export const getGoodreadsFeedItems: (rawFeed: unknown) => GoodreadsItem[];

lib/goodreads/goodreads.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// TODO: to be moved to a separate package
2+
import * as parser from "xml2js";
3+
import {
4+
isRawGoodreadsFeed,
5+
isRawGoodreadsFeedChannel,
6+
isRawGoodreadsFeedRSS,
7+
isRawGoodreadsItem,
8+
isValidClientSideFeed,
9+
} from "./typeguards";
10+
import { GoodreadsItem, RawGoodreadsItem } from "./types";
11+
12+
const CORS_PROXY = "https://api.allorigins.win/get?url=";
13+
const isBrowser = typeof document !== "undefined";
14+
15+
// Utils
16+
const transformRawGoodreadsItem = (item: RawGoodreadsItem): GoodreadsItem => {
17+
return {
18+
title: item.title[0],
19+
link: item.link[0],
20+
book_image_url: item["book_image_url"][0],
21+
author_name: item["author_name"][0],
22+
book_description: item["book_description"][0],
23+
};
24+
};
25+
26+
const parseXML = async (
27+
xml = "",
28+
/* eslint-disable @typescript-eslint/no-explicit-any */
29+
callback?: (err: Error | null, result: any) => void,
30+
) => {
31+
if (!callback) return parser.parseStringPromise(xml);
32+
parser.parseString(xml, callback);
33+
};
34+
35+
// Internal API
36+
37+
const getRawXMLGoodreadsFeed = async (feedUrl: string) => {
38+
try {
39+
const path = isBrowser
40+
? `${CORS_PROXY}${encodeURIComponent(feedUrl)}`
41+
: feedUrl;
42+
const promise = await fetch(path);
43+
if (promise.ok) return isBrowser ? promise.json() : promise.text();
44+
} catch (e) {
45+
throw new Error("Error occurred fetching Feed from Goodreads");
46+
}
47+
};
48+
49+
// Goodreads Public API
50+
export const getGoodreadsFeed = async (
51+
feedUrl: string,
52+
/* eslint-disable @typescript-eslint/no-explicit-any */
53+
callback?: (err: Error | null, result: unknown) => void,
54+
): Promise<unknown> => {
55+
const rawXML = await getRawXMLGoodreadsFeed(feedUrl);
56+
// NOTE: server side call
57+
if (!isBrowser) {
58+
return parseXML(rawXML, callback);
59+
}
60+
// NOTE: client side call
61+
if (!isValidClientSideFeed(rawXML))
62+
throw new Error("Error occurred fetching Feed from Substack");
63+
await parseXML(rawXML.contents, callback);
64+
};
65+
export const getGoodreadsFeedItems = (rawFeed: unknown): GoodreadsItem[] => {
66+
if (!isRawGoodreadsFeed(rawFeed))
67+
throw new Error("Goodreads feed is not in the correct format");
68+
if (!isRawGoodreadsFeedRSS(rawFeed.rss))
69+
throw new Error("Goodreads RSS feed is not in the correct format");
70+
const channels = rawFeed.rss.channel.filter(isRawGoodreadsFeedChannel);
71+
if (channels.length === 0)
72+
throw new Error("Goodreads feed does not contain any channels");
73+
const channel = channels[0];
74+
if (!Array.isArray(channel.item)) return [];
75+
return channel.item.filter(isRawGoodreadsItem).map(transformRawGoodreadsItem);
76+
};

lib/goodreads/typeguards.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Goodreads Feed Typeguards
2+
// TODO: to be moved to a separate package
3+
import {
4+
RawGoodreadsFeed,
5+
RawGoodreadsFeedChannel,
6+
RawGoodreadsFeedRSS,
7+
RawGoodreadsItem,
8+
} from "./types";
9+
10+
export const isRawGoodreadsFeed = (data: unknown): data is RawGoodreadsFeed => {
11+
return (
12+
data !== null && typeof data === "object" && data.hasOwnProperty("rss")
13+
);
14+
};
15+
16+
export const isRawGoodreadsFeedRSS = (
17+
data: unknown,
18+
): data is RawGoodreadsFeedRSS => {
19+
return (
20+
typeof data === "object" && data !== null && data.hasOwnProperty("channel")
21+
);
22+
};
23+
24+
export const isRawGoodreadsFeedChannel = (
25+
channel: unknown,
26+
): channel is RawGoodreadsFeedChannel => {
27+
return (
28+
typeof channel === "object" &&
29+
channel !== null &&
30+
channel.hasOwnProperty("title") &&
31+
channel.hasOwnProperty("item")
32+
);
33+
};
34+
export const isRawGoodreadsItem = (item: unknown): item is RawGoodreadsItem => {
35+
return (
36+
typeof item === "object" &&
37+
item !== null &&
38+
item.hasOwnProperty("title") &&
39+
item.hasOwnProperty("link") &&
40+
item.hasOwnProperty("book_image_url") &&
41+
item.hasOwnProperty("author_name") &&
42+
item.hasOwnProperty("book_description")
43+
);
44+
};
45+
46+
export const isValidClientSideFeed = (data: unknown): boolean => {
47+
return (
48+
typeof data === "object" &&
49+
data !== null &&
50+
data.hasOwnProperty("rss") &&
51+
data.hasOwnProperty("status")
52+
);
53+
};

lib/goodreads/types.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Goodreads Feed Types
2+
// TODO: to be moved to a separate package
3+
//
4+
export type RawGoodreadsFeed = {
5+
rss: RawGoodreadsFeedRSS;
6+
};
7+
8+
export type RawGoodreadsFeedRSS = {
9+
channel: RawGoodreadsFeedChannel[];
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
[key: string]: unknown;
12+
};
13+
14+
export type RawGoodreadsFeedChannel = {
15+
title: string[];
16+
item: RawGoodreadsItem[];
17+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18+
[key: string]: unknown;
19+
};
20+
21+
export type RawGoodreadsItem = {
22+
title: string[];
23+
link: string[];
24+
book_image_url: string[];
25+
author_name: string[];
26+
book_description: string[];
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
[key: string]: unknown;
29+
};
30+
31+
export type GoodreadsFeedChannel = {
32+
title: string;
33+
item: GoodreadsItem[];
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
[key: string]: unknown;
36+
};
37+
38+
export type GoodreadsItem = {
39+
title: string;
40+
link: string;
41+
book_image_url: string;
42+
author_name: string;
43+
book_description: string;
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
[key: string]: unknown;
46+
};

lib/main.ts

+74-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import * as parser from "xml2js";
2-
import { isRawFeed, isRawFeedChannel, isValidSubstackFeed } from "./typeguards";
3-
import { RawFeedChannel, RawItem, SubstackItem } from "./types";
2+
import {
3+
isRawFeed,
4+
isRawFeedChannel,
5+
isRawGoodreadsFeed,
6+
isRawGoodreadsFeedChannel,
7+
isRawGoodreadsFeedRSS,
8+
isRawGoodreadsItem,
9+
isValidClientSideFeed,
10+
isValidGoodreadsClientSideFeed,
11+
} from "./typeguards";
12+
import {
13+
GoodreadsItem,
14+
RawFeedChannel,
15+
RawGoodreadsItem,
16+
RawItem,
17+
SubstackItem,
18+
} from "./types";
419

520
const CORS_PROXY = "https://api.allorigins.win/get?url=";
621
const isBrowser = typeof document !== "undefined";
@@ -37,7 +52,7 @@ const transformRawItem = (item: RawItem): SubstackItem => {
3752
};
3853
};
3954

40-
// Public API
55+
// Substack Public API
4156

4257
export const getSubstackFeed = async (
4358
feedUrl: string,
@@ -50,7 +65,7 @@ export const getSubstackFeed = async (
5065
return parseXML(rawXML, callback);
5166
}
5267
// NOTE: client side call
53-
if (!isValidSubstackFeed(rawXML))
68+
if (!isValidClientSideFeed(rawXML))
5469
throw new Error("Error occurred fetching Feed from Substack");
5570
await parseXML(rawXML.contents, callback);
5671
};
@@ -71,3 +86,58 @@ export const getPosts = (channels: RawFeedChannel[]) => {
7186
const channel = channels[0];
7287
return channel.item.map(transformRawItem);
7388
};
89+
90+
// Goodreads Feed Parser
91+
// Utils
92+
const transformRawGoodreadsItem = (item: RawGoodreadsItem): GoodreadsItem => {
93+
if (!isRawGoodreadsItem(item))
94+
throw new Error("Goodreads item is not in the correct format");
95+
return {
96+
title: item.title[0],
97+
link: item.link[0],
98+
book_image_url: item["book_image_url"][0],
99+
author_name: item["author_name"][0],
100+
book_description: item["book_description"][0],
101+
};
102+
};
103+
// Internal API
104+
const getRawXMLGoodreadsFeed = async (feedUrl: string) => {
105+
try {
106+
const path = isBrowser
107+
? `${CORS_PROXY}${encodeURIComponent(feedUrl)}`
108+
: feedUrl;
109+
const promise = await fetch(path);
110+
if (promise.ok) return isBrowser ? promise.json() : promise.text();
111+
} catch (e) {
112+
throw new Error("Error occurred fetching Feed from Goodreads");
113+
}
114+
};
115+
116+
// Public API
117+
export const getGoodreadsFeed = async (
118+
feedUrl: string,
119+
/* eslint-disable @typescript-eslint/no-explicit-any */
120+
callback?: (err: Error | null, result: unknown) => void,
121+
): Promise<unknown> => {
122+
const rawXML = await getRawXMLGoodreadsFeed(feedUrl);
123+
// NOTE: server side call
124+
if (!isBrowser) {
125+
return parseXML(rawXML, callback);
126+
}
127+
// NOTE: client side call
128+
if (!isValidGoodreadsClientSideFeed(rawXML))
129+
throw new Error("Error occurred fetching Feed from Goodreads");
130+
await parseXML(rawXML.contents, callback);
131+
};
132+
export const getGoodreadsFeedItems = (rawFeed: unknown): GoodreadsItem[] => {
133+
if (!isRawGoodreadsFeed(rawFeed))
134+
throw new Error("Goodreads feed is not in the correct format");
135+
if (!isRawGoodreadsFeedRSS(rawFeed.rss))
136+
throw new Error("Goodreads RSS feed is not in the correct format");
137+
const channels = rawFeed.rss.channel.filter(isRawGoodreadsFeedChannel);
138+
if (channels.length === 0)
139+
throw new Error("Goodreads feed does not contain any channels");
140+
const channel = channels[0];
141+
if (!Array.isArray(channel.item)) return [];
142+
return channel.item.filter(isRawGoodreadsItem).map(transformRawGoodreadsItem);
143+
};

lib/typeguards.ts

+53-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
32
import {
43
AtomLink,
54
Enclosure,
65
Guid,
76
ItunesOwner,
87
RawFeed,
98
RawFeedChannel,
9+
RawGoodreadsFeed,
10+
RawGoodreadsFeedChannel,
11+
RawGoodreadsFeedRSS,
12+
RawGoodreadsItem,
1013
RawImage,
1114
RawItem,
1215
} from "./types";
@@ -161,6 +164,54 @@ export const isEnclosure = (data: any): data is Enclosure => {
161164
);
162165
};
163166

164-
export const isValidSubstackFeed = (data: any): boolean => {
167+
export const isValidClientSideFeed = (data: any): boolean => {
165168
return data && data.contents && data.status.http_code == 200;
166169
};
170+
171+
// Goodreads Feed Typeguards
172+
// TODO: to be moved to a separate package
173+
174+
export const isRawGoodreadsFeed = (data: unknown): data is RawGoodreadsFeed => {
175+
return (
176+
data !== null && typeof data === "object" && data.hasOwnProperty("rss")
177+
);
178+
};
179+
180+
export const isRawGoodreadsFeedRSS = (
181+
data: unknown,
182+
): data is RawGoodreadsFeedRSS => {
183+
return (
184+
typeof data === "object" && data !== null && data.hasOwnProperty("channel")
185+
);
186+
};
187+
188+
export const isRawGoodreadsFeedChannel = (
189+
channel: unknown,
190+
): channel is RawGoodreadsFeedChannel => {
191+
return (
192+
typeof channel === "object" &&
193+
channel !== null &&
194+
channel.hasOwnProperty("title") &&
195+
channel.hasOwnProperty("item")
196+
);
197+
};
198+
export const isRawGoodreadsItem = (item: unknown): item is RawGoodreadsItem => {
199+
return (
200+
typeof item === "object" &&
201+
item !== null &&
202+
item.hasOwnProperty("title") &&
203+
item.hasOwnProperty("link") &&
204+
item.hasOwnProperty("book_image_url") &&
205+
item.hasOwnProperty("author_name") &&
206+
item.hasOwnProperty("book_description")
207+
);
208+
};
209+
210+
export const isValidGoodreadsClientSideFeed = (data: unknown): boolean => {
211+
return (
212+
typeof data === "object" &&
213+
data !== null &&
214+
data.hasOwnProperty("contents") &&
215+
data.hasOwnProperty("status")
216+
);
217+
};

0 commit comments

Comments
 (0)