From 03355983b4afb93f9b8e3c632880df8330ca7705 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Mon, 12 Oct 2026 05:31:26 +0400 Subject: [PATCH] Add RSS and JSON feeds of incidents and an open dataset 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 --- CLAUDE.md | 9 +++++ README.md | 9 +++++ app.rb | 90 ++++++++++++++++++++++++++++++++++++++++++++++ lib/bingo.rb | 8 +++++ test/bingo_test.rb | 72 +++++++++++++++++++++++++++++++++++++ views/_footer.erb | 1 + views/feed_rss.erb | 24 +++++++++++++ views/layout.erb | 2 ++ 8 files changed, 215 insertions(+) create mode 100644 views/feed_rss.erb diff --git a/CLAUDE.md b/CLAUDE.md index 9c03692..d11e648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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), `/.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:,2026:incident-`, so they stay stable when an incident is + edited. Pages advertise both via `` 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. diff --git a/README.md b/README.md index 3ddc59e..53a732e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/app.rb b/app.rb index 8887339..5d4875c 100644 --- a/app.rb +++ b/app.rb @@ -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 = ["

#{h.(i.l(:title, @locale))}

", "

#{h.(dc_facts(i.datacenter).join(' '))}

"] + parts << %(

#{h.(t(:source))}

) 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 diff --git a/lib/bingo.rb b/lib/bingo.rb index a316852..514e192 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -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[Б І Н Г О], diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 595b22f..ad18d43 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -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, 'Source' + get '/uk/feed.xml' + assert_includes last_response.body, 'Бінго ЦОДів — Журнал інцидентів' + 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, '' + assert_includes body, 'type="application/feed+json"' + assert_includes body, 'RSS · JSON Feed · Open data (CC BY 4.0)' + 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 diff --git a/views/_footer.erb b/views/_footer.erb index 0e15a97..63c0f06 100644 --- a/views/_footer.erb +++ b/views/_footer.erb @@ -2,5 +2,6 @@

<%= t(:footer) %>

<%= t(:credits_ai) %> <%== format(Rack::Utils.escape_html(t(:credits_hym)), link: 'hackyourmom') %>

+

RSS · JSON Feed · <%= t(:open_data) %>

© <%= Date.today.year %> asxp.io · <%= t(:source_code) %>

diff --git a/views/feed_rss.erb b/views/feed_rss.erb new file mode 100644 index 0000000..437d3ce --- /dev/null +++ b/views/feed_rss.erb @@ -0,0 +1,24 @@ + +<% base = base_url %> + + + <%= format(t(:feed_title), site: t(:title)) %> + <%= base %>/<%= @locale %> + <%= t(:tagline) %> + <%= @locale %> + + <% if (top = @incidents.first) %> + <%= [*@incidents.map(&:updated_at).compact, feed_time(top)].max.utc.rfc2822 %> + <% end %> + <% @incidents.each do |i| %> + + <%= incident_headline(i) %> + <%= base %><%= dc_path(i.datacenter) %> + <%= incident_id(i) %> + <%= feed_time(i).rfc2822 %> + <%= kind_label(i.kind) %> + <%= incident_html(i) %> + + <% end %> + + diff --git a/views/layout.erb b/views/layout.erb index 29a7cca..e231d24 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -17,6 +17,8 @@ <% end %> + +