mirror of
https://github.com/thelittlerocket/rss-to-email.git
synced 2025-09-09 06:04:50 +08:00
Better feed error handling (#38)
* Handle errors better and clear cache when updating feeds.ts
This commit is contained in:
parent
8b2f98a98f
commit
7a46bd34a0
13 changed files with 232 additions and 113 deletions
2
.prettierignore
Normal file
2
.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
cache.json
|
|
@ -36,8 +36,6 @@ async function createServer() {
|
|||
|
||||
const { html, feeds } = await renderEmail({
|
||||
pretty: true,
|
||||
limit: 3, // Limit to the last n posts of every feed in feeds.ts
|
||||
actionUrl: 'http://localhost:5173',
|
||||
cache: getCache(),
|
||||
})
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Img } from '@react-email/img'
|
|||
import { Link } from '@react-email/link'
|
||||
import { Text } from '@react-email/text'
|
||||
import { Output } from 'rss-parser'
|
||||
import { CustomItem, ItemLink } from '../renderEmail'
|
||||
import { CustomItem, ItemLink } from '../parseFeeds'
|
||||
|
||||
interface Props {
|
||||
feed: Output<CustomItem>
|
||||
|
|
|
@ -5,38 +5,62 @@ import { Link } from '@react-email/link'
|
|||
import { Preview } from '@react-email/preview'
|
||||
import { Section } from '@react-email/section'
|
||||
import { Text } from '@react-email/text'
|
||||
import { Output } from 'rss-parser'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import FeedSwitch from './FeedSwitch'
|
||||
import { formatDate } from '../utils/formatter'
|
||||
import { CustomItem } from '../renderEmail'
|
||||
import { Heading } from '@react-email/heading'
|
||||
import { Column } from '@react-email/column'
|
||||
import { SettledFeed } from '../parseFeeds'
|
||||
import Rejected from './Rejected'
|
||||
|
||||
interface Props {
|
||||
feeds: Output<CustomItem>[]
|
||||
feeds: SettledFeed[]
|
||||
itemCount: number
|
||||
actionUrl?: string
|
||||
actionUrl: string
|
||||
from: Dayjs
|
||||
initialRun: boolean
|
||||
}
|
||||
|
||||
export default function Email({ feeds, itemCount, actionUrl }: Props) {
|
||||
const parseIntro = (initialRun: boolean, itemCount: number, from: Dayjs) => {
|
||||
if (initialRun) {
|
||||
return `First edition with ${itemCount} updates`
|
||||
}
|
||||
|
||||
const hours = dayjs().diff(from, 'hours')
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days === 1) {
|
||||
return `${itemCount} updates since yesterday`
|
||||
} else if (days > 0) {
|
||||
return `${itemCount} updates in the last ${days} days`
|
||||
} else if (hours === 1) {
|
||||
return `${itemCount} updates in the last hour`
|
||||
}
|
||||
return `${itemCount} updates in the last ${hours} hours`
|
||||
}
|
||||
|
||||
export default function Email({ feeds, itemCount, actionUrl, from, initialRun }: Props) {
|
||||
const intro = parseIntro(initialRun, itemCount, from)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview children={`RSS to Email with ${itemCount} updates`} />
|
||||
<Preview children={intro} />
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={sectionText}>RSS to Email with {itemCount} updates</Text>
|
||||
<Text style={section}>{intro}</Text>
|
||||
|
||||
{feeds.map((feed, i) => (
|
||||
<FeedSwitch key={feed.link} feed={feed} hasBottomSeparator={i < feeds.length - 1} />
|
||||
))}
|
||||
{feeds.map((feed, i) => {
|
||||
switch (feed.status) {
|
||||
case 'fulfilled':
|
||||
return <FeedSwitch key={feed.value.link} feed={feed.value} hasBottomSeparator={i < feeds.length - 1} />
|
||||
case 'rejected':
|
||||
return <Rejected key={feed.feed} feed={feed.feed} reason={feed.reason} />
|
||||
}
|
||||
})}
|
||||
|
||||
<Text style={sectionText}>
|
||||
{actionUrl && (
|
||||
<Link style={link} href={actionUrl}>
|
||||
{formatDate(new Date().toISOString())}
|
||||
</Link>
|
||||
)}
|
||||
<Text style={section}>
|
||||
<Link style={link} href={actionUrl}>
|
||||
{formatDate(new Date().toISOString())}
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
|
@ -54,9 +78,7 @@ const container = {
|
|||
padding: '0',
|
||||
}
|
||||
|
||||
const section = {}
|
||||
|
||||
const sectionText = {
|
||||
const section = {
|
||||
color: '#495057',
|
||||
fontFamily: 'Inter, Avenir, Helvetica, Arial, sans-serif',
|
||||
fontSize: '12px',
|
||||
|
@ -68,5 +90,6 @@ const sectionText = {
|
|||
|
||||
const link = {
|
||||
color: '#495057',
|
||||
textDecoration: 'underline',
|
||||
textDecoration: 'none',
|
||||
marginLeft: '8px',
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Output } from 'rss-parser'
|
||||
import { CustomItem } from '../renderEmail'
|
||||
import { CustomItem } from '../parseFeeds'
|
||||
import DaringFireballFeed from './DaringFireballFeed'
|
||||
import GenericFeed from './GenericFeed'
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Link } from '@react-email/link'
|
|||
import { Section } from '@react-email/section'
|
||||
import { Text } from '@react-email/text'
|
||||
import { Output } from 'rss-parser'
|
||||
import { CustomItem } from '../renderEmail'
|
||||
import { CustomItem } from '../parseFeeds'
|
||||
import { formatDate } from '../utils/formatter'
|
||||
|
||||
interface Props {
|
||||
|
|
38
src/email/Rejected.tsx
Normal file
38
src/email/Rejected.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Container } from '@react-email/container'
|
||||
import { Link } from '@react-email/link'
|
||||
import { Text } from '@react-email/text'
|
||||
|
||||
interface Props {
|
||||
feed: string
|
||||
reason: any
|
||||
}
|
||||
|
||||
export default ({ feed, reason }: Props) => {
|
||||
return (
|
||||
<Container style={box}>
|
||||
<Text style={text}>
|
||||
<Link style={link} href={feed}>
|
||||
{feed}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={text}>Reason: {JSON.stringify(reason)}</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const box = {
|
||||
padding: '32px 48px',
|
||||
backgroundColor: '#c92a2a',
|
||||
}
|
||||
|
||||
const text = {
|
||||
color: '#fff',
|
||||
fontFamily: 'Inter, Avenir, Helvetica, Arial, sans-serif',
|
||||
fontSize: '16px',
|
||||
wordBreak: 'break-word' as const,
|
||||
}
|
||||
|
||||
const link = {
|
||||
color: '#fff',
|
||||
textDecoration: 'underline',
|
||||
}
|
34
src/filterItems.ts
Normal file
34
src/filterItems.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { Item } from 'rss-parser'
|
||||
import { CustomItem, SettledFeed } from './parseFeeds'
|
||||
|
||||
const filterItems = (items: (Item & CustomItem)[], from: Dayjs, limit?: number) =>
|
||||
items.filter(({ pubDate }) => pubDate && dayjs(pubDate).isAfter(from)).slice(0, limit)
|
||||
|
||||
export const filterItemsFromFeed = (feeds: SettledFeed[], from: Dayjs, limit?: number) => {
|
||||
const filteredFeeds = feeds
|
||||
.map((feed) => {
|
||||
switch (feed.status) {
|
||||
case 'fulfilled':
|
||||
return { ...feed, value: { ...feed.value, items: filterItems(feed.value.items, from, limit) } }
|
||||
case 'rejected':
|
||||
return feed
|
||||
}
|
||||
})
|
||||
.filter((feed) => (feed.status === 'fulfilled' ? feed.value.items.length > 0 : true))
|
||||
|
||||
if (filteredFeeds.length === 0 || filteredFeeds.some(({ status }) => status === 'fulfilled')) {
|
||||
return filteredFeeds
|
||||
}
|
||||
|
||||
// At this point we have no updated items and one or more failed feeds
|
||||
filteredFeeds.forEach((feed) => {
|
||||
if (feed.status === 'rejected') {
|
||||
console.error(`Feed ${feed.feed} failed, reason: ${feed.reason}`)
|
||||
}
|
||||
})
|
||||
|
||||
throw new Error('One or more feeds failed while no new items!')
|
||||
}
|
||||
|
||||
export const getItemCount = (feeds: SettledFeed[]) => feeds.reduce((acc, feed) => acc + (feed.status === 'fulfilled' ? feed.value.items.length : 0), 0)
|
58
src/parseFeeds.ts
Normal file
58
src/parseFeeds.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { Dayjs } from 'dayjs'
|
||||
import Parser, { Output } from 'rss-parser'
|
||||
import { feeds } from './feeds'
|
||||
|
||||
// rss-parser has this sorta funky parsing when `keepArray: true` is set
|
||||
export interface ItemLink {
|
||||
$: {
|
||||
rel: 'alternate' | 'shorturl' | 'related'
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomItem = {
|
||||
id: string // Daring Fireball uses id instead of guid, so had to append that as a custom type for parsing
|
||||
link: string
|
||||
links: ItemLink[] // Daring Fireball uses rel= in multiple links so have to do some specific parsing
|
||||
}
|
||||
|
||||
export type SettledFeed =
|
||||
| {
|
||||
status: 'fulfilled'
|
||||
value: Output<CustomItem>
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
feed: string
|
||||
reason: any
|
||||
}
|
||||
|
||||
const parser: Parser = new Parser<{}, CustomItem>({
|
||||
customFields: {
|
||||
item: [
|
||||
['id', 'id'],
|
||||
['link', 'links', { keepArray: true }],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
export const parseFeeds = async () => {
|
||||
const settledFeeds = await Promise.allSettled(feeds.map((feed) => parser.parseURL(feed)))
|
||||
|
||||
return settledFeeds.reduce((acc, current, i) => {
|
||||
switch (current.status) {
|
||||
case 'fulfilled':
|
||||
return [...acc, { ...current, value: current.value as Output<CustomItem> }]
|
||||
case 'rejected':
|
||||
console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`)
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
...current,
|
||||
feed: feeds[i],
|
||||
},
|
||||
]
|
||||
}
|
||||
}, [] as SettledFeed[])
|
||||
}
|
21
src/parseLastSuccess.ts
Normal file
21
src/parseLastSuccess.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import dayjs from 'dayjs'
|
||||
|
||||
export const parseLastSuccess = (lastSuccess: string | undefined) => {
|
||||
if (!lastSuccess || lastSuccess.trim() === '') {
|
||||
return {
|
||||
from: dayjs().subtract(7, 'days'),
|
||||
initialRun: true,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = dayjs(lastSuccess)
|
||||
|
||||
if (parsed.isValid()) {
|
||||
return {
|
||||
from: parsed,
|
||||
initialRun: false,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown lastSuccess value: ${lastSuccess}`)
|
||||
}
|
|
@ -1,88 +1,30 @@
|
|||
import { render } from '@react-email/render'
|
||||
import Parser, { Output } from 'rss-parser'
|
||||
import { feeds } from './feeds'
|
||||
import Email from './email/Email'
|
||||
import { filterItemsFromFeed, getItemCount } from './utils/filter'
|
||||
import dayjs from 'dayjs'
|
||||
import { filterItemsFromFeed, getItemCount } from './filterItems'
|
||||
import { parseLastSuccess } from './parseLastSuccess'
|
||||
import { parseFeeds, SettledFeed } from './parseFeeds'
|
||||
|
||||
// rss-parser has this sorta funky parsing when `keepArray: true` is set
|
||||
export interface ItemLink {
|
||||
$: {
|
||||
rel: 'alternate' | 'shorturl' | 'related'
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
// Daring Fireball uses id instead of guid, so had to append that as a custom type for parsing
|
||||
export type CustomItem = {
|
||||
id: string
|
||||
link: string
|
||||
links: ItemLink[]
|
||||
interface Props {
|
||||
actionUrl: string
|
||||
cache: SettledFeed[]
|
||||
lastSuccess: string
|
||||
limit: number
|
||||
pretty: boolean
|
||||
}
|
||||
|
||||
const ITEMS_ON_INITIAL_RUN = 3
|
||||
const FALLBACK_ACTION_URL = 'https://github.com/bdevos/rss-to-email'
|
||||
|
||||
const parser: Parser = new Parser<{}, CustomItem>({
|
||||
customFields: {
|
||||
item: [
|
||||
['id', 'id'],
|
||||
['link', 'links', { keepArray: true }],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
actionUrl?: string
|
||||
cache?: Output<CustomItem>[]
|
||||
lastSuccess?: string
|
||||
limit?: number
|
||||
pretty?: boolean
|
||||
}
|
||||
|
||||
const parseLastSuccess = (lastSuccess: string | undefined) => {
|
||||
if (!lastSuccess || lastSuccess.trim() === '') {
|
||||
return {
|
||||
from: dayjs().subtract(7, 'days'),
|
||||
initialRun: true,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = dayjs(lastSuccess)
|
||||
|
||||
if (parsed.isValid()) {
|
||||
return {
|
||||
from: parsed,
|
||||
initialRun: false,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown lastSuccess value: ${lastSuccess}`)
|
||||
}
|
||||
|
||||
const parseFeeds = async (from: dayjs.Dayjs, limit: number | undefined) => {
|
||||
const settledFeeds = await Promise.allSettled(feeds.map((feed) => parser.parseURL(feed)))
|
||||
|
||||
const fulfilledFeeds = settledFeeds.reduce((acc, current, i) => {
|
||||
switch (current.status) {
|
||||
case 'fulfilled':
|
||||
return [...acc, current.value as Output<CustomItem>]
|
||||
case 'rejected':
|
||||
console.error(`Could not settle feed ${feeds[i]}, reason: ${current.reason}`)
|
||||
return acc
|
||||
}
|
||||
}, [] as Output<CustomItem>[])
|
||||
|
||||
return filterItemsFromFeed(fulfilledFeeds, from, limit)
|
||||
}
|
||||
|
||||
export async function renderEmail({ actionUrl, cache, lastSuccess, limit, pretty = false }: Props) {
|
||||
export async function renderEmail({ actionUrl = FALLBACK_ACTION_URL, cache, lastSuccess, limit, pretty = false }: Partial<Props>) {
|
||||
const { from, initialRun } = parseLastSuccess(lastSuccess)
|
||||
|
||||
const parsedFeeds = cache ?? (await parseFeeds(from, limit ?? initialRun ? ITEMS_ON_INITIAL_RUN : undefined))
|
||||
const parsedFeeds = cache ?? (await parseFeeds())
|
||||
|
||||
const itemCount = getItemCount(parsedFeeds)
|
||||
const filteredFeeds = filterItemsFromFeed(parsedFeeds, from, limit ?? initialRun ? ITEMS_ON_INITIAL_RUN : undefined)
|
||||
|
||||
const html = render(<Email feeds={parsedFeeds} itemCount={itemCount} actionUrl={actionUrl} />, {
|
||||
const itemCount = getItemCount(filteredFeeds)
|
||||
|
||||
const html = render(<Email actionUrl={actionUrl} feeds={filteredFeeds} from={from} initialRun={initialRun} itemCount={itemCount} />, {
|
||||
pretty,
|
||||
})
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { Item, Output } from 'rss-parser'
|
||||
import { CustomItem } from '../renderEmail'
|
||||
|
||||
const filterItems = (items: (Item & CustomItem)[], from: dayjs.Dayjs, limit?: number) =>
|
||||
items.filter(({ pubDate }) => pubDate && dayjs(pubDate).isAfter(from)).slice(0, limit)
|
||||
|
||||
export const filterItemsFromFeed = (feeds: Output<CustomItem>[], from: dayjs.Dayjs, limit?: number) =>
|
||||
feeds.map((feed) => ({ ...feed, items: filterItems(feed.items, from, limit) })).filter(({ items }) => items.length > 0)
|
||||
|
||||
export const getItemCount = (feeds: Output<CustomItem>[]) => feeds.reduce((acc, current) => acc + current.items.length, 0)
|
|
@ -1,7 +1,21 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { existsSync, unlinkSync } from 'fs'
|
||||
|
||||
const cachePath = './cache.json'
|
||||
|
||||
const FeedCacheHmr = () => ({
|
||||
name: 'feed-cache-hmr',
|
||||
enforce: 'pre' as const,
|
||||
handleHotUpdate({ file }) {
|
||||
// If the feeds.ts file is updated, remove the cached version
|
||||
if (file.endsWith('src/feeds.ts') && existsSync(cachePath)) {
|
||||
unlinkSync(cachePath)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), FeedCacheHmr()],
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue