DuoFarmer to narzędzie, które pomaga farmić PD, Dni z rzędu, Klejnoty, a nawet naprawiać zamrożone Dni z rzędu na Duolingo!.
// ==UserScript==
// @name Duolingo DuoFarmer
// @namespace https://duo-farmer.vercel.app
// @version 1.3.12
// @author Lamduck
// @description DuoFarmer is a tool that helps you farm XP, farm Streak, farm Gems or even repair frozen streak on Duolingo!.
// @description:en DuoFarmer is a tool that helps you farm XP, farm Streak, farm Gems or even repair frozen streak on Duolingo!.
// @description:ar DuoFarmer هي أداة تساعدك على تجميع نقاط الخبرة (XP) والسلاسل والجواهر أو حتى إصلاح سلسلة متجمدة على Duolingo!.
// @description:bg DuoFarmer е инструмент, който ви помага да фармите XP, серии, скъпоценни камъни или дори да поправяте замразени серии в Duolingo!.
// @description:bn DuoFarmer এমন একটি টুল যা আপনাকে Duolingo-তে XP, Streak, Gems ফার্ম করতে বা এমনকি জমে থাকা Streak মেরামত করতে সাহায্য করে!.
// @description:cs DuoFarmer je nástroj, který vám pomůže farmit XP, série, drahokamy nebo dokonce opravit zamrzlou sérii na Duolingu!.
// @description:da DuoFarmer er et værktøj, der hjælper dig med at farme XP, Streaks, Gems eller endda reparere en frossen streak på Duolingo!.
// @description:de DuoFarmer ist ein Tool, das Ihnen hilft, XP zu farmen, Streaks zu farmen, Edelsteine zu farmen oder sogar eingefrorene Streaks auf Duolingo zu reparieren!.
// @description:el Το DuoFarmer είναι ένα εργαλείο που σας βοηθά να φαρμάρετε XP, σερί, πετράδια ή ακόμα και να επισκευáσετε ένα παγωμένο σερί στο Duolingo!.
// @description:es DuoFarmer es una herramienta que te ayuda a farmear XP, Rachas, Gemas ¡o incluso reparar una racha congelada en Duolingo!.
// @description:fi DuoFarmer on työkalu, joka auttaa sinua farmaamaan XP:tä, putkia, jalokiviä tai jopa korjaamaan jäätyneen putken Duolingossa!.
// @description:fr DuoFarmer est un outil qui vous aide à farmer de l'XP, des Séries, des Gemmes ou même à réparer une série gelée sur Duolingo !.
// @description:he DuoFarmer הוא כלי שעוזר לך לצבור XP, רצפים, אבני חן או אפילו לתקן רצף קפוא ב-Duolingo!.
// @description:hi DuoFarmer एक उपकरण है जो आपको Duolingo पर XP, स्ट्रीक, जेम्स फार्म करने या जमी हुई स्ट्रीक की मरम्मत करने में मदद करता है!.
// @description:hu A DuoFarmer egy eszköz, amely segít XP-t, sorozatokat, drágaköveket farmolni, vagy akár egy befagyott sorozatot is megjavítani a Duolingón!.
// @description:id DuoFarmer adalah alat yang membantu Anda farming XP, Streak, Permata, atau bahkan memperbaiki streak yang dibekukan di Duolingo!.
// @description:it DuoFarmer è uno strumento che ti aiuta a farmare XP, Serie, Gemme o persino a riparare una serie congelata su Duolingo!.
// @description:ja DuoFarmerは、DuolingoでXP、連続記録、ジェムを稼いだり、凍結された連続記録を修復したりするのに役立つツールです!.
// @description:ko DuoFarmer는 듀오링고에서 XP, 연속 학습, 보석을 파밍하거나 얼어붙은 연속 학습을 수리하는 데 도움이 되는 도구입니다!.
// @description:ms DuoFarmer ialah alat yang membantu anda mendapatkan XP, Streak, Permata atau bahkan membaiki streak yang beku di Duolingo!.
// @description:nl DuoFarmer is een tool die je helpt XP te farmen, Streaks te farmen, Edelstenen te farmen of zelfs een bevroren streak op Duolingo te repareren!.
// @description:no DuoFarmer er et verktøy som hjelper deg med å farme XP, Streaks, Gems eller til og med reparere en frossen streak på Duolingo!.
// @description:pl DuoFarmer to narzędzie, które pomaga farmić PD, Dni z rzędu, Klejnoty, a nawet naprawiać zamrożone Dni z rzędu na Duolingo!.
// @description:pt-BR DuoFarmer é uma ferramenta que te ajuda a farmar XP, Sequências, Gemas ou até mesmo reparar uma sequência congelada no Duolingo!.
// @description:ro DuoFarmer este un instrument care te ajută să farmezi XP, serii, nestemate sau chiar să repari o serie înghețată pe Duolingo!.
// @description:ru DuoFarmer — это инструмент, который помогает вам фармить опыт, серии, самоцветы и даже восстанавливать замороженные серии в Duolingo!.
// @description:sv DuoFarmer är ett verktyg som hjälper dig att farma XP, Streaks, Ädelstenar eller till och med reparera en frusen streak på Duolingo!.
// @description:th DuoFarmer เป็นเครื่องมือที่ช่วยให้คุณฟาร์ม XP, ฟาร์ม Streak, ฟาร์ม Gems หรือแม้แต่ซ่อมแซม Streak ที่ถูกแช่แข็งบน Duolingo!.
// @description:tr DuoFarmer, Duolingo'da XP kasmanıza, Serileri kasmanıza, Mücevherleri kasmanıza ve hatta donmuş bir seriyi onarmanıza yardımcı olan bir araçtır!.
// @description:uk DuoFarmer — це інструмент, який допомагає вам фармити досвід, серії, самоцвіти та навіть відновлювати заморожені серії в Duolingo!.
// @description:vi DuoFarmer là một công cụ giúp bạn cày XP, cày Streak, cày Gems hoặc thậm chí phá băng streak bị đóng băng trên Duolingo!.
// @description:zh-CN DuoFarmer 是一款可以帮助您在 Duolingo 上刷经验值、刷连续记录、刷宝石,甚至修复冻结的连续记录的工具!.
// @description:zh-TW DuoFarmer 是一款可以幫助您在 Duolingo 上刷經驗值、刷連續記錄、刷寶石,甚至修復凍結的連續記錄的工具!.
// @license CC BY-NC-SA 4.0
// @icon https://www.google.com/s2/favicons?sz=64&domain=duolingo.com
// @match https://*.duolingo.com/*
// @grant GM_log
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const SETTINGS_KEY = "duofarmerSettings";
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 = {
gold_subscription: {
itemName: "gold_subscription",
subscriptionInfo: {
vendor: "STRIPE",
renewing: true,
isFamilyPlan: true,
expectedExpiration: 9999999999e3
}
}
};
function isMaxEnabled() {
try {
const saved = localStorage.getItem(SETTINGS_KEY);
if (saved) return JSON.parse(saved).enableMaxPatch || false;
} catch (e) {
}
return false;
}
function shouldIntercept(url, method = "GET") {
if (!isMaxEnabled()) return false;
if (method.toUpperCase() !== "GET") return false;
if (url.includes("/shop-items")) return false;
return TARGET_URL_REGEX.test(url);
}
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_gold_subscription = true;
data.shopItems = { ...data.shopItems, ...CUSTOM_SHOP_ITEMS };
return JSON.stringify(data);
} catch (e) {
return jsonText;
}
}
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;
}
}
}
function initPatcher() {
if (typeof window === "undefined") return;
const originalFetch = window.fetch;
window.fetch = function(resource, options) {
const url = resource instanceof Request ? resource.url : resource;
const method = resource instanceof Request ? resource.method : (options == null ? void 0 : 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);
};
const originalXhrOpen = XMLHttpRequest.prototype.open;
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._method = method;
this._url = url;
originalXhrOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function() {
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);
};
const manageSubObserver = new MutationObserver(() => {
if (isMaxEnabled()) removeManageSubscriptionSection();
});
manageSubObserver.observe(document.documentElement, { childList: true, subtree: true });
}
const templateRaw = `<div id="overlay"></div>
<div id="container">
<div id="header">
<div id="header-left">
<img src="https://img.icons8.com/?size=48&id=mPfeGOwngGmN&format=png&color=000000">
<span class="label">Duofarmer</span>
</div>
<button id="settings-btn">⚙️</button>
</div>
<div id="body">
<table id="table-main" class="table">
<thead>
<tr>
<th>Username</th>
<th>From</th>
<th>Learning</th>
</tr>
</thead>
<tbody>
<tr>
<td id="username">duofarmer</td>
<td id="from">any</td>
<td id="learn">any</td>
</tr>
</tbody>
</table>
<table id="table-progress" class="table">
<thead>
<tr>
<th>Streak</th>
<th>Gem</th>
<th>XP</th>
</tr>
</thead>
<tbody>
<tr>
<td id="streak">0</td>
<td id="gem">0</td>
<td id="xp">0</td>
</tr>
</tbody>
</table>
<div id="action-row">
<select id="select-option">
<!-- <option value="option1">Option 1</option> -->
<!-- <option value="option2">Option 2</option> -->
</select>
<button id="start-btn">Start</button>
<button id="stop-btn" hidden>Stop</button>
</div>
<div id="notify">Getting user info, please wait until it's done.<br /> If it takes too long, please refresh the
page.</div>
</div>
<div id="footer">
<span class="label">If info is wrong, reload the page!</span>
</div>
</div>
<div id="settings-container">
<div id="settings-menu" class="modal-content">
<div class="modal-header">
<span class="label">Settings</span>
<button id="settings-close" class="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="settings-group">
<h3>Duolingo Max</h3>
<div class="setting-item">
<span>Enable Duolingo Max Patch
<br>
<i class="muted">(Bypass subscription check to get Duolingo Max!, thanks <a href="https://github.com/apersongithub/Duolingo-Unlimited-Hearts" target="_blank">apersongithub</a>)</i>
</span>
<input type="checkbox" id="enable-max-patch">
</div>
</div>
<div class="settings-group">
<h3>General</h3>
<div class="setting-item">
<span>Auto open UI onload</span>
<input type="checkbox" id="auto-open-ui">
</div>
<div class="setting-item">
<span>Auto start farming onload</span>
<input type="checkbox" id="auto-start">
</div>
<div class="setting-item">
<span>Default farming option</span>
<select id="default-option">
<!-- option auto -->
</select>
</div>
<div class="setting-item">
<span>Hide username</span>
<input type="checkbox" id="hide-username">
</div>
<div class="setting-item">
<span>Keep screen on</span>
<input type="checkbox" id="keep-screen-on">
</div>
</div>
<div class="settings-group">
<h3>Performance</h3>
<div class="setting-item">
<span>Delay time (100ms - 10000ms):
<br>
<i class="muted">(Lower delay = faster = high limit rate ban)</i>
</span>
<input type="number" id="delay-time" min="100" max="10000" value="500">
</div>
<div class="setting-item">
<span>Retry time (100ms - 10000ms):
<br>
</span>
<input type="number" id="retry-time" min="100" max="10000" value="1000">
</div>
<div class="setting-item">
<span>Auto stop after (min) (set 0 for unlimited)</span>
<input type="number" id="auto-stop-time" min="0" max="60" value="0">
</div>
</div>
<div class="settings-group">
<h3>Advanced</h3>
<div class="setting-item">
<span>Get ur JWT token</span>
<button id="get-jwt-token" class="setting-btn">Get Token</button>
</div>
<div class="setting-item">
<span>Set account to public</span>
<button id="set-account-public" class="setting-btn">Set Public</button>
</div>
<div class="setting-item">
<span>Set account to private</span>
<button id="set-account-private" class="setting-btn">Set Private</button>
</div>
<div class="setting-item">
<span>Quick logout</span>
<button id="quick-logout" class="setting-btn">Logout</button>
</div>
<div class="setting-item">
<span>Reset setting</span>
<button id="reset-setting" class="setting-btn">Reset</button>
</div>
</div>
<div class="settings-group">
<h3>Others</h3>
<div class="setting-item">
<span>Blank page (best performance)</span>
<a href="https://www.duolingo.com/errors/0">Here</a>
</div>
<div class="setting-item">
<span>Duolingo homepage</span>
<a href="https://www.duolingo.com/">Here</a>
</div>
<div class="setting-item">
<span>Greasyfork</span>
<a href="https://greasyfork.org/vi/scripts/528621-duofarmer" target="_blank">Here</a>
</div>
<div class="setting-item">
<span>Telegram</span>
<a href="https://t.me/duofarmer" target="_blank">Here</a>
</div>
<div class="setting-item">
<span>Duofarmer Homepage</span>
<a href="https://duo-farmer.vercel.app" target="_blank">Here</a>
</div>
</div>
<div class="settings-group">
<h3>Donate me (please im unemployed 😭)</h3>
<div class="setting-item">
<span>Donate via PayPal</span>
<a href="https://duo-farmer.vercel.app/donate/paypal" target="_blank">Here</a>
</div>
<div class="setting-item">
<span>Donate via Momo ( Vietnam )</span>
<a href="https://t.me/duofarmer" target="_blank">Direct message me</a>
</div>
</div>
<div class="settings-group">
<h3>Feature Guide</h3>
<div class="setting-item">
<code id="feature-guide">
<p><b>- Enable Duolingo Max Patch:</b> Bypass subscription check to get Duolingo Max!, thanks <a href="https://github.com/apersongithub/Duolingo-Unlimited-Hearts" target="_blank">apersongithub</a>.</p>
<p><b>- Auto start farming onload:</b> Start farming default selected option automatically when the page loads. <br></p>
<p><b>- Repair streak:</b> Fills missing streak days from account creation date to now, it's also break all the frozen streak.<br></p>
<p><b>- Blank page (best performance):</b> Duolingo's error page with minimal load. It will have 100% power for farming 😎.<br></p>
<p><b>- Public/Private:</b> Toggle account visibility. Private = hidden from leaderboards. (Recommended to use private) <br></p>
</code>
</div>
</div>
<div class="settings-group">
<h3>All userinfo (json)</h3>
<div class="setting-item">
<code id="user-info-display">
Loading...
</code>
</div>
</div>
</div>
<div class="modal-footer">
<span></span>
<button id="save-settings" class="save-btn">Save</button>
</div>
</div>
</div>
<div id="floating-btn">🐸</div>`;
const cssText = "#action-row{width:90%;display:flex;justify-content:space-between;align-items:center;margin:8px 0;gap:8px}#blank-page-link{margin-bottom:8px;color:#fce6ff;font-weight:700;font-style:italic}#body{min-height:40vh;max-height:100%;min-width:0;background:#282828;display:flex;align-items:center;justify-content:center;width:100%;overflow-y:auto;flex:1;flex-direction:column}#body .label{font-size:1.2em}#body h3{margin:0;color:#fff;font-size:1.1em;font-weight:700;letter-spacing:1px}#container{width:90vw;max-width:800px;min-height:40vh;max-height:90vh;background:#222;color:#fff;border-radius:10px;box-shadow:0 2px 12px #0008;font-family:sans-serif;font-size:.9em;display:flex;flex-direction:column;align-items:center;justify-content:center;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:9999;box-sizing:border-box}#floating-btn{position:fixed;bottom:10%;right:2%;width:40px;height:40px;background:#35bd00;border-radius:50%;box-shadow:0 2px 8px #0000004d;z-index:10000;cursor:pointer;display:flex;align-items:center;justify-content:center}#footer{height:30px;background:#222;display:flex;align-items:center;justify-content:space-evenly;border-bottom-left-radius:10px;border-bottom-right-radius:10px;width:100%}#footer a,#footer span{text-decoration:none;color:#00aeff;font-size:1em;font-weight:700;font-style:italic}#header{height:60px;background:#333;display:flex;align-items:center;justify-content:space-between;border-top-left-radius:10px;border-top-right-radius:10px;width:100%;position:relative}#header .label{font-size:1.4em;font-weight:600;color:#fff}#header-left{display:flex;align-items:center}#header-left img{width:32px;height:32px;margin-right:8px;vertical-align:middle}#notify{width:90%;max-width:90%;min-height:10vh;margin:8px 0;padding:8px 12px;border-radius:6px;background:#333;color:#c8ff00;font-size:1em;word-wrap:break-word}#overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000c;z-index:9998;pointer-events:all}#select-option{width:90%;max-width:90%;margin-right:8px;padding:8px 12px;border-radius:6px;border:1px solid #444;background:#232323;color:#fff;font-size:1em;outline:none}#settings-btn{position:absolute;right:20px;background:none;border:none;color:#fff;font-size:20px;cursor:pointer;padding:5px;border-radius:3px}#settings-btn:hover{background:#555}#settings-container{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;display:flex;align-items:center;justify-content:center;background:#000c}#start-btn,#stop-btn{width:auto;margin-left:0;padding:8px 18px;border-radius:6px;border:none;background:#229100;color:#fff;font-size:1em;font-weight:700;cursor:pointer;box-shadow:0 2px 8px #0003}#stop-btn{background:#af0303}.modal-body{min-height:25vh;max-height:100%;min-width:0;background:#282828;display:flex;align-items:center;justify-content:flex-start;width:100%;overflow-y:auto;flex:1;flex-direction:column;padding:20px}.modal-close{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;padding:5px;border-radius:3px}.modal-close:hover{background:#555}.modal-content{width:90vw;max-width:800px;min-height:25vh;max-height:70vh;background:#222;color:#fff;border-radius:10px;box-shadow:0 2px 12px #0008;font-family:sans-serif;font-size:.9em;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;box-sizing:border-box}.modal-footer{height:60px;background:#333;display:flex;align-items:center;justify-content:flex-end;padding:5px 20px;border-top:1px solid #444;border-bottom-left-radius:10px;border-bottom-right-radius:10px;width:100%}.modal-header{height:60px;background:#333;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-top-left-radius:10px;border-top-right-radius:10px;width:100%}.modal-header .label{font-weight:700}.save-btn{padding:8px 10px;border-radius:6px;border:none;background:#229100;color:#fff;font-weight:bolder;cursor:pointer}.setting-item{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:10px;background:#333;border-radius:5px}.setting-item a{color:#4caf50;font-style:italic;text-decoration:none;margin-left:auto;font-size:.9em}.setting-item a:hover{color:#66bb6a;text-decoration:underline}.setting-item input:not([type=checkbox]){width:120px;padding:8px 12px;margin-left:auto;border-radius:6px;border:1px solid #444;background:#232323;color:#fff;font-size:1em;outline:none}.setting-item input[type=checkbox]{width:18px;height:18px;margin-left:auto;cursor:pointer;accent-color:#229100}.setting-item input[type=number]{width:120px;padding:8px 12px;margin-left:auto;border-radius:6px;border:1px solid #444;background:#232323;color:#fff;font-size:1em;text-align:center;outline:none}.setting-item input[type=number]:focus{border-color:#229100}.setting-item select{width:120px;padding:8px 12px;margin-left:auto;border-radius:6px;border:1px solid #444;background:#232323;color:#fff;font-size:1em;outline:none;cursor:pointer}.setting-item span{flex:1;margin-right:10px}.setting-item .setting-btn{padding:6px 12px;margin-left:auto;background:#555;border:1px solid #666;border-radius:4px;color:#fff;font-size:.9em;cursor:pointer}.settings-group{width:100%;margin-bottom:30px}.settings-group h3{margin:0 0 15px;color:#fff;font-size:16px;border-bottom:1px solid #444;padding-bottom:5px}.blur{filter:blur(4px)}code{background:#333;margin:10px 0;padding:8px 12px;border-left:#229100 3px solid;font-family:monospace;line-height:1.5em;display:block;border-radius:2px}.disable-btn{background:#52454560!important;cursor:not-allowed!important}.disabled{background:#26202060!important;color:#888!important;cursor:not-allowed!important;pointer-events:none!important}.hidden{display:none!important}.label{font-size:1em}.muted{color:#555!important;font-size:smaller!important}.table{width:100%;background:#232323;color:#fff;border-radius:8px;padding:8px 12px;text-align:center;table-layout:fixed}.table th,.table td{padding:9px 12px;text-align:center;border-bottom:1px solid #444;width:1%}.table tbody tr:last-child td{border-bottom:none}";
const log = (message) => {
if (typeof GM_log !== "undefined") {
GM_log(message);
} else {
console.log("[DuoFarmer]", message);
}
};
const logError = (error, context = "") => {
const message = (error == null ? void 0 : error.message) || (error == null ? void 0 : error.toString()) || "Unknown error";
const fullMessage = context ? `[${context}] ${message}` : message;
log(fullMessage);
};
const delay = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const toTimestamp = (dateStr) => {
return Math.floor(new Date(dateStr).getTime() / 1e3);
};
const getCurrentUnixTimestamp = () => {
return Math.floor(Date.now() / 1e3);
};
const daysBetween = (startTimestamp, endTimestamp) => {
return Math.floor((endTimestamp - startTimestamp) / (60 * 60 * 24));
};
const getJwtToken = () => {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.startsWith("jwt_token=")) {
return cookie.substring("jwt_token=".length);
}
}
return null;
};
const decodeJwtToken = (token) => {
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64).split("").map(function(c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
}).join("")
);
return JSON.parse(jsonPayload);
};
const formatHeaders = (jwtToken) => {
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${jwtToken}`,
"User-Agent": navigator.userAgent,
"Accept-Encoding": "gzip, deflate, br, zstd"
};
};
const extractSkillId = (currentCourse) => {
var _a, _b;
const sections = (currentCourse == null ? void 0 : currentCourse.pathSectioned) || [];
for (const section of sections) {
const units = section.units || [];
for (const unit of units) {
const levels = unit.levels || [];
for (const level of levels) {
const skillId = ((_a = level.pathLevelMetadata) == null ? void 0 : _a.skillId) || ((_b = level.pathLevelClientData) == null ? void 0 : _b.skillId);
if (skillId) return skillId;
}
}
}
return null;
};
const waitForBody = () => {
return new Promise((resolve) => {
if (document.body) {
resolve();
} else {
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
resolve();
}
});
observer.observe(document.documentElement, { childList: true });
}
});
};
class ApiService {
constructor(jwt, defaultHeaders, userInfo, sub) {
this.jwt = jwt;
this.defaultHeaders = defaultHeaders;
this.userInfo = userInfo;
this.sub = sub;
}
static async getUserInfo(userSub, headers) {
const userInfoUrl = `https://www.duolingo.com/2017-06-30/users/${userSub}?fields=id,username,fromLanguage,learningLanguage,streak,totalXp,level,numFollowers,numFollowing,gems,creationDate,streakData,privacySettings,currentCourse{pathSectioned{units{levels{pathLevelMetadata{skillId}}}}}`;
const response = await fetch(userInfoUrl, { method: "GET", headers });
return await response.json();
}
async sendRequest({ url, payload, headers, method = "PUT" }) {
try {
const res = await fetch(url, {
method,
headers,
body: payload ? JSON.stringify(payload) : void 0
});
return res;
} catch (error) {
return error;
}
}
async setPrivacyStatus(privacyStatus) {
const patchUrl = `https://www.duolingo.com/2017-06-30/users/${this.sub}/privacy-settings?fields=privacySettings`;
const patchBody = {
"DISABLE_SOCIAL": privacyStatus
};
return await this.sendRequest({ url: patchUrl, payload: patchBody, headers: this.defaultHeaders, method: "PATCH" });
}
async farmGemOnce() {
const idReward = "SKILL_COMPLETION_BALANCED-dd2495f4_d44e_3fc3_8ac8_94e2191506f0-2-GEMS";
const patchUrl = `https://www.duolingo.com/2017-06-30/users/${this.sub}/rewards/${idReward}`;
const patchBody = {
consumed: true,
learningLanguage: this.userInfo.learningLanguage,
fromLanguage: this.userInfo.fromLanguage
};
return await this.sendRequest({ url: patchUrl, payload: patchBody, headers: this.defaultHeaders, method: "PATCH" });
}
async farmSessionOnce(config = {}) {
const startTime = config.startTime || getCurrentUnixTimestamp();
const endTime = config.endTime || startTime + 60;
const sessionPayload = {
challengeTypes: [],
fromLanguage: this.userInfo.fromLanguage,
learningLanguage: this.userInfo.learningLanguage,
// isFinalLevel: false,
// isV2: true,
// juicy: true,
// smartTipsVersion: 2,
type: "GLOBAL_PRACTICE",
...config.sessionPayload || {}
};
const sessionRes = await this.sendRequest({ url: "https://www.duolingo.com/2017-06-30/sessions", payload: sessionPayload, headers: this.defaultHeaders, method: "POST" });
const sessionData = await sessionRes.json();
const updateSessionPayload = {
// ...sessionData,
id: sessionData.id,
metadata: sessionData.metadata,
type: sessionData.type,
fromLanguage: this.userInfo.fromLanguage,
learningLanguage: this.userInfo.learningLanguage,
challenges: [],
// empty for fast response
adaptiveChallenges: [],
// empty for fast response
sessionExperimentRecord: [],
experiments_with_treatment_contexts: [],
adaptiveInterleavedChallenges: [],
adaptiveChallenges: [],
sessionStartExperiments: [],
trackingProperties: [],
ttsAnnotations: [],
heartsLeft: 0,
startTime,
enableBonusPoints: false,
endTime,
failed: false,
maxInLessonStreak: 9,
shouldLearnThings: true,
...config.updateSessionPayload || {}
};
const updateRes = await this.sendRequest({ url: `https://www.duolingo.com/2017-06-30/sessions/${sessionData.id}`, payload: updateSessionPayload, headers: this.defaultHeaders, method: "PUT" });
return updateRes;
}
}
class SettingsManager {
constructor(shadowRoot, apiService = null) {
this.shadowRoot = shadowRoot;
this.apiService = apiService;
this.DEFAULT_SETTINGS = {
autoOpenUI: false,
autoStart: false,
defaultOption: 1,
// index of option in OPTIONS array (0-based)
hideUsername: false,
keepScreenOn: false,
delayTime: 500,
retryTime: 1e3,
autoStopTime: 0,
enableMaxPatch: false
};
this.settings = this.loadSettings();
}
loadSettings() {
try {
const saved = localStorage.getItem("duofarmerSettings");
if (saved) {
return { ...this.DEFAULT_SETTINGS, ...JSON.parse(saved) };
}
return { ...this.DEFAULT_SETTINGS };
} catch (error) {
return { ...this.DEFAULT_SETTINGS };
}
}
saveSettings(settings) {
this.settings = settings;
localStorage.setItem("duofarmerSettings", JSON.stringify(settings));
}
getSettings() {
return { ...this.settings };
}
loadSettingsToUI() {
const elements = this.getElements();
const mappings = [
{ key: "autoOpenUI", element: elements.autoOpenUI, setter: (el, val) => el.checked = val },
{ key: "autoStart", element: elements.autoStart, setter: (el, val) => el.checked = val },
{ key: "defaultOption", element: elements.defaultOption, setter: (el, val) => el.value = val.toString() },
{ key: "hideUsername", element: elements.hideUsername, setter: (el, val) => el.checked = val },
{ key: "keepScreenOn", element: elements.keepScreenOn, setter: (el, val) => el.checked = val },
{ key: "delayTime", element: elements.delayTime, setter: (el, val) => el.value = val },
{ key: "retryTime", element: elements.retryTime, setter: (el, val) => el.value = val },
{ key: "autoStopTime", element: elements.autoStopTime, setter: (el, val) => el.value = val },
{ key: "enableMaxPatch", element: elements.enableMaxPatch, setter: (el, val) => el.checked = val }
];
mappings.forEach(({ key, element, setter }) => {
if (element && this.settings[key] !== void 0) {
setter(element, this.settings[key]);
}
});
}
saveSettingsFromUI() {
const elements = this.getElements();
const getters = {
autoOpenUI: () => {
var _a;
return ((_a = elements.autoOpenUI) == null ? void 0 : _a.checked) || false;
},
autoStart: () => {
var _a;
return ((_a = elements.autoStart) == null ? void 0 : _a.checked) || false;
},
defaultOption: () => {
var _a;
return parseInt((_a = elements.defaultOption) == null ? void 0 : _a.value) || 1;
},
hideUsername: () => {
var _a;
return ((_a = elements.hideUsername) == null ? void 0 : _a.checked) || false;
},
keepScreenOn: () => {
var _a;
return ((_a = elements.keepScreenOn) == null ? void 0 : _a.checked) || false;
},
delayTime: () => {
var _a;
return Math.max(100, Math.min(1e4, parseInt((_a = elements.delayTime) == null ? void 0 : _a.value) || 500));
},
retryTime: () => {
var _a;
return Math.max(100, Math.min(1e4, parseInt((_a = elements.retryTime) == null ? void 0 : _a.value) || 1e3));
},
autoStopTime: () => {
var _a;
return parseInt((_a = elements.autoStopTime) == null ? void 0 : _a.value) || 0;
},
enableMaxPatch: () => {
var _a;
return ((_a = elements.enableMaxPatch) == null ? void 0 : _a.checked) || false;
}
};
const settings = Object.fromEntries(
Object.entries(getters).map(([key, getter]) => [key, getter()])
);
this.saveSettings(settings);
return settings;
}
getElements() {
return {
autoOpenUI: this.shadowRoot.getElementById("auto-open-ui"),
autoStart: this.shadowRoot.getElementById("auto-start"),
defaultOption: this.shadowRoot.getElementById("default-option"),
hideUsername: this.shadowRoot.getElementById("hide-username"),
keepScreenOn: this.shadowRoot.getElementById("keep-screen-on"),
delayTime: this.shadowRoot.getElementById("delay-time"),
retryTime: this.shadowRoot.getElementById("retry-time"),
autoStopTime: this.shadowRoot.getElementById("auto-stop-time"),
saveSettings: this.shadowRoot.getElementById("save-settings"),
quickLogout: this.shadowRoot.getElementById("quick-logout"),
getJwtToken: this.shadowRoot.getElementById("get-jwt-token"),
resetSetting: this.shadowRoot.getElementById("reset-setting"),
settingsContainer: this.shadowRoot.getElementById("settings-container"),
setAccountPublic: this.shadowRoot.getElementById("set-account-public"),
setAccountPrivate: this.shadowRoot.getElementById("set-account-private"),
enableMaxPatch: this.shadowRoot.getElementById("enable-max-patch")
};
}
addEventListeners() {
const elements = this.getElements();
elements.saveSettings.addEventListener("click", () => {
this.saveSettingsFromUI();
alert("Settings saved successfully, reload the page to apply changes!");
confirm("Reload now?") && location.reload();
});
elements.quickLogout.addEventListener("click", () => {
if (confirm("Are you sure you want to logout?")) {
window.location.href = "https://www.duolingo.com/logout";
}
});
elements.getJwtToken.addEventListener("click", () => {
const token = getJwtToken();
if (token) {
confirm(`Your JWT Token:
${token}
Copy to clipboard?`) && navigator.clipboard.writeText(token);
}
});
elements.resetSetting.addEventListener("click", () => {
if (confirm("Reset all settings to default? This cannot be undone.")) {
localStorage.removeItem("duofarmerSettings");
this.settings = { ...this.DEFAULT_SETTINGS };
this.loadSettingsToUI();
alert("All settings reset successfully! Reload to apply changes.");
}
});
elements.setAccountPublic.addEventListener("click", async () => {
if (confirm("Are you sure you want to set your account to public?")) {
try {
await this.apiService.setPrivacyStatus(false);
alert("Account set to public successfully! Reload the page to see changes.");
} catch (error) {
alert("Failed to set account to public: " + error.message);
}
}
});
elements.setAccountPrivate.addEventListener("click", async () => {
if (confirm("Are you sure you want to set your account to private?")) {
try {
await this.apiService.setPrivacyStatus(true);
alert("Account set to private successfully! Reload the page to see changes.");
} catch (error) {
alert("Failed to set account to private: " + error.message);
}
}
});
}
addEventSettings(container) {
const elements = this.getElements();
const settingsBtn = this.shadowRoot.getElementById("settings-btn");
const settingsContainer = elements.settingsContainer;
const settingsClose = this.shadowRoot.getElementById("settings-close");
const toggleModal2 = (modalElement, mainElement) => ({
show: () => {
mainElement.style.display = "none";
modalElement.style.display = "flex";
},
hide: () => {
modalElement.style.display = "none";
mainElement.style.display = "flex";
}
});
const settingsModal = toggleModal2(settingsContainer, container);
settingsBtn.addEventListener("click", settingsModal.show);
settingsClose.addEventListener("click", settingsModal.hide);
}
loadDefaultFarmingOption(optionsArray) {
const select = this.shadowRoot.getElementById("select-option");
const optionIndex = this.settings.defaultOption;
select.selectedIndex = optionIndex;
}
populateDefaultOptionSelect(optionsArray) {
const select = this.shadowRoot.getElementById("default-option");
select.innerHTML = "";
optionsArray.forEach((opt, index) => {
const option = document.createElement("option");
option.value = index.toString();
option.textContent = opt.label;
if (opt.disabled) option.disabled = true;
select.appendChild(option);
});
}
}
const safeCall = (callback, ...args) => callback == null ? void 0 : callback(...args);
const handleFarmingError = (error, context, callbacks) => {
const message = (error == null ? void 0 : error.status) ? `Error ${error.status}! Please report in telegram group!` : `Error in ${context}: ${(error == null ? void 0 : error.message) || error}`;
safeCall(callbacks.onError, message);
return message;
};
const FARMING_STRATEGIES = {
gem: (apiService, config, callbacks) => new GemFarming(apiService, config, callbacks),
xp: (apiService, config, callbacks) => new XpFarming(apiService, config, callbacks),
streak: (apiService, config, callbacks) => new StreakFarming(apiService, config, callbacks)
};
class GemFarming {
constructor(apiService, config, callbacks) {
this.apiService = apiService;
this.config = config;
this.callbacks = callbacks;
this.gemFarmed = 30;
}
async start(userInfo) {
while (this.config.isRunning) {
try {
await this.apiService.farmGemOnce(userInfo);
safeCall(this.callbacks.onUpdate, "gem", this.gemFarmed);
await this.callbacks.delay(this.config.delayTime);
} catch (error) {
handleFarmingError(error, "gemFarming", this.callbacks);
await this.callbacks.delay(this.config.retryTime);
}
}
}
}
class XpFarming {
constructor(apiService, config, callbacks) {
this.apiService = apiService;
this.config = config;
this.callbacks = callbacks;
}
async start(value, amount, config = {}, userInfo) {
while (this.config.isRunning) {
try {
const response = await this.apiService.farmSessionOnce(config);
if (response.status > 400) {
safeCall(this.callbacks.onError, `Something went wrong! Please try again later.`);
await this.callbacks.delay(this.config.retryTime);
continue;
}
const responseData = await response.json();
const xpFarmed = (responseData == null ? void 0 : responseData.awardedXp) || (responseData == null ? void 0 : responseData.xpGain) || 0;
safeCall(this.callbacks.onUpdate, "xp", xpFarmed);
await this.callbacks.delay(this.config.delayTime);
} catch (error) {
handleFarmingError(error, "xpFarming", this.callbacks);
await this.callbacks.delay(this.config.retryTime);
}
}
}
}
class StreakFarming {
constructor(apiService, config, callbacks) {
this.apiService = apiService;
this.config = config;
this.callbacks = callbacks;
this.SECONDS_PER_DAY = 86400;
this.SESSION_DURATION_SECONDS = 60;
}
async start(value = "farm", userInfo) {
const method = value === "repair" ? this.repair.bind(this) : this.farm.bind(this);
await method(userInfo);
}
async farm(userInfo) {
const hasStreak = !!userInfo.streakData.currentStreak;
const startStreakDate = hasStreak ? userInfo.streakData.currentStreak.startDate : /* @__PURE__ */ new Date();
const startFarmStreakTimestamp = toTimestamp(startStreakDate);
let currentTimestamp = hasStreak ? startFarmStreakTimestamp - this.SECONDS_PER_DAY : startFarmStreakTimestamp;
while (this.config.isRunning) {
try {
const sessionRes = await this.apiService.farmSessionOnce({
startTime: currentTimestamp,
endTime: currentTimestamp + this.SESSION_DURATION_SECONDS
});
if (sessionRes) {
currentTimestamp -= this.SECONDS_PER_DAY;
safeCall(this.callbacks.onUpdate, "streak", 1);
await this.callbacks.delay(this.config.delayTime);
} else {
safeCall(this.callbacks.onError, "Failed to farm streak session, I'm trying again...");
await this.callbacks.delay(this.config.retryTime);
continue;
}
} catch (error) {
handleFarmingError(error, "farmStreak", this.callbacks);
await this.callbacks.delay(this.config.retryTime);
continue;
}
}
}
validateRepair(userInfo) {
const creationDate = userInfo.creationDate;
const currentStreak = userInfo.streak || 0;
const currentTime = getCurrentUnixTimestamp();
const daysSinceCreation = daysBetween(creationDate, currentTime);
const maxPossibleStreak = daysSinceCreation + 1;
const missingStreaks = maxPossibleStreak - currentStreak;
if (currentStreak >= maxPossibleStreak) {
return {
valid: false,
message: `Current streak (${currentStreak}) is greater than or equal to maximum possible streak (${maxPossibleStreak}). No repair needed.`
};
}
if (missingStreaks <= 0) {
return {
valid: false,
message: "No missing streaks to repair."
};
}
return {
valid: true,
missingStreaks,
endTimestamp: creationDate,
maxPossibleStreak
};
}
async repair(userInfo) {
const validation = this.validateRepair(userInfo);
if (!validation.valid) {
safeCall(this.callbacks.onNotify, validation.message);
safeCall(this.callbacks.onStop);
return;
}
const { missingStreaks, maxPossibleStreak, endTimestamp } = validation;
if (!confirm(`This feature will repair ${missingStreaks} missing streaks, so your streak will be ${maxPossibleStreak} days. Are you sure you want to continue?`)) {
const message = `Streak repair cancelled.`;
safeCall(this.callbacks.onNotify, message);
safeCall(this.callbacks.onStop);
return;
}
safeCall(this.callbacks.onNotify, `Repairing ${missingStreaks} missing streaks...`);
const hasStreak = !!userInfo.streakData.currentStreak;
const startStreakDate = hasStreak ? userInfo.streakData.currentStreak.startDate : /* @__PURE__ */ new Date();
const startFarmStreakTimestamp = toTimestamp(startStreakDate);
let repairTimestamp = hasStreak ? startFarmStreakTimestamp - this.SECONDS_PER_DAY : startFarmStreakTimestamp;
let repairedCount = 0;
while (this.config.isRunning && repairTimestamp >= endTimestamp && repairedCount < missingStreaks) {
try {
const sessionRes = await this.apiService.farmSessionOnce({
startTime: repairTimestamp,
endTime: repairTimestamp + this.SESSION_DURATION_SECONDS
});
if (sessionRes) {
repairTimestamp -= this.SECONDS_PER_DAY;
safeCall(this.callbacks.onUpdate, "streak", 1);
repairedCount += 1;
await this.callbacks.delay(this.config.delayTime);
} else {
safeCall(this.callbacks.onError, "Failed to repair streak session, I'm trying again...");
await this.callbacks.delay(this.config.retryTime);
continue;
}
} catch (error) {
handleFarmingError(error, "repairStreak", this.callbacks);
await this.callbacks.delay(this.config.retryTime);
continue;
}
}
if (repairedCount >= missingStreaks || repairTimestamp < endTimestamp) {
const message = `Streak repair completed. Repaired ${repairedCount} day(s).`;
safeCall(this.callbacks.onNotify, message);
safeCall(this.callbacks.onStop);
}
}
}
class FarmingController {
constructor(apiService, config, callbacks) {
this.apiService = apiService;
this.config = config;
this.callbacks = callbacks;
this.isRunning = false;
this.autoStopTimerId = null;
this.currentFarming = null;
}
getIsRunning() {
return this.isRunning;
}
setIsRunning(running) {
this.isRunning = running;
if (!running && this.autoStopTimerId) {
clearTimeout(this.autoStopTimerId);
this.autoStopTimerId = null;
}
}
startAutoStopTimer(autoStopTimeMinutes) {
if (autoStopTimeMinutes > 0) {
this.autoStopTimerId = setTimeout(() => {
const message = `Auto-stopped by setting (stop after ${autoStopTimeMinutes} minutes).`;
safeCall(this.callbacks.onNotify, message);
safeCall(this.callbacks.onAlert, message);
this.stop();
}, autoStopTimeMinutes * 60 * 1e3);
}
}
async start(option, userInfo) {
if (this.isRunning) {
return;
}
this.setIsRunning(true);
this.startAutoStopTimer(this.config.autoStopTime);
const { type, value, amount, config } = option;
try {
const strategy = FARMING_STRATEGIES[type];
if (!strategy) {
throw new Error(`Unknown farming type: ${type}`);
}
this.currentFarming = strategy(this.apiService, this.config, this.callbacks);
if (type === "xp") {
await this.currentFarming.start(value, amount, config, userInfo);
} else if (type === "streak") {
await this.currentFarming.start(value, userInfo);
} else {
await this.currentFarming.start(userInfo);
}
} catch (error) {
handleFarmingError(error, "FarmingController.start", this.callbacks);
} finally {
this.setIsRunning(false);
}
}
stop() {
this.setIsRunning(false);
this.currentFarming = null;
}
}
class UserManager {
constructor(callbacks) {
this.userInfo = null;
this.callbacks = callbacks;
}
setUserInfo(userInfo) {
this.userInfo = userInfo;
if (this.callbacks.onUserInfoUpdate) {
this.callbacks.onUserInfoUpdate(this.userInfo);
}
}
getUserInfo() {
return this.userInfo;
}
updateFarmResult(type, farmedAmount) {
if (!this.userInfo) {
return;
}
switch (type) {
case "gem":
this.userInfo = { ...this.userInfo, gems: this.userInfo.gems + farmedAmount };
if (this.callbacks.onNotify) {
this.callbacks.onNotify(`You got ${farmedAmount} gem!!!`);
}
break;
case "xp":
this.userInfo = { ...this.userInfo, totalXp: this.userInfo.totalXp + farmedAmount };
if (this.callbacks.onNotify) {
this.callbacks.onNotify(`You got ${farmedAmount} XP!!!`);
}
break;
case "streak":
this.userInfo = { ...this.userInfo, streak: this.userInfo.streak + farmedAmount };
if (this.callbacks.onNotify) {
this.callbacks.onNotify(`You got ${farmedAmount} streak! (maybe some xp too, idk)`);
}
break;
}
if (this.callbacks.onUserInfoUpdate) {
this.callbacks.onUserInfoUpdate(this.userInfo);
}
}
}
function generateFarmOptions(userInfo) {
const skillId = extractSkillId(userInfo.currentCourse || {});
return [
{ type: "separator", label: "⟡ GEM FARMING ⟡", value: "", disabled: true },
{ type: "gem", label: "Gem 30", value: "fixed", amount: 30 },
{ type: "separator", label: "⟡ XP FARMING ⟡", value: "", disabled: true },
{ type: "xp", label: "XP 10", value: "xp", amount: 10, config: {} },
{ type: "xp", label: "XP 20", value: "xp", amount: 20, config: { updateSessionPayload: { hasBoost: true } } },
{ type: "xp", label: "XP 40", value: "xp", amount: 40, config: { updateSessionPayload: { hasBoost: true, type: "TARGET_PRACTICE" } } },
{ type: "xp", label: "XP 50", value: "xp", amount: 50, config: { updateSessionPayload: { enableBonusPoints: true, hasBoost: true, happyHourBonusXp: 10, type: "TARGET_PRACTICE" } } },
{ type: "xp", label: "XP 110", value: "xp", amount: 110, config: { sessionPayload: { type: "UNIT_TEST", skillIds: skillId ? [skillId] : [] }, updateSessionPayload: { type: "UNIT_TEST", hasBoost: true, happyHourBonusXp: 10, pathLevelSpecifics: { unitIndex: 0 } } }, disabled: !skillId },
{ type: "separator", label: "⟡ STREAK FARMING ⟡", value: "", disabled: true },
{ type: "streak", label: "Unlimited Streak", value: "farm" },
{ type: "streak", label: "Repair Streak", value: "repair" }
];
}
function getElements(shadowRoot) {
return {
startBtn: shadowRoot.getElementById("start-btn"),
stopBtn: shadowRoot.getElementById("stop-btn"),
select: shadowRoot.getElementById("select-option"),
floatingBtn: shadowRoot.getElementById("floating-btn"),
container: shadowRoot.getElementById("container"),
overlay: shadowRoot.getElementById("overlay"),
notify: shadowRoot.getElementById("notify"),
username: shadowRoot.getElementById("username"),
from: shadowRoot.getElementById("from"),
learn: shadowRoot.getElementById("learn"),
streak: shadowRoot.getElementById("streak"),
gem: shadowRoot.getElementById("gem"),
xp: shadowRoot.getElementById("xp"),
settingsBtn: shadowRoot.getElementById("settings-btn"),
settingsContainer: shadowRoot.getElementById("settings-container"),
settingsClose: shadowRoot.getElementById("settings-close"),
userInfoDisplay: shadowRoot.getElementById("user-info-display"),
setAccountPublic: shadowRoot.getElementById("set-account-public"),
setAccountPrivate: shadowRoot.getElementById("set-account-private")
};
}
function showElement(element) {
if (element) element.style.display = "flex";
}
function hideElement(element) {
if (element) element.style.display = "none";
}
function toggleModal(modalElement, mainElement) {
return {
show: () => {
hideElement(mainElement);
showElement(modalElement);
},
hide: () => {
hideElement(modalElement);
showElement(mainElement);
}
};
}
class UIController {
constructor(templateRaw2, cssText2) {
this.templateRaw = templateRaw2;
this.cssText = cssText2;
this.shadowRoot = null;
this.container = null;
}
init() {
this.container = document.createElement("div");
this.shadowRoot = this.container.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = this.cssText;
this.shadowRoot.appendChild(style);
const content = document.createElement("div");
content.innerHTML = this.templateRaw;
this.shadowRoot.appendChild(content);
document.body.appendChild(this.container);
const settingsContainer = this.shadowRoot.getElementById("settings-container");
if (settingsContainer) {
settingsContainer.style.display = "none";
}
const requiredElements = [
"start-btn",
"stop-btn",
"select-option",
"floating-btn",
"container",
"overlay",
"notify"
];
for (const id of requiredElements) {
if (!this.shadowRoot.getElementById(id)) {
throw new Error(`Required UI element '${id}' not found in template. Template may be corrupted.`);
}
}
return this.shadowRoot;
}
getShadowRoot() {
return this.shadowRoot;
}
setVisible(visible) {
const elements = getElements(this.shadowRoot);
if (visible) {
showElement(elements.container);
showElement(elements.overlay);
} else {
hideElement(elements.container);
hideElement(elements.overlay);
}
}
isVisible() {
const elements = getElements(this.shadowRoot);
return elements.container.style.display !== "none" && elements.container.style.display !== "";
}
toggle() {
this.setVisible(!this.isVisible());
}
}
class UIState {
constructor(shadowRoot) {
this.shadowRoot = shadowRoot;
this.isRunning = false;
this.autoStopTimerId = null;
}
setRunning(running) {
this.isRunning = running;
const elements = getElements(this.shadowRoot);
if (running) {
elements.startBtn.hidden = true;
elements.stopBtn.hidden = false;
elements.stopBtn.disabled = true;
elements.stopBtn.className = "disable-btn";
elements.select.disabled = true;
} else {
elements.stopBtn.hidden = true;
elements.startBtn.hidden = false;
elements.startBtn.disabled = true;
elements.startBtn.className = "disable-btn";
elements.select.disabled = false;
if (this.autoStopTimerId) {
clearTimeout(this.autoStopTimerId);
this.autoStopTimerId = null;
}
}
setTimeout(() => {
const elements2 = getElements(this.shadowRoot);
elements2.startBtn.className = "";
elements2.startBtn.disabled = false;
elements2.stopBtn.className = "";
elements2.stopBtn.disabled = false;
}, 3e3);
}
getIsRunning() {
return this.isRunning;
}
disableAllControls() {
const elements = getElements(this.shadowRoot);
elements.startBtn.disabled = true;
elements.startBtn.className = "disable-btn";
elements.stopBtn.disabled = true;
elements.select.disabled = true;
}
}
const updatePrivacyButtons = (elements, isPrivate) => {
elements.setAccountPublic.style.display = isPrivate ? "none" : "flex";
elements.setAccountPrivate.style.display = isPrivate ? "flex" : "none";
};
const extractOptionData = (selected) => ({
type: selected.getAttribute("data-type"),
amount: Number(selected.getAttribute("data-amount")),
value: selected.value,
label: selected.textContent,
config: selected.getAttribute("data-config") ? JSON.parse(selected.getAttribute("data-config")) : {}
});
class UIHandlers {
constructor(shadowRoot, farmingController, userManager, settingsManager, uiController, uiState) {
this.shadowRoot = shadowRoot;
this.farmingController = farmingController;
this.userManager = userManager;
this.settingsManager = settingsManager;
this.uiController = uiController;
this.uiState = uiState;
}
setupEventListeners() {
this.addEventStartBtn();
this.addEventStopBtn();
this.addEventFloatingBtn();
this.addEventSettings();
this.settingsManager.addEventListeners();
}
addEventStartBtn() {
const elements = getElements(this.shadowRoot);
elements.startBtn.addEventListener("click", async () => {
this.uiState.setRunning(true);
const selected = elements.select.options[elements.select.selectedIndex];
const optionData = extractOptionData(selected);
const userInfo = this.userManager.getUserInfo();
this.farmingController.start(optionData, userInfo).catch((error) => {
this.updateNotify(`Farming error: ${(error == null ? void 0 : error.message) || error}`);
this.uiState.setRunning(false);
});
});
}
addEventStopBtn() {
const elements = getElements(this.shadowRoot);
elements.stopBtn.addEventListener("click", () => {
this.farmingController.stop();
this.uiState.setRunning(false);
});
}
addEventFloatingBtn() {
const elements = getElements(this.shadowRoot);
elements.floatingBtn.addEventListener("click", () => {
if (this.uiState.getIsRunning()) {
if (confirm("Duofarmer is farming. Do you want to stop and hide UI?")) {
this.farmingController.stop();
this.uiState.setRunning(false);
this.uiController.setVisible(false);
}
return;
}
this.uiController.toggle();
});
}
addEventSettings() {
const elements = getElements(this.shadowRoot);
const settingsModal = toggleModal(elements.settingsContainer, elements.container);
elements.settingsBtn.addEventListener("click", settingsModal.show);
elements.settingsClose.addEventListener("click", settingsModal.hide);
}
updateNotify(message) {
const elements = getElements(this.shadowRoot);
const now = (/* @__PURE__ */ new Date()).toLocaleTimeString();
elements.notify.innerText = `[${now}] ` + message;
log(`[${now}] ${message}`);
}
updateUserInfo(userInfo, skillId, sub) {
var _a;
if (!userInfo) return;
const elements = getElements(this.shadowRoot);
elements.username.innerText = userInfo.username;
elements.from.innerText = userInfo.fromLanguage;
elements.learn.innerText = userInfo.learningLanguage;
elements.streak.innerText = userInfo.streak;
elements.gem.innerText = userInfo.gems;
elements.xp.innerText = userInfo.totalXp;
const isPrivate = (_a = userInfo.privacySettings) == null ? void 0 : _a.some(
(setting) => ["DISABLE_FRIENDS_QUESTS", "DISABLE_LEADERBOARDS"].includes(setting)
);
updatePrivacyButtons(elements, isPrivate);
elements.userInfoDisplay.innerText = JSON.stringify({
id: userInfo.id,
username: userInfo.username,
fromLanguage: userInfo.fromLanguage,
learningLanguage: userInfo.learningLanguage,
streak: userInfo.streak,
gems: userInfo.gems,
totalXp: userInfo.totalXp,
creationDate: userInfo.creationDate,
skillId,
jwt: "hidden - use get jwt button to view",
sub,
privacySettings: userInfo.privacySettings,
streakData: userInfo.streakData
}, null, 2);
}
populateOptions(farmOptions) {
const select = this.shadowRoot.getElementById("select-option");
select.innerHTML = "";
farmOptions.forEach((opt) => {
const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
option.setAttribute("data-type", opt.type);
if (opt.amount != null) option.setAttribute("data-amount", String(opt.amount));
if (opt.config) option.setAttribute("data-config", JSON.stringify(opt.config));
if (opt.disabled) option.disabled = true;
select.appendChild(option);
});
}
loadSavedSettings(settings) {
const elements = getElements(this.shadowRoot);
if (settings.autoOpenUI) {
this.uiController.setVisible(true);
}
if (settings.autoStart) {
this.uiController.setVisible(true);
elements.startBtn.click();
}
if (settings.hideUsername) {
elements.username.classList.add("blur");
}
if (settings.keepScreenOn && "wakeLock" in navigator) {
navigator.wakeLock.request("screen").then((wakeLock) => {
log("Screen wake lock active");
});
}
}
}
initPatcher();
function setupCallbacks(userManager, farmingController, uiHandlers, skillId, sub) {
userManager.callbacks.onUserInfoUpdate = (userInfo) => {
uiHandlers.updateUserInfo(userInfo, skillId, sub);
};
userManager.callbacks.onNotify = (message) => {
uiHandlers.updateNotify(message);
};
farmingController.callbacks.onError = (message) => {
uiHandlers.updateNotify(message);
};
farmingController.callbacks.onNotify = (message) => {
uiHandlers.updateNotify(message);
};
}
(async () => {
await waitForBody();
try {
const uiController = new UIController(templateRaw, cssText);
const shadowRoot = uiController.init();
uiController.setVisible(false);
const uiState = new UIState(shadowRoot);
const userManager = new UserManager({
onUserInfoUpdate: (userInfo2) => {
}
});
const jwt = getJwtToken();
if (!jwt) {
uiState.disableAllControls();
log("Please login to Duolingo and reload!");
return;
}
const defaultHeaders = formatHeaders(jwt);
const decodedJwt = decodeJwtToken(jwt);
const sub = decodedJwt.sub;
const userInfo = await ApiService.getUserInfo(sub, defaultHeaders);
if (!userInfo || !userInfo.id) {
uiState.disableAllControls();
log("Failed to get user info. Please reload!");
return;
}
userInfo.sub = sub;
userManager.setUserInfo(userInfo);
const apiService = new ApiService(jwt, defaultHeaders, userInfo, sub);
const settingsManager = new SettingsManager(shadowRoot, apiService);
const savedSettings = settingsManager.getSettings();
const skillId = extractSkillId(userInfo.currentCourse || {});
const farmOptions = generateFarmOptions(userInfo);
let farmingController;
const farmingConfig = {
get isRunning() {
return farmingController ? farmingController.getIsRunning() : false;
},
get delayTime() {
return savedSettings.delayTime;
},
get retryTime() {
return savedSettings.retryTime;
},
get autoStopTime() {
return savedSettings.autoStopTime;
}
};
farmingController = new FarmingController(
apiService,
farmingConfig,
{
delay,
onUpdate: (type, amount) => userManager.updateFarmResult(type, amount),
onError: () => {
},
// Will be set up after handlers are created
onNotify: () => {
},
// Will be set up after handlers are created
onStop: () => uiState.setRunning(false),
onAlert: (message) => alert(message)
}
);
const uiHandlers = new UIHandlers(
shadowRoot,
farmingController,
userManager,
settingsManager,
uiController,
uiState
);
setupCallbacks(userManager, farmingController, uiHandlers, skillId, sub);
uiHandlers.populateOptions(farmOptions);
settingsManager.populateDefaultOptionSelect(farmOptions);
settingsManager.loadDefaultFarmingOption(farmOptions);
settingsManager.loadSettingsToUI();
uiHandlers.updateUserInfo(userInfo, skillId, sub);
uiHandlers.setupEventListeners();
uiHandlers.loadSavedSettings(savedSettings);
uiHandlers.updateNotify('Duofarmer ready! For safety, I suggest that you use 2nd accounts.\nRecommended to use "Blank page" for best performance (check in setting)');
} catch (err) {
logError(err, "Duofarmer init error!");
}
})();
})();