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
This commit is contained in:
parent
9c8606fbac
commit
03355983b4
8 changed files with 215 additions and 0 deletions
|
|
@ -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`,
|
(card; `?view=all`, `?p[]=provider`), `/:locale/dc/:slug`, `/sitemap.xml`,
|
||||||
`/robots.txt`, `/p/:file` (photo proxy, immutable), `/<indexnow-key>.txt`,
|
`/robots.txt`, `/p/:file` (photo proxy, immutable), `/<indexnow-key>.txt`,
|
||||||
icons, `/healthz`.
|
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.
|
- `config.ru`: `www.` → `BINGO_HOST` 301.
|
||||||
- `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider
|
- `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider
|
||||||
filter, dialogs), `dc.erb`, partials. `assets/`: icons.
|
filter, dialogs), `dc.erb`, partials. `assets/`: icons.
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
[hackyourmom datacenter map](https://hackyourmom.com/en/karta-data-czentriv-na-rasii/)
|
||||||
(data by Cat Eyes OSINT), and each entry was re-verified.
|
(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`.
|
Development: `nix develop`, then `bin/test`. See `CLAUDE.md`.
|
||||||
|
|
|
||||||
90
app.rb
90
app.rb
|
|
@ -26,6 +26,8 @@ end
|
||||||
# database and photo bucket.
|
# database and photo bucket.
|
||||||
class BingoWeb < Sinatra::Base
|
class BingoWeb < Sinatra::Base
|
||||||
SOURCE_URL = 'https://github.com/asxpi/datacenterbingo'.freeze
|
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
|
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
|
||||||
|
|
||||||
configure do
|
configure do
|
||||||
|
|
@ -157,6 +159,28 @@ class BingoWeb < Sinatra::Base
|
||||||
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
|
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
|
||||||
end
|
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
|
def preferred_locale
|
||||||
langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/)
|
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'
|
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
|
erb :dc
|
||||||
end
|
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.
|
# IndexNow key file: proves to the engines that pings come from us.
|
||||||
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
|
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
|
||||||
halt 404 unless name == Bingo::IndexNow.key
|
halt 404 unless name == Bingo::IndexNow.key
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ module Bingo
|
||||||
|
|
||||||
def self.provider_name(slug, locale) = PROVIDERS.dig(slug, locale) || slug
|
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.
|
# Every row, column and both diagonals, as 0-based cell indices.
|
||||||
LINES = (
|
LINES = (
|
||||||
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
|
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
|
||||||
|
|
@ -54,6 +59,7 @@ module Bingo
|
||||||
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
|
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
|
||||||
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
|
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
|
||||||
source_code: 'Исходный код на GitHub',
|
source_code: 'Исходный код на GitHub',
|
||||||
|
feed_title: '%{site} — Журнал инцидентов', open_data: 'Открытые данные (CC BY 4.0)',
|
||||||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
||||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
||||||
letters: %w[Б И Н Г О],
|
letters: %w[Б И Н Г О],
|
||||||
|
|
@ -85,6 +91,7 @@ module Bingo
|
||||||
credits_ai: 'Information collected and verified with the help of Claude (Anthropic).',
|
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.',
|
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',
|
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.',
|
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' },
|
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
|
||||||
letters: %w[B I N G O],
|
letters: %w[B I N G O],
|
||||||
|
|
@ -116,6 +123,7 @@ module Bingo
|
||||||
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
|
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
|
||||||
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
|
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
|
||||||
source_code: 'Вихідний код на GitHub',
|
source_code: 'Вихідний код на GitHub',
|
||||||
|
feed_title: '%{site} — Журнал інцидентів', open_data: 'Відкриті дані (CC BY 4.0)',
|
||||||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
||||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
||||||
letters: %w[Б І Н Г О],
|
letters: %w[Б І Н Г О],
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,78 @@ class BingoTest < Minitest::Test
|
||||||
assert_includes last_response.body, 'github.com/asxpi/datacenterbingo'
|
assert_includes last_response.body, 'github.com/asxpi/datacenterbingo'
|
||||||
end
|
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&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
|
def test_static_files_not_served
|
||||||
get '/style.css'
|
get '/style.css'
|
||||||
assert_equal 404, last_response.status
|
assert_equal 404, last_response.status
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
<p><%= t(:footer) %></p>
|
<p><%= t(:footer) %></p>
|
||||||
<p class="credits"><%= t(:credits_ai) %>
|
<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>
|
<%== 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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
24
views/feed_rss.erb
Normal file
24
views/feed_rss.erb
Normal 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>
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
|
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
|
||||||
<% end %>
|
<% end %>
|
||||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
|
<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:type" content="website">
|
||||||
<meta property="og:site_name" content="<%= t(:title) %>">
|
<meta property="og:site_name" content="<%= t(:title) %>">
|
||||||
<meta property="og:title" content="<%= @title %>">
|
<meta property="og:title" content="<%= @title %>">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue