Skip to content

Instantly share code, notes, and snippets.

@simonwep
Last active December 30, 2021 17:30
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save simonwep/9341a5881f6b89973305d9f3291d94fa to your computer and use it in GitHub Desktop.
Save simonwep/9341a5881f6b89973305d9f3291d94fa to your computer and use it in GitHub Desktop.
Adds a few features to clozemaster.com to practice even faster
// ==UserScript==
// @name Closemaster enhanced
// @namespace http://tampermonkey.net/
// @version 4.1.2
// @description Adds a few features to clozemaster.com to practice even faster
// @author You
// @match https://*.clozemaster.com/*
// @grant none
// @run-at document-start
// @downloadURL https://gist.githubusercontent.com/Simonwep/9341a5881f6b89973305d9f3291d94fa/raw
// @updateURL https://gist.githubusercontent.com/Simonwep/9341a5881f6b89973305d9f3291d94fa/raw
// ==/UserScript==
(() => {
const locationPieces = document.location.href.match(/\/(play|review)/);
const location = locationPieces ? locationPieces[1] : document.location.href.match(/\/l\/[a-z-]+$/) ? 'dashboard' : null;
const dayId = (new Date()).toDateString().replace(/ +/g, '-').toLowerCase();
if (!location) {
console.warn('[CLOZEMASTER-TP] Unknown location.');
return;
}
/**
* Inject a middleware function in a object or instance
* @param ctx Object or instance
* @param fn Function name
* @param middleware Middleware function
* @param transform Transform function result
*/
function inject({
ctx,
fn,
middleware,
transform
}) {
const original = ctx[fn];
ctx[fn] = function () {
if (!middleware || middleware.call(this, ...arguments) !== false) {
const result = original.call(this, ...arguments);
return transform ? transform.call(this, result, ...arguments) : result;
}
};
}
/**
* Queries an element asynchron
*/
async function query(query, base = document, interval = 250) {
return new Promise(resolve => {
const iv = setInterval(() => {
if (Array.isArray(query)) {
const els = query.map(v => base.querySelector(v));
if (els.every(Boolean)) {
clearInterval(iv);
return resolve(els);
}
} else {
const el = base.querySelector(query);
if (el) {
clearInterval(iv);
return resolve(el);
}
}
}, interval);
});
}
/**
* Executes each function in a different animation frameElement
*/
async function chainAnimationFrames(...fns) {
return new Promise(resolve => {
const nextFrame = (fn, ...next) => {
if (!fn) {
return resolve();
}
requestAnimationFrame(() => {
fn();
nextFrame(...next);
});
};
nextFrame(...fns);
});
}
if (location === 'play' || location === 'review') {
let pointsToday = JSON.parse(localStorage.getItem(`ce-points-${dayId}`));
let pointsOfRound = 0;
let durations = [performance.now()];
let lastWord = null;
const updatePoints = () => {
const score = pointsToday + pointsOfRound;
query('.content .logo').then(el => {
el.innerHTML = score ? `${score} points!` : 'Nothing scored so far...';
});
}
// Fetch clozes
let fastTrack = null;
inject({
ctx: XMLHttpRequest.prototype,
fn: 'send',
middleware() {
this.addEventListener('loadend', () => {
if (this.responseURL.match(/api.*fluency-fast-track/) && !fastTrack) {
console.log('[CLOZEMASTER-TP] Clozeables stored.');
fastTrack = JSON.parse(this.responseText);
}
});
}
});
// ==================================================================================
// ===== AUTO START NEXT ROUND IF CURRENT ONE IS OVER
// ==================================================================================
query('.num.correct').then(counter => {
console.log('[CLOZEMASTER-TP] Auto-next-round active.', counter);
new MutationObserver(() => {
if (Number(counter.innerText) === 10) {
window.location.reload();
}
}).observe(counter, {
characterData: true,
subtree: true,
childList: true
});
});
// ==================================================================================
// 1. AUTOMATICALLY SUBMIT WORD IF CORRECT
// 2. ADD SHORTCUT CTRL + ? TO INSERT FIRST CHARACTER AS SMALL HINT
// ==================================================================================
query([
'input.input',
'.clozeable .translation'
]).then(([userInput, translation]) => {
console.log('[CLOZEMASTER-TP] Auto-submit active.');
console.log('[CLOZEMASTER-TP] CTRL + ? hint active.');
const getAnswer = () => {
const {
collectionClozeSentences
} = fastTrack;
const {
value
} = userInput;
const translationText = translation.innerText.trim();
const cloze = collectionClozeSentences.find(v => v.translation === translationText);
return cloze.text.match(/\{\{(.*?)\}\}/)[1];
};
const isValidAnswer = str => {
return str === getAnswer().trim().toLowerCase();
};
let previousScore = 0;
const autoSubmit = () => chainAnimationFrames(
// Accept
() => document.querySelector('.clozeable button.btn-success').click(),
// Submit
() => document.querySelector('.clozeable button.btn-success').click(),
// Update score to next leaderboard position
async () => {
const cur = Number((await query('.score.total .value')).innerText);
if (typeof cur !== 'number') {
throw new Error('[CLOZEMASTER-TP] Failed to update score.');
}
pointsOfRound = cur;
updatePoints();
}
);
// dict.cc shortcut
userInput.addEventListener('keydown', e => {
if (lastWord && e.key === '?' && e.ctrlKey) {
window.open(`https://dict.cc/?s=${encodeURIComponent(lastWord)}`, '_blank');
e.stopPropagation();
e.preventDefault();
}
});
// One character hint
userInput.addEventListener('keydown', e => {
const answer = getAnswer();
if (e.key === 'ß' && e.ctrlKey) {
userInput.value = answer[0];
e.stopPropagation();
e.preventDefault();
}
});
let blocked = false; // Prevents submitting too fast
userInput.addEventListener('keyup', e => {
const answer = getAnswer();
if (blocked) {
e.preventDefault();
} else if (!answer) {
throw new Error('[CLOZEMASTER-TP] Failed to find answer.');
} else if (e.code === 'Enter') {
durations[durations.length - 1] = performance.now();
lastWord = answer;
return; // Prevent skipping if answer was wrong
}
// Validate input
if (isValidAnswer(userInput.value.trim().toLowerCase())) {
const prev = durations[durations.length - 1];
const end = performance.now();
lastWord = answer;
durations.push(end);
// Submissions under 1s won't get counted, if you're faster than
// that wait the remaining time before submitting the word.
if (end - prev < 1000) {
blocked = true;
setTimeout(autoSubmit, end - prev);
} else {
autoSubmit();
}
}
});
});
// ==================================================================================
// SHOW AMOUNT OF POINTS
// ==================================================================================
window.addEventListener('beforeunload', () => {
localStorage.setItem(`ce-points-${dayId}`, JSON.stringify(pointsToday + pointsOfRound));
});
// ==================================================================================
// AVG ANSWER SPEED
// ==================================================================================
query([
'.row.status > .text-center',
'.stats.row > .text-left'
]).then(([avgCounter, currentCounter]) => {
avgCounter.insertAdjacentHTML('afterend', `
<div class="col-xs-4 text-center">
<span class="hidden-xs">AVG Speed:</span>
<span class="num avg-speed" style="transition: all 1s linear">???</span>
</div>
`);
currentCounter.insertAdjacentHTML('afterend', `
<div class="joystix cur-speed-wrapper">
<p class="cur-speed" style="transition: all 1s linear">0</p>
</div>
`);
const curSpeedEl = currentCounter.parentElement.querySelector('.cur-speed');
const avgSpeedEl = avgCounter.parentElement.querySelector('.avg-speed');
const timeLimit = 10; // Ten seconds
const updateContentForEl = (el, time) => {
const hue = time > timeLimit ? 0 : Math.max(120 - (time / timeLimit) * 120, 0);
el.style.color = `hsl(${hue}, 100%, 50%)`;
el.innerHTML = time < 0 ? '<1s' : `${time}s`;
};
// Update the counter each second
setInterval(() => {
const diff = Math.round((performance.now() - durations[durations.length - 1]) / 1000);
// Adjust color
updateContentForEl(curSpeedEl, diff);
if (durations.length < 2) {
updateContentForEl(avgSpeedEl, diff);
} else {
// Update average speed
const total = durations.reduce((acc, cur, idx, src) => {
return acc + (idx > 0 ? src[idx] - src[idx - 1] : 0);
}, 0) / (durations.length - 1);
updateContentForEl(avgSpeedEl, Math.ceil(total / 1000));
}
}, 1000);
});
updatePoints();
} else if (location === 'dashboard') {
// SAVE CURRENT POINTS
query('.points .value').then(val => {
const num = Number(val.innerText);
!Number.isNaN(num) && localStorage.setItem(`ce-points-${dayId}`, JSON.stringify(num));
});
// INSTANT REDIRECT TO REVIEW
query('button.review')
.then(review => review.addEventListener('click', () => {
setTimeout(() => { // Seriously, what is this shit??? Three modals???
document.querySelector('.modal.in tbody tr:last-child td button').click();
setTimeout(() => document.querySelector('.modal.in .btn-primary').click(), 500);
}, 500);
}));
// INSTANT REDIRECT TO FLUENT FAST-TRACK
query('.panel-body .btn-success')
.then(play => play.addEventListener('click', () => {
setTimeout(() => document.querySelector('.modal.in .btn-success').click(), 500);
}));
}
// ==================================================================================
// IMMEDIATLY SWITCH TO DARK MODE IF USER PREFERS THAT; PREVENTS THEME-FLASHING
// ==================================================================================
if (matchMedia('(prefers-color-scheme: dark)').matches) {
console.log('[CLOZEMASTER-TP] Dark-mode transition active.');
// Thankfully most of the content is transparent, that way we can make the body's
// background back to ensure a flawless transition on load :)
window.addEventListener('DOMContentLoaded', () => {
document.body.style.background = '#000';
});
}
// ==================================================================================
// HIDE PROMOTIONS; HIDE PRO-CONTROLS
// ==================================================================================
const style = document.createElement('style');
style.innerHTML = `
footer.footer, /* USELESS AND DISTRACTING */
.promo, /* PROMOTION */
.pro-controls, /* PRO STUFF */
.modal.review, /* PRO STUFF */
.modal.play, /* PRO STUFF */
.modal-backdrop, /* CONFUSING */
.round-complete, /* REDUNDANT */
.clozeable .btn-success, /* REDUNDANT */
.clozeable .controls, /* ANNOYING */
.round-complete-banner, /* ANNOYING */
#levelup-modal, /* ANNOYING */
.clozeable > .text-right, /* ANNOYING */
.playing-name, /* ANNOYING */
.stats.row .score.current, /* WE GOT THE TIMER INSTEAD */
.round-complete ~ .container .row.nextback {
display: none !important;
}
/* A few more layout adjustements to make it look better without footer */
.clozeable,
.sentence {
margin-top: 5vh !important;
}
/* Prettier input field */
input.input {
width: 131.977px;
padding: 0.1em 0.25em;
box-sizing: content-box;
outline: none;
border: none;
border-radius: 0.075em;
border: 1px solid black;
transition: box-shadow 0.3s, color 0.3s !important;
}
input.input:focus {
box-shadow: 0 0 0 1px white;
}
.row.status,
.stats.row {
display: flex !important;
justify-content: space-between;
}
.row.status > .col-xs-4,
.stats.row > div {
width: auto !important;
float: none !important;
margin: unset !important;
}
.stats.row > .clearfix {
display: none;
}
.cur-speed-wrapper {
display: flex;
align-items: center;
font-size: 2em;
}
body {
background: black !important;
}
`;
query('body').then(body => body.appendChild(style));
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment