Duolingo Gem Helper

Auto-extract JWT token and farm Duolingo gems

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name                Duolingo Gem Helper
// @name:vi             Duolingo Gem Helper
// @namespace           https://github.com/yourusername/GemHelper
// @version             1.0.0
// @description         Auto-extract JWT token and farm Duolingo gems
// @description:vi      Tự động trích xuất JWT token và farm gems Duolingo.
// @author              GemHelper Team & @2pixel
// @match               https://*.duolingo.com/*
// @match               https://*.duolingo.cn/*
// @icon                https://d35aaqx5ub95lt.cloudfront.net/images/gems/45c14e05be9c1af1d7d0b54c6eed7eee.svg
// @run-at              document-end
// @grant               GM_xmlhttpRequest
// @grant               GM_addStyle
// @connect             duolingo.com
// @compatible          chrome   Tested on Chrome 120+ with Tampermonkey
// @compatible          firefox  Tested on Firefox 120+ with Tampermonkey / Violentmonkey
// @compatible          edge     Tested on Edge 120+ with Tampermonkey
// @license             MIT
// ==/UserScript==

(function () {
'use strict';

const _fontLink = document.createElement('link');
_fontLink.rel = 'stylesheet';
_fontLink.href = 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap';
document.head.appendChild(_fontLink);

GM_addStyle(`
:root {
    --gh-green: 88, 204, 2;
    --gh-blue: 28, 176, 246;
    --gh-red: 255, 77, 77;
    --gh-orange: 255, 159, 10;
    --gh-gray: 175, 175, 175;
}

@media (prefers-color-scheme: dark) {
    :root {
        --gh-green: 88, 204, 2;
        --gh-blue: 28, 176, 246;
    }
}

#GH_Root * {
    box-sizing: border-box;
    font-family: 'Nunito', sans-serif;
}

#GH_Main {
    position: fixed;
    bottom: 16px;
    right: 16px;
    z-index: 999999;
    width: 380px;
}

#GH_Box {
    background: rgba(255,255,255,0.95);
    border-radius: 16px;
    box-shadow: 0 8px 32px rgba(0,0,0,0.15);
    overflow: hidden;
    backdrop-filter: blur(10px);
}

@media (prefers-color-scheme: dark) {
    #GH_Box {
        background: rgba(40,40,40,0.95);
        box-shadow: 0 8px 32px rgba(0,0,0,0.5);
    }
}

#GH_Header {
    background: linear-gradient(135deg, rgb(var(--gh-green)), rgb(88, 180, 2));
    padding: 16px 20px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    cursor: move;
    user-select: none;
}

#GH_Title {
    color: white;
    font-size: 18px;
    font-weight: 800;
    display: flex;
    align-items: center;
    gap: 8px;
}

#GH_Icon {
    width: 24px;
    height: 24px;
}

#GH_Close {
    background: rgba(255,255,255,0.2);
    border: none;
    color: white;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 18px;
    font-weight: bold;
    transition: all 0.2s;
}

#GH_Close:hover {
    background: rgba(255,255,255,0.3);
    transform: scale(1.1);
}

#GH_Content {
    padding: 20px;
}

.gh-section {
    margin-bottom: 20px;
}

.gh-section-title {
    font-size: 14px;
    font-weight: 700;
    color: #777;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    margin-bottom: 12px;
}

@media (prefers-color-scheme: dark) {
    .gh-section-title {
        color: #aaa;
    }
}

.gh-info-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    margin-bottom: 16px;
}

.gh-info-card {
    background: rgba(var(--gh-blue), 0.08);
    border-radius: 12px;
    padding: 12px;
    border: 2px solid rgba(var(--gh-blue), 0.15);
}

.gh-info-label {
    font-size: 11px;
    font-weight: 700;
    color: rgb(var(--gh-blue));
    text-transform: uppercase;
    letter-spacing: 0.3px;
    margin-bottom: 4px;
}

.gh-info-value {
    font-size: 20px;
    font-weight: 800;
    color: #333;
}

@media (prefers-color-scheme: dark) {
    .gh-info-value {
        color: #fff;
    }
}

.gh-button {
    width: 100%;
    padding: 14px;
    border: none;
    border-radius: 12px;
    font-size: 15px;
    font-weight: 800;
    cursor: pointer;
    transition: all 0.2s;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.gh-button:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 16px rgba(0,0,0,0.15);
}

.gh-button:active {
    transform: translateY(0);
}

.gh-button-primary {
    background: linear-gradient(135deg, rgb(var(--gh-green)), rgb(88, 180, 2));
    color: white;
}

.gh-button-danger {
    background: linear-gradient(135deg, rgb(var(--gh-red)), rgb(220, 60, 60));
    color: white;
}

.gh-button-secondary {
    background: linear-gradient(135deg, rgb(var(--gh-blue)), rgb(20, 150, 220));
    color: white;
}

.gh-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    transform: none !important;
}

.gh-log {
    background: rgba(0,0,0,0.03);
    border-radius: 10px;
    padding: 12px;
    max-height: 200px;
    overflow-y: auto;
    font-size: 12px;
    font-family: 'Courier New', monospace;
    margin-top: 12px;
}

@media (prefers-color-scheme: dark) {
    .gh-log {
        background: rgba(0,0,0,0.3);
    }
}

.gh-log-entry {
    padding: 4px 0;
    color: #333;
    word-wrap: break-word;
}

@media (prefers-color-scheme: dark) {
    .gh-log-entry {
        color: #ddd;
    }
}

.gh-log-success {
    color: rgb(var(--gh-green));
    font-weight: 700;
}

.gh-log-error {
    color: rgb(var(--gh-red));
    font-weight: 700;
}

.gh-log-info {
    color: rgb(var(--gh-blue));
    font-weight: 700;
}

.gh-status {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 14px;
    background: rgba(var(--gh-gray), 0.1);
    border-radius: 10px;
    margin-bottom: 12px;
}

.gh-status-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: rgb(var(--gh-gray));
}

.gh-status-dot.active {
    background: rgb(var(--gh-green));
    animation: pulse 2s infinite;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
}

.gh-status-text {
    font-size: 13px;
    font-weight: 600;
    color: #666;
}

@media (prefers-color-scheme: dark) {
    .gh-status-text {
        color: #aaa;
    }
}

.gh-minimize {
    background: rgba(255,255,255,0.2);
    border: none;
    color: white;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 18px;
    font-weight: bold;
    transition: all 0.2s;
    margin-right: 8px;
}

.gh-minimize:hover {
    background: rgba(255,255,255,0.3);
}

#GH_Toggle {
    position: fixed;
    bottom: 16px;
    right: 16px;
    z-index: 999998;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: linear-gradient(135deg, rgb(var(--gh-green)), rgb(88, 180, 2));
    border: none;
    cursor: pointer;
    box-shadow: 0 4px 16px rgba(0,0,0,0.2);
    display: none;
    align-items: center;
    justify-content: center;
    transition: all 0.2s;
}

#GH_Toggle:hover {
    transform: scale(1.1);
    box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}

#GH_Toggle svg {
    width: 28px;
    height: 28px;
    fill: white;
}
`);

let _token = null;
let _userId = null;
let _fromLang = null;
let _learningLang = null;
let _isRunning = false;
let _gems = 0;

const html = `
<div id="GH_Root">
    <button id="GH_Toggle">
        <img src="https://d35aaqx5ub95lt.cloudfront.net/images/gems/45c14e05be9c1af1d7d0b54c6eed7eee.svg" style="width: 32px; height: 32px;">
    </button>
    <div id="GH_Main">
        <div id="GH_Box">
            <div id="GH_Header">
                <div id="GH_Title">
                    <img id="GH_Icon" src="https://d35aaqx5ub95lt.cloudfront.net/images/gems/45c14e05be9c1af1d7d0b54c6eed7eee.svg" style="width: 28px; height: 28px;">
                    GEM HELPER
                </div>
                <div style="display: flex; gap: 8px;">
                    <button id="GH_Minimize" class="gh-minimize">−</button>
                    <button id="GH_Close">×</button>
                </div>
            </div>
            <div id="GH_Content">
                <div class="gh-section">
                    <div class="gh-section-title">Account Info</div>
                    <div class="gh-info-grid">
                        <div class="gh-info-card">
                            <div class="gh-info-label">Current Gems</div>
                            <div class="gh-info-value" id="GH_Gems">---</div>
                        </div>
                        <div class="gh-info-card">
                            <div class="gh-info-label">User ID</div>
                            <div class="gh-info-value" id="GH_UserId" style="font-size: 12px; word-break: break-all;">---</div>
                        </div>
                    </div>
                    <div class="gh-status">
                        <div class="gh-status-dot" id="GH_StatusDot"></div>
                        <div class="gh-status-text" id="GH_StatusText">Disconnected</div>
                    </div>
                </div>

                <div class="gh-section">
                    <button class="gh-button gh-button-primary" id="GH_StartBtn">
                        Start Farming
                    </button>
                </div>

                <div class="gh-section">
                    <div class="gh-section-title">Log</div>
                    <div class="gh-log" id="GH_Log">
                        <div class="gh-log-entry">Ready to farm gems...</div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
`;

document.body.insertAdjacentHTML('beforeend', html);

const _main = document.getElementById('GH_Main');
const _toggle = document.getElementById('GH_Toggle');
const _log = document.getElementById('GH_Log');

function log(message, type = 'info') {
    const entry = document.createElement('div');
    entry.className = `gh-log-entry gh-log-${type}`;
    entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
    _log.appendChild(entry);
    _log.scrollTop = _log.scrollHeight;
}

function updateStatus(connected) {
    const dot = document.getElementById('GH_StatusDot');
    const text = document.getElementById('GH_StatusText');
    if (connected) {
        dot.classList.add('active');
        text.textContent = 'Connected';
    } else {
        dot.classList.remove('active');
        text.textContent = 'Disconnected';
    }
}

function extractToken() {
    try {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
            const [name, value] = cookie.trim().split('=');
            if (name === 'jwt_token') {
                return value;
            }
        }

        const authHeader = localStorage.getItem('authorization');
        if (authHeader && authHeader.startsWith('Bearer ')) {
            return authHeader.substring(7);
        }

        return null;
    } catch (e) {
        console.error('Token extraction error:', e);
        return null;
    }
}

async function fetchUserData() {
    if (!_token) {
        _token = extractToken();
        if (!_token) {
            log('Failed to extract JWT token', 'error');
            return false;
        }
    }

    try {
        const decoded = JSON.parse(atob(_token.split('.')[1]));
        _userId = decoded.sub;
        document.getElementById('GH_UserId').textContent = _userId;

        const response = await fetch(`https://www.duolingo.com/2017-06-30/users/${_userId}?fields=fromLanguage,learningLanguage,currentCourseId`, {
            headers: {
                'authorization': `Bearer ${_token}`,
                'content-type': 'application/json'
            }
        });

        if (!response.ok) throw new Error('Failed to fetch user data');

        const userData = await response.json();
        _fromLang = userData.fromLanguage;
        _learningLang = userData.learningLanguage;

        log(`Detected: Learning ${_learningLang} from ${_fromLang}`, 'success');

        await updateGems();
        updateStatus(true);
        return true;
    } catch (e) {
        log(`Connection error: ${e.message}`, 'error');
        updateStatus(false);
        return false;
    }
}

async function updateGems() {
    try {
        const response = await fetch(`https://www.duolingo.com/2023-05-23/users/${_userId}?fields=gemsConfig`, {
            headers: {
                'authorization': `Bearer ${_token}`,
                'content-type': 'application/json'
            }
        });

        if (!response.ok) throw new Error('Failed to fetch gems');

        const data = await response.json();
        _gems = data.gemsConfig.gems;
        document.getElementById('GH_Gems').textContent = _gems.toLocaleString();
    } catch (e) {
        log(`Failed to update gems: ${e.message}`, 'error');
    }
}

async function fetchRewards() {
    try {
        const response = await fetch(`https://www.duolingo.com/2023-05-23/users/${_userId}?fields=rewardBundles`, {
            headers: {
                'authorization': `Bearer ${_token}`,
                'content-type': 'application/json'
            }
        });

        if (!response.ok) throw new Error('Failed to fetch rewards');

        const data = await response.json();
        const bundles = data.rewardBundles || [];
        const gemRewards = [];

        for (let bundle of bundles) {
            for (let reward of bundle.rewards || []) {
                if (!reward.consumed && (reward.id.includes('GEMS') || reward.currency === 'GEMS')) {
                    gemRewards.push({
                        id: reward.id,
                        amount: reward.amount || 0
                    });
                }
            }
        }

        return gemRewards;
    } catch (e) {
        log(`Failed to fetch rewards: ${e.message}`, 'error');
        return [];
    }
}

async function exploitReward(rewardId) {
    const body = {
        consumed: true,
        fromLanguage: _fromLang,
        learningLanguage: _learningLang,
        pathLevelSpecifics: {
            anchorSkillId: 'f22fd38157eea63965dc39eeac3c40c1',
            indexSinceAnchorSkill: 0,
            treeId: '14b1a2672c1bb3b250ebaa31b86c343e',
            nodeState: 'active'
        }
    };

    try {
        const response = await fetch(`https://www.duolingo.com/2023-05-23/users/${_userId}/rewards/${rewardId}`, {
            method: 'PATCH',
            headers: {
                'authorization': `Bearer ${_token}`,
                'content-type': 'application/json',
                'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
                'x-amzn-trace-id': `User=${_userId}`,
                'x-requested-with': 'XMLHttpRequest',
                'referer': 'https://www.duolingo.com/learn',
                'origin': 'https://www.duolingo.com'
            },
            body: JSON.stringify(body)
        });

        return response.ok;
    } catch (e) {
        return false;
    }
}

async function startFarming() {
    if (_isRunning) {
        _isRunning = false;
        document.getElementById('GH_StartBtn').textContent = 'Start Farming';
        document.getElementById('GH_StartBtn').className = 'gh-button gh-button-primary';
        log('Farming stopped', 'info');
        return;
    }

    if (!_token || !_userId) {
        const connected = await fetchUserData();
        if (!connected) return;
    }

    _isRunning = true;
    document.getElementById('GH_StartBtn').textContent = 'Stop Farming';
    document.getElementById('GH_StartBtn').className = 'gh-button gh-button-danger';

    log('Fetching available rewards...', 'info');
    const rewards = await fetchRewards();

    if (rewards.length === 0) {
        log('No rewards available to farm', 'error');
        _isRunning = false;
        document.getElementById('GH_StartBtn').textContent = 'Start Farming';
        document.getElementById('GH_StartBtn').className = 'gh-button gh-button-primary';
        return;
    }

    log(`Found ${rewards.length} gem rewards`, 'success');

    const threadCount = 1;
    const gemsBefore = _gems;
    let totalGained = 0;

    for (let reward of rewards) {
        if (!_isRunning) break;

        log(`Exploiting ${reward.id} (${reward.amount} base gems)...`, 'info');

        const startTime = Date.now();
        const promises = [];

        for (let i = 0; i < threadCount; i++) {
            promises.push(exploitReward(reward.id));
        }

        await Promise.all(promises);

        const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);

        await new Promise(resolve => setTimeout(resolve, 500));
        await updateGems();

        const gained = _gems - gemsBefore - totalGained;
        totalGained += gained;

        log(`✓ Completed in ${elapsed}s → +${gained} gems`, 'success');
    }

    log(`Total farmed: ${totalGained} gems`, 'success');
    log(`Final gems: ${_gems.toLocaleString()}`, 'success');

    _isRunning = false;
    document.getElementById('GH_StartBtn').textContent = 'Start Farming';
    document.getElementById('GH_StartBtn').className = 'gh-button gh-button-primary';
}

document.getElementById('GH_StartBtn').addEventListener('click', startFarming);

document.getElementById('GH_Close').addEventListener('click', () => {
    _main.style.display = 'none';
});

document.getElementById('GH_Minimize').addEventListener('click', () => {
    _main.style.display = 'none';
    _toggle.style.display = 'flex';
});

document.getElementById('GH_Toggle').addEventListener('click', () => {
    _main.style.display = 'block';
    _toggle.style.display = 'none';
});

let isDragging = false;
let currentX, currentY, initialX, initialY;

const header = document.getElementById('GH_Header');

header.addEventListener('mousedown', (e) => {
    if (e.target.tagName === 'BUTTON') return;
    isDragging = true;
    initialX = e.clientX - _main.offsetLeft;
    initialY = e.clientY - _main.offsetTop;
});

document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    e.preventDefault();
    currentX = e.clientX - initialX;
    currentY = e.clientY - initialY;
    _main.style.left = currentX + 'px';
    _main.style.top = currentY + 'px';
    _main.style.bottom = 'auto';
    _main.style.right = 'auto';
});

document.addEventListener('mouseup', () => {
    isDragging = false;
});

setTimeout(() => {
    fetchUserData();
}, 1000);

})();