// index.js import Fastify from "fastify"; import RSS from "rss"; import TTLCache from '@isaacs/ttlcache'; import '@dotenvx/dotenvx/config.js'; import { ApifyClient } from 'apify-client'; import { env } from './env.js'; import packageJson from "./package.json" with { type: "json" }; import cors from '@fastify/cors' const fastify = Fastify({ logger: true }); fastify.register(cors); const client = new ApifyClient({ token: env.APIFY_TOKEN, }); const cache = new TTLCache({ max: 5000, ttl: 30 * 60 * 1000, // 30 minutes }); const inflight = new Map(); // Format date as YYYY-MM-DD_HH:mm:ss_UTC function formatTwitterDate(date) { const pad = (n) => String(n).padStart(2, "0"); const year = date.getUTCFullYear(); const month = pad(date.getUTCMonth() + 1); const day = pad(date.getUTCDate()); const hours = pad(date.getUTCHours()); const minutes = pad(date.getUTCMinutes()); const seconds = pad(date.getUTCSeconds()); return `${year}-${month}-${day}_${hours}:${minutes}:${seconds}_UTC`; } async function getJson(username) { // 15 days ago const sinceDate = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); const formatted = formatTwitterDate(sinceDate); const { defaultDatasetId } = await client.actor('kaitoeasyapi/twitter-x-data-tweet-scraper-pay-per-result-cheapest').call({ searchTerms: [`from:${username} since:${formatted}`], maxItems: 15, }); // Fetches results from the actor's dataset. const { items } = await client.dataset(defaultDatasetId).listItems(); return items; } // Helper to build RSS feed function buildFeed(username, tweets) { const feed = new RSS({ title: `${username} Tweets`, description: `Latest tweets from ${username}`, feed_url: `https://${env.ORIGIN}/${username}`, site_url: `https://x.com/${username}`, language: "en", }); tweets.forEach((tweet) => { if (tweet.id === -1) return; // skip mock API filler feed.item({ title: tweet.text.substring(0, 100), description: tweet.text, url: tweet.url, guid: tweet.id.toString(), date: new Date(tweet.createdAt), }); }); return feed.xml({ indent: true }); } fastify.get("/", (request, reply) => { return reply .code(200) .header("Content-Type", "text/html; charset=utf-8") .send(`HELLO
THIS IS rssapp ${packageJson.version}
OK
try /projektmelody`); }); fastify.get("/:username", async (request, reply) => { const { username } = request.params; const key = username.toLowerCase(); if (!key) { return reply.code(401).send({ error: "Username missing" }); } if (!env.WHITELIST.has(key)) { return reply.code(403).send({ error: "Forbidden username" }); } // Serve from cache if exists const cached = cache.get(key); if (cached) { fastify.log.info('sending cached data'); return reply.header("Content-Type", "application/rss+xml").send(cached.xml); } let dataPromise; if (inflight.has(key)) { // Wait for the in-progress fetch fastify.log.info('waiting for in-flight fetch'); dataPromise = inflight.get(key); } else { // Start a new fetch dataPromise = getJson(key); inflight.set(key, dataPromise); // Ensure we remove from inflight map when done dataPromise.finally(() => inflight.delete(key)); } const data = await dataPromise; const xml = buildFeed(key, data); // Cache result cache.set(key, { xml, timestamp: Date.now() }); return reply.header("Content-Type", "application/rss+xml").send(xml); }); // Start server const start = async () => { try { fastify.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();