Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,468 changes: 6,468 additions & 0 deletions goodreads.read.feed.json

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type RawFeed = {
rss: {
channel: RawFeedChannel[];
[key: string]: any;
[key: string]: unknown;
};
};

Expand Down Expand Up @@ -90,3 +90,22 @@ export function getSubstackFeed(
): Promise<string | undefined>;
export function getFeedByLink(rawFeed: unknown, link: string): RawFeedChannel[];
export function getPosts(channels: RawFeedChannel[]): SubstackItem[];

// Goodreads RSS Feed Parser

// Goodreads Public Types
export interface GoodreadsItem {
title: string[];
link: string[];
book_image_url: string[];
author_name: string[];
book_description: string[];
[key: string]: unknown;
}

// Goodreads Public API
export const getGoodreadsFeed: (
feedUrl: string,
callback?: (err: Error | null, result: unknown) => void,
) => Promise<unknown>;
export const getGoodreadsFeedItems: (rawFeed: unknown) => GoodreadsItem[];
76 changes: 76 additions & 0 deletions lib/goodreads/goodreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// TODO: to be moved to a separate package
import * as parser from "xml2js";
import {
isRawGoodreadsFeed,
isRawGoodreadsFeedChannel,
isRawGoodreadsFeedRSS,
isRawGoodreadsItem,
isValidClientSideFeed,
} from "./typeguards";
import { GoodreadsItem, RawGoodreadsItem } from "./types";

const CORS_PROXY = "https://api.allorigins.win/get?url=";
const isBrowser = typeof document !== "undefined";

// Utils
const transformRawGoodreadsItem = (item: RawGoodreadsItem): GoodreadsItem => {
return {
title: item.title[0],
link: item.link[0],
book_image_url: item["book_image_url"][0],
author_name: item["author_name"][0],
book_description: item["book_description"][0],
};
};

const parseXML = async (
xml = "",
/* eslint-disable @typescript-eslint/no-explicit-any */
callback?: (err: Error | null, result: any) => void,
) => {
if (!callback) return parser.parseStringPromise(xml);
parser.parseString(xml, callback);
};

// Internal API

const getRawXMLGoodreadsFeed = async (feedUrl: string) => {
try {
const path = isBrowser
? `${CORS_PROXY}${encodeURIComponent(feedUrl)}`
: feedUrl;
const promise = await fetch(path);
if (promise.ok) return isBrowser ? promise.json() : promise.text();
} catch (e) {
throw new Error("Error occurred fetching Feed from Goodreads");
}
};

// Goodreads Public API
export const getGoodreadsFeed = async (
feedUrl: string,
/* eslint-disable @typescript-eslint/no-explicit-any */
callback?: (err: Error | null, result: unknown) => void,
): Promise<unknown> => {
const rawXML = await getRawXMLGoodreadsFeed(feedUrl);
// NOTE: server side call
if (!isBrowser) {
return parseXML(rawXML, callback);
}
// NOTE: client side call
if (!isValidClientSideFeed(rawXML))
throw new Error("Error occurred fetching Feed from Substack");
await parseXML(rawXML.contents, callback);
};
export const getGoodreadsFeedItems = (rawFeed: unknown): GoodreadsItem[] => {
if (!isRawGoodreadsFeed(rawFeed))
throw new Error("Goodreads feed is not in the correct format");
if (!isRawGoodreadsFeedRSS(rawFeed.rss))
throw new Error("Goodreads RSS feed is not in the correct format");
const channels = rawFeed.rss.channel.filter(isRawGoodreadsFeedChannel);
if (channels.length === 0)
throw new Error("Goodreads feed does not contain any channels");
const channel = channels[0];
if (!Array.isArray(channel.item)) return [];
return channel.item.filter(isRawGoodreadsItem).map(transformRawGoodreadsItem);
};
53 changes: 53 additions & 0 deletions lib/goodreads/typeguards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Goodreads Feed Typeguards
// TODO: to be moved to a separate package
import {
RawGoodreadsFeed,
RawGoodreadsFeedChannel,
RawGoodreadsFeedRSS,
RawGoodreadsItem,
} from "./types";

