// ==UserScript==
// @name Google Street View Panorama Info
// @namespace https://gf.qytechs.cn/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
// ==/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();
})();