Greasy Fork is available in English.

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: