DEV Community

K. Bear
K. Bear

Posted on

I built a global conflict monitor in a single HTML file — here's how the serverless architecture works

When I started building CrisisPulse, I had one constraint: it had to work without a backend database, a framework, or a build pipeline. The result is crisispulse.org — a live global conflict monitor + emergency supply calculator, shipped as a single HTML file.

Here's how the architecture works.

The Stack

Frontend: Pure HTML/CSS/JS with D3.js for the world map. No React, no build step. The entire app ships as one file (~99KB). Zero dependencies to install, zero build times.

Daily news updates: A Netlify Scheduled Function runs @daily, fetching Bing RSS feeds for 25+ conflict zones. It parses article counts to calculate intensity scores and deltas, translates headlines to Chinese via the Google Translate gtx endpoint, and stores everything to Netlify Blobs.

Persistence without a database: Netlify Blobs is a built-in KV store included with Netlify. Visitor counts by country, conflict data, subscriber emails — all stored there. No Postgres, no Redis, no external API keys for storage.

The Tricky Part — Bing RSS URL Decoding

Bing's RSS feeds double-encode their redirect URLs. The <link> tags look like this:

https://www.bing.com/news/apiclick.aspx?url=https%3A%2F%2F...&amp;ref=...
Enter fullscreen mode Exit fullscreen mode

The &amp; breaks new URL() parsing. The fix:

function extractRealUrl(bingUrl) {
  let clean = bingUrl.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  try {
    if (clean.includes('apiclick.aspx') || clean.includes('bing.com/news')) {
      const u = new URL(clean);
      const real = u.searchParams.get('url');
      if (real) return decodeURIComponent(real);
    }
    if (clean.startsWith('http')) return clean;
  } catch (_) {}
  return clean;
}
Enter fullscreen mode Exit fullscreen mode

Decode HTML entities before parsing the URL params. Simple fix, non-obvious bug.

Bilingual Support Without a Translation API Key

A static CONFLICT_ZH map covers all 25 conflict names, types, and descriptions. Dynamic news descriptions get batch-translated via the free Google Translate gtx endpoint (no API key required). Language switching re-runs the risk calculation rather than serving stale cached strings — a subtle bug I hit early on where the cache stored already-translated strings.

Visitor Tracking by Country

Each page load hits a Netlify Function that:

  1. Reads the visitor's country from context.geo (Netlify's built-in geolocation)
  2. Falls back to GPS if available
  3. Increments a per-country counter in Netlify Blobs
  4. Returns the updated counts for the left-side visitor panel

No external analytics, no cookies, no tracking scripts.

What I'd Do Differently

The single-file constraint was a feature, not a limitation. It forced every line to justify its existence. But I'd reconsider using the free Google Translate gtx endpoint in production — it's undocumented and could break without notice.

Live site: crisispulse.org
Free, no signup, supports English and Chinese.

Top comments (0)