Add RSS and JSON feeds of incidents and an open dataset
All checks were successful
Build and Deploy to Production / test (push) Successful in 21s
Build and Deploy to Production / build (push) Successful in 49s
Build and Deploy to Production / deploy (push) Successful in 37s

Let readers and bots follow new incidents without polling the card:
/:locale/feed.xml (RSS 2.0) and /:locale/feed.json (JSON Feed 1.1),
the latest 50 incidents with stable IDs, advertised in the page head
and footer.

/data.json publishes every datacenter with its specs and sourced
incidents under CC BY 4.0, for journalists and OSINT reuse.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-12 05:31:26 +04:00
commit 03355983b4
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
8 changed files with 215 additions and 0 deletions

View file

@ -31,6 +31,15 @@ Content is edited in the asxp.io admin (`/admin/bingo`, repo `asxpio`:
(card; `?view=all`, `?p[]=provider`), `/:locale/dc/:slug`, `/sitemap.xml`,
`/robots.txt`, `/p/:file` (photo proxy, immutable), `/<indexnow-key>.txt`,
icons, `/healthz`.
- Feeds: `/:locale/feed.xml` (RSS 2.0) and `/:locale/feed.json` (JSON Feed
1.1), the latest 50 incidents, newest first. Item IDs are
`tag:<host>,2026:incident-<id>`, so they stay stable when an incident is
edited. Pages advertise both via `<link rel="alternate">` and in the
footer.
- `/data.json`: every datacenter with names in all locales, specs, pages,
photo credit and incidents with sources. Licensed CC BY 4.0, with the
attribution inside the file. Feeds and dataset send
`Access-Control-Allow-Origin: *`.
- `config.ru`: `www.` → `BINGO_HOST` 301.
- `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider
filter, dialogs), `dc.erb`, partials. `assets/`: icons.

View file

@ -13,4 +13,13 @@ of Claude (Anthropic). Part of the list came from the
[hackyourmom datacenter map](https://hackyourmom.com/en/karta-data-czentriv-na-rasii/)
(data by Cat Eyes OSINT), and each entry was re-verified.
## Data
- **Incident feeds:** `/en/feed.xml` (RSS) and `/en/feed.json` (JSON Feed).
Swap `en` for `ru` or `uk`.
- **Full dataset:** [`/data.json`](https://datacenterbingo.xyz/data.json),
licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
Credit "Datacenter Bingo (datacenterbingo.xyz)"; the full attribution
line is in the file.
Development: `nix develop`, then `bin/test`. See `CLAUDE.md`.

90
app.rb
View file

@ -26,6 +26,8 @@ end
# database and photo bucket.
class BingoWeb < Sinatra::Base
SOURCE_URL = 'https://github.com/asxpi/datacenterbingo'.freeze
LICENSE_URL = 'https://creativecommons.org/licenses/by/4.0/'.freeze
FEED_SIZE = 50
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
configure do
@ -157,6 +159,28 @@ class BingoWeb < Sinatra::Base
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
end
# Stable per incident across edits, so readers don't show duplicates.
def incident_id(i) = "tag:#{ENV.fetch('BINGO_HOST', request.host)},2026:incident-#{i.id}"
# Feed entry body: what happened, the datacenter facts, the source.
def incident_html(i)
h = Rack::Utils.method(:escape_html)
parts = ["<p>#{h.(i.l(:title, @locale))}</p>", "<p>#{h.(dc_facts(i.datacenter).join(' '))}</p>"]
parts << %(<p><a href="#{h.(i.url)}">#{h.(t(:source))}</a></p>) if i.url
parts.join
end
def incident_headline(i)
"#{kind_label(i.kind)}: #{i.datacenter.l(:name, @locale)} — #{i.l(:title, @locale)}"
end
def feed_incidents
Bingo::Incident.order(Sequel.desc(:occurred_on), Sequel.desc(:id)).limit(FEED_SIZE)
.eager(datacenter: :incidents).all
end
def feed_time(i) = Time.utc(i.occurred_on.year, i.occurred_on.month, i.occurred_on.day)
def preferred_locale
langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/)
langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en'
@ -232,6 +256,72 @@ class BingoWeb < Sinatra::Base
erb :dc
end
# Incident feeds per locale: RSS 2.0 and JSON Feed 1.1, newest first.
get %r{/(ru|en|uk)/feed\.xml} do |locale|
@locale = locale
@incidents = feed_incidents
content_type 'application/rss+xml', charset: 'utf-8'
cache_control :public, max_age: 300
erb :feed_rss, layout: false
end
get %r{/(ru|en|uk)/feed\.json} do |locale|
@locale = locale
content_type 'application/feed+json', charset: 'utf-8'
headers 'Access-Control-Allow-Origin' => '*'
cache_control :public, max_age: 300
JSON.pretty_generate(
version: 'https://jsonfeed.org/version/1.1',
title: format(t(:feed_title), site: t(:title)),
description: t(:tagline),
home_page_url: "#{base_url}/#{locale}",
feed_url: "#{base_url}/#{locale}/feed.json",
language: locale,
icon: "#{base_url}/apple-touch-icon.png",
favicon: "#{base_url}/favicon.ico",
authors: [{ name: 'asxpi', url: 'https://asxp.io' }],
items: feed_incidents.map do |i|
dc = i.datacenter
{ id: incident_id(i), url: "#{base_url}#{dc_path(dc)}", external_url: i.url,
title: incident_headline(i), content_html: incident_html(i),
image: (dc.photo_key && "#{base_url}#{photo_path(dc, 1200)}"),
date_published: feed_time(i).iso8601, date_modified: i.updated_at&.utc&.iso8601,
tags: [kind_label(i.kind), Bingo.provider_name(dc.provider, locale)].compact.uniq }.compact
end
)
end
# The whole dataset, every language, for reuse under CC BY 4.0.
get '/data.json' do
content_type 'application/json', charset: 'utf-8'
headers 'Access-Control-Allow-Origin' => '*'
cache_control :public, max_age: 300
loc = ->(row, field) { Bingo::LOCALES.to_h { |l| [l, row.l(field, l)] } }
dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
JSON.pretty_generate(
title: 'Datacenter Bingo dataset',
description: "Russia's largest datacenters with publicly sourced incidents (strikes, fires, outages).",
home_page_url: "#{base_url}/",
license: 'CC-BY-4.0',
license_url: LICENSE_URL,
attribution: "Datacenter Bingo (#{base_url}/), collected and verified with the help of Claude (Anthropic); " \
'part of the list from the hackyourmom datacenter map (data by Cat Eyes OSINT), re-verified.',
updated_at: Bingo::Board.load.updated_on&.utc&.iso8601,
datacenters: dcs.map do |dc|
{ slug: dc.slug, rank: dc.position, name: loc.(dc, :name), city: loc.(dc, :city),
operator: dc.operator, provider: dc.provider, status: dc.status,
mw: dc.mw, mw_value: Bingo.mw_value(dc.mw), mw_type: dc.mw_type, year: dc.year,
info_url: dc.info_url,
pages: Bingo::LOCALES.to_h { |l| [l, "#{base_url}#{dc_path(dc, l)}"] },
photo: dc.photo_key && { url: "#{base_url}#{photo_path(dc, 1200)}", credit: dc.photo_credit,
source_url: dc.photo_source_url },
incidents: dc.incidents.map do |i|
{ date: i.occurred_on.iso8601, kind: i.kind, title: loc.(i, :title), source_url: i.url }
end }
end
)
end
# IndexNow key file: proves to the engines that pings come from us.
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
halt 404 unless name == Bingo::IndexNow.key

View file

@ -29,6 +29,11 @@ module Bingo
def self.provider_name(slug, locale) = PROVIDERS.dig(slug, locale) || slug
# "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil
def self.mw_value(mw)
mw.to_s.tr(',', '.').scan(/\d+(?:\.\d+)?/).map(&:to_f).max
end
# Every row, column and both diagonals, as 0-based cell indices.
LINES = (
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
@ -54,6 +59,7 @@ module Bingo
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
source_code: 'Исходный код на GitHub',
feed_title: '%{site} — Журнал инцидентов', open_data: 'Открытые данные (CC BY 4.0)',
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О],
@ -85,6 +91,7 @@ module Bingo
credits_ai: 'Information collected and verified with the help of Claude (Anthropic).',
credits_hym: 'Thanks to %{link} (data by Cat Eyes OSINT) for part of the list; every entry was re-verified against operator sites and the press.',
source_code: 'Source code on GitHub',
feed_title: '%{site} — Incident log', open_data: 'Open data (CC BY 4.0)',
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
letters: %w[B I N G O],
@ -116,6 +123,7 @@ module Bingo
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
source_code: 'Вихідний код на GitHub',
feed_title: '%{site} — Журнал інцидентів', open_data: 'Відкриті дані (CC BY 4.0)',
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О],

View file

@ -342,6 +342,78 @@ class BingoTest < Minitest::Test
assert_includes last_response.body, 'github.com/asxpi/datacenterbingo'
end
def test_rss_feed
old = hit!(1, kind: 'fire')
old.update(occurred_on: Date.new(2026, 1, 2), url: 'https://example.com/fire?a=1&b=2')
hit!(3, kind: 'strike')
get '/en/feed.xml'
assert last_response.ok?
assert_equal 'application/rss+xml;charset=utf-8', last_response.content_type
require 'rexml/document'
doc = REXML::Document.new(last_response.body)
assert_equal 'Datacenter Bingo — Incident log', doc.elements['rss/channel/title'].text
items = doc.get_elements('rss/channel/item')
assert_equal 2, items.size
assert_equal 'Drone strike: ЦОД 4 — Авария 3', items[0].elements['title'].text # newest first, ru fallback
assert_equal 'https://example.org/en/dc/dc-2', items[1].elements['link'].text
assert_equal "tag:example.org,2026:incident-#{old.id}", items[1].elements['guid'].text
assert_equal 'Fri, 02 Jan 2026 00:00:00 -0000', items[1].elements['pubDate'].text
assert_includes items[1].elements['description'].text, '<a href="https://example.com/fire?a=1&amp;b=2">Source</a>'
get '/uk/feed.xml'
assert_includes last_response.body, '<title>Бінго ЦОДів — Журнал інцидентів</title>'
get '/xx/feed.xml'
assert_equal 404, last_response.status
end
def test_json_feed
hit!(0, kind: 'outage').update(url: 'https://example.com/n')
@dcs[0].update(provider: 'yandex', photo_key: 'bingo/dc-1-0123abcd.jpg', photo_variants: true)
get '/ru/feed.json'
assert last_response.ok?
assert_equal 'application/feed+json;charset=utf-8', last_response.content_type
assert_equal '*', last_response.headers['access-control-allow-origin']
feed = JSON.parse(last_response.body)
assert_equal 'https://jsonfeed.org/version/1.1', feed['version']
assert_equal 'https://example.org/ru/feed.json', feed['feed_url']
item = feed['items'].fetch(0)
assert_equal 'Отключение: ЦОД 1 — Авария 0', item['title']
assert_equal 'https://example.org/ru/dc/dc-1', item['url']
assert_equal 'https://example.com/n', item['external_url']
assert_equal 'https://example.org/p/dc-1-0123abcd-1200.webp', item['image']
assert_equal %w[Отключение Яндекс], item['tags']
assert_equal "#{Date.today.iso8601}T00:00:00Z", item['date_published']
end
def test_open_dataset
hit!(4, kind: 'fire').update(url: 'https://example.com/f', title_en: 'Fire')
@dcs[4].update(mw: '25–30', mw_type: 'it', provider: 'rtk')
get '/data.json'
assert last_response.ok?
assert_equal '*', last_response.headers['access-control-allow-origin']
data = JSON.parse(last_response.body)
assert_equal 'CC-BY-4.0', data['license']
assert_includes data['attribution'], 'Cat Eyes OSINT'
assert_equal 25, data['datacenters'].size
dc = data['datacenters'].find { |d| d['slug'] == 'dc-5' }
assert_equal 5, dc['rank']
assert_equal 30.0, dc['mw_value']
assert_equal({ 'ru' => 'ЦОД 5', 'en' => 'ЦОД 5', 'uk' => 'ЦОД 5' }, dc['name'])
assert_equal 'https://example.org/uk/dc/dc-5', dc['pages']['uk']
assert_equal [{ 'date' => Date.today.iso8601, 'kind' => 'fire',
'title' => { 'ru' => 'Авария 4', 'en' => 'Fire', 'uk' => 'Аварія 4' },
'source_url' => 'https://example.com/f' }], dc['incidents']
end
def test_pages_link_the_feeds
get '/en'
body = last_response.body
assert_includes body, '<link rel="alternate" type="application/rss+xml" title="Datacenter Bingo — Incident log" href="https://example.org/en/feed.xml">'
assert_includes body, 'type="application/feed+json"'
assert_includes body, '<a href="/en/feed.xml">RSS</a> · <a href="/en/feed.json">JSON Feed</a> · <a href="/data.json">Open data (CC BY 4.0)</a>'
get '/ru/dc/dc-1'
assert_includes last_response.body, 'href="https://example.org/ru/feed.xml"'
end
def test_static_files_not_served
get '/style.css'
assert_equal 404, last_response.status

View file

@ -2,5 +2,6 @@
<p><%= t(:footer) %></p>
<p class="credits"><%= t(:credits_ai) %>
<%== format(Rack::Utils.escape_html(t(:credits_hym)), link: '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>') %></p>
<p><a href="/<%= @locale %>/feed.xml">RSS</a> · <a href="/<%= @locale %>/feed.json">JSON Feed</a> · <a href="/data.json"><%= t(:open_data) %></a></p>
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a> · <a href="<%= BingoWeb::SOURCE_URL %>" rel="noopener"><%= t(:source_code) %></a></p>
</footer>

24
views/feed_rss.erb Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<% base = base_url %>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><%= format(t(:feed_title), site: t(:title)) %></title>
<link><%= base %>/<%= @locale %></link>
<description><%= t(:tagline) %></description>
<language><%= @locale %></language>
<atom:link href="<%= base %>/<%= @locale %>/feed.xml" rel="self" type="application/rss+xml"/>
<% if (top = @incidents.first) %>
<lastBuildDate><%= [*@incidents.map(&:updated_at).compact, feed_time(top)].max.utc.rfc2822 %></lastBuildDate>
<% end %>
<% @incidents.each do |i| %>
<item>
<title><%= incident_headline(i) %></title>
<link><%= base %><%= dc_path(i.datacenter) %></link>
<guid isPermaLink="false"><%= incident_id(i) %></guid>
<pubDate><%= feed_time(i).rfc2822 %></pubDate>
<category><%= kind_label(i.kind) %></category>
<description><%= incident_html(i) %></description>
</item>
<% end %>
</channel>
</rss>

View file

@ -17,6 +17,8 @@
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
<% end %>
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<link rel="alternate" type="application/rss+xml" title="<%= format(t(:feed_title), site: t(:title)) %>" href="<%= base %>/<%= @locale %>/feed.xml">
<link rel="alternate" type="application/feed+json" title="<%= format(t(:feed_title), site: t(:title)) %>" href="<%= base %>/<%= @locale %>/feed.json">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<%= t(:title) %>">
<meta property="og:title" content="<%= @title %>">