Files
homarr/packages/request-handler/src/rss-feeds.ts
T

119 lines
3.7 KiB
TypeScript

import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
import { extract } from "@extractus/feed-extractor";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "rssFeedsRequestHandler" });
export const rssFeedsRequestHandler = createWidgetRequestHandler({
// Authorized editors can supply arbitrary URLs, so keep this cache process-local and bounded.
async requestAsync(input: { url: string; count: number; descriptionMaxLength: number }, signal) {
const result = (await extract(
input.url,
{
descriptionMaxLen: input.descriptionMaxLength,
getExtraEntryFields: (feedEntry) => {
const media = attemptGetImageFromEntry(input.url, feedEntry);
if (!media) {
return {};
}
return {
enclosure: media,
};
},
},
{ signal },
)) as ExtendedFeedData;
return {
...result,
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
};
},
});
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
const media = getFirstMediaProperty(entry);
if (media !== null) {
return media;
}
return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry));
};
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
const regex =
/https?:\/\/[^\s"'<>\\]+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)\b(?:\?[^\s"'<>\\#]*)?(?:#[^\s"'<>\\]*)?/i;
const result = regex.exec(content);
if (result == null) {
return null;
}
console.debug(
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
);
return result[0];
};
const mediaProperties = ["enclosure", "media:content", "media:thumbnail"] as const;
/**
* The RSS and Atom standards are poorly adhered to in most of the web.
* We want to show pretty background images on the posts and therefore need to extract
* the enclosure (aka. media images). This function checks known media fields in priority
* order and picks the first valid image URL from a field.
* @param feedObject The object to scan for.
* @returns the first valid media URL, or null when none is found
*/
const getFirstMediaProperty = (feedObject: object) => {
for (const mediaProperty of mediaProperties) {
const mediaValue: unknown = Object.entries(feedObject).find(([key]) => key === mediaProperty)?.[1];
const mediaEntries: unknown[] = Array.isArray(mediaValue) ? mediaValue : [mediaValue];
for (const mediaEntry of mediaEntries) {
if (mediaEntry === null || typeof mediaEntry !== "object") {
continue;
}
const url: unknown = Object.entries(mediaEntry).find(([key]) => key === "@_url")?.[1];
const validationResult = z.string().url().safeParse(url);
if (!validationResult.success) {
continue;
}
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
return validationResult.data;
}
}
return null;
};
/**
* We extend the feed with custom properties.
* This interface adds properties on top of the default ones.
*/
export interface ExtendedFeedEntry extends FeedEntry {
feedUrl: string;
enclosure?: string;
}
/**
* We extend the feed with custom properties.
* This interface omits the default entries with our custom definition.
*/
type ExtendedFeedData = Modify<
FeedData,
{
entries?: ExtendedFeedEntry[];
}
>;
export interface RssFeed {
feedUrl: string;
feed: ExtendedFeedData;
}