图寻连击计数器

自动记录国家/一级行政区连击次数

// ==UserScript==
// @name         图寻连击计数器
// @namespace    https://greasyfork.org/users/1179204
// @version      1.1.7
// @description  自动记录国家/一级行政区连击次数
// @author       KaKa
// @match        *://tuxun.fun/*
// @exclude      *://tuxun.fun/replay-pano?*
// @icon         data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNDggNDgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0iIzAwMDAwMCI+PGcgaWQ9IlNWR1JlcG9fYmdDYXJyaWVyIiBzdHJva2Utd2lkdGg9IjAiPjwvZz48ZyBpZD0iU1ZHUmVwb190cmFjZXJDYXJyaWVyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjwvZz48ZyBpZD0iU1ZHUmVwb19pY29uQ2FycmllciI+PHRpdGxlPjcwIEJhc2ljIGljb25zIGJ5IFhpY29ucy5jbzwvdGl0bGU+PHBhdGggZD0iTTI0LDEuMzJjLTkuOTIsMC0xOCw3LjgtMTgsMTcuMzhBMTYuODMsMTYuODMsMCwwLDAsOS41NywyOS4wOWwxMi44NCwxNi44YTIsMiwwLDAsMCwzLjE4LDBsMTIuODQtMTYuOEExNi44NCwxNi44NCwwLDAsMCw0MiwxOC43QzQyLDkuMTIsMzMuOTIsMS4zMiwyNCwxLjMyWiIgZmlsbD0iI2ZmOTQyNyI+PC9wYXRoPjxwYXRoIGQ9Ik0yNS4zNywxMi4xM2E3LDcsMCwxLDAsNS41LDUuNUE3LDcsMCwwLDAsMjUuMzcsMTIuMTNaIiBmaWxsPSIjZmZmZmZmIj48L3BhdGg+PC9nPjwvc3ZnPg==
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require      https://unpkg.com/gcoord/dist/gcoord.global.prod.js
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @license      BSD
// ==/UserScript==

