// ==UserScript== // @name Pano Detective // @namespace https://greasyfork.org/users/1179204 // @version 1.3.7 // @description Find the exact time a Google Street View image was taken (recent coverage) // @author KaKa // @match *://www.google.com/* // @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 // @license MIT // @downloadURL none // ==/UserScript== (function() { let dateSpan,detectButton,downloadButton,previousListener,zoomLevel,w,h let accuracy=2; let type=2; 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) { let payload; if (mode === 'GetMetadata') { const length=coorData.length if (length>22){ type=10 } 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 =parseFloat( coorData.lat); var lng = parseFloat( 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],15],[[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) } const offsetDiff = systemTimezoneOffset -offset_hours*3600; const convertedTimestamp = Math.round(timestamp - offsetDiff); return convertedTimestamp; } catch (error) { throw error; } } function formatTimestamp(timestamp) { 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'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } async function addCustomButton() { 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.spotlight']") const buttonContainer = symbol.parentNode; detectButton.id = 'detect-button'; setupButton(detectButton, svgUrl); downloadButton.id = 'download-button'; setupButton(downloadButton, iconUrl); downloadButton.style.marginLeft = '5px'; 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: 'Zoom Level', 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; 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]) } catch (error){ try{ w=parseInt(metaData[1][2][2][1]) h=parseInt(metaData[1][2][2][0]) } catch (error){ console.log(error) return } } if(w&&h){ const fileName = `${panoId}.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=extractParams(currentUrl).lat; var lng=extractParams(currentUrl).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; } let panoDate; try { panoDate = metaData[1][0][6][7]; } catch (error) { try { panoDate = metaData[1][6][7]; } 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'); } const 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 setupButton(button, backgroundUrl) { 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'; } 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(); }, 1000); } window.addEventListener('load', onPageLoad); })();