refactor: migrate hytale-blog from JSON API to RSS feed

This commit is contained in:
devilreef 2026-03-08 17:58:40 +06:00
parent ca7de2c7ab
commit 191898c2f4
4 changed files with 79 additions and 144 deletions

View file

@ -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
}

View file

@ -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)}`
}

View file

@ -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')
}
})
}

View file

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&apos;/g, '\'')
.replace(/&quot;/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}`
}