Last active
January 7, 2026 09:06
-
-
Save fuddl/157fc76740bc970fbafcd1a44d083704 to your computer and use it in GitHub Desktop.
OSM Node - Inject JSON-LD Place
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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