(function() {
    const Language='zh' // ISO 639-1 语言代码 - https://baike.baidu.com/item/ISO%20639

    let viewer,map,finalGuess,currentRound,gameState=false,roundPins={},gameMode,roundState,countsDiv,countsTitle,countsValue,mapsId,avgScore,avgValue_,previousWidth=0

    let api_key=''//JSON.parse(localStorage.getItem('api_key'));

    let streakCounts=JSON.parse(localStorage.getItem('streakCounts'))
    let streakMode=JSON.parse(localStorage.getItem('streakMode'))
    if (!streakCounts){
        streakCounts={}
    }
    if (!streakMode){
        streakMode='country'
    }

    const CC_DICT = {
        AX: "FI", AS: "US", AI: "GB", AW: "NL", BM: "GB", BQ: "NL", BV: "NO", IO: "GB", KY: "UK",
        CX: "AU", CC: "AU", CK: "NZ", CW: "NL", FK: "AR", FO: "DK", GF: "FR", PF: "FR", TF: "FR",
        GI: "UK", GL: "DK", GP: "FR", GU: "US", GG: "GB", HM: "AU", HK: "CN", IM: "GB", JE: "GB",
        MO: "CN", MQ: "FR", YT: "FR", MS: "GB", AN: "NL", NC: "FR", NU: "NZ", NF: "AU", MP: "US",
        PS: "IL", PN: "GB", PR: "US", RE: "FR", BL: "FR", SH: "GB", MF: "FR", PM: "FR", SX: "NL",
        GS: "GB", SJ: "NO", TK: "NZ", TC: "GB", UM: "US", VG: "GB", VI: "US", WF: "FR", EH: "MA",
        TW: "CN"
    };

    let intervalId=setInterval(function(){
        const streetViewContainer= document.getElementById('viewer')
        if(streetViewContainer){
            getSVContainer()
            getMap()
            if(map&&viewer&&viewer.location&&gameMode){
                mapListener()
                clearInterval(intervalId)}
        }
    },500);

    function getMap(){
        var mapContainer = document.getElementById('map')
        const keys = Object.keys(mapContainer)
        const key = keys.find(key => key.startsWith("__reactFiber$"))
        const props = mapContainer[key]
        const x = props.child.memoizedProps.value.map
        map=x.getMap()

    }

    function getSVContainer(){
        const streetViewContainer= document.getElementById('viewer')
        const keys = Object.keys(streetViewContainer)
        const key = keys.find(key => key.startsWith("__reactFiber"))
        const props = streetViewContainer[key]
        viewer=props.return.child.memoizedProps.children[1].props.googleMapInstance
        const gameData=props.return.return.return.return.return.memoizedState.next.next.memoizedState.current.gameData
        if(gameData){
            if(gameData.status&&gameData.status==='ongoing'){
                gameState=roundState=true
                mapsId=gameData.mapsId
                if (['challenge','infinity'].includes(gameData.type)) gameMode=gameData.type
                if (!streakCounts[mapsId]){
                    streakCounts[mapsId]={'country':0,'state':0}
                }
                currentRound=gameData.rounds.length
                if(gameData.rounds[currentRound-1].endTime) currentRound+=1
            }
        }
    }

    function mapListener(){
        setMapObserver()
        setSVObserver()
        if (!roundPins[currentRound]){
            getRoundPin()
            updatePanel(streakMode)
        }
        var mapContainer = document.querySelector('.maplibregl-canvas')
        const observer = new MutationObserver((mutationsList, observer) => {

            for(let mutation of mutationsList) {
                if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
                    handleSizeChange(mapContainer);
                }
            }
        });
        observer.observe(mapContainer, { attributes: true, attributeFilter: ['style'] });
    }

    function setMapObserver() {
        map.on('click', (e) => {
            if (gameState&&roundState) finalGuess=e.lngLat
        });
    }

    function setSVObserver() {
        viewer.addListener('position_changed', () => {
            if (!roundPins[currentRound]&&gameState){
                getRoundPin()
            }
        });

    }


    async function getRoundPin(){
        var lat,lng,add
        const panoId=viewer.getPano()
        if(panoId.length===27) {
            [lat,lng]=await checkBDPano(viewer.pano)
            if(api_key) add=await queryGD(lat,lng)
            else add=await queryOSM(lat,lng)
        }
        else if(panoId.length==23){
            [lat,lng]=await checkQQPano(viewer.pano)
            if(api_key) add=await queryGD(lat,lng)
            else add=await queryOSM(lat,lng)
        }
        else{
            lat=viewer.location.latLng.lat()
            lng=viewer.location.latLng.lng()
            if(api_key) add=await queryGD(lat,lng)
            else add=await queryOSM(lat,lng)}
        roundPins[currentRound]=add
    }

    function handleSizeChange(target) {
        const { width, height } = target.getBoundingClientRect();
        const currentScreenWidth = window.innerWidth;
        const widthRatio = (width / currentScreenWidth) * 100;

        if (widthRatio>=90&&previousWidth<90) {
            streakCheck()
        }
        else {
            roundState=true
            updatePanel()
        }
        if(previousWidth!=widthRatio)previousWidth=widthRatio
    }


    async function queryOSM(lat, lng) {
        const url =`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=jsonv2&accept-language=${Language}`;

        const response = await fetch(url);

        if (response.ok) {
            let data = await response.json();
            if(data.address) return data.address
        } else {
            return null;
        }
    }

    async function streakCheck(){
        if(!roundState) return
        const panoId=viewer.getPano()
        if(finalGuess){
            var guess,answer
            if(panoId.length===27) {
                if(api_key) guess=await queryGD(finalGuess.lat,finalGuess.lng)
                else guess=await queryOSM(finalGuess.lat,finalGuess.lng)
            }
            else {
                if(api_key) guess=await queryGD(finalGuess.lat,finalGuess.lng)
                else guess=await queryOSM(finalGuess.lat,finalGuess.lng)
            }
            answer=roundPins[currentRound]

            var isStreak
            if(streakMode==='country'){
                if(panoId.length!=27){
                    if(matchCountryCode(guess)===matchCountryCode(answer)){
                        isStreak=true
                    }}
                else{
                    if(guess&&(guess.country_code in ['tw','cn','mo','hk'])) isStreak=true
                }
            }
            else if(streakMode==='state'){
                if(matchState(guess)===matchState(answer)){
                    isStreak=true
                }
                else if (guess.country_code=='tw'&answer.country_code=='tw') isStreak=true
            }
            if(guess) updateBar(isStreak,guess,answer,streakMode)
            else updateBar(false,'Undefined',answer,streakMode)
            currentRound+=1
        }
        roundState=false
    }

    function correctCountry(item){
        if(item==='Undefined') return item
        try{
            if(['Taiwan','HongKong','Macau','臺灣','台湾'].includes(item.country)) return Language=== 'zh' ? '中国' : 'China'
            else if(item.country_code==='xk') return Language=== 'zh' ? '塞尔维亚' : 'Serbia'
            else if(item['ISO3166-2-lvl4']==='IN-AR') return Language=== 'zh' ? '中国' : 'China'
            else if(item.country.length===0) return 'Undefined'
            else return item.country
        }
        catch (error){
            console.error('failed to correct country')
            return 'Undefined'
        }
    }

    function correctState(item){
        try{
            if (item.country.length===0) return 'Undefined'

            else if(item['ISO3166-2-lvl4']==='IN-AR')return Language=== 'zh' ? '西藏自治区': 'Tibet'

            else if(item.country_code==='tw') return Language=== 'zh' ? '台湾省' : 'Taiwan Province'

            else if(item['ISO3166-2-lvl4']==='JP-13') return Language=== 'zh' ?'东京都': 'Tokyo'

            else if(item.country_code==='fk')return Language==='zh'?'福克兰群岛':'Falkland Islands'

            else if(item.country_code==='pn')return Language==='zh'?'皮特凯恩群岛':'Pitcairn Islands'

            else return matchState(item)
        }
        catch(error) {
            console.error('failed to correct state')
            return 'Undefined'
        }
    }

    function updateBar(status,pin,result){
        const roundBar=document.querySelector('.scoreReulstValue___gFyI2')
        if (roundBar)roundBar.textContent=roundBar.textContent.split('/')[0]
        const infoBar=document.querySelector('.controls___yY74y')
        const pText=infoBar.querySelector('p')
        if(pText) pText.style.display='none'
        const streakText = document.createElement('div')
        streakText.style.fontSize='24px'
        streakText.style.color='#fff'
        streakText.style.marginTop='15px'
        streakText.style.fontFamily='Baloo Bhaina'
        infoBar.appendChild(streakText)
        if (infoBar) {
            let message = '';
            let answer = '';
            let guess = '';
            let streakMessage = '';
            const correctTextColor = 'green';
            const userTextColor = 'red';
            const streakColor = 'yellow';

            if (status) {
                streakCounts[mapsId][streakMode] += 1;
                if (streakMode === 'country') {
                    answer = correctCountry(result).split('/')[0];
                    message = `恭喜你选对 <span style="color: ${correctTextColor};">${answer}</span> , 连击次数: <span style="color: ${streakColor};">${streakCounts[mapsId][streakMode]}</span>`;
                } else if (streakMode === 'state') {
                    answer = correctState(result).split('/')[0];
                    message = `恭喜你选对 <span style="color: ${correctTextColor};">${answer}</span> , 连击次数: <span style="color: ${streakColor};">${streakCounts[mapsId][streakMode]}</span>`;
                }
            } else {
                const end_count = streakCounts[mapsId][streakMode];
                streakCounts[mapsId][streakMode] = 0;
                if (streakMode === 'country') {
                    answer = correctCountry(result).split('/')[0];
                    guess = correctCountry(pin).split('/')[0];
                    message = `答案是 <span style="color: ${correctTextColor};">${answer}</span> , 你选了 <span style="color: ${userTextColor};">${guess}</span> , 连击次数: <span style="color: ${streakColor};">${streakCounts[mapsId][streakMode]}</span> , 本轮达成连击: <span style="color: ${streakColor};">${end_count}</span>`;
                } else if (streakMode === 'state') {
                    answer = correctState(result).split('/')[0];
                    guess = correctState(pin).split('/')[0];
                    message = `答案是 <span style="color: ${correctTextColor};">${answer}</span> , 你选了 <span style="color: ${userTextColor};">${guess}</span> , 连击次数: <span style="color: ${streakColor};">${streakCounts[mapsId][streakMode]}</span> , 本轮达成连击: <span style="color: ${streakColor};">${end_count}</span>`;
                }
            }

            streakText.innerHTML = message;
            localStorage.setItem('streakCounts', JSON.stringify(streakCounts));
        }

        const scoreBar=document.querySelector('.scoreReulst___qqkPH')

        const scoresDiv=document.querySelectorAll('.scoreReulstValue___gFyI2')[3]
        if(scoresDiv.textContent) avgScore=parseInt(scoresDiv.textContent.replace(',', ''))
    }

    async function queryGD(lat, lng) {
        const apiUrl = `https://restapi.amap.com/v3/geocode/regeo?output=json&location=${lng},${lat}&key=${api_key}&radius=50`;

        try {
            const response = await fetch(apiUrl);
            if (!response.ok) {
                throw new Error('Request failed with status: ' + response.status);
            }

            const data = await response.json();
            if (data.status === '1' && data.regeocode) {
                return data.regeocode.addressComponent;
            } else {
                localStorage.removeItem('api_key');
                Swal.fire('无效的API密钥', '请刷新页面并重新输入正确的高德地图API密钥', 'error');
                throw new Error('Request failed: ' + data.info);
            }
        } catch (error) {
            console.error('Error fetching address:', error);
            throw error;
        }
    }

    function checkBDPano(id) {
        return new Promise((resolve, reject) => {
            const url = `https://mapsv0.bdimg.com/?qt=sdata&sid=${id}`;
            fetch(url)
                .then(response => response.json())
                .then(data => {
                try {
                    if (data.result.error !== 404) {
                        var lat,lng
                        if(Math.floor(Math.log10(data.content[0].X)) + 1<7) [lng,lat]= gcoord.transform([data.content[0].X, data.content[0].Y],gcoord.BD09MC,gcoord.WGS84)
                        else [lng,lat] = gcoord.transform([data.content[0].X/100, data.content[0].Y /100],gcoord.BD09MC, gcoord.WGS84)
                        resolve([lat,lng])
                    }
                    else {
                        resolve(false)
                    }
                }
                catch (error) {
                    resolve(false)
                }
            })
                .catch(error => {
                console.error('Request failed:', error);
                reject(error);
            });
        });
    }
    function checkQQPano(id) {
        return new Promise((resolve, reject) => {
            const url = `https://sv.map.qq.com/sv?svid=${id}&output=json`;;
            fetch(url)
                .then(response => response.json())
                .then(data => {
                try {
                    if (data.detail) {
                        var pano=data.detail.addr
                        resolve([pano.y_lat,pano.x_lng])
                    }
                    else {
                        resolve(false)
                    }
                }
                catch (error) {
                    resolve(false)
                }
            })
                .catch(error => {
                console.error('Request failed:', error);
                reject(error);
            });
        });
    }
    function updatePanel(){
        const panel_container=document.querySelector('.roundWrapper___eTnOj ')
        if(!countsDiv){
            countsDiv=document.createElement('div')
            countsDiv.className='roundInfoBox___ikizG'
            countsTitle=document.createElement('div')
            countsTitle.className='roundInfoTitle___VOdv2'
            if(streakMode==='country') countsTitle.textContent='国家连击'
            else countsTitle.textContent='一级行政区连击'

            countsValue=document.createElement('div')
            countsValue.className='roundInfoValue___zV6GS'
            countsDiv.appendChild(countsTitle)
            countsDiv.appendChild(countsValue)

            const divider = document.createElement('div');
            divider.classList.add('ant-divider', 'css-i874aq', 'ant-divider-vertical');
            divider.setAttribute('role', 'separator');

            panel_container.appendChild(divider)
            panel_container.appendChild(countsDiv)

            if(gameMode){
                const divider_ = document.createElement('div');
                divider_.classList.add('ant-divider', 'css-i874aq', 'ant-divider-vertical');
                divider_.setAttribute('role', 'separator');
                panel_container.appendChild(divider_)
                const avgDiv=document.createElement('div')
                avgDiv.className='roundInfoBox___ikizG'
                const avgTitle=document.createElement('div')
                avgTitle.className='roundInfoTitle___VOdv2'
                avgTitle.textContent='平均分'

                avgValue_=document.createElement('div')
                avgValue_.className='roundInfoValue___zV6GS'
                avgValue_.textContent=avgScore

                avgDiv.appendChild(avgTitle)
                avgDiv.appendChild(avgValue_)
                panel_container.appendChild(avgDiv)
            }


        }
        if(panel_container){
            countsValue.textContent=streakCounts[mapsId][streakMode]
            avgValue_.textContent=avgScore
        }
    }

    function matchCountryCode(t) {
        if(t.country==='印度'&&t.state==='阿鲁纳恰尔邦') t.country_code='CN'
        else if(t.country==='India'&&t.state==='Arunachal Pradesh') t.country_code='CN'
        if (t&&t.country_code){
            const cc=t.country_code.toUpperCase()
            if(CC_DICT[cc])return CC_DICT[cc]
            else return cc
        }
        else return 'Undefined'
    }

    function matchState(t) {
        if(!t) return 'Undefined'
        if (t.state) {
            return t.state;
        }else if (t.province) {
            return t.province;
        } else if (t.territory) {
            return t.territory;
        } else if (t.state_district) {
            return t.state_district;
        } else if (t.county) {
            return t.county;
        } else {
            return 'Undefined';
        }
    }

    function formatNumber(number) {
        const numberStr = number.toString();

        const formattedNumber = numberStr.replace(/\B(?=(\d{3})+(?!\d))/g, ',');

        return formattedNumber;
    }
    async function genShortLink(panoId){

        const location=viewer.getPosition()
        const POV=viewer.getPov()
        const zoom=viewer.getZoom()
        var shortUrl
        if(panoId.length!=27) shortUrl=await getGoogleSL(panoId,location,POV.heading,POV.pitch,zoom);
        else if (panoId.length==23) shortUrl=`https://map.qq.com/#from=web&heading=${POV.heading}&pano=${panoId}&pitch=${POV.pitch}&ref=web&zoom=${parseInt(zoom)}`
        else shortUrl=await getBDSL(panoId,POV.heading,POV.pitch)
        return shortUrl

    }

    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 getGoogleSL(panoId, loc, h, t, z) {
        const url = 'https://www.google.com/maps/rpc/shorturl';
        const y=calculateFOV(z)
        const pb = `!1shttps://www.google.com/maps/@${loc.lat()},${loc.lng()},3a,${y}y,${h}h,${t+90}t/data=*213m7*211e1*213m5*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}%26thumbfov%3D100*217i16384*218i8192?coh=205410&entry=tts&g_ep=EgoyMDI0MDgyOC4wKgBIAVAD!2m2!1sH5TSZpaqObbBvr0PvKOJ0AI!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);
                }
            });
        });
    }

    async function getBDSL(panoId, h, t) {
        const url = 'https://j.map.baidu.com/?';
        const target = `https://map.baidu.com/?newmap=1&shareurl=1&panoid=${panoId}&panotype=street&heading=${h}&pitch=${t}&l=13&tn=B_NORMAL_MAP&sc=0&newmap=1&shareurl=1&pid=${panoId}`;

        const params = new URLSearchParams({
            url: target,
            web: 'true',
            pcevaname: 'pc4.1',
            newfrom:'zhuzhan_webmap',
            callback:'jsonp94641768'
        }).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 data = response.responseText;
                            const urlRegex = /\((\{.*?\})\)$/;
                            const match = data.match(urlRegex);
                            if (match && match[1]) {
                                const jsonData = JSON.parse(match[1].replace(/\\\//g, '/'));
                                resolve(jsonData.url)
                            } else {
                                console.log('URL not found');
                                resolve(currentLink)
                            }

                        } catch (error) {
                            reject('Failed to parse response: ' + error);
                        }
                    } else {
                        reject('Request failed with status: ' + response.status);
                    }
                },
                onerror: function(error) {
                    reject('Request error: ' + error);
                }
            });
        });
    }

    let onKeyDown =async (e) => {
        if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
            return;
        }
        if (e.key === 'p' || e.key === 'P') {
            e.stopImmediatePropagation();
            if(streakMode!='state')streakMode='state'
            else streakMode='country'
            countsTitle.textContent = streakMode === 'country' ? '国家连击' : '一级行政区连击';
            countsValue.textContent=streakCounts[mapsId][streakMode]
            localStorage.setItem('streakMode',JSON.stringify(streakMode))
            Swal.fire({
                title: '切换成功',
                text:`${streakMode === 'country' ? '国家连击' : '一级行政区连击'}连击计数器已就绪`,
                icon: 'success',
                timer: 1200,
                showConfirmButton: false,
            });
        }
        else if ((e.shiftKey)&&(e.key === 'c' || e.key === 'C')){
            const panoId=viewer.getPano()

            const currentLink=await genShortLink(panoId)
            if(currentLink){
                GM_setClipboard(currentLink, 'text');
                Swal.fire({
                    title: '复制成功',
                    text: '街景链接已复制到你的剪贴板中',
                    icon: 'success',
                    timer: 1000,
                    showConfirmButton: false,
                });
            }

        }
    }
    document.addEventListener("keydown", onKeyDown);
})();