Google Street View Panorama Info

Displays the country name, coordinates, panoId and share link for a given Google Street View panorama

// ==UserScript==
// @name         Google Street View Panorama Info
// @namespace    https://gf.qytechs.cn/users/1340965-zecageo
// @version      1.17
// @description  Displays the country name, coordinates, panoId and share link for a given Google Street View panorama
// @author       ZecaGeo <[email protected]>
// @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://accounts.google.com/
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com
// @connect      nominatim.openstreetmap.org
// @license      MIT
// @copyright    2025, zecageo
// ==/UserScript==


(function () {
  'use strict';

  let zgDebugMode, zgSVPanorama;

  const init = () => {
    zgDebugMode = new ZGDebug(config.debugMode);
    ZGDOM.waitForElement('.pB8Nmf', updateTitleCard, '#titlecard');
  }

  const updateTitleCard = async (referenceElement) => {
    const lastChild = referenceElement.lastElementChild;
    zgSVPanorama = new ZGStreetViewPanorama();

    referenceElement.insertBefore(
      ZGDOM.cloneNode(await getCountry(zgSVPanorama.latitude, zgSVPanorama.longitude)),
      lastChild
    );
    referenceElement.insertBefore(
      ZGDOM.cloneNode(zgSVPanorama.latitude, true),
      lastChild
    );
    referenceElement.insertBefore(
      ZGDOM.cloneNode(zgSVPanorama.longitude, true),
      lastChild
    );
    referenceElement.insertBefore(ZGDOM.cloneNode(zgSVPanorama.panoId, true), lastChild);

    if (!referenceElement) {
      zgDebugMode.error('Reference element not found.');
      return;
    }

    const shareLinkNode = referenceElement.insertBefore(
      ZGDOM.cloneNode('Click here to generate a share link', true),
      lastChild
    );
    shareLinkNode.onclick = shareLinkOnClickHandler;
  }

  const shareLinkOnClickHandler = async (event) => {
    await generatePanoramaShareLink(zgSVPanorama);
    zgDebugMode.debug('zgSVPanorama:', zgSVPanorama);
    event.target.innerText = zgSVPanorama.shareLink;
    GM_setClipboard(zgSVPanorama.shareLink);
  }

  const getCountry = async (latitude, longitude) => {
    const osmUrl = `https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&accept-language=en-US`;
    const response = await ZGNetwork.promiseRequest(osmUrl);
    const data = JSON.parse(response.responseText);
    return data?.address?.country ?? 'Country not found';
  }

  const generatePanoramaShareLink = async () => {
    const shareLinkRequestUrl = zgSVPanorama.generateShareLinkRequestUrl();
    const response = await ZGNetwork.promiseRequest(shareLinkRequestUrl);
    const rawText = response.responseText;
    zgSVPanorama.shareLink = rawText.substring(7, rawText.length - 2);
  }

  /** Settings */
  const config = {
    get debugMode() {
      return GM_getValue('zgDebugMode', false);
    },
    set debugMode(value) {
      GM_setValue('zgDebugMode', value);
    },
  };

  /** Google Street View Panorama */
  class ZGStreetViewPanorama {
    url;
    latitude;
    longitude;
    panoId;
    thumb;
    fov;
    zoom;
    heading;
    pitch;
    shareLink;

    constructor() {
      this.#update();
    }

    #update() {
      this.url = window.location.href;
      const tempUrl = new URL(this.url);
      const pathname = tempUrl.pathname;
      let pathnameRegex = new RegExp(
        /@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^h]+)h,([^t]+)t\/.*?!1s([^!]+)!2e/
      );
      let matches = pathnameRegex.exec(pathname);
      if (matches && matches.length === 8) {
        this.latitude = parseFloat(matches[1]);
        this.longitude = parseFloat(matches[2]);
        this.thumb = parseInt(matches[3]);
        this.fov = parseFloat(matches[4]);
        this.zoom = Math.log(180 / this.fov) / Math.log(2);
        this.heading = parseFloat(matches[5]);
        this.pitch = Math.round((parseFloat(matches[6]) - 90) * 100) / 100;
        this.panoId = matches[7];
        return;
      }

      // Sometimes the heading is missing in the URL, so ignore it in the regex expression
      if(!matches) {
        pathnameRegex = new RegExp(/@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^t]+)t\/.*?!1s([^!]+)!2e/);
        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.zoom = Math.log(180 / this.fov) / Math.log(2);
          this.heading = 0; // Default heading
          this.pitch = Math.round((parseFloat(matches[5]) - 90) * 100) / 100;
          this.panoId = matches[6];
          return;
        }
      }
      console.error('Invalid Google Street View URL format.', this.url);
    }

    generateShareLinkRequestUrl = () => {
      this.#update();
      const encodedUrl = encodeURIComponent(this.url.replaceAll('!', '*21'));
      return `https://www.google.com/maps/rpc/shorturl?pb=!1s${encodedUrl}!2m1!7e81!6b1`;
    }
  }

  ZGStreetViewPanorama.prototype.toString = function () {
    return `
      Panorama: { 
        panoId: ${this.panoId},
        latitude: ${this.latitude},
        longitude: ${this.longitude},
        thumb: ${this.thumb},
        fov: ${this.fov},
        zoom: ${this.zoom},
        heading: ${this.heading},
        pitch: ${this.pitch},
        shareLink: ${this.shareLink}
      }`;
  };

  /** DOM manipulation */
  class ZGDOM {
    static 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;
    }

    static waitForElement = (selector, callback, targetNode) => {
      new MutationObserver((mutationsList, observer) => {
        for (const mutation of mutationsList) {
          if (mutation.type === 'childList') {
            const matchingElement = document.querySelector(selector);
            if (matchingElement) {
              observer.disconnect();
              callback(matchingElement);
              return;
            }
          }
        }
      }).observe(
        targetNode ? document.querySelector(targetNode) : document.body,
        {
          childList: true,
          subtree: true,
        }
      );
    };
  }

  /** Networking */
  class ZGNetwork { 
    static promiseRequest = (url, method = 'GET') => {
      if (!url) return Promise.reject('URL is empty');

      zgDebugMode.debug(
        [
          '---PROMISEREQUEST---',
          '\tmethod: ' + method,
          '\turl: ' + url,
          '---PROMISEREQUEST---',
        ].join('\n')
      );

      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: method,
          url: url,
          onload: (result) => {
            this.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) => {
            this.responseInfo(result);
            reject(
              ' error: ' + result.status + ', message: ' + result.statusText
            );
          },
        });
      });
    }

    static responseInfo = (response) => {
      zgDebugMode.debug(
        [
          '',
          'finalUrl: \t\t' + (response.finalUrl || '-'),
          'status: \t\t' + (response.status || '-'),
          'statusText: \t' + (response.statusText || '-'),
          'readyState: \t' + (response.readyState || '-'),
          'responseHeaders: ' +
          (response.responseHeaders.replaceAll('\response\n', ';') || '-'),
          'responseText: \t' + (response.responseText || '-'),
        ].join('\n')
      );
    }
  }

  /** Debugging */
  class ZGDebug {
    constructor(debugMode) {
      this.debugMode = debugMode;
    }

    #debugLog = (level, ...msg) => {
      if (this.debugMode) {
        console[level](...msg);
      }
    };

    debug = (...msg) => {
      this.#debugLog('log', ...msg);
    };

    warn = (...msg) => {
      this.#debugLog('warn', ...msg);
    };

    error = (...msg) => {
      this.#debugLog('error', ...msg);
    };
  }

  init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址