bingo: ping IndexNow when the card changes

A newly recorded incident only showed up in search once a crawler
happened to come back. IndexNow lets Bing, Yandex and the other
participating engines know about changed URLs right away.

Bingo::IndexNow posts the card URLs plus the affected datacenter pages
to api.indexnow.org, in a background thread so admin saves don't wait.
Incidents ping their datacenter. Edits ping the datacenter, or every
page when positions move (create, delete, reorder, sort). A deleted
datacenter's pages are included so the engines see the 404. BingoWeb
serves the key at /<key>.txt.

The key comes from the INDEXNOW Forgejo secret, written to .env as
INDEXNOW_KEY. With no key set this does nothing.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 09:42:46 +04:00
commit 26e862ff04
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
6 changed files with 106 additions and 2 deletions

View file

@ -397,6 +397,45 @@ module Bingo
end
end
# Tells IndexNow engines (Bing, Yandex, Seznam, ...) which bingo URLs
# changed, so new incidents get recrawled within minutes. The key comes
# from INDEXNOW_KEY and BingoWeb serves it at /<key>.txt; without the key
# or BINGO_HOST (dev, tests) this does nothing.
module IndexNow
ENDPOINT = URI('https://api.indexnow.org/indexnow')
module_function
def key = ENV['INDEXNOW_KEY'].to_s[/\A[A-Za-z0-9-]{8,128}\z/]
def host = ENV['BINGO_HOST'].to_s[/\A\S+\z/]
# The card in every locale plus the pages of the given datacenters.
def urls(dcs = [])
paths = LOCALES.map { |l| "/#{l}" } + dcs.flat_map { |dc| LOCALES.map { |l| "/#{l}/dc/#{dc.slug}" } }
paths.map { |p| "https://#{host}#{p}" }
end
def payload(dcs = [])
{ host: host, key: key, keyLocation: "https://#{host}/#{key}.txt", urlList: urls(dcs) }
end
# Fire and forget: an admin save doesn't wait on a third party.
def ping(dcs = [])
return unless key && host
body = JSON.generate(payload(dcs))
Thread.new { deliver(body) }
end
def deliver(body)
res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
http.post(ENDPOINT.path, body, 'Content-Type' => 'application/json; charset=utf-8')
end
$logger.warn("bingo: IndexNow answered #{res.code}") unless res.is_a?(Net::HTTPSuccess)
rescue StandardError => e
$logger.warn("bingo: IndexNow #{e.class}: #{e.message}")
end
end
# Positions stay dense 1..N; the card shows the first SIZE*SIZE, the rest
# wait in reserve. Placing a datacenter inserts it at pos (default: last)
# and shifts everyone after it.

View file

@ -73,6 +73,7 @@ module BingoAdmin
@dc.save
Bingo.place!(@dc, position_param)
audit!('bingo.dc_create', @dc.slug, "##{@dc.refresh.position} #{@dc.name_ru}")
Bingo::IndexNow.ping(Bingo::Datacenter.all) # positions shifted
session[:bingo_flash] = "Added #{@dc.name_ru} at ##{@dc.refresh.position}."
redirect "/admin/bingo/dc/#{@dc.id}"
end
@ -90,8 +91,10 @@ module BingoAdmin
errs = assign_dc(dc)
if errs.empty?
dc.save_changes
Bingo.place!(dc, position_param) if position_param && position_param != dc.position
moved = position_param && position_param != dc.position
Bingo.place!(dc, position_param) if moved
audit!('bingo.dc_update', dc.slug, dc.name_ru)
Bingo::IndexNow.ping(moved ? Bingo::Datacenter.all : [dc])
session[:bingo_flash] = 'Saved.'
else
session[:bingo_flash] = errs.join('; ')
@ -109,6 +112,7 @@ module BingoAdmin
Bingo.renumber!
end
audit!('bingo.dc_delete', dc.slug, "#{dc.name_ru}, #{n} incident(s)")
Bingo::IndexNow.ping([dc, *Bingo::Datacenter.all]) # its pages now 404, the rest moved up
session[:bingo_flash] = "Deleted #{dc.name_ru}#{" and #{n} incident(s)" if n.positive?}."
redirect '/admin/bingo'
end
@ -116,6 +120,7 @@ module BingoAdmin
app.post '/admin/bingo/sort' do
Bingo.sort_by_mw!
audit!('bingo.sort_by_mw')
Bingo::IndexNow.ping(Bingo::Datacenter.all)
session[:bingo_flash] = 'Sorted by MW (unknown last).'
redirect '/admin/bingo'
end
@ -212,6 +217,7 @@ module BingoAdmin
title_uk: blank_nil(params[:title_uk]), url: blank_nil(url)
)
audit!('bingo.incident_create', dc.slug, "#{date} #{params[:kind]}: #{params[:title_ru].strip}")
Bingo::IndexNow.ping([dc])
session[:bingo_flash] = "Incident recorded for #{dc.name_ru}."
else
session[:bingo_flash] = errs.join('; ')
@ -223,6 +229,7 @@ module BingoAdmin
inc = Bingo::Incident[params[:id].to_i]
inc&.destroy # hook bumps the datacenter's updated_at
audit!('bingo.incident_delete', inc&.datacenter&.slug, inc && "#{inc.occurred_on} #{inc.title_ru}")
Bingo::IndexNow.ping([inc.datacenter]) if inc
session[:bingo_flash] = 'Incident deleted.'
redirect '/admin/bingo'
end