add rssapp

This commit is contained in:
CJ_Clippy 2025-08-25 10:13:59 -08:00
parent 640e9ee892
commit 8a7baefd76
8 changed files with 2009 additions and 0 deletions

View File

@ -0,0 +1,20 @@
# .dockerignore
# Generated by Data Craftsman Tools
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.node_repl_history
.nyc_output
coverage/
dist/
build/
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

144
services/rssapp/.gitignore vendored Normal file
View File

@ -0,0 +1,144 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

View File

@ -0,0 +1,23 @@
# --- Base image
FROM node:22-alpine AS base
WORKDIR /usr/src/app
# --- Install dependencies
FROM base AS deps
COPY package*.json ./
RUN npm ci --omit=dev
# --- Build / final image
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

148
services/rssapp/README.md Normal file
View File

@ -0,0 +1,148 @@
# rssapp
A HTTP service which dynamically generates RSS feeds for recent lewdtuber tweets (X Posts). Tweet data is cached for 30 mins.
## Example usage
GET `https://rssapp.example.com/projektmelody`
### Example response
```xml
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title><![CDATA[projektmelody Tweets]]></title>
<description><![CDATA[Latest tweets from projektmelody]]></description>
<link>https://x.com/projektmelody</link>
<generator>RSS for Node</generator>
<lastBuildDate>Mon, 25 Aug 2025 14:50:15 GMT</lastBuildDate>
<atom:link href="https://rssapp.sbtp.xyz/projektmelody.xml" rel="self" type="application/rss+xml"/>
<language><![CDATA[en]]></language>
<item>
<title><![CDATA[Did I mention this exists?? 🎶]]></title>
<description><![CDATA[Did I mention this exists?? 🎶]]></description>
<link>https://x.com/ProjektMelody/status/1959854383627489458</link>
<guid isPermaLink="false">1959854383627489458</guid>
<pubDate>Mon, 25 Aug 2025 05:44:32 GMT</pubDate>
</item>
<item>
<title><![CDATA[I'M LIVE!
FANSLY: https://t.co/mh1izesG2p
OF: https://t.co/PhUjaazNrn
CB: https://t.co/cxNMAItlt2]]></title>
<description><![CDATA[I'M LIVE!
FANSLY: https://t.co/mh1izesG2p
OF: https://t.co/PhUjaazNrn
CB: https://t.co/cxNMAItlt2]]></description>
<link>https://x.com/ProjektMelody/status/1959774984387416463</link>
<guid isPermaLink="false">1959774984387416463</guid>
<pubDate>Mon, 25 Aug 2025 00:29:01 GMT</pubDate>
</item>
<item>
<title><![CDATA[runnning 10 min late sry!!!!]]></title>
<description><![CDATA[runnning 10 min late sry!!!!]]></description>
<link>https://x.com/ProjektMelody/status/1959766931898249709</link>
<guid isPermaLink="false">1959766931898249709</guid>
<pubDate>Sun, 24 Aug 2025 23:57:01 GMT</pubDate>
</item>
<item>
<title><![CDATA[BRUH I HAVE A SCHECHULE!!!!!!!! https://t.co/jXwtWlozwJ]]></title>
<description><![CDATA[BRUH I HAVE A SCHECHULE!!!!!!!! https://t.co/jXwtWlozwJ]]></description>
<link>https://x.com/ProjektMelody/status/1959428737021407540</link>
<guid isPermaLink="false">1959428737021407540</guid>
<pubDate>Sun, 24 Aug 2025 01:33:09 GMT</pubDate>
</item>
<item>
<title><![CDATA[@RemixKun i h8 it here]]></title>
<description><![CDATA[@RemixKun i h8 it here]]></description>
<link>https://x.com/ProjektMelody/status/1959185583223550227</link>
<guid isPermaLink="false">1959185583223550227</guid>
<pubDate>Sat, 23 Aug 2025 09:26:57 GMT</pubDate>
</item>
<item>
<title><![CDATA[At least it isn't inflation https://t.co/jgCU1Ouy3e]]></title>
<description><![CDATA[At least it isn't inflation https://t.co/jgCU1Ouy3e]]></description>
<link>https://x.com/ProjektMelody/status/1959184510421004782</link>
<guid isPermaLink="false">1959184510421004782</guid>
<pubDate>Sat, 23 Aug 2025 09:22:41 GMT</pubDate>
</item>
<item>
<title><![CDATA[🦋⁺. ✦ NEW COLLAB SONG DROPPED!!! ✦ . ⁺ 🦋
"SNØWCRASH N' BURN" (track 2) is so pretty! Hard not to ]]></title>
<description><![CDATA[🦋⁺. ✦ NEW COLLAB SONG DROPPED!!! ✦ . ⁺ 🦋
"SNØWCRASH N' BURN" (track 2) is so pretty! Hard not to get lost in the drum beats &amp; synth waves. My chest is full of little virtual butterflies.
Cynthoni sure puts out bangers.
LISTEN HERE: https://t.co/VEoQgLc269 https://t.co/e9YU1vSTPi]]></description>
<link>https://x.com/ProjektMelody/status/1959100103350001770</link>
<guid isPermaLink="false">1959100103350001770</guid>
<pubDate>Sat, 23 Aug 2025 03:47:17 GMT</pubDate>
</item>
<item>
<title><![CDATA[@brittblusierra なんだ??]]></title>
<description><![CDATA[@brittblusierra なんだ??]]></description>
<link>https://x.com/ProjektMelody/status/1959069147528405036</link>
<guid isPermaLink="false">1959069147528405036</guid>
<pubDate>Sat, 23 Aug 2025 01:44:17 GMT</pubDate>
</item>
<item>
<title><![CDATA[@RemixKun yes, totally not wearing a push-up bra stuffed with cantalope]]></title>
<description><![CDATA[@RemixKun yes, totally not wearing a push-up bra stuffed with cantalope]]></description>
<link>https://x.com/ProjektMelody/status/1959066423764165007</link>
<guid isPermaLink="false">1959066423764165007</guid>
<pubDate>Sat, 23 Aug 2025 01:33:27 GMT</pubDate>
</item>
<item>
<title><![CDATA[@ArmadaJones ill make it fit &gt;.&gt;]]></title>
<description><![CDATA[@ArmadaJones ill make it fit &gt;.&gt;]]></description>
<link>https://x.com/ProjektMelody/status/1959066077096550732</link>
<guid isPermaLink="false">1959066077096550732</guid>
<pubDate>Sat, 23 Aug 2025 01:32:05 GMT</pubDate>
</item>
<item>
<title><![CDATA[STREAM TOMORROW EVENING!~
I'm gonna give so many hugs!
Wait... I'm in the computer. O.o
Ok, I'll h]]></title>
<description><![CDATA[STREAM TOMORROW EVENING!~
I'm gonna give so many hugs!
Wait... I'm in the computer. O.o
Ok, I'll have to use my mouth.
Mouth hugs for everyone! https://t.co/hidiMBkc2H]]></description>
<link>https://x.com/ProjektMelody/status/1959065532155797966</link>
<guid isPermaLink="false">1959065532155797966</guid>
<pubDate>Sat, 23 Aug 2025 01:29:55 GMT</pubDate>
</item>
<item>
<title><![CDATA[.✧˚RAWR! I'VE RETURNED!˚️✧.
My chakras are aligned. Goals are clear.
&amp; I'm ready for a new ch]]></title>
<description><![CDATA[.✧˚RAWR! I'VE RETURNED!˚️✧.
My chakras are aligned. Goals are clear.
&amp; I'm ready for a new chapter.
I think i just needed a break tbh... but im back. And missed the balls outta y'all! (TᴖT)
STREAM RESUMES➡Aug 23 @ 7 pm CST
(Fansly/CB/OF)
See yall soon, space cowboys!]]></description>
<link>https://x.com/ProjektMelody/status/1958897664072065467</link>
<guid isPermaLink="false">1958897664072065467</guid>
<pubDate>Fri, 22 Aug 2025 14:22:52 GMT</pubDate>
</item>
</channel>
</rss>
```

