fp/services/rssapp/index.js
CJ_Clippy 0d53e49d89
Some checks are pending
ci / build (push) Waiting to run
ci / test (push) Waiting to run
add cors
2025-08-25 15:11:56 -08:00

143 lines
3.7 KiB
JavaScript

// 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}`]
});
// 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<br>THIS IS rssapp ${packageJson.version}<br>OK<br>try <a href="/projektmelody">/projektmelody</a>`);
});
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();