From 191898c2f4a2fc64d3f67bbc91fd731cc057219e Mon Sep 17 00:00:00 2001
From: devilreef <86633411+devilr33f@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:58:40 +0600
Subject: [PATCH] refactor: migrate hytale-blog from JSON API to RSS feed
---
src/modules/discord-webhooks/formatter.ts | 16 +---
src/modules/hytale-blog/formatter.ts | 8 +-
src/modules/hytale-blog/index.ts | 103 +++++-----------------
src/modules/hytale-blog/tracker.ts | 96 +++++++++++---------
4 files changed, 79 insertions(+), 144 deletions(-)
diff --git a/src/modules/discord-webhooks/formatter.ts b/src/modules/discord-webhooks/formatter.ts
index f1a1147..4b82d01 100644
--- a/src/modules/discord-webhooks/formatter.ts
+++ b/src/modules/discord-webhooks/formatter.ts
@@ -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
}
diff --git a/src/modules/hytale-blog/formatter.ts b/src/modules/hytale-blog/formatter.ts
index 76fe050..1511e8f 100644
--- a/src/modules/hytale-blog/formatter.ts
+++ b/src/modules/hytale-blog/formatter.ts
@@ -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 `📰 New Blog Post
${escapeHtml(post.title)}
-By ${escapeHtml(post.author)}
-Published: ${formatDate(post.publishedAt)}
-Created: ${formatDate(post.createdAt)}`
+Published: ${formatDate(post.pubDate)}`
}
diff --git a/src/modules/hytale-blog/index.ts b/src/modules/hytale-blog/index.ts
index 7a95858..375779c 100644
--- a/src/modules/hytale-blog/index.ts
+++ b/src/modules/hytale-blog/index.ts
@@ -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 {
- 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 {
- if (!this.running)
- return
-
- if (this.intervalId) {
- clearInterval(this.intervalId)
- this.intervalId = null
- }
- this.running = false
- }
-
- private async check(): Promise {
- 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')
+ }
+ })
}
diff --git a/src/modules/hytale-blog/tracker.ts b/src/modules/hytale-blog/tracker.ts
index 804a42b..299f5e9 100644
--- a/src/modules/hytale-blog/tracker.ts
+++ b/src/modules/hytale-blog/tracker.ts
@@ -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 {
- 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 = /- ([\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(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 {
+ 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(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}`
-}