CF-virtual-pretest

Show only pretest result when participating in virtual contest in Codeforces

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         CF-virtual-pretest
// @version      0.1.1
// @description  Show only pretest result when participating in virtual contest in Codeforces
// @match        *://codeforces.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-start
// @namespace    https://greasyfork.org/users/410786
// ==/UserScript==


(function(){

	//OK:1,
	//COMPILATION_ERROR:1,
	//CRASHED:1, // example: https://codeforces.com/contest/566/submission/42421894
	//FAILED:1,  // example: https://codeforces.com/contest/566/submission/16877130

	/*
	const wrong_verdicts={
		WRONG_ANSWER:1,
		TIME_LIMIT_EXCEEDED:1,
		RUNTIME_ERROR:1,
		MEMORY_LIMIT_EXCEEDED:1,
		IDLENESS_LIMIT_EXCEEDED:1,

		0:0}*/

	const pretest_passed_verdicts={
		SKIPPED:1,
		CHALLENGED:1, // aka hacked

		0:0}

	function getPassedTestCount(x){
		return x.passedTestCount
	}

	//let csrf_token=Codeforces.getCsrfToken()
	//let csrf_token=document.getElementsByName('X-Csrf-Token')[0].content
	let csrf_token=undefined // TODO

	function parseCsrfToken(html){
		if(typeof(html)==='string')
			html=$.parseHTML(html)
		return html.find(z=>z.name=='X-Csrf-Token').content
	}

	let logged_out=false // When the user is taking part in a virtual contest, it
	// isn't possible to get submission result directly.

	async function isValid(submissionId){
		//return true; // HACK TODO 403 in virtual participation

		// Return whether a skipped submission has no
		// wrong answer/run time error/memory limit exceeded/etc. test case, excluding hacks (can
		// happen when the user cheated in the contest). Example
		// https://codeforces.com/contest/1221/submission/60879848.

		/*
		let data=$.post('//codeforces.com/data/submitSource',{
			submissionId: submissionId,
			csrf_token:Codeforces.getCsrfToken()
		})
		*/

		console.log('isValid',submissionId)
		const options={
			url: '//codeforces.com/data/submitSource',
			type:'post',
			data: {submissionId: submissionId},
			headers: {'X-Csrf-Token': csrf_token},
			dataType: 'json',
		}

		let data
		try{
			console.log('url = ',options)
			data=await $.ajax(options)
		}catch(e){
			/*
			if(e.status===403){
				const match=location.href.match('://codeforces.com/contest/(\\d+)')
				const url=match ? '://codeforces.com/contest/'+match[1]+'/my?force_get=1' : undefined
				document.body.innerHTML=(
					'Failed to load. If you are taking part in a virtual contest, please open ' +
					(match? `<a href="${url}">${url}</a>`: url) +
					' in an incognito window, then reload this page.'
				)
			}
			*/

			console.log('trying again with new csrf_token | error =',e)
			options.headers['X-Csrf-Token']=csrf_token=parseCsrfToken(await $.get('/'))
			console.log('new csrf=',csrf_token)
			try{
				data=await $.ajax(options)
			}catch(e){
				console.log('trying again after logging out | error =',e)
				options.headers['X-Csrf-Token']=csrf_token=parseCsrfToken(
					await $.ajax({
						type:'get',
						url:document.querySelector('[href$="logout"]').href,
						headers:{'X-Csrf-Token': csrf_token},
					}))
				logged_out=true
				console.log('new csrf=',csrf_token)
				try{
					data=await $.ajax(options)
				}catch(e){
					console.log('??? | error =',e)
					throw e
				}
			}
		}

		return Object.keys(data).filter(
			x=>x.startsWith('verdict#')&&data[x]!='OK'
		).length==0
	}

	function pretestCountFetched(contestId){
		const key='pretest_count_'+contestId
		return GM_getValue(key)!==undefined
	}

	async function getPretestCount(contestId){
		if(searchParams.has('mock_pretest_count'))
			return new Proxy({}, { get: _=>[10, 20] })

		const key='pretest_count_'+contestId
		{
			const stored_result=GM_getValue(key)
			if(stored_result!==undefined)
				return JSON.parse(stored_result)
		}

		console.log('aaa')
		const data=await (async function(url){
			// cache the GET requests for development purposes
			let data

			//data=GM_getValue('stored_api_get_'+url)
			//if(data!==undefined)
			//	return JSON.parse(data)

			data=await $.get(url)

			// compress data
			data.result=data.result.filter(x=>x.author.participantType=="CONTESTANT")
			console.log('length=',data.result.length)

			// NOT WORK - always exceed the quota
			//GM_setValue('stored_api_get_'+url, JSON.stringify(data))

			return data
		})('//codeforces.com/api/contest.status?contestId='+contestId+'&from=1&count=100000000')

		console.log('bbb')
		let result={} // {problemIndex /* A/B/C/... */: [minPretestCount, maxPretestCount]}
		for(const problemIndex of new Set(data.result.map(x=>x.problem.index))){
			let problemResult=data.result.filter(x=>
				x.problem.index==problemIndex&&
				x.author.participantType=="CONTESTANT"
			)
			let minPretestCount,maxPretestCount

			s1=problemResult.filter(x=>
				pretest_passed_verdicts[x.verdict]&&x.passedTestCount!=0
				// it's possible for SKIPPED submissions to have 0 tests passed when
				// the user submits the second solution before the first one is judged
			)
			s1.sort((a,b)=>b.passedTestCount-a.passedTestCount)
			if(s1.length!=0&&(await isValid(s1[0].id))){
				minPretestCount=maxPretestCount=s1[0].passedTestCount
			}else{
				minPretestCount=1+Math.max(...
					problemResult.filter(
						// x=>x.testset=="PRETESTS"&&wrong_verdicts[x.verdict]
						// cannot be "skipped" -> must fail on pretest
						x=>x.testset=="PRETESTS"
					).map(getPassedTestCount)
				)
				maxPretestCount=Math.min(...
					problemResult.filter(
						x=>x.testset=="TESTS"
					).map(getPassedTestCount)
				)
			}
			result[problemIndex]=[minPretestCount,maxPretestCount]
		}

		GM_setValue(key,JSON.stringify(result))
		console.log(result)

		if(logged_out){
			document.body.innerHTML='You are logged out. Please refresh the page.'
			location.reload()
		}

		return result
	}

	/*
	function getContestId(){
		return location.pathname.match('^/contest/(\\d+)')[1]
	}
	const contestId=getContestId()
	*/

	let searchParams=new URL(location).searchParams
	// always_show, reset_button, mock_pretest_count, force_get

	let cache={} // problemId -> result
	let cacheSubmissions={} // submissionId -> item
	let participantId // assume participantId is fixed

	/*
    function get_csrf_token(){ // use Codeforces.getCsrfToken()
        return csrf_token
    }
	*/

	if(location.href.match('://codeforces.com/contestRegistration/\\d*/virtual/true')){
		const contestId=location.href.match('://codeforces.com/contestRegistration/(\\d*)/virtual/true')[1]
		if(pretestCountFetched(contestId))
			return
		window.addEventListener('load',function(){
			if(!confirm('Do you want to prefetch the pretest count of this contest?')){
				alert("Note: the pretest will still be fetched inside the contest, and that may log you out. "+
					"If you don't want that to happen, you should disable the script.")
				return
			}
			const registerButton=document.querySelector('[value="Register for virtual participation"]')
			if(registerButton===null){
				$.jGrowl('Something unexpected happened. Please wait until "Done" is displayed before registering.')
			}else{
				registerButton.disabled=true
				registerButton.value="Fetching pretest data..."
			}
			getPretestCount(contestId).then(function(){
				$.jGrowl('Done! You can register now.')
				location.reload()
			})
		})
	}else if(searchParams.has('force_get')&&location.href.match('://codeforces.com/contest/\\d*/my')){
		console.log('force_get')
		window.addEventListener('load',function(){
			const contestId=location.href.match('://codeforces.com/contest/(\\d*)/my')[1]
			getPretestCount(contestId).then(function(){
				let url=new URL(location)
				url.searchParams.delete('force_get')
				document.body.innerHTML=`Done. Redirecting to <a href="${url}">${url}</a>...`
				location.href=url
			})
		})
	}else if(searchParams.has('always_show')||location.href.match('://codeforces.com/contest/\\d*/my')){
		console.log('start')
		function restoreAll(){
			observer.disconnect()
			document.querySelectorAll('span').forEach(function(t){
				if(t.__oldTextContent!==undefined){
					t.textContent=t.__oldTextContent
					t.className=t.__oldClassName
				}
			})
		}

		let button
		function clickResetButton(){
			restoreAll()
			if(searchParams.has('reset_button')){
				document.body.removeChild(button)
				button=undefined
			}
		}
		function createResetButton(){
			if(button===undefined){
				if(searchParams.has('reset_button')){
					button=document.createElement('button')
					button.innerHTML='Reset'
					button.onclick=clickResetButton
					document.body.appendChild(button)
				}
			}
		}

		let pendingNodes=[]
		let getPretestCountRunning=false
		let pretestCount={}

		function processPendingNodes(){
			let oldPendingNodes=pendingNodes
			pendingNodes=[]
			oldPendingNodes.forEach(processSpan)
		}

		const loadingText='Loading...'

		function processSpan(t){
			if(t.textContent==='Running') // before any test
				return
			console.log('processSpan',t.textContent)
			let modified=t.textContent===loadingText||t.textContent==='Pretest passed'||t.textContent.includes(' pretest ')

			if(!modified){
				t.__oldTextContent=t.textContent
				t.__oldClassName=t.className
			}

			let contestId,problemIndex
			{
				let tableRow=t
				console.log('tableRow=',tableRow)
				while(tableRow.tagName!='TR'){
					tableRow=tableRow.parentNode
					console.log('tableRow=',tableRow)
					if(tableRow===null){
						console.log('??? not added to document?')
						return
					}
				}
				const problemUrl=tableRow.children[3].children[0].href
				const match=problemUrl.match('/contest/(\\d*)/problem/(.*)$$$')||problemUrl.match('/problemset/problem/(\\d*)/(.*)$$$')
				// the second format is only used in problemset status page (when always_show is on)
				contestId=match[1]
				problemIndex=match[2]
			}

			if(pretestCount[contestId]===undefined||pretestCount[contestId]==='running'){
				t.textContent=loadingText
				t.className=''
				pendingNodes.push(t)

				if(pretestCount[contestId]!=='running'){
					getPretestCount(contestId).then(function(result){
						pretestCount[contestId]=result
						processPendingNodes() // for pages different from contest/my this may cause the span to be push back to pendingNodes list
					})
					pretestCount[contestId]='running'
				}
				return
			}

			if(['Accepted','Happy New Year!'].includes(t.__oldTextContent)){
				createResetButton()
				t.textContent='Pretest passed'
				t.className=t.__oldClassName
				return
			}

			t.classList.replace('verdict-accepted','verdict-rejected')

			if(!(
				t.__oldClassName.match(/\bverdict-rejected\b/)||
				t.__oldClassName.match(/\bverdict-waiting\b/)
			)) throw new Error


			try{
				let wrongTestIndex=t.__oldTextContent.match(/ on test (\d+)$/)[1]
				if(wrongTestIndex<=pretestCount[contestId][problemIndex][0]){
					t.textContent=t.__oldTextContent.replace('on test','on pretest')
					t.className=t.__oldClassName // rejected || waiting
				}else if(wrongTestIndex<=pretestCount[contestId][problemIndex][1]){
					t.textContent='???'
					t.className=''
				}else{
					t.textContent='Pretest passed'
					t.className='verdict-accepted'
				}
			}catch(e){
				console.log(t.__oldTextContent,e)
			}

			//let problemId=tableRow.children[3].getAttribute('data-problemId') // int-parseable string
			//let submissionId=parseInt(tableRow.children[0].textContent)

			//if(participantId===undefined)
			//	participantId=parseInt(tableRow.children[2].getAttribute('data-participantId'))
		}

		let observer=new MutationObserver(function(mutations, observer){
			for (let r of mutations){
				for (let t of r.addedNodes){ // t must be in local scope
					if(t.tagName==='SPAN'){
						// t.classList.contains('contest-state-phase') // 'Contest is running' | 'Finished'
						if(t.classList.contains('contest-state-regular')&&!t.classList.contains('countdown')){
							console.log('contest-state = ',t,t.textContent)
							if(
								t.querySelector('.toggle-favourite')===null&&
								t.textContent!=='Virtual Participation' // | 'Practice' | '???'
								&&!searchParams.has('always_show')
							){
								console.log('bad state')
								clickResetButton()
								observer.disconnect()
								return
							}
						}else if(t.classList.contains('verdict-accepted')||t.classList.contains('verdict-rejected')||t.classList.contains('verdict-waiting')){
							processSpan(t)
						}
					}else if(t.tagName==='DIV'){
						// jGrowl
						if(t.classList.contains('jGrowl-notification')){
							let z=t.getElementsByClassName('message')
							if(z.length!==0&&
								z[0].textContent.match(/^Accepted$| on test \d+$/)
							)
								z[0].textContent='???'
						}
					}
				}
			}
		})

		observer.observe(document,{
			childList:true,
			subtree:true,
			attributes:true
		});
	}
})()


// TODO incomplete (rewrite standings table)

// vim: set ts=4 sw=4 fdm=indent: