// ==UserScript==
// @name Duolingo Super
// @icon https://d35aaqx5ub95lt.cloudfront.net/images/hearts/b3a04a561c7d0b2b5247a40e18d64946.svg
// @namespace https://tampermonkey.net/
// @version 1.2
// @description Intercepts Duolingo's API Responses
// @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-10-13
/*
* Below this is the actual fetch interception and modification logic for Unlimited Hearts
*/
(function() {
'use strict';
// --- Configuration ---
const TARGET_URL_REGEX = /https:\/\/www\.duolingo\.com\/\d{4}-\d{2}-\d{2}\/users\/.+/;
const CUSTOM_SHOP_ITEMS = {
premium_subscription: {
itemName: "premium_subscription",
subscriptionInfo: {
vendor: "STRIPE",
renewing: true,
isFamilyPlan: true,
expectedExpiration: 9999999999000
}
}
};
function shouldIntercept(url) {
const isMatch = TARGET_URL_REGEX.test(url);
if (isMatch) { try { console.log(`[API Intercept DEBUG] MATCH FOUND for URL: ${url}`); } catch {} }
return isMatch;
}
function modifyJson(jsonText) {
try {
const data = JSON.parse(jsonText);
try { console.log("[API Intercept] Original Data:", data); } catch {}
data.hasPlus = true;
if (!data.trackingProperties || typeof data.trackingProperties !== 'object') data.trackingProperties = {};
data.trackingProperties.has_item_gold_subscription = true;
data.shopItems = CUSTOM_SHOP_ITEMS;
try { console.log("[API Intercept] Modified Data:", data); } catch {}
return JSON.stringify(data);
} catch (e) {
try { console.error("[API Intercept] Failed to parse or modify JSON. Returning original text.", e); } catch {}
return jsonText;
}
}
// fetch
const originalFetch = window.fetch;
window.fetch = function(resource, options) {
const url = resource instanceof Request ? resource.url : resource;
if (shouldIntercept(url)) {
try { console.log(`[API Intercept] Intercepting fetch request to: ${url}`); } catch {}
return originalFetch.apply(this, arguments).then(async (response) => {
const cloned = response.clone();
const jsonText = await cloned.text();
const modified = modifyJson(jsonText);
let hdrs = response.headers;
try { const obj = {}; response.headers.forEach((v,k)=>obj[k]=v); hdrs = obj; } catch {}
return new Response(modified, { status: response.status, statusText: response.statusText, headers: hdrs });
}).catch(err => { try { console.error('[API Intercept] fetch error', err); } catch {}; throw err; });
}
return originalFetch.apply(this, arguments);
};
// XHR
const originalXhrOpen = XMLHttpRequest.prototype.open;
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._intercept = shouldIntercept(url);
this._url = url;
originalXhrOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function() {
if (this._intercept) {
try { console.log(`[API Intercept] Intercepting XHR request to: ${this._url}`); } catch {}
const originalOnReadyStateChange = this.onreadystatechange;
const xhr = this;
this.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
try {
const modifiedText = modifyJson(xhr.responseText);
Object.defineProperty(xhr, 'responseText', { writable: true, value: modifiedText });
Object.defineProperty(xhr, 'response', { writable: true, value: modifiedText });
} catch (e) { try { console.error("[API Intercept] XHR Modification Failed:", e); } catch {} }
}
if (originalOnReadyStateChange) originalOnReadyStateChange.apply(this, arguments);
};
}
originalXhrSend.apply(this, arguments);
};
// =============================
// UI banner + sanitization logic
// =============================
var JSON_URL = 'https://raw.githubusercontent.com/apersongithub/Duolingo-Unlimited-Hearts/refs/heads/main/userscript-version.json';
(function () {
'use strict';
const newElementId = 'extension-banner';
const FALLBACK_CONFIG = {
"BANNER": `
<div class='thPiC'><img class='_1xOxM'
src='https://raw.githubusercontent.com/apersongithub/Duolingo-Unlimited-Hearts/refs/heads/main/extras/icon.svg'
style='border-radius:100px'></div>
<div class='_3jiBp'>
<h4 class='qyEhl'>Duolingo Super Userscript</h4><span class='_3S2Xa'>Created by <a
href='https://github.com/apersongithub' target='_blank' style='color:#07b3ec'>apersongithub</a></span>
</div>
<div class='_36kJA'>
<div><a href='https://html-preview.github.io/?url=https://raw.githubusercontent.com/apersongithub/Duolingo-Unlimited-Hearts/refs/heads/main/extras/donations.html'
target='_blank'><button class='_1ursp _2V6ug _2paU5 _3gQUj _7jW2t rdtAy'><span class='_9lHjd'
style='color:#d7d62b'>💵 Donate</span></button></a></div>
</div>
`
};
function addCustomElement(config, root = document) {
if (document.getElementById(newElementId)) return;
const refElement = root.querySelector('.MGk8p');
if (!refElement) return;
const ul = document.createElement('ul');
ul.className = 'Y6o36';
const newLi = document.createElement('li');
newLi.id = newElementId;
newLi.className = '_17J_p';
newLi.innerHTML = config.BANNER;
ul.appendChild(newLi);
refElement.parentNode.insertBefore(ul, refElement.nextSibling);
try { console.log('Extension banner successfully added!'); } catch {}
}
async function loadConfigAndInject() {
if (!window.location.pathname.includes('/settings/super')) return;
function sanitizeHTML(unsafeHTML) {
const template = document.createElement('template');
template.innerHTML = unsafeHTML || '';
const ALLOWED_TAGS = new Set(['DIV','SECTION','H1','H2','H3','H4','H5','H6','P','SPAN','SMALL','A','BUTTON','UL','OL','LI','STRONG','EM','B','I','U','BR','HR','IMG']);
const ALLOWED_ATTRS = new Set(['class','id','href','src','target','rel','style','alt','title','role','aria-label','aria-hidden','aria-describedby','aria-expanded','aria-controls','width','height','tabindex']);
template.content.querySelectorAll('script, iframe, object, embed, style, link, meta').forEach(el => el.remove());
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
let node;
while ((node = walker.nextNode())) {
if (!ALLOWED_TAGS.has(node.tagName)) {
const parent = node.parentNode;
if (parent) parent.replaceChild(document.createDocumentFragment().append(...node.childNodes), node);
continue;
}
[...node.attributes].forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (name.startsWith('on') || !ALLOWED_ATTRS.has(name)) { node.removeAttribute(attr.name); return; }
if (name === 'href' || name === 'src') {
const lower = value.toLowerCase();
if (!/^https?:\/\//.test(lower)) { node.removeAttribute(attr.name); return; }
if (lower.startsWith('javascript:') || lower.startsWith('data:')) { node.removeAttribute(attr.name); return; }
}
if (name === 'style') {
if (/expression|javascript:|url\s*\(\s*javascript:/i.test(value)) {
node.removeAttribute(attr.name);
}
}
});
}
return template.innerHTML;
}
try {
// JSON_URL may not be defined; fallback is used if fetch fails.
const response = await fetch(JSON_URL, { cache: 'no-store' });
if (!response.ok) throw new Error('Failed to fetch JSON');
const remote = await response.json();
const sanitized = sanitizeHTML(remote && remote.BANNER ? remote.BANNER : FALLBACK_CONFIG.BANNER);
addCustomElement({ BANNER: sanitized });
} catch (err) {
try { console.warn('Failed to load external JSON, using fallback:', err); } catch {}
const sanitizedFallback = sanitizeHTML(FALLBACK_CONFIG.BANNER);
addCustomElement({ BANNER: sanitizedFallback });
}
}
function removeManageSubscriptionSection(root = document) {
const sections = root.querySelectorAll('section._3f-te');
for (const section of sections) {
const h2 = section.querySelector('h2._203-l');
if (h2 && h2.textContent.trim() === 'Manage subscription') {
section.remove();
break;
}
}
}
const manageSubObserver = new MutationObserver(() => removeManageSubscriptionSection());
manageSubObserver.observe(document.documentElement, { childList: true, subtree: true });
removeManageSubscriptionSection();
loadConfigAndInject();
const observer = new MutationObserver(() => loadConfigAndInject());
observer.observe(document.documentElement, { childList: true, subtree: true });
})();
})();