From feac965d02890823faa362be1dde16a6d5fcd188 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 09:40:16 +0400 Subject: [PATCH] bingo: a page per datacenter Everything lived on one URL per language, with each datacenter's details and incidents inside a . That page can rank for the brand and a couple of head terms, but not for searches like "Yandex Sasovo drone strike". Add /:locale/dc/:slug for every datacenter, reserve included. Each page has its own title (naming the latest incident when there is one), a description built from the facts, canonical and hreflang links, the photo as og:image, and JSON-LD Place + BreadcrumbList. It shows the facts in a sentence or two, the incident list with sources, and prev/next links by position. The slug never changes, so the URLs are stable. The card cells are now links to these pages. A plain click still opens the dialog, while modified clicks open the page. The dialog and the incident log link to the page too, and the sitemap lists every page in all three languages. The layout takes title, description, alternates and JSON-LD from the route, and the dialog and the page share the meta and incident partials. Assisted-by: Claude:opus-5 --- bingo_web.rb | 70 +++++++++++++++++++++++++++++++++-- lib/bingo.rb | 30 +++++++++++++-- test/bingo_test.rb | 56 ++++++++++++++++++++++++++++ views/bingo/_dc_incidents.erb | 10 +++++ views/bingo/_dc_meta.erb | 9 +++++ views/bingo/dc.erb | 33 +++++++++++++++++ views/bingo/index.erb | 39 ++++++++----------- views/bingo/layout.erb | 47 ++++++++++++++--------- views/bingo/sitemap.erb | 12 ++++-- 9 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 views/bingo/_dc_incidents.erb create mode 100644 views/bingo/_dc_meta.erb create mode 100644 views/bingo/dc.erb diff --git a/bingo_web.rb b/bingo_web.rb index cf72a86..5d7c567 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -28,6 +28,8 @@ class BingoWeb < Sinatra::Base view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}" end + def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}" + def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}" # WebSite + ItemList of the card's datacenters, for rich results. @@ -54,7 +56,8 @@ class BingoWeb < Sinatra::Base # Incidents are Events at the place. def ld_place(dc) address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } - place = { '@type' => 'Place', 'name' => dc.l(:name, @locale), 'address' => address } + url = "#{base_url}#{dc_path(dc)}" + place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address } place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url events = dc.incidents.map do |i| @@ -66,6 +69,38 @@ class BingoWeb < Sinatra::Base place end + # Datacenter page: the Place plus a breadcrumb back to the card. + def dc_json_ld(dc) + JSON.generate( + '@context' => 'https://schema.org', + '@graph' => [ + ld_place(dc), + { '@type' => 'BreadcrumbList', 'itemListElement' => [ + { '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" }, + { '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" } + ] } + ] + ).gsub(' t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)), + format(t(:dc_rank), pos: dc.position)].compact + end + + def dc_latest(dc) + i = dc.incidents.first or return t(:no_incidents) + format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale)) + end + + # Cut at a word boundary to fit a search snippet. + def snippet(text, max = 160) + text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…" + end + # Preview image: the most recent hit with a photo, else the first photo. def og_dc(board) board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key) @@ -105,27 +140,54 @@ class BingoWeb < Sinatra::Base "User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n" end + # The card and every datacenter page (reserve included), each with its + # language alternates. get '/sitemap.xml' do content_type 'application/xml' cache_control :public, max_age: 3600 - @lastmod = Bingo::Board.load.updated_on + dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all + @entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] + + dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] } erb :sitemap, layout: false end # One URL per page: /en/ is a duplicate of /en. - get %r{/(ru|en|uk)/} do |locale| + get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path| qs = request.query_string - redirect "/#{locale}#{"?#{qs}" unless qs.empty?}", 301 + redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301 end get %r{/(ru|en|uk)} do |locale| @locale = locale @view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all' @board = Bingo::Board.load(@view) + @title = t(:seo_title) + @desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) + @noindex = @board.operational_only? + @path_for = ->(l) { "/#{l}" } + @x_default = '/' + @og = og_dc(@board) + @ld = json_ld(@board, @desc) cache_control :public, max_age: 60 erb :index end + get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug| + @locale = locale + @dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404 + @prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) } + name, city = @dc.l(:name, locale), @dc.l(:city, locale) + hit = @dc.incidents.first + @title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on)) + : format(t(:dc_title), name: name, city: city) + @desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' ')) + @path_for = ->(l) { dc_path(@dc, l) } + @og = @dc if @dc.photo_key + @ld = dc_json_ld(@dc) + cache_control :public, max_age: 60 + erb :dc + end + # Icons from assets/bingo/ (public/ is asxp.io's). Search engines want # a crawlable favicon URL; a data: URI doesn't count. ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon', diff --git a/lib/bingo.rb b/lib/bingo.rb index 707e96c..0b052b5 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -40,7 +40,15 @@ module Bingo footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' }, letters: %w[Б И Н Г О], - view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать' + view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать', + dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов', + dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов', + dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.', + dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.', + dc_since: 'Работает с %d года.', + dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.', + dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты', + back: '← К карточке', dc_page: 'Страница ЦОДа →' }, 'en' => { seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', @@ -59,7 +67,15 @@ module Bingo 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], - view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show' + view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show', + dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo', + dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter Bingo', + dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.', + dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.', + dc_since: 'In service since %d.', + dc_rank: "No. %{pos} by power among Russia's largest datacenters.", + dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents', + back: '← Back to the card', dc_page: 'Datacenter page →' }, 'uk' => { seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', @@ -78,7 +94,15 @@ module Bingo footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' }, letters: %w[Б І Н Г О], - view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати' + view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати', + dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів', + dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів', + dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.', + dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.', + dc_since: 'Працює з %d року.', + dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.', + dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти', + back: '← До картки', dc_page: 'Сторінка ЦОДу →' } }.freeze diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 808d8af..1bf0232 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -174,6 +174,9 @@ class BingoTest < Minitest::Test get '/sitemap.xml' assert last_response.ok? %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}" } + %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}/dc/dc-25" } + assert_includes last_response.body, '' + assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only assert_includes last_response.body, 'hreflang="x-default"' refute_includes last_response.body, 'changefreq' assert_match %r{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo" + assert_includes body, '' + assert_includes body, '' + refute_includes body, 'x-default' + assert_includes body, '

