Intercepts and modifies fetch Duolingo's API responses to give Unlimited Hearts.
// ==UserScript==
// @name Duolingo Unlimited Hearts
// @icon https://d35aaqx5ub95lt.cloudfront.net/images/hearts/fa8debbce8d3e515c3b08cb10271fbee.svg
// @namespace https://tampermonkey.net/
// @version 3.4.1
// @description Intercepts and modifies fetch Duolingo's API responses to give Unlimited Hearts.
// @author apersongithub
// @match *://*.duolingo.com/*
// @match *://*.duolingo.cn/*
// @grant none
// @run-at document-start
// @license MPL-2.0
// ==/UserScript==
// WORKS AS OF 2025-11-22
/*
* 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);
// Helper to validate if data is a full profile (prevents NaN bugs)
function isFullProfile(data) {
if (!data) return false;
return (data.gems !== undefined || data.lingots !== undefined || data.rupees !== undefined);
}
if (isMobile) {
// --- MOBILE LOGIC ---
const pageFn = function() {
function log(...args){ try{ console.log('[Injected][Mobile]', ...args); } catch(_){} }
const CACHE_TTL = 5000;
const cache = new Map();
const inFlight = new Map();
// --- UPDATED FUNCTION TO HANDLE ALL DATES ---
function isUserDataUrl(u){
if (typeof u !== 'string') return false;
if (u.includes('/shop-items')) return false;
// Using Regex to match ANY date format (2017-06-30, 2023-05-23, etc.)
return /\/\d{4}-\d{2}-\d{2}\/users\//.test(u);
}
// --------------------------------------------
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){
if (isFullProfile(resMeta.data)) {
cache.set(key, { ...resMeta, ts: Date.now() });
}
}
// Re-declare for injected scope
function isFullProfile(data) {
if (!data) return false;
return (data.gems !== undefined || data.lingots !== undefined || data.rupees !== undefined);
}
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
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;
}
} catch(e) { log('Failed to patch Response.json', e); }
// Patch fetch
try {
if (!window.fetch.__patched) {
const origFetch = window.fetch;
window.fetch = async function(url, init) {
// Detect method even if 'url' is a Request object
let method = 'GET';
if (init && init.method) {
method = init.method.toUpperCase();
} else if (url instanceof Request && url.method) {
method = url.method.toUpperCase();
}
// If NOT GET, stop.
if (method !== 'GET') {
return origFetch.apply(this, arguments);
}
const u = (typeof url === 'string') ? url : (url && url.url) || '';
if (!isUserDataUrl(u)) return origFetch.apply(this, arguments);
const key = cacheKey(u);
const cached = getValidCache(key);
if (cached) return buildResponse(cached);
if (inFlight.has(key)) {
try { await inFlight.get(key); } catch(_) {}
const after = getValidCache(key);
if (after) return buildResponse(after);
}
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;
}
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;
}
} catch(e) { log('Failed to patch fetch', e); }
}
applyPatches();
let count = 0;
const interval = setInterval(() => { applyPatches(); count++; if (count > 15) clearInterval(interval); }, 2000);
setInterval(() => { if (!Response.prototype.json.__patched || !window.fetch.__patched) applyPatches(); }, 5000);
};
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 LOGIC (Already regex compatible) ---
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;
const USER_DATA_REGEX = /\\/\\d{4}-\\d{2}-\\d{2}\\/users\\//;
window.fetch = async function(url, config) {
// Method check
let method = 'GET';
if (config && config.method) {
method = config.method.toUpperCase();
} else if (url instanceof Request && url.method) {
method = url.method.toUpperCase();
}
if (method !== 'GET') return originalFetch(url, config);
const urlString = (typeof url === 'string') ? url : (url.url || '');
if (urlString.includes('/shop-items')) return originalFetch(url, config);
if (USER_DATA_REGEX.test(urlString)) {
const cachedData = localStorage.getItem(CACHE_KEY);
if (cachedData) {
const parsedCache = JSON.parse(cachedData);
if (Date.now() - parsedCache.timestamp < CACHE_EXPIRATION_TIME) {
return new Response(JSON.stringify(parsedCache.data), {
status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json' }
});
} else {
localStorage.removeItem(CACHE_KEY);
}
}
const response = await originalFetch(url, config);
const clonedResponse = response.clone();
let data;
try { data = await clonedResponse.json(); } catch (e) { return response; }
if (data.health) data.health.unlimitedHeartsAvailable = true;
// Integrity Check
const hasCurrency = (data.gems !== undefined || data.lingots !== undefined || data.rupees !== undefined);
if (hasCurrency) {
const cachePayload = { data: data, timestamp: Date.now() };
localStorage.setItem(CACHE_KEY, JSON.stringify(cachePayload));
}
return new Response(JSON.stringify(data), {
status: response.status, statusText: response.statusText, headers: response.headers
});
}
return originalFetch(url, config);
};
})();
`;
(document.head || document.documentElement).appendChild(script);
}
// --- UI & Buttons ---
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.cssText = 'height:36px;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.append(imgWrap, textWrap, 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();
})();