webring 0.0.0-dev.706
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +280 -0
- package/README.md +153 -0
- package/bun.lock +1241 -0
- package/dist/cache.d.ts +4 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +61 -0
- package/dist/cache.js.map +1 -0
- package/dist/fetch.d.ts +14 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +56 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +69 -0
- package/dist/index.test.js.map +1 -0
- package/dist/types.d.ts +467 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +87 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +20 -0
- package/dist/util.js.map +1 -0
- package/package.json +72 -0
- package/src/__snapshots__/index.test.ts.snap +7 -0
- package/src/cache.ts +99 -0
- package/src/fetch.ts +84 -0
- package/src/index.test.ts +81 -0
- package/src/index.ts +50 -0
- package/src/types.ts +103 -0
- package/src/util.ts +32 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
|
2
|
+
|
|
3
|
+
exports[`it should fetch an RSS feed without caching 1`] = `"[{"title":"Creating an already-completed asynchronous activity in C++/WinRT, part 4","url":"https://devblogs.microsoft.com/oldnewthing/20240712-00/?p=109967","date":"2024-07-12T14:00:00.000Z","source":{"title":"rss 10","url":"http://localhost:PORT/rss-10.xml"},"preview":"Failing is easy. Failing correctly is hard. The post Creating an already-completed asynchronous activity in C++/WinRT, part 4 appeared first on The Old New Thing."}]"`;
|
|
4
|
+
|
|
5
|
+
exports[`it should fetch several RSS feeds 1`] = `"[{"title":"Creating an already-completed asynchronous activity in C++/WinRT, part 4","url":"https://devblogs.microsoft.com/oldnewthing/20240712-00/?p=109967","date":"2024-07-12T14:00:00.000Z","source":{"title":"rss 10","url":"http://localhost:PORT/rss-10.xml"},"preview":"Failing is easy. Failing correctly is hard. The post Creating an already-completed asynchronous activity in C++/WinRT, part 4 appeared first on The Old New Thing."},{"title":"Garlic and gravel","url":"https://www.henrikkarlsson.xyz/p/garlic-and-gravel","date":"2024-07-12T11:34:25.000Z","source":{"title":"rss 16","url":"http://localhost:PORT/rss-16.xml"},"preview":"fragments"},{"title":"My Glorious Ascension To Thought Leadership","url":"https://ludic.mataroa.blog/blog/my-glorious-ascension-to-thought-leadership/","date":"2024-07-11T00:00:00.000Z","source":{"title":"rss 5","url":"http://localhost:PORT/rss-5.xml"},"preview":"I. This Escalated Quickly I mentioned one post ago that this blog has done approximately 2M+ lifetime views, with over a million of them being in the past two weeks in response to the now-infamous rant about AI, then I went out of my way to not write the most maximally viral thing possible for perso..."}]"`;
|
|
6
|
+
|
|
7
|
+
exports[`it should fetch an RSS feed with caching 1`] = `"[{"title":"Creating an already-completed asynchronous activity in C++/WinRT, part 4","url":"https://devblogs.microsoft.com/oldnewthing/20240712-00/?p=109967","date":"2024-07-12T14:00:00.000Z","source":{"url":"http://localhost:PORT/rss-10.xml","title":"rss 10"},"preview":"Failing is easy. Failing correctly is hard. The post Creating an already-completed asynchronous activity in C++/WinRT, part 4 appeared first on The Old New Thing."}]"`;
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as R from "remeda";
|
|
2
|
+
import {
|
|
3
|
+
type Cache,
|
|
4
|
+
type Result,
|
|
5
|
+
type ResultEntry,
|
|
6
|
+
type CacheEntry,
|
|
7
|
+
type Source,
|
|
8
|
+
type CachedConfiguration,
|
|
9
|
+
CacheSchema,
|
|
10
|
+
type CacheConfiguration,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
import { fetch } from "./fetch.ts";
|
|
13
|
+
import { asyncMapFilterUndefined } from "./util.ts";
|
|
14
|
+
import * as fs from "node:fs/promises";
|
|
15
|
+
|
|
16
|
+
async function loadCache({
|
|
17
|
+
cache_file: cacheFilePath,
|
|
18
|
+
}: CacheConfiguration): Promise<Cache> {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(cacheFilePath);
|
|
21
|
+
const cacheFileContent = await fs.readFile(cacheFilePath);
|
|
22
|
+
return CacheSchema.parse(JSON.parse(cacheFileContent.toString()));
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function saveCache(
|
|
29
|
+
{ cache_file: cacheFilePath }: CacheConfiguration,
|
|
30
|
+
cache: Cache,
|
|
31
|
+
) {
|
|
32
|
+
const dir = cacheFilePath.split("/").slice(0, -1).join("/");
|
|
33
|
+
if (dir !== "") {
|
|
34
|
+
await fs.mkdir(cacheFilePath.split("/").slice(0, -1).join("/"), {
|
|
35
|
+
recursive: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
await fs.writeFile(cacheFilePath, JSON.stringify(cache));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toCacheEntry(result: ResultEntry, now: Date): [string, CacheEntry] {
|
|
42
|
+
return [result.source.url, { timestamp: now, data: result }];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toCache(results: ResultEntry[], now: Date): Cache {
|
|
46
|
+
return R.pipe(
|
|
47
|
+
results,
|
|
48
|
+
R.map((result) => toCacheEntry(result, now)),
|
|
49
|
+
R.fromEntries(),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function updateCache(
|
|
54
|
+
results: ResultEntry[],
|
|
55
|
+
config: CachedConfiguration,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const now = new Date();
|
|
58
|
+
const updatedCache = toCache(results, now);
|
|
59
|
+
return saveCache(config.cache, updatedCache);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function fetchAllCached(
|
|
63
|
+
config: CachedConfiguration,
|
|
64
|
+
): Promise<Result> {
|
|
65
|
+
const cache = await loadCache(config.cache);
|
|
66
|
+
|
|
67
|
+
const results = await asyncMapFilterUndefined(config.sources, (source) =>
|
|
68
|
+
fetchWithCache(source, cache, config),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await updateCache(results, config);
|
|
72
|
+
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetchWithCache(
|
|
77
|
+
source: Source,
|
|
78
|
+
cache: Cache,
|
|
79
|
+
config: CachedConfiguration,
|
|
80
|
+
): Promise<ResultEntry | undefined> {
|
|
81
|
+
const cacheEntry = cache[source.url];
|
|
82
|
+
if (cacheEntry) {
|
|
83
|
+
const now = new Date();
|
|
84
|
+
if (
|
|
85
|
+
now.getTime() - cacheEntry.timestamp.getTime() <
|
|
86
|
+
config.cache.cache_duration_minutes * 60 * 1000
|
|
87
|
+
) {
|
|
88
|
+
console.warn(`Cache entry found for ${source.url}.`);
|
|
89
|
+
return cacheEntry.data;
|
|
90
|
+
} else {
|
|
91
|
+
console.warn(`Cache entry for ${source.url} is too old.`);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
console.warn(`No cache entry for ${source.url}.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.warn(`Fetching ${source.url}`);
|
|
98
|
+
return fetch(source, config.truncate);
|
|
99
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Parser from "rss-parser";
|
|
2
|
+
import sanitizeHtml from "sanitize-html";
|
|
3
|
+
import * as truncateHtml from "truncate-html";
|
|
4
|
+
import { z } from "zod/v3";
|
|
5
|
+
import {
|
|
6
|
+
type Source,
|
|
7
|
+
type ResultEntry,
|
|
8
|
+
FeedEntrySchema,
|
|
9
|
+
type Configuration,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
import * as R from "remeda";
|
|
12
|
+
import { asyncMapFilterUndefined } from "./util.ts";
|
|
13
|
+
|
|
14
|
+
// Handle ESM/CommonJS interop - truncate-html exports differently in different environments
|
|
15
|
+
const TruncateFnSchema = z
|
|
16
|
+
.function()
|
|
17
|
+
.args(z.string(), z.number().optional())
|
|
18
|
+
.returns(z.string());
|
|
19
|
+
const TruncateModuleSchema = z.object({ default: TruncateFnSchema });
|
|
20
|
+
|
|
21
|
+
function truncate(html: string, length?: number): string {
|
|
22
|
+
const mod = truncateHtml as unknown;
|
|
23
|
+
const fnResult = TruncateFnSchema.safeParse(mod);
|
|
24
|
+
if (fnResult.success) {
|
|
25
|
+
return fnResult.data(html, length);
|
|
26
|
+
}
|
|
27
|
+
const modResult = TruncateModuleSchema.safeParse(mod);
|
|
28
|
+
if (modResult.success) {
|
|
29
|
+
return modResult.data.default(html, length);
|
|
30
|
+
}
|
|
31
|
+
throw new Error("truncate-html module could not be resolved");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchAll(config: Configuration) {
|
|
35
|
+
return await asyncMapFilterUndefined(config.sources, (source) =>
|
|
36
|
+
fetch(source, config.truncate),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function fetch(
|
|
41
|
+
source: Source,
|
|
42
|
+
length: number,
|
|
43
|
+
): Promise<ResultEntry | undefined> {
|
|
44
|
+
const parser = new Parser();
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const feed = await parser.parseURL(source.url);
|
|
48
|
+
|
|
49
|
+
const firstItem = R.pipe(
|
|
50
|
+
feed.items,
|
|
51
|
+
R.map((item) => FeedEntrySchema.parse(item)),
|
|
52
|
+
R.sortBy((item) => new Date(item.date).getTime()),
|
|
53
|
+
R.reverse(),
|
|
54
|
+
R.first(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!firstItem) {
|
|
58
|
+
throw new Error("no items found in feed");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const preview =
|
|
62
|
+
firstItem.contentSnippet ??
|
|
63
|
+
firstItem.content ??
|
|
64
|
+
firstItem.description ??
|
|
65
|
+
firstItem["content:encoded"];
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
title: firstItem.title,
|
|
69
|
+
url: firstItem.link,
|
|
70
|
+
date: new Date(firstItem.date),
|
|
71
|
+
source,
|
|
72
|
+
preview:
|
|
73
|
+
preview !== undefined && preview !== ""
|
|
74
|
+
? truncate(
|
|
75
|
+
sanitizeHtml(preview, { parseStyleAttributes: false }),
|
|
76
|
+
length,
|
|
77
|
+
)
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`Error fetching ${source.url}: ${String(error)}`);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { Configuration } from "./types.ts";
|
|
3
|
+
import { run } from "./index.ts";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { mkdtemp } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import express from "express";
|
|
8
|
+
|
|
9
|
+
const app = express();
|
|
10
|
+
app.use(express.static("src/testdata"));
|
|
11
|
+
|
|
12
|
+
const port = Math.floor(Math.random() * 10_000) + 3000;
|
|
13
|
+
app.listen(port, () => {
|
|
14
|
+
console.warn(`Test server listening at http://localhost:${port.toString()}`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
18
|
+
|
|
19
|
+
function createUrl(urlPath: string): string {
|
|
20
|
+
return `http://localhost:${port.toString()}/${urlPath}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createSources(count: number): Configuration["sources"] {
|
|
24
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
25
|
+
title: `rss ${i.toString()}`,
|
|
26
|
+
url: createUrl(`rss-${i.toString()}.xml`),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("it should fetch an RSS feed without caching", async () => {
|
|
31
|
+
const config: Configuration = {
|
|
32
|
+
sources: createSources(19),
|
|
33
|
+
number: 1,
|
|
34
|
+
truncate: 300,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const result = await run(config);
|
|
38
|
+
let string = JSON.stringify(result);
|
|
39
|
+
// replace the port number with a fixed value
|
|
40
|
+
string = string.replaceAll(port.toString(), "PORT");
|
|
41
|
+
expect(string).toMatchSnapshot();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("it should fetch several RSS feeds", async () => {
|
|
45
|
+
const config: Configuration = {
|
|
46
|
+
sources: createSources(19),
|
|
47
|
+
number: 3,
|
|
48
|
+
truncate: 300,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const result = await run(config);
|
|
52
|
+
let string = JSON.stringify(result);
|
|
53
|
+
// replace the port number with a fixed value
|
|
54
|
+
string = string.replaceAll(port.toString(), "PORT");
|
|
55
|
+
expect(string).toMatchSnapshot();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("it should fetch an RSS feed with caching", async () => {
|
|
59
|
+
const config: Configuration = {
|
|
60
|
+
sources: createSources(19),
|
|
61
|
+
number: 1,
|
|
62
|
+
truncate: 300,
|
|
63
|
+
cache: {
|
|
64
|
+
cache_file: `${await createTempDir()}/cache.json`,
|
|
65
|
+
cache_duration_minutes: 1,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = await run(config);
|
|
70
|
+
let string = JSON.stringify(result);
|
|
71
|
+
// replace the port number with a fixed value
|
|
72
|
+
string = string.replaceAll(port.toString(), "PORT");
|
|
73
|
+
expect(string).toMatchSnapshot();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// https://sdorra.dev/posts/2024-02-12-vitest-tmpdir
|
|
77
|
+
async function createTempDir() {
|
|
78
|
+
const ostmpdir = tmpdir();
|
|
79
|
+
const dir = path.join(ostmpdir, "unit-test-");
|
|
80
|
+
return await mkdtemp(dir);
|
|
81
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as R from "remeda";
|
|
2
|
+
import { fetchAllCached } from "./cache.ts";
|
|
3
|
+
import { fetchAll as fetchAllUncached } from "./fetch.ts";
|
|
4
|
+
import {
|
|
5
|
+
type Configuration,
|
|
6
|
+
type Result,
|
|
7
|
+
CachedConfigurationSchema,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export type { Configuration, Result, ResultEntry } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export async function run(config: Configuration): Promise<Result> {
|
|
13
|
+
const { success, data } = CachedConfigurationSchema.safeParse(config);
|
|
14
|
+
|
|
15
|
+
let fetched: Result;
|
|
16
|
+
if (success) {
|
|
17
|
+
console.warn(`Using cache at ${data.cache.cache_file}.`);
|
|
18
|
+
fetched = await fetchAllCached(data);
|
|
19
|
+
} else {
|
|
20
|
+
console.warn("Cache disabled.");
|
|
21
|
+
fetched = await fetchAllUncached(config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let results = R.pipe(
|
|
25
|
+
fetched,
|
|
26
|
+
R.sortBy((entry) => entry.date.getTime()),
|
|
27
|
+
R.reverse(),
|
|
28
|
+
R.filter((entry) => {
|
|
29
|
+
const filterFn = entry.source.filter;
|
|
30
|
+
if (
|
|
31
|
+
filterFn === undefined ||
|
|
32
|
+
entry.preview === undefined ||
|
|
33
|
+
entry.preview === ""
|
|
34
|
+
) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return filterFn(entry.preview);
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// shuffle if wanted
|
|
42
|
+
if (config.shuffle === true) {
|
|
43
|
+
results = R.shuffle(results);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// take n
|
|
47
|
+
results = R.take(results, config.number);
|
|
48
|
+
|
|
49
|
+
return results;
|
|
50
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod/v3";
|
|
2
|
+
|
|
3
|
+
export type Source = z.infer<typeof SourceSchema>;
|
|
4
|
+
/** An RSS source */
|
|
5
|
+
const SourceSchema = z.object({
|
|
6
|
+
/** The URL of an RSS feed */
|
|
7
|
+
url: z.string(),
|
|
8
|
+
/** A title to describe the feed */
|
|
9
|
+
title: z.string().describe("A title for the feed"),
|
|
10
|
+
/** Takes a entry preview and returns whether or not it should be displayed */
|
|
11
|
+
filter: z.function().args(z.string()).returns(z.boolean()).optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type CacheConfiguration = z.infer<typeof CacheConfigurationSchema>;
|
|
15
|
+
/** Configuration for the cache */
|
|
16
|
+
const CacheConfigurationSchema = z.object({
|
|
17
|
+
/** How long to cache a result for */
|
|
18
|
+
cache_duration_minutes: z.number().default(60),
|
|
19
|
+
/** The location of a file to use as a cache */
|
|
20
|
+
cache_file: z.string().default("cache.json"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type Configuration = z.infer<typeof ConfigurationSchema>;
|
|
24
|
+
/** A configuration object with caching possibly configured */
|
|
25
|
+
const ConfigurationSchema = z.object({
|
|
26
|
+
/** A list of sources to fetch */
|
|
27
|
+
sources: SourceSchema.array(),
|
|
28
|
+
/** Return the n latest updates from the source list. */
|
|
29
|
+
number: z.number().default(3),
|
|
30
|
+
/** How many words the preview field should be truncated to in characters after HTML has been sanitized and parsed. */
|
|
31
|
+
truncate: z.number().default(300),
|
|
32
|
+
/** Configuration for the cache */
|
|
33
|
+
cache: CacheConfigurationSchema.optional(),
|
|
34
|
+
/** Randomize the output order */
|
|
35
|
+
shuffle: z.boolean().default(false).optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type CachedConfiguration = z.infer<typeof CachedConfigurationSchema>;
|
|
39
|
+
/** A configuration object with caching definitely configured */
|
|
40
|
+
export const CachedConfigurationSchema = ConfigurationSchema.extend({
|
|
41
|
+
/** Configuration for the cache */
|
|
42
|
+
cache: CacheConfigurationSchema,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type ResultEntry = z.infer<typeof ResultEntrySchema>;
|
|
46
|
+
/** A single entry from an RSS feed */
|
|
47
|
+
const ResultEntrySchema = z.object({
|
|
48
|
+
/** The title of the entry */
|
|
49
|
+
title: z.string(),
|
|
50
|
+
/** A direct link to the entry */
|
|
51
|
+
url: z.string(),
|
|
52
|
+
/** The date of the entry */
|
|
53
|
+
date: z.coerce.date(),
|
|
54
|
+
/** The source the entry is from */
|
|
55
|
+
source: SourceSchema,
|
|
56
|
+
/** A preview of the entry. This may contain sanitized HTML. */
|
|
57
|
+
preview: z.string().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type Result = z.infer<typeof ResultSchema>;
|
|
61
|
+
/** A list of results */
|
|
62
|
+
export const ResultSchema = z.array(ResultEntrySchema);
|
|
63
|
+
|
|
64
|
+
export type CacheEntry = z.infer<typeof CacheEntrySchema>;
|
|
65
|
+
/** A single cache entry */
|
|
66
|
+
export const CacheEntrySchema = z.object({
|
|
67
|
+
/** The time a source was last checked */
|
|
68
|
+
timestamp: z.coerce.date(),
|
|
69
|
+
/** The data from the source */
|
|
70
|
+
data: ResultEntrySchema,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type Cache = z.infer<typeof CacheSchema>;
|
|
74
|
+
/** A mapping of source URLs to cache entries */
|
|
75
|
+
export const CacheSchema = z.record(CacheEntrySchema);
|
|
76
|
+
|
|
77
|
+
/** The expected format fetched RSS feed entries */
|
|
78
|
+
export const FeedEntrySchema = z
|
|
79
|
+
.object({
|
|
80
|
+
title: z.string(),
|
|
81
|
+
link: z.string(),
|
|
82
|
+
isoDate: z.coerce.date().optional(),
|
|
83
|
+
pubDate: z.coerce.date().optional(),
|
|
84
|
+
content: z.string().optional(),
|
|
85
|
+
contentSnippet: z.string().optional(),
|
|
86
|
+
"content:encoded": z.string().optional(),
|
|
87
|
+
description: z.string().optional(),
|
|
88
|
+
})
|
|
89
|
+
.transform((entry) => {
|
|
90
|
+
const date = entry.isoDate ?? entry.pubDate;
|
|
91
|
+
if (!date) {
|
|
92
|
+
throw new Error("no date found in feed entry");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
title: entry.title,
|
|
96
|
+
link: entry.link,
|
|
97
|
+
date,
|
|
98
|
+
content: entry.content,
|
|
99
|
+
contentSnippet: entry.contentSnippet,
|
|
100
|
+
description: entry.description,
|
|
101
|
+
"content:encoded": entry["content:encoded"],
|
|
102
|
+
};
|
|
103
|
+
});
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as R from "remeda";
|
|
2
|
+
|
|
3
|
+
// run an async map operation, filtering out undefined results
|
|
4
|
+
export async function asyncMapFilterUndefined<T, U>(
|
|
5
|
+
input: T[],
|
|
6
|
+
fn: (x: T) => Promise<U | undefined>,
|
|
7
|
+
): Promise<U[]> {
|
|
8
|
+
const results = await asyncMap(input, fn);
|
|
9
|
+
return filterUndefined(results);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function asyncMap<T, U>(
|
|
13
|
+
input: T[],
|
|
14
|
+
fn: (x: T) => Promise<U>,
|
|
15
|
+
): Promise<U[]> {
|
|
16
|
+
const promises = R.pipe(
|
|
17
|
+
input,
|
|
18
|
+
R.map((item) => fn(item)),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return Promise.all(promises);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function filterUndefined<T>(input: (T | undefined)[]): T[] {
|
|
25
|
+
const result: T[] = [];
|
|
26
|
+
for (const item of input) {
|
|
27
|
+
if (item !== undefined) {
|
|
28
|
+
result.push(item);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|