// ==UserScript== // @name Google Street View Panorama Info // @namespace https://greasyfork.org/users/1340965-zecageo // @version 1.15 // @description Displays the country name, coordinates, and panoId for a given Google Street View panorama // @author ZecaGeo // @run-at document-end // @match https://www.google.com/maps/* // @match https://www.google.at/maps/* // @match https://www.google.ca/maps/* // @match https://www.google.de/maps/* // @match https://www.google.fr/maps/* // @match https://www.google.it/maps/* // @match https://www.google.ru/maps/* // @match https://www.google.co.uk/maps/* // @match https://www.google.co.jp/maps/* // @match https://www.google.com.br/maps/* // @exclude https://ogs.google.com/ // @exclude https://account.google.com/ // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @icon https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com // @connect nominatim.openstreetmap.org // @license MIT // @copyright 2025, zecageo // @downloadURL none // ==/UserScript== /* jshint esversion: 11 */ (function () { 'use strict'; const DEBUG_MODE = true; function init() { console.info( `>>> Userscript '${GM_info.script.name}' v${GM_info.script.version} by ${GM_info.script.author} <<<` ); waitForElement('.pB8Nmf', updateTitleCard, '#titlecard'); } async function updateTitleCard(referenceElement) { const lastChild = referenceElement.lastElementChild; const panorama = new StreetViewPanorama(window.location.href); referenceElement.insertBefore( cloneNode(await getCountry(panorama.latitude, panorama.longitude)), lastChild ); referenceElement.insertBefore( cloneNode(panorama.latitude, true), lastChild ); referenceElement.insertBefore( cloneNode(panorama.longitude, true), lastChild ); referenceElement.insertBefore(cloneNode(panorama.panoId, true), lastChild); panorama.shareLink = await getShareLink( panorama.createShareLinkRequestUrl() ); log(`Share link: ${panorama.shareLink}`); if (!referenceElement) { error('Reference element not found.'); return; } referenceElement.insertBefore( cloneNode(panorama.shareLink, true), lastChild ); } /* * StreetViewPanorama class * This class is used to parse and store information about a Google Street View panorama. * @param {string} url - The URL to parse. * @returns {Array} - An array containing the latitude, longitude, and panoId. */ class StreetViewPanorama { url; latitude; longitude; panoId; thumb; fov; heading; pitch; shareLink; constructor(url) { this.url = url; this.initPanoramaData(); } initPanoramaData() { const shortPanoramaRegex = new RegExp( /@(-?\d+\.\d+),(-?\d+\.\d+),.*?\/data=!3m\d+!1e\d+!3m\d+!1s([^!]+)!2e/ ); const matches = shortPanoramaRegex.exec(this.url); if (matches && matches.length === 4) { this.latitude = parseFloat(matches[1]); this.longitude = parseFloat(matches[2]); this.panoId = matches[3]; log('Panorama Data:'); log(this); } else { error('Invalid Google Street View URL format.', matches); } } createShareLinkRequestUrl() { const shareLinkRequestUrl = new URL( 'https://www.google.com/maps/rpc/shorturl' ); const tempUrl = new URL(this.url); const pathname = tempUrl.pathname; const pathnameRegex = new RegExp( /@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^h]+)h,([^t]+)t/ ); const matches = pathnameRegex.exec(pathname); if (matches && matches.length === 7) { this.latitude = parseFloat(matches[1]); this.longitude = parseFloat(matches[2]); this.thumb = parseInt(matches[3]); this.fov = parseFloat(matches[4]); this.heading = parseFloat(matches[5]); this.pitch = parseFloat(matches[6]); } else { error('Invalid Google Street View URL format.', matches); } // const pb = `!1shttps://www.google.com/maps/@${this.latitude},${ // this.longitude // },${this.thumb}a,${this.fov}y,${this.heading}h,${ // this.pitch // }t/data=*213m7*211e1*213m5*211s${ // this.panoId // }*212e0*216shttps%3A%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D${ // this.pitch - 90 // }%26panoid%3D${this.panoId}%26yaw%3D${ // this.heading // }*217i16384*218i8192?entry=tts!2m1!7e81!6b1`; const pb = `!1shttps://www.google.com/maps/@?api=1&map_action=pano&pano=${this.panoId}&heading=${this.heading}&pitch=${this.pitch}&fov=${this.fov}?entry=tts!2m1!7e81!6b1`; log(`pb parameter: ${pb}`); const shareLinkSearchParams = new URLSearchParams({ pb: pb, }).toString(); shareLinkRequestUrl.search = shareLinkSearchParams; log(`Share link request url: ${shareLinkRequestUrl.href}`); return shareLinkRequestUrl.href; } } StreetViewPanorama.prototype.toString = function () { return `Panorama: { panoId: ${this.panoId}, latitude: ${this.latitude}, longitude: ${this.longitude} }`; }; // Network functions async function getCountry(latitude, longitude) { const url = `https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&accept-language=en-US`; const response = await promiseRequest('GET', url); const data = JSON.parse(response.responseText); return data?.address?.country ?? 'Country not found'; } async function getShareLink(shareLinkRequestUrl) { const response = await promiseRequest('GET', shareLinkRequestUrl); const rawText = response.responseText; return rawText.substring(7, rawText.length - 2); } function promiseRequest(method, url) { log( [ '---PROMISEREQUEST---', '\tmethod: ' + method, '\turl: ' + url, '---PROMISEREQUEST---', ].join('\n') ); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: method, url: url, onload: (result) => { responseInfo(result); if (result.status >= 200 && result.status < 300) { resolve(result); } else { reject(result.responseText); } }, ontimeout: () => { let l = new URL(url); reject( ' timeout detected: "no answer from ' + l.host + ' for ' + l.timeout / 1000 + 's"' ); }, onerror: (result) => { // network error responseInfo(result); reject( ' error: ' + result.status + ', message: ' + result.statusText ); }, }); }); } function responseInfo(r) { log( [ '', 'finalUrl: \t\t' + (r.finalUrl || '-'), 'status: \t\t' + (r.status || '-'), 'statusText: \t' + (r.statusText || '-'), 'readyState: \t' + (r.readyState || '-'), 'responseHeaders: ' + (r.responseHeaders.replaceAll('\r\n', ';') || '-'), 'responseText: \t' + (r.responseText || '-'), ].join('\n') ); } // DOM manipulation functions function cloneNode(value, isClickable = false) { let h2Element = document.createElement('h2'); h2Element.setAttribute('class', 'lsdM5 fontBodySmall'); let divElement = document.createElement('div'); divElement.appendChild(h2Element); let node = divElement.cloneNode(true); node.querySelector('h2').innerText = value; if (isClickable) { node.style.cursor = 'pointer'; node.onclick = () => GM_setClipboard(value); } return node; } const waitForElement = (selector, callback, targetNode) => { new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const element = document.querySelector(selector); if (element) { observer.disconnect(); callback(element); return; } } } }).observe( targetNode ? document.querySelector(targetNode) : document.body, { childList: true, subtree: true, } ); }; // debug output functions const toLog = (level, msg) => { if (DEBUG_MODE) { console[level](msg); } }; const log = (msg) => { toLog('log', msg); }; const error = (msg) => { toLog('error', msg); }; init(); })();