Greasy Fork

Pano Detective

Find the exact time a Google Street View image was taken (recent coverage)

当前为 2024-08-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         Pano Detective
// @namespace    https://greasyfork.org/users/1179204
// @version      1.3.8
// @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/[email protected]/dist/geotz.min.js
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==
(function() {
    let dateSpan,detectButton,downloadButton,previousListener,zoomLevel,w,h
    let accuracy=2;
    let type=2;

    let dateSvg=`<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#9AA0a6"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g id="Calendar / Calendar_Days"> <path id="Vector" d="M8 4H7.2002C6.08009 4 5.51962 4 5.0918 4.21799C4.71547 4.40973 4.40973 4.71547 4.21799 5.0918C4 5.51962 4 6.08009 4 7.2002V8M8 4H16M8 4V2M16 4H16.8002C17.9203 4 18.4796 4 18.9074 4.21799C19.2837 4.40973 19.5905 4.71547 19.7822 5.0918C20 5.5192 20 6.07899 20 7.19691V8M16 4V2M4 8V16.8002C4 17.9203 4 18.4801 4.21799 18.9079C4.40973 19.2842 4.71547 19.5905 5.0918 19.7822C5.5192 20 6.07899 20 7.19691 20H16.8031C17.921 20 18.48 20 18.9074 19.7822C19.2837 19.5905 19.5905 19.2842 19.7822 18.9079C20 18.4805 20 17.9215 20 16.8036V8M4 8H20M16 16H16.002L16.002 16.002L16 16.002V16ZM12 16H12.002L12.002 16.002L12 16.002V16ZM8 16H8.002L8.00195 16.002L8 16.002V16ZM16.002 12V12.002L16 12.002V12H16.002ZM12 12H12.002L12.002 12.002L12 12.002V12ZM8 12H8.002L8.00195 12.002L8 12.002V12Z" stroke="#9AA0a6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g> </g></svg>`
    let date_Svg=`<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g id="Calendar / Calendar_Days"> <path id="Vector" d="M8 4H7.2002C6.08009 4 5.51962 4 5.0918 4.21799C4.71547 4.40973 4.40973 4.71547 4.21799 5.0918C4 5.51962 4 6.08009 4 7.2002V8M8 4H16M8 4V2M16 4H16.8002C17.9203 4 18.4796 4 18.9074 4.21799C19.2837 4.40973 19.5905 4.71547 19.7822 5.0918C20 5.5192 20 6.07899 20 7.19691V8M16 4V2M4 8V16.8002C4 17.9203 4 18.4801 4.21799 18.9079C4.40973 19.2842 4.71547 19.5905 5.0918 19.7822C5.5192 20 6.07899 20 7.19691 20H16.8031C17.921 20 18.48 20 18.9074 19.7822C19.2837 19.5905 19.5905 19.2842 19.7822 18.9079C20 18.4805 20 17.9215 20 16.8036V8M4 8H20M16 16H16.002L16.002 16.002L16 16.002V16ZM12 16H12.002L12.002 16.002L12 16.002V16ZM8 16H8.002L8.00195 16.002L8 16.002V16ZM16.002 12V12.002L16 12.002V12H16.002ZM12 12H12.002L12.002 12.002L12 12.002V12ZM8 12H8.002L8.00195 12.002L8 12.002V12Z" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g> </g></svg>`
    let iconSvg=`<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g id="Interface / Download"> <path id="Vector" d="M6 21H18M12 3V17M12 17L17 12M12 17L7 12" stroke="#9AA0a6" stroke-width="2.16" stroke-linecap="round" stroke-linejoin="round"></path> </g> </g></svg>`
    let icon_Svg=`<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g id="Interface / Download"> <path id="Vector" d="M6 21H18M12 3V17M12 17L17 12M12 17L7 12" stroke="#ffffff" stroke-width="2.16" stroke-linecap="round" stroke-linejoin="round"></path> </g> </g></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 now=new Date()
            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 = 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:
                    '<select id="zoom-select" class="swal2-input" style="width:180px; height:40px; font-size:16px;white-space:prewrap">' +
                    '<option value="1">1 (100KB~500KB)</option>' +
                    '<option value="2">2 (500KB~1MB)</option>' +
                    '<option value="3">3 (1MB~4MB)</option>' +
                    '<option value="4">4 (4MB~8MB)</option>' +
                    '<option value="5">5 (8MB~15MB)</option>' +
                    '</select>',
                    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);
})();