feat: add rss widget (#760)

Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
This commit is contained in:
Manuel
2024-07-27 18:11:29 +02:00
committed by GitHub
parent 4380aa9b3e
commit 15d9327d46
23 changed files with 528 additions and 11 deletions

View File

@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/ui/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "~/styles/scroll-area.scss";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";

View File

@@ -0,0 +1,4 @@
.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) {
width: 100%;
display: inherit !important;
}

View File

@@ -0,0 +1,29 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { z } from "@homarr/validation";
import { publicProcedure } from "../trpc";
export const createOneItemMiddleware = (kind: WidgetKind) => {
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
});
if (!item) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Item with id ${input.itemId} not found`,
});
}
return next({
ctx: {
item,
},
});
});
};

View File

@@ -4,6 +4,7 @@ import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { mediaServerRouter } from "./media-server";
import { notebookRouter } from "./notebook";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
import { weatherRouter } from "./weather";
@@ -15,4 +16,5 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
rssFeed: rssFeedRouter,
});

View File

@@ -0,0 +1,12 @@
import type { RssFeed } from "@homarr/cron-jobs";
import { createItemChannel } from "@homarr/redis";
import { createOneItemMiddleware } from "../../middlewares/item";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.unstable_concat(createOneItemMiddleware("rssFeed")).query(async ({ input }) => {
const channel = createItemChannel<RssFeed[]>(input.itemId);
return await channel.getAsync();
}),
});

View File

@@ -38,7 +38,8 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
);
await creatorOptions.onCallbackSuccess?.(name);
} catch (error) {
creatorOptions.logger.logError(error);
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
await creatorOptions.onCallbackError?.(name, error);
}
};

View File

@@ -21,18 +21,20 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"@extractus/feed-extractor": "^7.1.3",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -4,6 +4,8 @@ import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
import { createCronJobGroup } from "./lib";
export const jobGroup = createCronJobGroup({
@@ -13,6 +15,8 @@ export const jobGroup = createCronJobGroup({
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
rssFeeds: rssFeedsJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
export type { RssFeed };

View File

@@ -0,0 +1,135 @@
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
import { extract } from "@extractus/feed-extractor";
import SuperJSON from "superjson";
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import { createItemChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../widgets";
import { createCronJob } from "../lib";
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_5_MINUTES).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "rssFeed"),
});
for (const item of itemsForIntegration) {
const options = SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options);
const feeds = await Promise.all(
options.feedUrls.map(async (feedUrl) => ({
feedUrl,
feed: (await extract(feedUrl, {
getExtraEntryFields: (feedEntry) => {
const media = attemptGetImageFromEntry(feedUrl, feedEntry);
if (!media) {
return {};
}
return {
enclosure: media,
};
},
})) as ExtendedFeedData,
})),
);
const channel = createItemChannel<RssFeed[]>(item.id);
await channel.publishAndUpdateLastStateAsync(feeds);
}
});
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)/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 = [
{
path: ["enclosure", "@_url"],
},
{
path: ["media:content", "@_url"],
},
];
/**
* 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 uses the dynamic properties defined above
* to search through the possible paths and detect valid image URLs.
* @param feedObject The object to scan for.
* @returns the value of the first path that is found within the object
*/
const getFirstMediaProperty = (feedObject: object) => {
for (const mediaProperty of mediaProperties) {
let propertyIndex = 0;
let objectAtPath: object = feedObject;
while (propertyIndex < mediaProperty.path.length) {
const key = mediaProperty.path[propertyIndex];
if (key === undefined) {
break;
}
const propertyEntries = Object.entries(objectAtPath);
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
if (!propertyEntry) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [_, propertyEntryValue] = propertyEntry;
objectAtPath = propertyEntryValue as object;
propertyIndex++;
}
const validationResult = z.string().url().safeParse(objectAtPath);
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.
*/
interface ExtendedFeedEntry extends FeedEntry {
enclosure?: string;
}
/**
* We extend the feed with custom properties.
* This interface omits the default entries with our custom definition.
*/
interface ExtendedFeedData extends Omit<FeedData, "entries"> {
entries?: ExtendedFeedEntry;
}
export interface RssFeed {
feedUrl: string;
feed: ExtendedFeedData;
}

View File

@@ -10,5 +10,6 @@ export const widgetKinds = [
"smartHome-executeAutomation",
"mediaServer",
"calendar",
"rssFeed",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -1,6 +1,6 @@
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel } from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(

View File

@@ -163,6 +163,14 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
const channelName = `item:${kind}:integration:${integrationId}`;
return createChannelWithLatestAndEvents<TData>(channelName);
};
export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};
const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
return {
subscribe: (callback: (data: TData) => void) => {
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {

View File

@@ -1033,6 +1033,21 @@ export default {
description: "Show the current streams on your media servers",
option: {},
},
rssFeed: {
name: "RSS feeds",
description: "Monitor and display one or more generic RSS, ATOM or JSON feeds",
option: {
feedUrls: {
label: "Feed URLs",
},
textLinesClamp: {
label: "Description line clamp",
},
maximumAmountPosts: {
label: "Amount posts limit",
},
},
},
},
widgetPreview: {
toggle: {
@@ -1494,6 +1509,9 @@ export default {
mediaOrganizer: {
label: "Media Organizers",
},
rssFeeds: {
label: "RSS feeds",
},
},
},
},

View File

@@ -22,6 +22,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@extractus/feed-extractor": "^7.1.3",
"@homarr/api": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",

View File

@@ -1,6 +1,7 @@
import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
@@ -12,7 +13,7 @@ const mapping = {
text: WidgetTextInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: () => null,
multiText: WidgetMultiTextInput,
number: WidgetNumberInput,
select: WidgetSelectInput,
slider: WidgetSliderInput,

View File

@@ -0,0 +1,101 @@
import React, { useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
const t = useWidgetInputTranslation(kind, property);
const tCommon = useScopedI18n("common");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const [search, setSearch] = useState("");
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const values = inputProps.value as string[];
const onChange = inputProps.onChange as (values: string[]) => void;
const handleRemove = (optionIndex: number) => {
onChange(values.filter((_, index) => index !== optionIndex));
};
const currentValidationResult = React.useMemo(() => {
if (!options.validate) {
return {
success: false,
result: null,
};
}
const validationResult = options.validate.safeParse(search);
return {
success: validationResult.success,
result: validationResult,
};
}, [search]);
const error = React.useMemo(() => {
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
if (!currentValidationResult.success && currentValidationResult.result && search.length !== 0) {
return currentValidationResult.result.error?.issues[0]?.message;
}
return null;
}, [currentValidationResult, search]);
return (
<Combobox store={combobox}>
<Combobox.DropdownTarget>
<PillsInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
onClick={() => combobox.openDropdown()}
error={error}
>
<Pill.Group>
{values.map((option, index) => (
<Pill key={option} onRemove={() => handleRemove(index)} withRemoveButton>
{option}
</Pill>
))}
<Combobox.EventsTarget>
<PillsInput.Field
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
value={search}
placeholder={tCommon("multiText.placeholder")}
onChange={(event) => {
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Backspace" && search.length === 0) {
event.preventDefault();
onChange(values.slice(0, -1));
} else if (event.key === "Enter") {
event.preventDefault();
if (search.length === 0 || !currentValidationResult.success) {
return;
}
if (values.includes(search)) {
return;
}
onChange([...values, search]);
setSearch("");
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
</Combobox>
);
};

View File

@@ -14,6 +14,7 @@ import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as mediaServer from "./media-server";
import * as notebook from "./notebook";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as video from "./video";
@@ -37,6 +38,7 @@ export const widgetImports = {
"smartHome-executeAutomation": smartHomeExecuteAutomation,
mediaServer,
calendar,
rssFeed,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -1,6 +1,6 @@
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import type { z } from "@homarr/validation";
import type { z, ZodType } from "@homarr/validation";
import { widgetImports } from ".";
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
@@ -91,10 +91,12 @@ const optionsFactory = {
},
withDescription: input?.withDescription ?? false,
}),
multiText: (input?: CommonInput<string[]>) => ({
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
type: "multiText" as const,
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
values: [] as string[],
validate: input?.validate,
}),
app: (input?: Omit<CommonInput<string>, "defaultValue">) => ({
type: "app" as const,

View File

@@ -0,0 +1,11 @@
.backgroundImage {
position: absolute;
width: 100%;
height: 100%;
filter: blur(5px);
transform: scaleX(-1.05) scaleZ(-1.05);
opacity: 0.25;
top: 0;
left: 0;
object-fit: cover;
}

View File

@@ -0,0 +1,74 @@
import React from "react";
import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) {
if (serverData?.initialData === undefined) {
return null;
}
const entries = serverData.initialData
.filter((feedGroup) => feedGroup.feed.entries !== undefined)
.flatMap((feedGroup) => feedGroup.feed.entries)
.filter((entry) => entry !== undefined)
.sort((entryA, entryB) => {
if (!entryA.published || !entryB.published) {
return -1;
}
return new Date(entryB.published).getTime() - new Date(entryA.published).getTime();
})
.slice(0, options.maximumAmountPosts as number);
return (
<ScrollArea className="scroll-area-w100" w="100%" p="4cqmin">
<Stack w={"100%"} gap="4cqmin">
{entries.map((feedEntry) => (
<Card
key={feedEntry.id}
withBorder
component={"a"}
href={feedEntry.link}
radius="2.5cqmin"
target="_blank"
w="100%"
p="2.5cqmin"
>
{feedEntry.enclosure && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
)}
<Flex gap="2.5cqmin" direction="column" w="100%">
<Text fz="4cqmin" lh="5cqmin" lineClamp={2}>
{feedEntry.title}
</Text>
{feedEntry.description && (
<Text
className={feedEntry.description}
c="dimmed"
size="3.5cqmin"
lineClamp={options.textLinesClamp as number}
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
/>
)}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Card>
))}
</Stack>
</ScrollArea>
);
}
const InfoDisplay = ({ date }: { date: string }) => (
<Group gap="2.5cqmin">
<IconClock size="2.5cqmin" />
<Text size="2.5cqmin" c="dimmed" pt="1cqmin">
{date}
</Text>
</Group>
);

View File

@@ -0,0 +1,32 @@
import { IconRss } from "@tabler/icons-react";
import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
/**
* Feed must conform to one of the following standards:
* - https://www.rssboard.org/rss-specification (https://web.resource.org/rss/1.0/spec)
* - https://datatracker.ietf.org/doc/html/rfc5023
* - https://www.jsonfeed.org/version/1.1/
*/
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
options: optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({
defaultValue: [],
validate: z.string().url(),
}),
textLinesClamp: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(50),
}),
maximumAmountPosts: factory.number({
defaultValue: 100,
validate: z.number().min(1).max(9999),
}),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,21 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ itemId }: WidgetProps<"rssFeed">) {
if (!itemId) {
return {
initialData: undefined,
lastUpdatedAt: null,
};
}
const data = await api.widget.rssFeed.getFeeds({
itemId,
});
return {
initialData: data?.data,
lastUpdatedAt: data?.timestamp,
};
}

55
pnpm-lock.yaml generated
View File

@@ -671,6 +671,9 @@ importers:
packages/cron-jobs:
dependencies:
'@extractus/feed-extractor':
specifier: ^7.1.3
version: 7.1.3
'@homarr/analytics':
specifier: workspace:^0.1.0
version: link:../analytics
@@ -707,6 +710,9 @@ importers:
'@homarr/translation':
specifier: workspace:^0.1.0
version: link:../translation
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -1248,6 +1254,9 @@ importers:
packages/widgets:
dependencies:
'@extractus/feed-extractor':
specifier: ^7.1.3
version: 7.1.3
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
@@ -2047,6 +2056,10 @@ packages:
resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@extractus/feed-extractor@7.1.3':
resolution: {integrity: sha512-USRVpGw4fWlnz8O8gB95UDJJaU5wg2EFESDe9nut0mHFJ8bOxDKGoo3g6EaKU24YzaPUBweKENdEJfuTUgr/uA==}
engines: {node: '>= 18'}
'@floating-ui/core@1.6.0':
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
@@ -3285,6 +3298,10 @@ packages:
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
engines: {node: '>= 10.0.0'}
bellajs@11.2.0:
resolution: {integrity: sha512-Wjss+Bc674ZABPr+SCKWTqA4V1pyYFhzDTjNBJy4jdmgOv0oGIGXeKBRJyINwP5tIy+iIZD9SfgZpztduzQ5QA==}
engines: {node: '>= 18.4'}
better-sqlite3@11.1.2:
resolution: {integrity: sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==}
@@ -3545,6 +3562,9 @@ packages:
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -4116,6 +4136,10 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-xml-parser@4.4.0:
resolution: {integrity: sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==}
hasBin: true
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
@@ -4384,6 +4408,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-entities@2.5.2:
resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -5976,6 +6003,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@@ -7119,6 +7149,15 @@ snapshots:
'@eslint/object-schema@2.1.4': {}
'@extractus/feed-extractor@7.1.3':
dependencies:
bellajs: 11.2.0
cross-fetch: 4.0.0
fast-xml-parser: 4.4.0
html-entities: 2.5.2
transitivePeerDependencies:
- encoding
'@floating-ui/core@1.6.0':
dependencies:
'@floating-ui/utils': 0.2.1
@@ -8556,6 +8595,8 @@ snapshots:
- encoding
- supports-color
bellajs@11.2.0: {}
better-sqlite3@11.1.2:
dependencies:
bindings: 1.5.0
@@ -8836,6 +8877,12 @@ snapshots:
dependencies:
cross-spawn: 7.0.3
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@@ -9584,6 +9631,10 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-xml-parser@4.4.0:
dependencies:
strnum: 1.0.5
fastq@1.17.1:
dependencies:
reusify: 1.0.4
@@ -9873,6 +9924,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-entities@2.5.2: {}
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
@@ -11639,6 +11692,8 @@ snapshots:
strip-json-comments@3.1.1: {}
strnum@1.0.5: {}
styled-jsx@5.1.1(@babel/core@7.24.6)(react@18.3.1):
dependencies:
client-only: 0.0.1