From 71d07659e32b929b8b4daa61550fb7f52fd92685 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 12 Jun 2026 08:05:06 +0000
Subject: [PATCH] feat: Truth-Social-RSS-Parser + Coin-Keyword-Matching
---
src/server/signals/truth.test.ts | 32 +++++++++++++++++++++++++
src/server/signals/truth.ts | 41 ++++++++++++++++++++++++++++++++
2 files changed, 73 insertions(+)
create mode 100644 src/server/signals/truth.test.ts
create mode 100644 src/server/signals/truth.ts
diff --git a/src/server/signals/truth.test.ts b/src/server/signals/truth.test.ts
new file mode 100644
index 0000000..8087b7d
--- /dev/null
+++ b/src/server/signals/truth.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from 'bun:test';
+import { matchCoins, parseTruthFeed } from './truth';
+
+const XML = `
+- https://trumpstruth.org/statuses/1Fri, 12 Jun 2026 01:49:56 +0000Bitcoin is going to the MOON. Buy BTC!
]]>
+- https://trumpstruth.org/statuses/2Thu, 11 Jun 2026 09:00:00 +0000
+`;
+
+describe('parseTruthFeed', () => {
+ test('extrahiert URL, Timestamp, Klartext (Tags entfernt)', () => {
+ const posts = parseTruthFeed(XML);
+ expect(posts).toHaveLength(2);
+ expect(posts[0].url).toBe('https://trumpstruth.org/statuses/1');
+ expect(posts[0].ts).toBe(Date.parse('Fri, 12 Jun 2026 01:49:56 +0000'));
+ expect(posts[0].text).toContain('Bitcoin is going to the MOON');
+ expect(posts[0].text).not.toContain('');
+ });
+});
+
+describe('matchCoins', () => {
+ test('Name case-insensitive, Ticker nur exakt groß', () => {
+ expect(matchCoins('I love BITCOIN and solana')).toEqual(['BTC', 'SOL']);
+ expect(matchCoins('Buy ETH now')).toEqual(['ETH']);
+ expect(matchCoins('the ethics committee')).toEqual([]); // 'eth' klein/Teilwort matcht nicht
+ expect(matchCoins('Das sei seitwärts')).toEqual([]); // 'SEI' nur in Großschreibung
+ expect(matchCoins('THE ARENA IS PACKED')).toEqual([]); // 'ENA' nur mit Wortgrenze
+ expect(matchCoins('Tron will be huge')).toEqual(['TRX']); // nicht handelbar, aber Event
+ });
+ test('dedupliziert Mehrfach-Erwähnungen im selben Text', () => {
+ expect(matchCoins('BTC BTC Bitcoin')).toEqual(['BTC']);
+ });
+});
diff --git a/src/server/signals/truth.ts b/src/server/signals/truth.ts
new file mode 100644
index 0000000..a4f6bf8
--- /dev/null
+++ b/src/server/signals/truth.ts
@@ -0,0 +1,41 @@
+import { COIN_KEYWORDS } from './watchlist';
+
+export interface TruthPost {
+ url: string;
+ ts: number; // Unix ms
+ text: string;
+}
+
+const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** RSS-Items per Regex (kein XML-Parser nötig — Feed ist flach und stabil). */
+export function parseTruthFeed(xml: string): TruthPost[] {
+ const posts: TruthPost[] = [];
+ for (const item of xml.match(/- [\s\S]*?<\/item>/g) ?? []) {
+ const url = item.match(/([^<]+)<\/link>/)?.[1]?.trim();
+ const pubDate = item.match(/([^<]+)<\/pubDate>/)?.[1];
+ const descRaw = item.match(/([\s\S]*?)<\/description>/)?.[1] ?? '';
+ if (!url || !pubDate) continue;
+ const ts = Date.parse(pubDate);
+ if (Number.isNaN(ts)) continue;
+ const text = descRaw
+ .replace(/^$/g, '')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+ posts.push({ url, ts, text });
+ }
+ return posts;
+}
+
+/** Erwähnte Coins (Symbole, dedupliziert, in COIN_KEYWORDS-Reihenfolge). */
+export function matchCoins(text: string): string[] {
+ const hits: string[] = [];
+ for (const c of COIN_KEYWORDS) {
+ const nameHit = c.names.some((n) => new RegExp(`\\b${esc(n)}\\b`, 'i').test(text));
+ const tickerHit = c.tickers.some((t) => new RegExp(`\\b${esc(t)}\\b`).test(text)); // case-sensitive
+ if (nameHit || tickerHit) hits.push(c.symbol);
+ }
+ return hits;
+}