export const isRawGoodreadsFeed = (data: unknown): data is RawGoodreadsFeed => {
return (
data !== null && typeof data === "object" && data.hasOwnProperty("rss")
);
};

export const isRawGoodreadsFeedRSS = (
data: unknown,
): data is RawGoodreadsFeedRSS => {
return (
typeof data === "object" && data !== null && data.hasOwnProperty("channel")
);
};

export const isRawGoodreadsFeedChannel = (
channel: unknown,
): channel is RawGoodreadsFeedChannel => {
return (
typeof channel === "object" &&
channel !== null &&
channel.hasOwnProperty("title") &&
channel.hasOwnProperty("item")
);
};
export const isRawGoodreadsItem = (item: unknown): item is RawGoodreadsItem => {
return (
typeof item === "object" &&
item !== null &&
item.hasOwnProperty("title") &&
item.hasOwnProperty("link") &&
item.hasOwnProperty("book_image_url") &&
item.hasOwnProperty("author_name") &&
item.hasOwnProperty("book_description")
);
};

export const isValidClientSideFeed = (data: unknown): boolean => {
return (
typeof data === "object" &&
data !== null &&
data.hasOwnProperty("rss") &&
data.hasOwnProperty("status")
);
};
46 changes: 46 additions & 0 deletions lib/goodreads/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Goodreads Feed Types
// TODO: to be moved to a separate package
//
export type RawGoodreadsFeed = {
rss: RawGoodreadsFeedRSS;
};

