// ==UserScript==
// @name Pano Detective
// @namespace https://greasyfork.org/users/1179204
// @version 1.5.0
// @description Find the exact time a Google Street View image was taken (default coverage)
// @author KaKa
// @match *://www.google.com/*
// @match *://www.google.ru/*
// @match *://www.google.de/*
// @match *://www.google.fr/*
// @match *://www.google.ca/*
// @match *://www.google.it/*
// @match *://www.google.co.uk/*
// @match *://www.google.co.jp/*
// @match *://www.google.co.id/*
// @match *://www.google.co.in/*
// @match *://www.google.co.kr/*
// @match *://www.google.co.za/*
// @match *://www.google.com.hk/*
// @match *://www.google.com.br/*
// @match *://www.google.com.tw/*
// @icon https://www.svgrepo.com/show/485785/magnifier.svg
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require https://cdn.jsdelivr.net/npm/browser-geo-tz@0.1.0/dist/geotz.min.js
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @license BSD
// @downloadURL none
// ==/UserScript==
(function() {
const date_format=0 // 0:default(locale format) 1:yyyy-mm-dd 2:yyyy/mm/dd 3:dd/mm/yyyy 4:mm/dd/yyyy 5:Month dd, yyyy 6:dd Month, yyyy
const time_format=1 // 1:(hh:mm:ss) 2:(hh:mm:ss am/pm)
const accuracy=2; // default setting is 2 seconds
/*===================================================================================================================================================================================================================================*/
let dateSpan,detectButton,downloadButton,previousListener,zoomLevel,w,h,formattedTime,capturePano,cookie
let type=2;
let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun','Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateSvg=``
let date_Svg=``
let iconSvg=``
let icon_Svg=``
const iconUrl=svgToUrl(iconSvg)
const icon_Url=svgToUrl(icon_Svg)
const svgUrl=svgToUrl(dateSvg)
const svg_Url=svgToUrl(date_Svg)
function svgToUrl(svgText) {
const svgBlob = new Blob([svgText], {type: 'image/svg+xml'});
const svgUrl = URL.createObjectURL(svgBlob);
return svgUrl;
}
function extractParams(link) {
const regex = /@(-?\d+\.\d+),(-?\d+\.\d+),.*?\/data=!3m\d+!1e\d+!3m\d+!1s([^!]+)!/;
const match = link.match(regex);
if (match && match.length === 4) {
var lat = match[1];
var lng = match[2];
var panoId = match[3];
return {lat,lng,panoId}
} else {
console.error('Invalid Google Street View link format');
return null;
}
}
async function UE(t, e, s, d) {
try {
const r = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`;
let payload = createPayload(t, e,s,d);
const response = await fetch(r, {
method: "POST",
headers: {
"content-type": "application/json+protobuf",
"x-user-agent": "grpc-web-javascript/0.1"
},
body: payload,
mode: "cors",
credentials: "omit"
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} else {
return await response.json();
}
} catch (error) {
console.error(`There was a problem with the UE function: ${error.message}`);
}
}
function createPayload(mode,coorData,s,d,r) {
if(!r)r=15
let payload;
if (mode === 'GetMetadata') {
const length=coorData.length
if (length>22){
type=10
}
else type=2
payload = [["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],["en","US"],[[[type,coorData]]],[[1,2,3,4,8,6]]];
}
else if (mode === 'SingleImageSearch') {
var lat =coorData.lat;
var lng = coorData.lng;
lat = lat % 1 !== 0 && lat.toString().split('.')[1].length >6 ? parseFloat(lat.toFixed(6)) : lat;
lng = lng % 1 !== 0 && lng.toString().split('.')[1].length > 6 ? parseFloat(lng.toFixed(6)) : lng;
payload=[["apiv3"],[[null,null,lat,lng],r],[[null,null,null,null,null,null,null,null,null,null,[s,d]],null,null,null,null,null,null,null,[2],null,[[[type,true,2]]]],[[2,6]]]}
else {
throw new Error("Invalid mode!");
}
return JSON.stringify(payload);
}
async function binarySearch(c, start,end) {
let capture
let response
while( (end - start >= accuracy)) {
let mid= Math.round((start + end) / 2);
response = await UE("SingleImageSearch", c, start,end);
if (response&&response[0][2]== "Search returned no images." ){
start=mid+start-end
end=start-mid+end
mid=Math.round((start+end)/2)
} else {
start=mid
mid=Math.round((start+end)/2)
}
capture=mid
}
return capture
}
async function downloadPanoramaImage(panoId, fileName,panoramaWidth,panoramaHeight) {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoomLevel}&nbt=1&fover=2`;
const tileWidth = 512;
const tileHeight = 512;
const zoomTiles=[2,4,8,16,32]
const tilesPerRow = Math.min(Math.ceil(panoramaWidth / tileWidth),zoomTiles[zoomLevel-1]);
const tilesPerColumn = Math.min(Math.ceil(panoramaHeight / tileHeight),zoomTiles[zoomLevel-1]/2);
const canvasWidth = tilesPerRow * tileWidth;
const canvasHeight = tilesPerColumn * tileHeight;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
for (let y = 0; y < tilesPerColumn; y++) {
for (let x = 0; x < tilesPerRow; x++) {
const tileUrl = `${imageUrl}&x=${x}&y=${y}`;
const tile = await loadImage(tileUrl);
ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
}
}
canvas.toBlob(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
resolve();
}, 'image/jpeg');
} catch (error) {
Swal.fire('Error!', error.toString(),'error');
reject(error);
}
});
}
async function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
img.src = url;
});
}
function monthToTimestamp(m) {
const [year, month] = m
const startDate =Math.round( new Date(year, month-1,1).getTime()/1000);
const endDate =Math.round( new Date(year, month, 1).getTime()/1000)-1;
return { startDate, endDate };
}
async function getLocal(coord, timestamp) {
const systemTimezoneOffset = -new Date().getTimezoneOffset() * 60;
try {
var offset_hours
const timezone=await GeoTZ.find(coord[0],coord[1])
const offset = await GeoTZ.toOffset(timezone);
if(offset){
offset_hours=parseInt(offset/60)
}
else if (offset===0) offset_hours=0
const offsetDiff = systemTimezoneOffset -offset_hours*3600;
const convertedTimestamp = timestamp -offsetDiff;
return convertedTimestamp;
} catch (error) {
throw error;
}
}
function formatTimestamp(timestamp) {
var date_text,time_text
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
if (date_format===2) date_text=`${year}/${month}/${day}`
else if (date_format===3) date_text=`${day}/${month}/${year}`
else if (date_format===4) date_text=`${month}/${day}/${year}`
else if (date_format===5) date_text=`${months[parseInt(month)]} ${parseInt(day)}, ${year}`
else if (date_format===6) date_text=`${parseInt(day)} ${months[parseInt(month)]}, ${year}`
else date_text=`${year}-${month}-${day}` ;
if(time_format===1) time_text=`${hours}:${minutes}:${seconds}`
else{
const period = parseInt(hours) >= 12 ? 'pm' : 'am';
var _hours = parseInt(hours) % 12 || 12;
_hours = String(_hours).padStart(2, '0');
time_text= `${_hours}:${minutes}:${seconds} ${period}`;
}
if(!date_format)return date.toLocaleString().replace('T', ' ');
else return `${date_text} ${time_text}`;
}
function formatLatLng(lat, lng) {
const latDirection = lat >= 0 ? 'N' : 'S';
const latAbs = Math.abs(lat).toFixed(4);
const latStr = `${latAbs}°${latDirection}`;
const lngDirection = lng >= 0 ? 'E' : 'W';
const lngAbs = Math.abs(lng).toFixed(4);
const lngStr = `${lngAbs}°${lngDirection}`;
return `${latStr}, ${lngStr}`;
}
async function addCustomButton() {
const controlContainer=document.getElementById('app-container')
const navigationDiv = document.querySelector("[role='navigation']");
if (!navigationDiv) {
console.error('Navigation div not found inside titlecard');
return;
}
dateSpan = navigationDiv.querySelector('span.mqX5ad');
if (!dateSpan){
dateSpan = navigationDiv.querySelector('span.lchoPb');
if(!dateSpan){
dateSpan = navigationDiv.querySelector('div.mqX5ad');
if(!dateSpan)
{
dateSpan = navigationDiv.querySelector('div.lchoPb');
}
}
}
if (!detectButton){
detectButton = document.createElement("button");
}
if (!downloadButton){
downloadButton = document.createElement("button");
}
const symbol=document.querySelector("[jsaction='titlecard.settings']")
const buttonContainer = symbol.parentNode;
if(symbol){
const rect = symbol.getBoundingClientRect();
var absoluteTop = rect.top
var absoluteLeft = rect.left
}
detectButton.id = 'detect-button';
detectButton.style.marginTop = '1px';
setupButton(detectButton, svgUrl,absoluteTop,absoluteLeft);
downloadButton.id = 'download-button';
setupButton(downloadButton, iconUrl,absoluteTop,absoluteLeft);
if(absoluteLeft&&absoluteTop) downloadButton.style.marginLeft = '26px';
else downloadButton.style.marginLeft='1px'
if (!previousListener){
previousListener=true
addButtonHoverEffect(detectButton, svg_Url, svgUrl);
addButtonHoverEffect(downloadButton, icon_Url, iconUrl);
downloadButton.addEventListener("click",async function(){
const { value: zoom, dismiss: inputDismiss } = await Swal.fire({
title: 'Image Quality',
html:
'',
icon: 'question',
showCancelButton: true,
showCloseButton: true,
allowOutsideClick: false,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes',
cancelButtonText: 'Cancel',
preConfirm: () => {
return document.getElementById('zoom-select').value;
}
});
if (zoom){
zoomLevel=parseInt(zoom)
const currentUrl = window.location.href;
var panoId=extractParams(currentUrl).panoId;
var panoDate,lat,lng
const metaData = await UE('GetMetadata', panoId);
if (!metaData) {
console.error('Failed to get metadata');
return;
}
try{
w=parseInt(metaData[1][0][2][2][1])
h=parseInt(metaData[1][0][2][2][0])
panoDate=metaData[1][0][6][7]
lat=metaData[1][0][5][0][1][0][2]
lng=metaData[1][0][5][0][1][0][3]
}
catch (error){
try{
w=parseInt(metaData[1][2][2][1])
h=parseInt(metaData[1][2][2][0])
panoDate=metaData[1][6][7]
lat=metaData[1][5][0][1][0][2]
lng=metaData[1][5][0][1][0][3]
}
catch (error){
console.log(error)
return
}
}
if(w&&h){
const gpsTag=formatLatLng(lat,lng)
var timeTag
if(panoId===capturePano&&formattedTime) timeTag=formattedTime
else timeTag=`${panoDate[0]}-${panoDate[1]}`
const fileName = `${gpsTag}(${timeTag}).jpg`;
const swal = Swal.fire({
title: 'Downloading',
text: 'Please wait...',
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
await downloadPanoramaImage(panoId, fileName,w,h);
swal.close()
Swal.fire('Success!','Download completed', 'success');
}
}
})
detectButton.addEventListener("click",async function() {
if (dateSpan){
dateSpan.textContent='loading...'
}
const currentUrl = window.location.href;
var lat,lng
var panoId=extractParams(currentUrl).panoId;
if (panoId.length>22)type=3
try {
const metaData = await UE('GetMetadata', panoId);
if (!metaData) {
console.error('Failed to get metadata');
return;
}
var panoDate
try {
panoDate = metaData[1][0][6][7];
lat=metaData[1][0][5][0][1][0][2]
lng=metaData[1][0][5][0][1][0][3]
capturePano=metaData[1][0][1][1]
} catch (error) {
try {
panoDate = metaData[1][6][7];
lat=metaData[1][5][0][1][0][2]
lng=metaData[1][5][0][1][0][3]
capturePano=metaData[1][1][1]
} catch (error) {
console.log(error);
return;
}
}
if (!panoDate) {
dateSpan.textContent='unknown'
console.error('Failed to get panoDate');
return;
}
const timeRange = monthToTimestamp(panoDate);
if (!timeRange) {
console.error('Failed to convert panoDate to timestamp');
return;
}
try {
const captureTime = await binarySearch({"lat":lat,"lng":lng},timeRange.startDate,timeRange.endDate);
if (!captureTime) {
console.error('Failed to get capture time');
return;
}
const exactTime=await getLocal([lat,lng],captureTime)
if(!exactTime){
console.error('Failed to get exact time');
}
formattedTime=formatTimestamp(exactTime)
if(dateSpan){
dateSpan.textContent = formattedTime;
}
} catch (error) {
console.log(error);
}
} catch (error) {
console.error(error);
}
})
}
if (navigationDiv) {
const previewButton=navigationDiv.querySelector('#detect-button')
if (!previewButton){
buttonContainer.appendChild(detectButton)
}
const previewButton_=navigationDiv.querySelector('#download-button')
if (!previewButton_){
buttonContainer.appendChild(downloadButton)
}
}
}
function checkPosition(button) {
const symbol=document.querySelector("[jsaction='titlecard.settings']")
if(!symbol) return
const rect = symbol.getBoundingClientRect();
const absoluteTop = rect.top
const absoluteLeft = rect.left
if(absoluteTop&&absoluteLeft){
button.style.position='fixed'
button.style.top=`${absoluteTop+40}px`
button.style.left=`${absoluteLeft-25}px`}
}
function setupButton(button, backgroundUrl,top,left) {
button.style.backgroundImage = `url(${backgroundUrl})`;
button.style.backgroundSize = 'cover';
button.style.backgroundPosition = 'center';
button.style.display = 'block';
button.style.width = '24px';
button.style.height = '24px';
button.style.fontSize = '12px';
button.style.borderRadius = '10px';
button.style.cursor = 'pointer';
button.style.backgroundColor = 'transparent';
if(top&&left){
button.style.position='fixed'
button.style.top=`${top+40}px`
button.style.left=`${left-25}px`}
setInterval(function (){checkPosition(button)}, 100)
}
function calculateFOV(zoom) {
const pi = Math.PI;
const argument = (3 / 4) * Math.pow(2, 1 - zoom);
const radians = Math.atan(argument);
const degrees = (360 / pi) * radians;
return degrees;
}
async function getShortUrl(pageUrl) {
const url = 'https://www.google.com/maps/rpc/shorturl';
const panoId=extractParams(pageUrl).panoId;
const urlObj = new URL(pageUrl);
const path = urlObj.pathname;
const pathRegex = /@([^,]+),([^,]+),3a,([^y]+)y,([^h]+)h,([^t]+)t/;
const pathMatch = path.match(pathRegex);
const lat = pathMatch ? pathMatch[1] : null;
const lng = pathMatch ? pathMatch[2] : null;
const y = pathMatch ? pathMatch[3] : null;
const h = pathMatch ? pathMatch[4] : null;
const t = pathMatch ? pathMatch[5] : null;
const pb = `!1shttps://www.google.com/maps/@${lat},${lng},3a,${y}y,${h}h,${t}t/data=*213m5*211e1*213m3*211s${panoId}*212e0*216shttps%3A%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fpanoid%3D${panoId}%26cb_client%3Dmaps_sv.share%26w%3D900%26h%3D600%26yaw%3D${h}%26pitch%3D${t-90}%26thumbfov%3D100*217i16384*218i8192?coh=205410&entry=tts&g_ep=EgoyMDI0MDgyOC4wKgBIAVAD!2m2!1s${cookie}!7e81!6b1`;
const params = new URLSearchParams({
authuser: '0',
hl: 'en',
gl: 'us',
pb: pb
}).toString();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${url}?${params}`,
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
try {
const text = response.responseText;
const match = text.match(/"([^"]+)"/);
if (match && match[1]) {
resolve(match[1]);
} else {
reject('No URL found.');
}
} catch (error) {
reject('Failed to parse response: ' + error);
}
} else {
reject('Request failed with status: ' + response.status);
}
},
onerror: function (error) {
reject('Request error: ' + error);
}
});
});
}
function addButtonHoverEffect(button, hoverImageUrl, defaultImageUrl) {
button.addEventListener("mouseenter", function(event) {
button.style.backgroundImage = `url(${hoverImageUrl})`;
});
button.addEventListener("mouseleave", function(event) {
button.style.backgroundImage = `url(${defaultImageUrl})`;
});
}
function onPageLoad() {
const sceneFooter=document.getElementsByClassName('scene-footer')[0]
const observer = new MutationObserver(function(mutationsList) {
for (let mutation of mutationsList) {
if (mutation) addCustomButton()
};
});
const config = { childList: true, subtree: true, attributes: true };
observer.observe(sceneFooter, config);
setTimeout(function() {
addCustomButton();
}, 200);
}
window.addEventListener('load', onPageLoad);
var realSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(value) {
this.addEventListener('load', function() {
var responseData
if (this._url && this._url.includes('pegman')) {
const match = this._url.match(/!1s([^!]+)!7/);
if (match && match[1]) {
cookie = match[1];
} else {
console.log('No match found');
}
}
},false)
realSend.call(this, value);
}
let onKeyDown = async (e) => {
if ((e.ctrlKey || e.metaKey)&&(e.key === 'x' || e.key === 'X')) {
e.stopImmediatePropagation();
let pageUrl = window.location.href;
const currentLink=await getShortUrl(pageUrl)
GM_setClipboard(currentLink, 'text');
}
}
document.addEventListener("keydown", onKeyDown);
})();