ЦОД 4

' + assert_includes body, 'ЦОД 4 is a datacenter in Moscow operated by Op. Capacity: 4 MW.' + assert_includes body, 'No. 4 by power' + assert_includes body, 'href="/en/dc/dc-3" rel="prev"' + assert_includes body, 'href="/en/dc/dc-5" rel="next"' + assert_match(/(.*?)}m, 1]) + place = ld['@graph'].find { |n| n['@type'] == 'Place' } + assert_equal 'https://example.org/en/dc/dc-4', place['url'] + assert_equal 'Outage: Авария 3', place['event'][0]['name'] + crumbs = ld['@graph'].find { |n| n['@type'] == 'BreadcrumbList' } + assert_equal 'https://example.org/en', crumbs['itemListElement'][0]['item'] + + get '/uk/dc/dc-1' + assert_includes last_response.body, 'ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів' + refute_includes last_response.body, 'rel="prev"' + end + + def test_reserve_dc_has_a_page + Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Тверь', operator: 'Op') + get '/ru/dc/dc-26' + assert last_response.ok? + assert_includes last_response.body, 'href="/ru/dc/dc-25" rel="prev"' + get '/sitemap.xml' + assert_includes last_response.body, 'https://example.org/ru/dc/dc-26' + end + + def test_dc_page_unknown_and_trailing_slash + get '/en/dc/nope' + assert_equal 404, last_response.status + get '/en/dc/dc-1/' + assert_equal 301, last_response.status + assert_equal 'http://example.org/en/dc/dc-1', last_response.location + end + + def test_card_links_to_dc_pages + hit!(0) + get '/en' + body = last_response.body + assert_includes body, 'Datacenter page →' + assert_includes body, '
DC 1' + end + def test_root_varies_on_language get '/' assert_equal 'Accept-Language', last_response.headers['Vary'] diff --git a/views/bingo/_dc_incidents.erb b/views/bingo/_dc_incidents.erb new file mode 100644 index 0000000..63f78f7 --- /dev/null +++ b/views/bingo/_dc_incidents.erb @@ -0,0 +1,10 @@ +<% if dc.incidents.empty? %> +

<%= t(:no_incidents) %>

+<% else %> +
    + <% dc.incidents.each do |i| %> +
  • <%= kind_icon(i.kind) %> — + <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %>
  • + <% end %> +
+<% end %> diff --git a/views/bingo/_dc_meta.erb b/views/bingo/_dc_meta.erb new file mode 100644 index 0000000..d71b859 --- /dev/null +++ b/views/bingo/_dc_meta.erb @@ -0,0 +1,9 @@ +
+ <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> +
+<% if dc.photo_credit || dc.info_url %> +
+ <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> + <% if dc.info_url %> · <%= t(:source) %><% end %> +
+<% end %> diff --git a/views/bingo/dc.erb b/views/bingo/dc.erb new file mode 100644 index 0000000..5beced5 --- /dev/null +++ b/views/bingo/dc.erb @@ -0,0 +1,33 @@ +<% dc = @dc %> +
+
+ + +
+ +
+ <% if (photo = photo_path(dc, 1200)) %><%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %><% end %> +