export type RawGoodreadsFeedRSS = {
channel: RawGoodreadsFeedChannel[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: unknown;
};

export type RawGoodreadsFeedChannel = {
title: string[];
item: RawGoodreadsItem[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: unknown;
};

export type RawGoodreadsItem = {
title: string[];
link: string[];
book_image_url: string[];
author_name: string[];
book_description: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: unknown;
};

export type GoodreadsFeedChannel = {
title: string;
item: GoodreadsItem[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: unknown;
};

export type GoodreadsItem = {
title: string;
link: string;
book_image_url: string;
author_name: string;
book_description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: unknown;
};
78 changes: 74 additions & 4 deletions lib/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import * as parser from "xml2js";
import { isRawFeed, isRawFeedChannel, isValidSubstackFeed } from "./typeguards";
import { RawFeedChannel, RawItem, SubstackItem } from "./types";
import {
isRawFeed,
isRawFeedChannel,
isRawGoodreadsFeed,
isRawGoodreadsFeedChannel,
isRawGoodreadsFeedRSS,
isRawGoodreadsItem,
isValidClientSideFeed,
isValidGoodreadsClientSideFeed,
} from "./typeguards";
import {
GoodreadsItem,
RawFeedChannel,
RawGoodreadsItem,
RawItem,
SubstackItem,
} from "./types";

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

// Public API
// Substack Public API

export const getSubstackFeed = async (
feedUrl: string,
Expand All @@ -50,7 +65,7 @@ export const getSubstackFeed = async (
return parseXML(rawXML, callback);
}
// NOTE: client side call
if (!isValidSubstackFeed(rawXML))
if (!isValidClientSideFeed(rawXML))
throw new Error("Error occurred fetching Feed from Substack");
await parseXML(rawXML.contents, callback);
};
Expand All @@ -71,3 +86,58 @@ export const getPosts = (channels: RawFeedChannel[]) => {
const channel = channels[0];
return channel.item.map(transformRawItem);
};

// Goodreads Feed Parser
// Utils
const transformRawGoodreadsItem = (item: RawGoodreadsItem): GoodreadsItem => {
if (!isRawGoodreadsItem(item))
throw new Error("Goodreads item is not in the correct format");
return {
title: item.title[0],
link: item.link[0],
book_image_url: item["book_image_url"][0],
author_name: item["author_name"][0],
book_description: item["book_description"][0],
};
};
// Internal API
const getRawXMLGoodreadsFeed = async (feedUrl: string) => {
try {
const path = isBrowser
? `${CORS_PROXY}${encodeURIComponent(feedUrl)}`
: feedUrl;
const promise = await fetch(path);
if (promise.ok) return isBrowser ? promise.json() : promise.text();
} catch (e) {
throw new Error("Error occurred fetching Feed from Goodreads");
}
};

// Public API
export const getGoodreadsFeed = async (
feedUrl: string,
/* eslint-disable @typescript-eslint/no-explicit-any */
callback?: (err: Error | null, result: unknown) => void,
): Promise<unknown> => {
const rawXML = await getRawXMLGoodreadsFeed(feedUrl);
// NOTE: server side call
if (!isBrowser) {
return parseXML(rawXML, callback);
}
// NOTE: client side call
if (!isValidGoodreadsClientSideFeed(rawXML))
throw new Error("Error occurred fetching Feed from Goodreads");
await parseXML(rawXML.contents, callback);
};
export const getGoodreadsFeedItems = (rawFeed: unknown): GoodreadsItem[] => {
if (!isRawGoodreadsFeed(rawFeed))
throw new Error("Goodreads feed is not in the correct format");
if (!isRawGoodreadsFeedRSS(rawFeed.rss))
throw new Error("Goodreads RSS feed is not in the correct format");
const channels = rawFeed.rss.channel.filter(isRawGoodreadsFeedChannel);
if (channels.length === 0)
throw new Error("Goodreads feed does not contain any channels");
const channel = channels[0];
if (!Array.isArray(channel.item)) return [];
return channel.item.filter(isRawGoodreadsItem).map(transformRawGoodreadsItem);
};
55 changes: 53 additions & 2 deletions lib/typeguards.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import {
AtomLink,
Enclosure,
Guid,
ItunesOwner,
RawFeed,
RawFeedChannel,
RawGoodreadsFeed,
RawGoodreadsFeedChannel,
RawGoodreadsFeedRSS,
RawGoodreadsItem,
RawImage,
RawItem,
} from "./types";
Expand Down Expand Up @@ -161,6 +164,54 @@ export const isEnclosure = (data: any): data is Enclosure => {
);
};

export const isValidSubstackFeed = (data: any): boolean => {
export const isValidClientSideFeed = (data: any): boolean => {
return data && data.contents && data.status.http_code == 200;
};

// Goodreads Feed Typeguards
// TODO: to be moved to a separate package

export const isRawGoodreadsFeed = (data: unknown): data is RawGoodreadsFeed => {
return (
data !== null && typeof data === "object" && data.hasOwnProperty("rss")
);
};

export const isRawGoodreadsFeedRSS = (
data: unknown,
): data is RawGoodreadsFeedRSS => {
return (
typeof data === "object" && data !== null && data.hasOwnProperty("channel")
);
};

export const isRawGoodreadsFeedChannel = (
channel: unknown,
): channel is RawGoodreadsFeedChannel => {
return (
typeof channel === "object" &&
channel !== null &&
channel.hasOwnProperty("title") &&
channel.hasOwnProperty("item")
);
};
export const isRawGoodreadsItem = (item: unknown): item is RawGoodreadsItem => {
return (
typeof item === "object" &&
item !== null &&
item.hasOwnProperty("title") &&
item.hasOwnProperty("link") &&
item.hasOwnProperty("book_image_url") &&
item.hasOwnProperty("author_name") &&
item.hasOwnProperty("book_description")
);
};

export const isValidGoodreadsClientSideFeed = (data: unknown): boolean => {
return (
typeof data === "object" &&
data !== null &&
data.hasOwnProperty("contents") &&
data.hasOwnProperty("status")
);
};
Loading