Skip to content

Instantly share code, notes, and snippets.

@fuddl
Last active January 7, 2026 09:06
Show Gist options
  • Select an option

  • Save fuddl/157fc76740bc970fbafcd1a44d083704 to your computer and use it in GitHub Desktop.

Select an option

Save fuddl/157fc76740bc970fbafcd1a44d083704 to your computer and use it in GitHub Desktop.
OSM Node - Inject JSON-LD Place
// ==UserScript==
// @name OSM - Inject JSON-LD Place + geo.position (node/way/relation, auto-refresh)
// @namespace https://example.com/
// @version 1.2.0
// @description Injects schema.org Place JSON-LD and geo.position meta into <head> on OpenStreetMap node/way/relation pages; re-runs when the document title changes (SPA-ish updates).
// @match https://www.openstreetmap.org/node/*
// @match https://www.openstreetmap.org/way/*
// @match https://www.openstreetmap.org/relation/*
// @match https://www.openstreetmap.org/*/node/*
// @match https://www.openstreetmap.org/*/way/*
// @match https://www.openstreetmap.org/*/relation/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
"use strict";
const JSONLD_ID = "osm-jsonld-place";
const GEO_META_ID = "osm-geo-position-meta";
// Debounce so multiple rapid title changes don't spam work
let debounceTimer = null;
function scheduleRefresh(reason) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => refresh(reason), 150);
}
function currentCanonicalUrl() {
// Prefer canonical if present
const canon = document.querySelector('link[rel="canonical"][href]');
if (canon?.href) return canon.href;
return window.location.origin + window.location.pathname;
}
function getNameFromTagsTable() {
// Find the "name" tag row in the tags table, then take the <td> text
const rows = Array.from(document.querySelectorAll("tr"));
for (const tr of rows) {
const th = tr.querySelector("th");
const td = tr.querySelector("td");
if (!th || !td) continue;
// Preferred: match the Key:name wiki link
const a = th.querySelector('a[href*="wiki.openstreetmap.org/wiki/Key:name"]');
if (a) {
const val = td.textContent.trim();
if (val) return val;
}
// Fallback: match by header text "name"
const headerText = th.textContent.trim().toLowerCase();
if (headerText === "name") {
const val = td.textContent.trim();
if (val) return val;
}
}
// Extra fallback: sometimes name can be shown as a headline/title-like element
// Only use if non-empty and not just "Way: ####" etc.
const h1 = document.querySelector("h1");
const h1Text = h1?.textContent?.trim();
if (h1Text && !/^(node|way|relation)\s*:\s*\d+/i.test(h1Text)) return h1Text;
return null;
}
function getLatLon() {
// On object pages, OSM commonly renders these spans near "Location:"
const latEl = document.querySelector("span.latitude");
const lonEl = document.querySelector("span.longitude");
if (!latEl || !lonEl) return null;
const lat = parseFloat(latEl.textContent.trim());
const lon = parseFloat(lonEl.textContent.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
}
function upsertGeoPositionMeta(lat, lon) {
// Common convention is "lat;lon"
const content = `${lat};${lon}`;
let meta =
document.getElementById(GEO_META_ID) ||
document.querySelector('meta[name="geo.position"]');
if (!meta) {
meta = document.createElement("meta");
document.head.appendChild(meta);
}
meta.id = GEO_META_ID;
meta.setAttribute("name", "geo.position");
meta.setAttribute("content", content);
}
function upsertJsonLd(name, lat, lon, sameAs) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Place",
name,
sameAs,
geo: {
"@type": "GeoCoordinates",
latitude: lat,
longitude: lon,
},
};
let script = document.getElementById(JSONLD_ID);
if (!script) {
script = document.createElement("script");
script.id = JSONLD_ID;
script.type = "application/ld+json";
document.head.appendChild(script);
}
script.textContent = JSON.stringify(jsonLd, null, 2);
}
function cleanupInjected() {
document.getElementById(JSONLD_ID)?.remove();
// Don't remove an existing site-provided geo.position meta if we didn't create it,
// but we *did* set an id on the element we manage.
document.getElementById(GEO_META_ID)?.remove();
}
function isOnObjectPage() {
// Accept language-prefixed routes too (handled by @match)
return /\/(node|way|relation)\/\d+/.test(window.location.pathname);
}
function refresh(reason) {
if (!isOnObjectPage()) {
cleanupInjected();
return;
}
const name = getNameFromTagsTable();
const coords = getLatLon();
// If page is mid-transition and data isn't there yet, retry soon
if (!name || !coords) {
cleanupInjected();
// quick retry in case DOM is still being filled by OSM JS
setTimeout(() => {
// Avoid infinite loops: only retry if still on an object page
if (isOnObjectPage()) scheduleRefresh("retry-missing-data");
}, 500);
return;
}
const sameAs = currentCanonicalUrl();
upsertJsonLd(name, coords.lat, coords.lon, sameAs);
upsertGeoPositionMeta(coords.lat, coords.lon);
}
// 1) Initial run
refresh("initial");
// 2) Watch <title> changes and refresh when it updates
// OSM updates title on in-page navigations / AJAX loads
const titleEl = document.querySelector("head > title");
if (titleEl) {
const titleObserver = new MutationObserver(() => scheduleRefresh("title-mutation"));
titleObserver.observe(titleEl, { childList: true, subtree: true, characterData: true });
} else {
// Fallback: observe head for a title element being added later
const headObserver = new MutationObserver(() => {
const t = document.querySelector("head > title");
if (t) {
scheduleRefresh("title-added");
}
});
headObserver.observe(document.head, { childList: true, subtree: true });
}
// 3) Also catch back/forward SPA-ish URL changes (some browsers update title late)
window.addEventListener("popstate", () => scheduleRefresh("popstate"));
// 4) Optional: if OSM uses pushState without popstate, hook history methods
// This is safe and only schedules refresh.
const _pushState = history.pushState;
history.pushState = function () {
const ret = _pushState.apply(this, arguments);
scheduleRefresh("pushState");
return ret;
};
const _replaceState = history.replaceState;
history.replaceState = function () {
const ret = _replaceState.apply(this, arguments);
scheduleRefresh("replaceState");
return ret;
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment