bingo: fix the JSON-LD place markup

Each Place carried its info_url as sameAs, but sameAs means "this is
the same entity", and the URL is an operator page or news article
about the datacenter. Use subjectOf with a CreativeWork instead.

Incidents were missing from the structured data entirely. Add them as
Events at the place (name, start date, source URL), and give the
ItemList a dateModified.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 09:29:35 +04:00
commit b100b6a8cd
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
2 changed files with 28 additions and 6 deletions

View file

@ -39,17 +39,31 @@ class BingoWeb < Sinatra::Base
'publisher' => { '@type' => 'Person', 'name' => 'asxpi', 'url' => 'https://asxp.io' } },
{ '@type' => 'ItemList', 'url' => url, 'name' => t(:seo_title), 'description' => desc,
'inLanguage' => @locale, 'numberOfItems' => board.cells.size,
'dateModified' => board.updated_on&.utc&.iso8601,
'itemListElement' => board.cells.each_with_index.map do |dc, i|
place = { '@type' => 'Place', 'name' => dc.l(:name, @locale),
'address' => { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } }
place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key
place['sameAs'] = dc.info_url if dc.info_url
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => place }
end }
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => ld_place(dc) }
end }.compact
]
).gsub('</', '<\/')
end
# The info_url describes the datacenter (operator page, news), it isn't
# the datacenter's own identity, so subjectOf rather than sameAs.
# 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 }
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|
{ '@type' => 'Event', 'name' => "#{kind_label(i.kind)}: #{i.l(:title, @locale)}",
'startDate' => i.occurred_on.iso8601, 'url' => i.url,
'location' => { '@type' => 'Place', 'name' => place['name'], 'address' => address } }.compact
end
place['event'] = events if events.any?
place
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)

View file

@ -209,6 +209,7 @@ class BingoTest < Minitest::Test
def test_seo_head
hit!(3)
@dcs[3].update(photo_key: 'bingo/dc-4-0123abcd.jpg', photo_variants: true)
@dcs[0].update(info_url: 'https://example.com/dc-1')
get '/en'
body = last_response.body
assert_includes body, '<title>Datacenter Bingo — strikes'
@ -228,6 +229,13 @@ class BingoTest < Minitest::Test
list = ld['@graph'].find { |n| n['@type'] == 'ItemList' }
assert_equal 25, list['itemListElement'].size
assert_equal 'DC 1', list['itemListElement'][0]['item']['name']
hit = list['itemListElement'][3]['item']
assert_equal Date.today.iso8601, hit['event'][0]['startDate']
assert_equal 'Outage: Авария 3', hit['event'][0]['name']
refute list['itemListElement'][0]['item'].key?('event')
assert_equal({ '@type' => 'CreativeWork', 'url' => 'https://example.com/dc-1' }, list['itemListElement'][0]['item']['subjectOf'])
refute_includes body, 'sameAs'
assert list['dateModified']
end
def test_photo_falls_back_to_original_without_variants