<%= dc.l(:name, @locale) %>

+ <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> +

<%= dc_facts(dc).join(' ') %>

+
+

<%= t(:incidents) %>

+ <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> +
+
+ + +

<%= t(:back) %>

+ +
+

<%= t(:footer) %>

+

© <%= Date.today.year %> asxp.io

+
+
diff --git a/views/bingo/index.erb b/views/bingo/index.erb index c9c85d0..2628dd3 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -33,7 +33,7 @@
<% b.cells.each_with_index do |dc, idx| %> <% photo = photo_path(dc, 480) %> -
<% if b.hit[idx] %>
<%= kind_icon(dc.incidents.first.kind) %>
<% end %> - + <% end %>
@@ -62,7 +62,7 @@ <%= kind_icon(i.kind) %>
<% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %> -
<%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %>
+
<%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %>
<% end %> @@ -80,31 +80,22 @@ <% if (photo = photo_path(dc, 1200)) %><%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %><% end %>

<%= dc.l(:name, @locale) %>

-
- <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> + <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> + <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> +
+ <%= t(:dc_page) %> +
- <% if dc.photo_credit || dc.info_url %> -
- <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> - <% if dc.info_url %> · <%= t(:source) %><% end %> -
- <% end %> - <% if dc.incidents.empty? %> -

<%= t(:no_incidents) %>

- <% else %> -
    - <% dc.incidents.each do |i| %> -
  • <%= kind_icon(i.kind) %> <%= fmt_date(i.occurred_on) %> — - <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %>
  • - <% end %> -
- <% end %> -
<% end %> diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index f559b77..7811422 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -1,45 +1,45 @@ <% base = base_url %> -<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %> -<% og = og_dc(@board) %> -<% og_img = og && "#{base}/p/#{og.og_file}" %> +<% og_img = @og && "#{base}/p/#{@og.og_file}" %> -<%= t(:seo_title) %> - - +<%= @title %> + + - + <% Bingo::LOCALES.each do |l| %> - + +<% end %> +<% if @x_default %> + <% end %> - - - - + + + <% (Bingo::LOCALES - [@locale]).each do |l| %> <% end %> <% if og_img %> -<% if og.photo_og %> +<% if @og.photo_og %> <% end %> - + <% end %> - - - + + + @@ -87,7 +87,7 @@ position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; justify-content: space-between; - text-align: left; color: inherit; font: inherit; cursor: pointer; + text-align: left; color: inherit; font: inherit; text-decoration: none; cursor: pointer; } .cell.photo { color: var(--on-photo); border-color: transparent; } .cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; } @@ -125,6 +125,17 @@ dialog h3 { margin: 0 0 4px; } dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; } dialog ul { padding-left: 18px; } + dialog .actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; } + dialog form { margin: 0; } + .crumbs a { font-weight: 700; text-decoration: none; } + .crumbs a:hover { text-decoration: underline; } + .dc-page img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; margin-bottom: 16px; } + .dc-page h1 { font-size: clamp(26px, 5vw, 38px); } + .dc-page.hit h1 { color: var(--hit); } + .dc-page .credit { font-size: 12px; color: var(--muted); margin-top: 4px; } + .dc-page .facts { max-width: 65ch; } + .dc-page ul { padding-left: 18px; } + .pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); } dialog button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; } footer { margin-top: 32px; color: var(--muted); font-size: 13px; } footer p { margin: 0 0 4px; } diff --git a/views/bingo/sitemap.erb b/views/bingo/sitemap.erb index fe49fef..0c3013b 100644 --- a/views/bingo/sitemap.erb +++ b/views/bingo/sitemap.erb @@ -1,12 +1,16 @@ +<% @entries.each do |path_for, lastmod, x_default| %> <% Bingo::LOCALES.each do |l| %> - <%= base_url %>/<%= l %> -<% if @lastmod %> <%= @lastmod.utc.iso8601 %> + <%= base_url %><%= path_for.(l) %> +<% if lastmod %> <%= lastmod.utc.iso8601 %> +<% end %> +<% Bingo::LOCALES.each do |alt| %> +<% end %> +<% if x_default %> <% end %> -<% Bingo::LOCALES.each do |alt| %> -<% end %> <% end %> +<% end %>