- // ==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();
- })();