// ==UserScript==
// @name Duolingo Unlimited Hearts
// @icon https://d35aaqx5ub95lt.cloudfront.net/images/hearts/fa8debbce8d3e515c3b08cb10271fbee.svg
// @namespace https://tampermonkey.net/
// @version 2.5
// @description Intercepts and modifies fetch Duolingo's API responses for user data with caching support.
// @author apersongithub
// @match *://www.duolingo.com/*
// @match *://www.duolingo.cn/*
// @grant none
// @run-at document-start
// @license MPL-2.0
// ==/UserScript==
// WORKS AS OF 2025-09-24
/*
* Below this is the actual fetch interception and modification logic for Unlimited Hearts
*/
(function() {
'use strict';
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) ? navigator.userAgent : '';
const isMobile = /Mobi|Android|iPhone|iPad|iPod|Opera Mini|IEMobile/i.test(ua);
if (isMobile) {
// Mobile patch with multi-request dedupe + short-term caching
const pageFn = function() {
function log(...args){ try{ console.log('[Injected][Mobile]', ...args); } catch(_){} }
// Simple in-memory cache (per page load)
const CACHE_TTL = 5000; // 5s (Duolingo may refetch rapidly on mobile)
const cache = new Map(); // key -> { data, ts, status, statusText, headers }
const inFlight = new Map(); // key -> Promise
function isUserDataUrl(u){
return typeof u === 'string' && u.includes('/2017-06-30/users/');
}
function cacheKey(u){
return (u && typeof u === 'string') ? u.split('?')[0] : u;
}
function buildResponse(entry){
return new Response(JSON.stringify(entry.data), {
status: entry.status,
statusText: entry.statusText,
headers: entry.headers
});
}
function storeCache(key, resMeta){
cache.set(key, { ...resMeta, ts: Date.now() });
}
function getValidCache(key){
const c = cache.get(key);
if (!c) return null;
if (Date.now() - c.ts > CACHE_TTL){
cache.delete(key);
return null;
}
return c;
}
function ensureHearts(data){
if (data && data.health) {
data.health.unlimitedHeartsAvailable = true;
}
}
function applyPatches() {
// Patch Response.json (fallback / double assurance)
try {
if (!Response.prototype.json.__patched) {
const origJson = Response.prototype.json;
Response.prototype.json = async function() {
try {
const url = this.url || '';
if (isUserDataUrl(url)) {
const text = await this.clone().text();
let data;
try { data = JSON.parse(text); } catch(_) { return origJson.apply(this, arguments); }
ensureHearts(data);
return data;
}
} catch(e) { log('json error', e); }
return origJson.apply(this, arguments);
};
Response.prototype.json.__patched = true;
log('Patched Response.json');
}
} catch(e) { log('Failed to patch Response.json', e); }
// Patch fetch with dedupe + short-term cache
try {
if (!window.fetch.__patched) {
const origFetch = window.fetch;
window.fetch = async function(url, init) {
const u = (typeof url === 'string') ? url : (url && url.url) || '';
if (!isUserDataUrl(u)) {
return origFetch.apply(this, arguments);
}
const key = cacheKey(u);
// Serve fresh cache if valid
const cached = getValidCache(key);
if (cached) {
log('Serving cached user data', key);
return buildResponse(cached);
}
// Deduplicate concurrent requests
if (inFlight.has(key)) {
log('Awaiting in-flight request', key);
try { await inFlight.get(key); } catch(_) {}
const after = getValidCache(key);
if (after) return buildResponse(after);
// fall through to refetch if something failed
}
const p = (async () => {
const res = await origFetch.apply(this, arguments);
let data, txt;
try {
txt = await res.clone().text();
data = JSON.parse(txt);
} catch(_) {
return res; // Non-JSON; just return original
}
ensureHearts(data);
const headers = {};
res.headers.forEach((v,k)=> headers[k]=v);
const entry = {
data,
status: res.status,
statusText: res.statusText,
headers
};
storeCache(key, entry);
return buildResponse(entry);
})();
inFlight.set(key, p);
let finalRes;
try {
finalRes = await p;
} finally {
inFlight.delete(key);
}
return finalRes;
};
window.fetch.__patched = true;
log('Patched fetch (dedupe + cache)');
}
} catch(e) { log('Failed to patch fetch', e); }
}
applyPatches();
// Reapply periodically (every 2s for ~30s)
let count = 0;
const interval = setInterval(() => {
applyPatches();
count++;
if (count > 15) clearInterval(interval);
}, 2000);
// Watchdog (light) every 5s
setInterval(() => {
if (!Response.prototype.json.__patched || !window.fetch.__patched) {
log('Re-patching via watchdog');
applyPatches();
}
}, 5000);
log('Mobile persistent injection (with multi-fetch handling) running');
};
try {
const code = '(' + pageFn.toString() + ')();';
const blob = new Blob([code], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const s = document.createElement('script');
s.src = url;
s.onload = function(){ URL.revokeObjectURL(url); s.remove(); };
(document.head || document.documentElement).appendChild(s);
} catch(e) {
const s = document.createElement('script');
s.textContent = '(' + pageFn.toString() + ')();';
(document.head || document.documentElement).appendChild(s);
s.remove();
}
} else {
// Desktop (PC) lightweight cached fetch patch only
const script = document.createElement('script');
script.textContent = `
(function() {
const originalFetch = window.fetch;
const CACHE_KEY = 'user_data_cache';
const CACHE_EXPIRATION_TIME = 5 * 60 * 1000; // 5 minutes
window.fetch = async function(url, config) {
if (typeof url === 'string' && url.includes('/2017-06-30/users/')) {
console.log('[Injected][PC] Intercepting fetch:', url);
try {
const cachedData = localStorage.getItem(CACHE_KEY);
if (cachedData) {
const parsed = JSON.parse(cachedData);
if (Date.now() - parsed.timestamp < CACHE_EXPIRATION_TIME) {
console.log('[Injected][PC] Returning cached data');
return new Response(JSON.stringify(parsed.data), {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' }
});
} else {
localStorage.removeItem(CACHE_KEY);
}
}
} catch(e){ console.log('[Injected][PC] Cache read error', e); }
const response = await originalFetch(url, config);
let data;
try {
data = await response.clone().json();
} catch(e) {
return response;
}
if (data && data.health) {
data.health.unlimitedHeartsAvailable = true;
}
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() }));
} catch(e){ console.log('[Injected][PC] Cache write error', e); }
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
return originalFetch(url, config);
};
console.log('[Injected][PC] Lightweight fetch interceptor active');
})();
`;
(document.head || document.documentElement).appendChild(script);
}
/*
* UI buttons + attribution (common to both platforms)
*/
const updateVp1giElements = () => {
document.querySelectorAll('.vp1gi').forEach(el => {
const span = document.createElement('span');
span.className = '_3S2Xa';
span.innerHTML = 'Created by <a href="https://github.com/apersongithub" target="_blank" style="color:#07b3ec">apersongithub</a>';
el.replaceWith(span);
});
};
const addCustomButtons = (targetNode) => {
if (!targetNode) return;
if (!targetNode.querySelector('[data-custom="max-extension"]')) {
const maxContainer = document.createElement('div');
maxContainer.className = '_2uJd1';
const maxButton = document.createElement('button');
maxButton.className = '_2V6ug _1ursp _7jW2t uapW2';
maxButton.dataset.custom = 'max-extension';
maxButton.addEventListener('click', () => {
window.open('https://github.com/apersongithub/Duolingo-Unlimited-Hearts/tree/main', '_blank');
});
const wrapper = document.createElement('div');
wrapper.className = '_2-M1N';
const imgWrap = document.createElement('div');
imgWrap.className = '_3jaRf';
const img = document.createElement('img');
img.src = 'https://d35aaqx5ub95lt.cloudfront.net/images/max/9f30dad6d7cc6723deeb2bd9e2f85dd8.svg';
img.style.height = '36px';
img.style.width = '36px';
imgWrap.appendChild(img);
const textWrap = document.createElement('div');
textWrap.className = '_2uCBj';
const titleDiv = document.createElement('div');
titleDiv.className = '_3Kmn9';
titleDiv.textContent = 'Duoingo Max Extension';
textWrap.appendChild(titleDiv);
const subWrap = document.createElement('div');
subWrap.className = 'k5zYn';
const subDiv = document.createElement('div');
subDiv.className = '_3l5Lz zfGJk';
if (/Android/i.test(ua)) {
subDiv.textContent = 'get for firefox android or pc';
subDiv.style.color = '#07b3ec';
} else if (isMobile) {
subDiv.textContent = 'PC/ANDROID ONLY';
subDiv.style.color = 'red';
} else {
subDiv.textContent = 'Get IT free';
}
subWrap.appendChild(subDiv);
wrapper.appendChild(imgWrap);
wrapper.appendChild(textWrap);
wrapper.appendChild(subWrap);
maxButton.appendChild(wrapper);
maxContainer.appendChild(maxButton);
const firstButtonContainer = targetNode.querySelector('._2uJd1');
if (firstButtonContainer && firstButtonContainer.nextSibling) {
targetNode.insertBefore(maxContainer, firstButtonContainer.nextSibling);
} else {
targetNode.appendChild(maxContainer);
}
}
if (!targetNode.querySelector('.donate-button-custom')) {
const buttonContainer = document.createElement('div');
buttonContainer.className = '_2uJd1';
const donateButton = document.createElement('button');
donateButton.className = '_1ursp _2V6ug _2paU5 _3gQUj _7jW2t rdtAy donate-button-custom';
donateButton.addEventListener('click', () => {
window.open('https://html-preview.github.io/?url=https://raw.githubusercontent.com/apersongithub/Duolingo-Unlimited-Hearts/refs/heads/main/extras/donations.html', '_blank');
});
const buttonText = document.createElement('span');
buttonText.className = '_9lHjd';
buttonText.style.color = '#d7d62b';
buttonText.textContent = '💵 Donate';
donateButton.appendChild(buttonText);
buttonContainer.appendChild(donateButton);
targetNode.appendChild(buttonContainer);
}
};
const setupObservers = () => {
if (!document.body) {
setTimeout(setupObservers, 50);
return;
}
const observerCallback = () => {
const targetElement = document.querySelector('._2wpqL');
if (targetElement) {
addCustomButtons(targetElement);
updateVp1giElements();
}
};
const observer = new MutationObserver(observerCallback);
observer.observe(document.body, { childList: true, subtree: true });
observerCallback();
};
setupObservers();
})();