A crisis feed. In three languages. In seconds.
Curated, AI-translated real-time news for the Iranian diaspora. RSS in, Telegram out, full RTL, under €50/month.
Why this exists.
For the Iranian diaspora — and people inside Iran itself — during crisis events there is no central, multilingual hub that bundles verified news in real time. Relevant reports are scattered across dozens of Persian Telegram channels, Twitter accounts, and Western outlets that lag behind.
Existing news portals are either Farsi-only (unreadable for the second diaspora generation), English/German-only (without Persian context), or pure aggregators without crisis classification and push. AzadiFeed curates RSS sources automatically, uses AI to translate into EN / DE / FA, and pushes breaking news within seconds to Telegram subscribers with city-level filtering.
The box it had to fit in.
How it runs.
Four deliberate choices.
Coolify + Docker instead of Vercel.
setInterval-scheduler in src/lib/scheduler.tsvercel.json cron jobsMongoDB as the queue, instead of Redis.
pending_feeds → published_feeds) with an aiProcessed: false flag as the work queue, distributed lock via a Mongo documentOpenAI multi-model routing at runtime.
src/lib/openai-config.tsSocket.IO instead of SSE or Pusher.
src/pages/api/socket.ts) on Next.js's built-in server — same host as the API, optional Redis adapter for horizontal scalingThings that were not obvious.
RTL with mixed LTR segments
#MahsaAmini), and numerals. Naive RTL setups break the layout because browsers apply the bidi algorithm inconsistently to mixed strings.Current state:
<html dir="rtl"> per locale, Vazirmatn as a variable font with a unicode-range fallback to Inter for Latin glyphs — the browser's bidi algorithm (UAX #9) handles the rest. Carries mainstream cases. On the roadmap: unicode-bidi: plaintext plus explicit Unicode wrapping \u202B…\u202C of the OpenAI outputs — bidi belongs in the content pipeline, not the stylesheet.Distributed lock via a MongoDB document
Solution in
src/lib/cron-lock.ts: an atomic findOneAndUpdate on a locks document plus an expireAfterSeconds: 0 TTL index on expiresAt; lock duration is per-job (300–900 s). The process that loses the lock returns early. The TTL also catches crashes — no stuck process after an OOM kill.Content enrichment fallback chain
Three-stage chain wired up in
src/app/api/cron/rss-sync/route.ts: (1) JSDOM-scrape the article URL via src/lib/content-fetcher.ts for og:image / twitter:image / link rel=image_src, (2) fall back to the RSS item's image fields in priority order: media:content (highest resolution) > <enclosure> > media:thumbnail > first <img> inside the content HTML, (3) if both stages turn up nothing, <NewsPlaceholder> renders a gradient card with a category- and crisis-type-aware Lucide icon — never a broken image. Each network fetch is wrapped in a 10–15 s AbortController timeout so a dead source doesn't block the pipeline.Crisis relevance as the auto-publish gate
crisisRelevanceScore: 0–100 (persisted as crisisRelevance). An admin-configurable threshold (minCrisisRelevanceThreshold) decides autoPost — anything below lands in the moderation queue.Solves two problems at once: (a) weather news doesn't get pushed as "breaking", (b) the admin can temporarily lower the threshold during acute situations to let more through.
What's running.
What I learned.
Budget as an architectural constraint.
The €50/month cap forced more good decisions than it prevented. MongoDB-as-queue, multi-model routing, a self-hosted socket server — all decisions I wouldn't have followed through on so rigorously without the cap. Constraints are features.
Bidi isn't a CSS property, it's a product problem.
RTL with interspersed LTR content needs a content-pipeline decision, not a stylesheet property. Push it onto the CSS layer and you'll fail. On the next multilingual project I'll plan bidi normalization as a translation-output format from day one.