Duolingo Unlimited Hearts

Intercepts and modifies fetch Duolingo's API responses to give Unlimited Hearts.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();