Duolingo Super

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Duolingo Super
// @icon         https://d35aaqx5ub95lt.cloudfront.net/images/hearts/b3a04a561c7d0b2b5247a40e18d64946.svg
// @namespace    https://tampermonkey.net/
// @version      3.4.1
// @description  Intercepts and modifies fetch Duolingo's API responses to give Duolingo Super.
// @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 Duolingo Super
 */

(function () {
  'use strict';

  // --- Configuration ---
  const TARGET_URL_REGEX = /https?:\/\/(?:[a-zA-Z0-9-]+\.)?duolingo\.[a-zA-Z]{2,6}(?:\.[a-zA-Z]{2})?\/\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, method = 'GET') {
    // FILTER 1: Do not intercept POST/PUT/DELETE. Only GET requests load profile data.
    if (method.toUpperCase() !== 'GET') return false;

    const isMatch = TARGET_URL_REGEX.test(url);
    // FILTER 2: Explicitly exclude the shop-items endpoint to prevent the 400 error loop
    if (url.includes('/shop-items')) return false;

    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);
      data.hasPlus = true;
      if (!data.trackingProperties || typeof data.trackingProperties !== 'object') data.trackingProperties = {};
      data.trackingProperties.has_item_premium_subscription = true;

      // FIX: Merge existing shop items with your custom subscription
      // (Previous code deleted all real items, breaking the shop UI)
      data.shopItems = { ...data.shopItems, ...CUSTOM_SHOP_ITEMS };

      return JSON.stringify(data);
    } catch (e) {
      return jsonText;
    }
  }

  // fetch Override
  const originalFetch = window.fetch;
  window.fetch = function (resource, options) {
    const url = resource instanceof Request ? resource.url : resource;
    // Detect method to ensure we only intercept GET
    const method = (resource instanceof Request) ? resource.method : (options?.method || 'GET');

    if (shouldIntercept(url, method)) {
      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 => { throw err; });
    }
    return originalFetch.apply(this, arguments);
  };

  // XHR Override
  const originalXhrOpen = XMLHttpRequest.prototype.open;
  const originalXhrSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function (method, url, ...args) {
    this._method = method; // Store method for the check in .send()
    this._url = url;
    originalXhrOpen.call(this, method, url, ...args);
  };
  XMLHttpRequest.prototype.send = function () {
    // Check method here as well
    if (shouldIntercept(this._url, this._method)) {
      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) { }
        }
        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 });
  })();
})();