, Error>("lists", listFetcher);
return (
@@ -49,7 +49,7 @@ const HomePage = () => {
.sort((a, b) => a.name.localeCompare(b.name))
.map((source) => (
- {
-
+
)}
diff --git a/src/app/source/route.ts b/src/app/source/route.ts
new file mode 100644
index 0000000..6817aaa
--- /dev/null
+++ b/src/app/source/route.ts
@@ -0,0 +1,192 @@
+import { after } from "next/server";
+import { createClient } from "redis";
+import { toTemporalInstant } from "temporal-polyfill";
+import * as v from "valibot";
+import { ResponseError } from "@/errors/ResponseError";
+import type { SourceResponse, SourceResponseDebugInfo, SourceResponseItem } from "@/index";
+import { sourceRequestSchema } from "@/schemas/sourceRequestSchema";
+import { createDOM } from "@/utils/createDOM";
+import { select } from "@/utils/select";
+import { selectAll } from "@/utils/selectAll";
+
+export const GET = async (request: Request) => {
+ const debugInfo: SourceResponseDebugInfo = {
+ htmlSamples: {
+ excludeFilter: [],
+ includeFilter: [],
+ matched: [],
+ },
+ itemsMatched: 0,
+ itemsRemovedByExcludeFilter: 0,
+ itemsRemovedByIncludeFilter: 0,
+ };
+
+ try {
+ const redis = process.env.REDIS_URL ? createClient({ url: process.env.REDIS_URL }) : null;
+
+ if (redis) await redis.connect();
+
+ after(() => {
+ if (redis) redis.destroy();
+ });
+
+ const items: Array = [];
+
+ const requestUrl = new URL(request.url);
+
+ const params: { [key: string]: string } = {};
+
+ for (const [key, value] of requestUrl.searchParams) {
+ if (key in params) {
+ throw new ResponseError(`Duplicate query params: ${key}`, 400);
+ }
+ params[key] = value;
+ }
+
+ const parsedParams = v.safeParse(sourceRequestSchema, params);
+
+ if (!parsedParams.success) {
+ throw new ResponseError(
+ "Invalid query params",
+ 400,
+ parsedParams.issues.map((issue) =>
+ issue.path ? `${issue.path.join(".")}: ${issue.message}` : issue.message,
+ ),
+ );
+ }
+
+ const { debug, exclude, include, itemSelector, linkSelector, titleSelector, url } =
+ parsedParams.output;
+
+ const sourceResponse = await fetch(url, {
+ next: {
+ revalidate: process.env.NODE_ENV === "development" ? 60 : 3600,
+ },
+ });
+
+ if (sourceResponse.ok === false) {
+ throw new ResponseError("Failed to fetch from source", 502);
+ }
+
+ const sourceDateHeader = sourceResponse.headers.get("date");
+
+ const sourceUpdatedInstant = toTemporalInstant.apply(
+ sourceDateHeader ? new Date(sourceDateHeader) : new Date(Date.now()),
+ );
+
+ const html = await sourceResponse.text();
+
+ const dom = await createDOM(html, url);
+
+ if (!dom) {
+ throw new ResponseError("Unable to parse source");
+ }
+
+ const matchedItems = selectAll(dom.window.document, itemSelector);
+
+ debugInfo.itemsMatched = matchedItems.length;
+
+ for (const item of matchedItems) {
+ const lowercaseText = item.textContent.toLocaleLowerCase();
+
+ if (include.length > 0 && item.textContent !== null) {
+ if (include.every((phrase) => lowercaseText.includes(phrase) === false)) {
+ debugInfo.itemsRemovedByIncludeFilter += 1;
+ if (debugInfo.htmlSamples.includeFilter.length < 3) {
+ debugInfo.htmlSamples.includeFilter.push(item.outerHTML);
+ }
+ continue;
+ }
+ }
+
+ if (exclude.length > 0 && item.textContent !== null) {
+ if (exclude.some((phrase) => lowercaseText.includes(phrase) === true)) {
+ debugInfo.itemsRemovedByExcludeFilter += 1;
+ if (debugInfo.htmlSamples.excludeFilter.length < 3) {
+ debugInfo.htmlSamples.excludeFilter.push(item.outerHTML);
+ }
+ continue;
+ }
+ }
+
+ if (debugInfo.htmlSamples.matched.length < 3) {
+ debugInfo.htmlSamples.matched.push(item.outerHTML);
+ }
+
+ const title =
+ titleSelector && titleSelector.trim() !== ""
+ ? select(item, titleSelector)?.textContent
+ : item.textContent;
+
+ if (title && items.every((item) => item.title !== title)) {
+ const link = linkSelector && linkSelector.trim() !== "" ? select(item, linkSelector) : item;
+ const itemUrl =
+ link?.tagName === "A" ? (link.getAttribute("href") ?? undefined) : undefined;
+ const parsedUrl = itemUrl ? URL.parse(itemUrl, url)?.href : undefined;
+
+ items.push({
+ firstSeen: sourceUpdatedInstant.toString(),
+ title,
+ url: parsedUrl,
+ });
+ }
+ }
+
+ if (redis) {
+ const keys = items.map(
+ ({ title }) => `${url}:${itemSelector}:${titleSelector ?? ""}:${title.slice(0, 100)}`,
+ );
+
+ if (keys.length > 0) {
+ const firstSeenValues = await redis.HMGET("first_seen", keys);
+
+ for (let i = 0; i < firstSeenValues.length; i++) {
+ const value = firstSeenValues[i];
+ const item = items[i];
+
+ if (item && value) item.firstSeen = value;
+ }
+
+ await redis
+ .multi()
+ .HSET(
+ "first_seen",
+ Object.fromEntries(
+ keys.map((key, index) => [
+ key,
+ firstSeenValues[index] ?? sourceUpdatedInstant.toString(),
+ ]),
+ ),
+ )
+ .HEXPIRE("first_seen", keys, 60 * 60 * 24 * 28)
+ .exec();
+ }
+ }
+
+ const response: SourceResponse = {
+ debug: debug ? debugInfo : undefined,
+ fetchedAt: sourceUpdatedInstant.toString(),
+ items,
+ ok: true,
+ };
+
+ return Response.json(response, {
+ headers: {
+ "Vercel-CDN-Cache-Control": "max-age=10",
+ },
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : `Unknown error: ${error}`;
+
+ const response: SourceResponse = {
+ debug: debugInfo,
+ errors: error instanceof ResponseError ? error.errors : [message],
+ ok: false,
+ };
+
+ return Response.json(response, {
+ status: error instanceof ResponseError ? error.statusCode : 500,
+ statusText: message,
+ });
+ }
+};
diff --git a/src/dialogs/AddSourceDialog.tsx b/src/dialogs/AddSourceDialog.tsx
index 198e352..cc31247 100644
--- a/src/dialogs/AddSourceDialog.tsx
+++ b/src/dialogs/AddSourceDialog.tsx
@@ -41,7 +41,7 @@ export const AddSourceDialog = () => {
/>
)}
hue="gray"
- width="medium"
+ width="large"
>
Add
diff --git a/src/dialogs/EditSourceDialog.tsx b/src/dialogs/EditSourceDialog.tsx
index 31fa8db..2284cbd 100644
--- a/src/dialogs/EditSourceDialog.tsx
+++ b/src/dialogs/EditSourceDialog.tsx
@@ -2,6 +2,7 @@
import { Dialog } from "@colonydb/anthill/Dialog";
import { Icon } from "@colonydb/anthill/Icon";
+import { useCallback } from "react";
import useSWRMutation from "swr/mutation";
import { SourceForm } from "@/app/SourceForm";
import type { SourceConfig } from "@/index";
@@ -13,31 +14,31 @@ type Props = {
export const EditSourceDialog = ({ source }: Props) => {
const { trigger } = useSWRMutation("lists", editSource);
- return (
- }
- padded
- render={(closeDialog) => (
- {
+
+ const render = useCallback(
+ (closeDialog: () => void) => (
+ {
+ closeDialog();
+ }}
+ onSubmit={async (source) => {
+ await trigger(source);
+ }}
+ onSuccess={() => {
+ setTimeout(() => {
closeDialog();
- }}
- onSubmit={async (source) => {
- await trigger(source);
- }}
- onSuccess={() => {
- setTimeout(() => {
- closeDialog();
- }, 1000);
- }}
- title="Edit Source"
- />
- )}
- width="medium"
- >
+ }, 1000);
+ }}
+ title="Edit Source"
+ />
+ ),
+ [source, trigger],
+ );
+
+ return (
+ } padded render={render} width="large">
Edit
);
diff --git a/src/dialogs/RemoveSourceDialog.tsx b/src/dialogs/RemoveSourceDialog.tsx
index f087e06..658635e 100644
--- a/src/dialogs/RemoveSourceDialog.tsx
+++ b/src/dialogs/RemoveSourceDialog.tsx
@@ -1,13 +1,12 @@
"use client";
-import { Action } from "@colonydb/anthill/Action";
import { Button } from "@colonydb/anthill/Button";
import { Card } from "@colonydb/anthill/Card";
import { CardContent } from "@colonydb/anthill/CardContent";
import { Dialog } from "@colonydb/anthill/Dialog";
+import { DialogHeader } from "@colonydb/anthill/DialogHeader";
import { Form } from "@colonydb/anthill/Form";
import { FormFooter } from "@colonydb/anthill/FormFooter";
-import { Header } from "@colonydb/anthill/Header";
import { Heading } from "@colonydb/anthill/Heading";
import { Icon } from "@colonydb/anthill/Icon";
import useSWRMutation from "swr/mutation";
@@ -44,20 +43,9 @@ export const RemoveSourceDialog = ({ source }: Props) => {
>
}
- onClick={() => {
- closeDialog();
- }}
- title="close"
- />
- }
- >
+
Remove Source
-