17
services/rssapp/env.js Normal file
View File

@ -0,0 +1,17 @@
import '@dotenvx/dotenvx/config';
import { z } from 'zod';
export const EnvSchema = z.object({
APIFY_TOKEN: z.string().min(1, "APIFY_TOKEN is required"),
ORIGIN: z.string().min(1, "ORIGIN is required"),
WHITELIST: z.string().min(1, "WHITELIST is required"), // raw comma-separated string
});
// Parse raw env first
const parsed = EnvSchema.parse(process.env);
// Return env object with WHITELIST converted to Set
export const env = {
...parsed,
WHITELIST: new Set(parsed.WHITELIST.split(',').map(u => u.trim().toLowerCase())),
};

116
services/rssapp/index.js Normal file
View File

@ -0,0 +1,116 @@
// index.js
import Fastify from "fastify";
import fs from "fs";
import path from "path";
import RSS from "rss";
import TTLCache from '@isaacs/ttlcache';
import '@dotenvx/dotenvx/config.js';
import { ApifyClient } from 'apify-client';
import { env } from './env.js';
const fastify = Fastify({ logger: true });
const client = new ApifyClient({
token: env.APIFY_TOKEN,
});
const cache = new TTLCache({
max: 5000,
ttl: 30 * 60 * 1000, // 30 minutes
});
// 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("/:username", async (request, reply) => {
const { username } = request.params;
if (!env.WHITELIST.has(username.toLowerCase())) {
return reply.code(403).send({ error: "Forbidden username" });
}
// Serve from cache if exists
const cached = cache.get(username);
if (cached) {
fastify.log.info('sending cached data');
fastify.log.info(cached);
return reply
.header("Content-Type", "application/rss+xml")
.send(cached.xml);
}
const data = await getJson(username)
const xml = buildFeed(username, data);
// Cache result
cache.set(username, { 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();

1519
services/rssapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "rssapp",
"version": "69.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js"
},
"author": "",
"license": "Unlicense",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@isaacs/ttlcache": "^1.4.1",
"apify-client": "^2.15.1",
"fastify": "^5.5.0",
"lru-cache": "^11.1.0",
"rss": "^1.2.2",
"zod": "^4.1.1"
}
}