// ==UserScript== // @name Pano Detective // @namespace https://greasyfork.org/users/1179204 // @version 1.6.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/* // @icon https://www.svgrepo.com/show/485785/magnifier.svg // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @require https://cdn.jsdelivr.net/npm/chinese-lunar@0.1.4/lib/chinese-lunar.min.js // @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 7:Lunar 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) const moon_phase=['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'] 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,r) { try { const url = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`; let payload = createPayload(t, e,s,d,r); const response = await fetch(url, { 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) { 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') { payload=[["apiv3"],[[null,null,parseFloat(coorData.lat),parseFloat(coorData.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,panoId) { let capture let response while( (end - start > accuracy)) { let mid=Math.round((start + end)/2) ; response = await UE("SingleImageSearch", c, start,end,30); if (response&&response.length>1){ if (response[1][1][1]==panoId){ start=mid } else{ start=mid+start-end end=start-mid+end } } else { start=mid+start-end end=start-mid+end } capture=Math.round((start + end)/2) } 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,0).getTime()/1000); const endDate =Math.round(new Date(year, month, 2).getTime()/1000) 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 getMoonPhaseIcon(dayOfMonth) { const cycleDays = 29.53; const phaseIndex = Math.floor((dayOfMonth % cycleDays) / (cycleDays / moon_phase.length)); return moon_phase[phaseIndex]; } 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 if (date_format===7) { const lunarDate=chineseLunar.solarToLunar(date) date_text = `${year}-${month}-${day} ${chineseLunar.format(lunarDate, 'MD')} ${getMoonPhaseIcon(lunarDate.day)}`;} 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; } const logoSpan=navigationDiv.querySelector('span.ilzTS') const logoDiv=navigationDiv.querySelector('div.p4x6kc') 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 spotlight=document.querySelector("[jsaction='titlecard.spotlight']") const rect_=spotlight.getBoundingClientRect() if(rect_.top){ 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 altitude var panoId=extractParams(currentUrl).panoId; var lat=extractParams(currentUrl).lat var lng=extractParams(currentUrl).lng 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]; capturePano=metaData[1][0][1][1] altitude=metaData[1][0][5][0][1][1][0] } catch (error) { try { panoDate = metaData[1][6][7]; capturePano=metaData[1][1][1] altitude=metaData[1][5][0][1][1][0] } catch (error) { console.log(error); return; } } if (logoSpan){ logoSpan.textContent=`${altitude}m` logoDiv.style.backgroundImage=`url(${altitude>50?'https://www.svgrepo.com/show/406668/mountain.svg':'https://www.svgrepo.com/show/414747/ocean-sea-splash.svg'})` GM_setClipboard(`${altitude},"${panoId}"`,'text') } 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,panoId); 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,backgroundUrl) { const symbol=document.querySelector("[jsaction='titlecard.settings']") if(!symbol) return const rect = symbol.getBoundingClientRect(); const spotlight=document.querySelector("[jsaction='titlecard.spotlight']") const rect_=spotlight.getBoundingClientRect() var absoluteTop = rect.top var absoluteLeft = rect.left if(rect_.top){ if(absoluteTop&&absoluteLeft){ button.style.position='fixed' button.style.top=`${absoluteTop+40}px` button.style.left=`${absoluteLeft-25}px`}} else{ button.style.position=null button.style.top=null button.style.left=null } } 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,backgroundUrl)}, 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 = /@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^h]+)h,([^t]+)t/; const pathMatch = path.match(pathRegex); const lat = pathMatch ? pathMatch[1] : null; const lng = pathMatch ? pathMatch[2] : null; const a=pathMatch ? pathMatch[3] : null; const y = pathMatch ? pathMatch[4] : null; const h = pathMatch ? pathMatch[5] : null; const t = pathMatch ? pathMatch[6] : null; const pb = `!1shttps://www.google.com/maps/@${lat},${lng},${a}a,${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) { const controlContainer=document.getElementById('app-container') if(controlContainer){ addCustomButton()} }; }); const config = { childList: true, subtree: true, attributes: true }; observer.observe(sceneFooter, config); setTimeout(function() { addCustomButton(); }, 1200); } 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'); Swal.fire({ title: 'Copy Succeed', text: 'Short Link has been pasted to your clipboard!', icon: 'success', timer: 1000, showConfirmButton: false, }); } } document.addEventListener("keydown", onKeyDown); })();