refactor: migrate hytale-blog from JSON API to RSS feed
This commit is contained in:
parent
ca7de2c7ab
commit
191898c2f4
4 changed files with 79 additions and 144 deletions
|
|
@ -5,7 +5,6 @@ import type { PatchesUpdate } from '../hytale-patches/tracker.js'
|
|||
import type { PresskitUpdate } from '../hytale-presskit/tracker.js'
|
||||
import type { ServerUpdate } from '../hytale-server/tracker.js'
|
||||
import type { DiscordEmbed } from './types.js'
|
||||
import { getBlogUrl, getThumbnailUrl } from '../hytale-blog/tracker.js'
|
||||
|
||||
const EMBED_COLOR = 0x64A7D1
|
||||
const HYTALE_LOGO = 'https://files.femboy.page/hytale-logo.png'
|
||||
|
|
@ -109,20 +108,11 @@ function formatBytes(bytes: number): string {
|
|||
}
|
||||
|
||||
export function formatBlogEmbed(post: BlogPost): DiscordEmbed {
|
||||
const url = getBlogUrl(post)
|
||||
const imageUrl = getThumbnailUrl(post)
|
||||
|
||||
const embed: DiscordEmbed = {
|
||||
return {
|
||||
title: post.title,
|
||||
description: `*by **${post.author}***\n\n[Read the full post](${url})`,
|
||||
description: `[Read the full post](${post.link})`,
|
||||
color: EMBED_COLOR,
|
||||
footer: { text: 'Published' },
|
||||
timestamp: post.publishedAt,
|
||||
timestamp: post.pubDate,
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
embed.image = { url: imageUrl }
|
||||
}
|
||||
|
||||
return embed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { BlogPost } from './tracker.js'
|
||||
|
||||
function formatDate(isoDate: string): string {
|
||||
const date = new Date(isoDate)
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
const month = months[date.getUTCMonth()]
|
||||
const day = date.getUTCDate()
|
||||
|
|
@ -22,8 +22,6 @@ export function formatBlogUpdate(post: BlogPost): string {
|
|||
return `<b>📰 New Blog Post</b>
|
||||
|
||||
<b>${escapeHtml(post.title)}</b>
|
||||
By ${escapeHtml(post.author)}
|
||||
|
||||
<b>Published:</b> ${formatDate(post.publishedAt)}
|
||||
<b>Created:</b> ${formatDate(post.createdAt)}`
|
||||
<b>Published:</b> ${formatDate(post.pubDate)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,38 @@
|
|||
import type { Module, ModuleDependencies } from '../../core/module.js'
|
||||
import type { ModuleDependencies } from '../../core/module.js'
|
||||
import type { HytaleBlogConfig } from '../../types.js'
|
||||
import { createPollingModule } from '../../core/create-polling-module.js'
|
||||
import { formatBlogEmbed } from '../discord-webhooks/formatter.js'
|
||||
import { formatBlogUpdate } from './formatter.js'
|
||||
import { checkBlogUpdates, getBlogUrl, getThumbnailUrl } from './tracker.js'
|
||||
import { checkBlogUpdates } from './tracker.js'
|
||||
|
||||
export function hytaleBlogFactory(
|
||||
moduleConfig: unknown,
|
||||
deps: ModuleDependencies,
|
||||
): Module {
|
||||
) {
|
||||
const config = moduleConfig as HytaleBlogConfig
|
||||
|
||||
class HytaleBlogModule implements Module {
|
||||
readonly name = 'hytale-blog'
|
||||
private dependencies: ModuleDependencies
|
||||
private config: HytaleBlogConfig
|
||||
private running = false
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
return createPollingModule('hytale-blog', config, deps, async () => {
|
||||
const newPosts = await checkBlogUpdates(deps.stateStore)
|
||||
|
||||
constructor(cfg: HytaleBlogConfig, deps: ModuleDependencies) {
|
||||
this.config = cfg
|
||||
this.dependencies = deps
|
||||
}
|
||||
for (const post of newPosts) {
|
||||
const buttons = [[{ text: 'Read the full post', url: post.link }]]
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
await deps.telegram.sendMessage({
|
||||
text: formatBlogUpdate(post),
|
||||
chatIds: config.chatIds,
|
||||
disableLinkPreview: false,
|
||||
buttons,
|
||||
})
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running)
|
||||
return
|
||||
|
||||
this.dependencies.logger(this.name, `Starting (poll interval: ${this.config.pollIntervalMinutes}min)`)
|
||||
|
||||
// Initial check
|
||||
await this.check()
|
||||
|
||||
// Start polling
|
||||
this.intervalId = setInterval(() => {
|
||||
this.check().catch((err) => {
|
||||
this.dependencies.logger(this.name, `Check failed: ${err}`)
|
||||
})
|
||||
}, this.config.pollIntervalMinutes * 60 * 1000)
|
||||
|
||||
this.running = true
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running)
|
||||
return
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
this.running = false
|
||||
}
|
||||
|
||||
private async check(): Promise<void> {
|
||||
const newPosts = await checkBlogUpdates(this.dependencies.stateStore)
|
||||
|
||||
for (const post of newPosts) {
|
||||
const message = formatBlogUpdate(post)
|
||||
const thumbnail = getThumbnailUrl(post)
|
||||
const blogUrl = getBlogUrl(post)
|
||||
const buttons = [[{ text: 'Read the full post', url: blogUrl }]]
|
||||
|
||||
if (thumbnail) {
|
||||
await this.dependencies.telegram.sendPhoto({
|
||||
photoUrl: thumbnail,
|
||||
caption: message,
|
||||
chatIds: this.config.chatIds,
|
||||
buttons,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await this.dependencies.telegram.sendMessage({
|
||||
text: message,
|
||||
chatIds: this.config.chatIds,
|
||||
disableLinkPreview: false,
|
||||
buttons,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.dependencies.webhooks) {
|
||||
await this.dependencies.webhooks.send('blog', formatBlogEmbed(post))
|
||||
}
|
||||
|
||||
this.dependencies.logger(this.name, `New post: ${post.title}`)
|
||||
if (deps.webhooks) {
|
||||
await deps.webhooks.send('blog', formatBlogEmbed(post))
|
||||
}
|
||||
|
||||
if (newPosts.length === 0) {
|
||||
this.dependencies.logger(this.name, 'No new posts')
|
||||
}
|
||||
deps.logger('hytale-blog', `new post: ${post.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
return new HytaleBlogModule(config, deps)
|
||||
if (newPosts.length === 0) {
|
||||
deps.logger('hytale-blog', 'no new posts')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,80 @@
|
|||
import type { StateStore } from '../../core/state-store.js'
|
||||
|
||||
export interface BlogPost {
|
||||
_id: string
|
||||
title: string
|
||||
author: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
coverImage?: {
|
||||
variants: string[]
|
||||
s3Key: string
|
||||
}
|
||||
bodyExcerpt: string
|
||||
link: string
|
||||
guid: string
|
||||
description: string
|
||||
pubDate: string
|
||||
}
|
||||
|
||||
interface BlogState {
|
||||
seenIds: string[]
|
||||
seenGuids: string[]
|
||||
lastCheck: string
|
||||
}
|
||||
|
||||
const ENDPOINT = 'https://hytale.com/api/blog/post/published'
|
||||
const RSS_URL = 'https://hytale.com/rss.xml'
|
||||
const STATE_KEY = 'hytale-blog'
|
||||
const MAX_SEEN_IDS = 100
|
||||
const MAX_SEEN = 100
|
||||
|
||||
export async function checkBlogUpdates(stateStore: StateStore): Promise<BlogPost[]> {
|
||||
const response = await fetch(`${ENDPOINT}?_=${Date.now()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Blog endpoint returned ${response.status}`)
|
||||
function parseItems(xml: string): BlogPost[] {
|
||||
const items: BlogPost[] = []
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||||
|
||||
for (const match of xml.matchAll(itemRegex)) {
|
||||
const block = match[1]
|
||||
const tag = (name: string) =>
|
||||
block.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`))?.[1]?.trim() ?? ''
|
||||
|
||||
items.push({
|
||||
title: unescape(tag('title')),
|
||||
link: tag('link'),
|
||||
guid: tag('guid'),
|
||||
description: unescape(tag('description')),
|
||||
pubDate: tag('pubDate'),
|
||||
})
|
||||
}
|
||||
|
||||
const posts = await response.json() as BlogPost[]
|
||||
const state = stateStore.get<BlogState>(STATE_KEY)
|
||||
const seenIds = new Set(state?.seenIds ?? [])
|
||||
return items
|
||||
}
|
||||
|
||||
function unescape(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, '\'')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function checkBlogUpdates(stateStore: StateStore): Promise<BlogPost[]> {
|
||||
const response = await fetch(RSS_URL, {
|
||||
headers: { 'user-agent': 'HytaleSays/1.0' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`rss feed returned ${response.status}`)
|
||||
}
|
||||
|
||||
const xml = await response.text()
|
||||
const posts = parseItems(xml)
|
||||
const state = stateStore.get<BlogState>(STATE_KEY)
|
||||
const seenGuids = new Set(state?.seenGuids ?? [])
|
||||
|
||||
// First run: mark all current posts as seen, don't notify
|
||||
if (!state) {
|
||||
const allIds = posts.map(p => p._id).slice(0, MAX_SEEN_IDS)
|
||||
stateStore.set(STATE_KEY, { seenIds: allIds, lastCheck: new Date().toISOString() })
|
||||
const allGuids = posts.map(p => p.guid).slice(0, MAX_SEEN)
|
||||
stateStore.set(STATE_KEY, { seenGuids: allGuids, lastCheck: new Date().toISOString() })
|
||||
return []
|
||||
}
|
||||
|
||||
// Find new posts
|
||||
const newPosts = posts.filter(p => !seenIds.has(p._id))
|
||||
const newPosts = posts.filter(p => !seenGuids.has(p.guid))
|
||||
|
||||
if (newPosts.length > 0) {
|
||||
// Add new IDs to seen list, keep bounded
|
||||
const updatedIds = [...newPosts.map(p => p._id), ...state.seenIds].slice(0, MAX_SEEN_IDS)
|
||||
stateStore.set(STATE_KEY, { seenIds: updatedIds, lastCheck: new Date().toISOString() })
|
||||
const updatedGuids = [...newPosts.map(p => p.guid), ...state.seenGuids].slice(0, MAX_SEEN)
|
||||
stateStore.set(STATE_KEY, { seenGuids: updatedGuids, lastCheck: new Date().toISOString() })
|
||||
}
|
||||
else {
|
||||
// Just update check time
|
||||
stateStore.set(STATE_KEY, { ...state, lastCheck: new Date().toISOString() })
|
||||
}
|
||||
|
||||
return newPosts
|
||||
}
|
||||
|
||||
export function getBlogUrl(post: BlogPost): string {
|
||||
const date = new Date(post.publishedAt)
|
||||
const year = date.getUTCFullYear()
|
||||
const month = date.getUTCMonth() + 1
|
||||
return `https://hytale.com/news/${year}/${month}/${post.slug}`
|
||||
}
|
||||
|
||||
export function getThumbnailUrl(post: BlogPost): string | undefined {
|
||||
if (!post.coverImage?.variants?.length || !post.coverImage.s3Key) {
|
||||
return undefined
|
||||
}
|
||||
const variant = post.coverImage.variants.at(-1)
|
||||
return `https://cdn.hytale.com/variants/${variant}_${post.coverImage.s3Key}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue