DatafetchPro
    1775622350259_ylwfhe.webp
    Apr 8, 20265 min read27 views

    Duolingo Vocabulary Exporter Script: Automate Word Tracking & Build Your Own Learning Dataset

    Learn how to automatically capture and organize vocabulary from Duolingo using a custom userscript. This guide explains how automation can turn passive learning into structured data, helping you build your own word library, export it to CSV, and optimize your language learning workflow.

    Making Duolingo a Data Engine (Not Just an App)


    Most people use Duolingo passively -- you go through lessons, you forget half the words, and it goes on. But what would happen if every word you come across could be automatically captured, tracked and exported?


    And that's what this script does. It turns Duolingo into a lightweight data collection system using browser automation techniques: no API, no backend, just smart DOM observation and local storage.


    If you are into automation, scraping or building datasets, the fun part is where it gets fascinating.





    What This Script Really Does


    The script is basically monitoring for changes on the page and extracting words on the fly. The script will: - Whenever Duolingo shows new content (hints or challenge text, etc.), it will:


    • Identifies new words dynamically

    • Cleans and filters them (takes out stop words, punctuation, noise)

    • Store locally, track frequency

    • Presents them in a clean UI

    • Allows you to export everything as CSV


    That means you’re not just learning; you’re automatically creating your own database of vocabulary.


    Powerful Features You’ll Love


    1. Internet Vocabulary Learning


    The script watches the page like a bot. It extracts meaningful text instantly as soon as new elements appear.


    This is basically client-side scraping – no requests, no rate limits, no detection


    2. Intelligent Filtering System


    Not all words are beneficial The script deletes:

    • Common filler words (e.g., “the”, “is”, “at”)

    • Noise of a character alone

    • Punctuation & hidden unicode characters


    This keeps your data set clean and usable.


    3. Frequency following


    Each word is not only stored, but tracked.

    You receive:

    • How many times have you seen a word

    • When it was introduced


    This is useful if you want to do so later:

    • Use difficult words first

    • Build SRS

    • Training a Custom NLP Model


    4. Built-in UI (No Need External Dashboard)


    A floating widget displays:


    • Total words gathered

    • newly acquired vocabulary

    • Quick actions like block/unblock


    The UI is minimal yet functional, perfect for rapid interaction without breaking your learning rhythm.


    5. System of blacklisting


    Occasionally you don't want some words in your data set.


    With the script, you can do the following:


    • Bloquear palabras indeseadas

    • We need to maintain a clean vocabulary list

    • Re-enable them at any time


    Essentially, this process is manual data curation on top of automatic scraping.


    6. Export CSV in one click


    With one click you can export everything to a CSV file:


    This allows:

    • Excel analysis.

    • Import to Anki or other tools

    • Feeding data to Python scripts

    • Making Your Own Language Datasets


    Why This Matters (Outside of Duolingo)


    It isn’t only about language learning.


    This script is a real-world example of browser-based automation and scraping, used in a clean and pragmatic way.


    It shows:


    • No APIs? Scraping DOM

    • Event-based automation

    • Lightweight data persistence

    • Integrate micro-tools into existing platforms


    This is the kind of approach to scale into if you work in automation or web scraping:


    • Lead extraction devices

    • Bots that monitor content

    • Processes of data pipelines

    • RPA Workflows in Browsers



    Watch on YouTube



    Technical Highlights (For Developers)

    A couple of things to note:

    • MutationObserver reacts to DOM changes instead of polling
    • Session Cache → Prevents duplicate processing in short intervals
    • Local Storage via GM_setValue → Persistant storage without backend
    • Regex Cleaning Pipeline → Keeps dataset usable
    • Dynamic UI Rendering → Updates instantly after every change

    It’s stealthy, efficient, and doesn’t bog down the browser.”


    Where to Take This Next

    If you want to take it up a notch:

    • Sync data to backend (supabase / postgresql)
    • Add translation APIs for every word
    • Build a spaced repetition system
    • Auto synchronisation with Anki
    • Build one dashboard with multiple users

    At that point you’re not just learning. You’re developing a product.


    Here is the script.

    // ==UserScript==
    // @name Duolingo Vocab Master (UI Readability)
    // @version 2.1
    // @description Duolingo Exporter
    // @match https://www.duolingo.com/*
    // @grant GM_setValue
    // @grant GM_getValue
    // @grant GM_addStyle
    // ==/UserScript==

    (function() {
    'use strict';

    const COLORS = {
    green: "#58cc02",
    red: "#ff4b4b",
    blue: "#1cb0f6",
    darkGray: "#4b4b4b", // Better readability
    lightGray: "#f1f1f1",
    border: "#e5e5e5"
    };

    const STOP_WORDS = new Set(["the", "of", "a", "an", "to", "in", "is", "it", "you", "that", "he", "was", "for", "on", "refer", "are", "with", "as", "i", "his", "they", "be", "at", "one", "have", "this", "from", "or", "had", "by", "but", "what", "some", "we", "can", "out", "other", "were", "all", "there", "when", "up", "use", "your", "how", "she", "each", "has", "been", "my", "me"]);
    const sessionCache = new Map();

    const getData = () => GM_getValue("duo_vocab_v11", { words: {}, blacklist: [], isMainOpen: false, isBlacklistOpen: false });
    const saveData = (data) => GM_setValue("duo_vocab_v11", data);

    function addWordToLibrary(word) {
    if (!word) return;
    let clean = word.toLowerCase().trim().replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/[.,!?;🙁)0-9"']/g, "");
    if (clean.length <= 1 || STOP_WORDS.has(clean)) return;

    let data = getData();
    if (data.blacklist.includes(clean)) return;

    let now = Date.now();
    if (now - (sessionCache.get(clean) || 0) < 5000) return;
    sessionCache.set(clean, now);

    if (!data.words[clean]) {
    data.words[clean] = { count: 1, date: new Date().toLocaleDateString() };
    triggerPulse();
    } else {
    data.words[clean].count++;
    }
    saveData(data);
    updateUI();
    }

    // --- UI STYLES ---
    GM_addStyle(`
    #duo-launcher {
    position: fixed; bottom: 25px; right: 25px; z-index: 10001;
    width: 55px; height: 55px; background: ${COLORS.green};
    border-radius: 50%; border: none; cursor: pointer;
    box-shadow: 0 4px 0 #46a302; display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    color: white; font-family: "din-round", sans-serif; transition: all 0.2s;
    }
    #duo-launcher:active { transform: translateY(2px); box-shadow: none; }
    #duo-launcher .count-num { font-size: 18px; font-weight: bold; line-height: 1; }
    #duo-launcher .count-label { font-size: 8px; font-weight: bold; text-transform: uppercase; margin-top: 2px; }

    @keyframes duo-pulse {
    0% { transform: scale(1); }
    50% { transform: scale(1.15); box-shadow: 0 0 20px ${COLORS.green}; }
    100% { transform: scale(1); }
    }
    .pulse-anim { animation: duo-pulse 0.4s ease-out; }

    #duo-master-container {
    position: fixed; top: 15px; right: 15px; z-index: 10000;
    background: white; border: 2px solid ${COLORS.border}; border-radius: 16px;
    width: 300px; max-height: 80vh; display: none; flex-direction: column;
    font-family: "din-round", sans-serif; box-shadow: 0 4px 0 ${COLORS.border};
    }
    #duo-master-container.open { display: flex; }

    .duo-header {
    padding: 12px; background: ${COLORS.green}; color: white;
    border-radius: 13px 13px 0 0; font-weight: bold;
    display: flex; justify-content: space-between; align-items: center;
    }
    .duo-content { overflow-y: auto; padding: 12px; flex-grow: 1; background: #fff; }
    .word-item {
    display: flex; justify-content: space-between; align-items: center;
    padding: 8px 0; border-bottom: 2px solid #f0f0f0;
    }
    .word-text { color: ${COLORS.green}; font-weight: bold; }

    .blacklist-toggle {
    padding: 12px; background: #f7f7f7; cursor: pointer;
    border-top: 2px solid ${COLORS.border}; font-weight: bold;
    display: flex; justify-content: space-between; color: ${COLORS.darkGray};
    font-size: 12px; letter-spacing: 0.5px;
    }
    .blacklist-content { padding: 10px; display: none; background: #fff; max-height: 150px; overflow-y: auto; border-radius: 0 0 16px 16px; }
    .blacklist-content.open { display: block; }

    .btn-duo {
    cursor: pointer; border: none; border-radius: 12px;
    padding: 6px 12px; font-size: 10px; font-weight: bold;
    text-transform: uppercase; box-shadow: 0 2px 0 rgba(0,0,0,0.1);
    }
    .btn-red { background: ${COLORS.red}; color: white; }
    .btn-blue { background: ${COLORS.blue}; color: white; }

    /* BLACKLIST CHIP FIX */
    .chip {
    display: inline-flex;
    align-items: center;
    background: ${COLORS.lightGray};
    color: ${COLORS.darkGray}; /* Fixed: Dark text on light background */
    padding: 5px 10px;
    border-radius: 14px;
    margin: 3px;
    font-size: 12px;
    font-weight: 500;
    border: 1px solid #ddd;
    }
    .chip-remove {
    margin-left: 8px;
    color: ${COLORS.red};
    cursor: pointer;
    font-weight: 800;
    font-size: 14px;
    line-height: 1;
    }
    .chip-remove:hover { transform: scale(1.2); }
    `);

    // --- DOM SETUP ---
    const launcher = document.createElement('button');
    launcher.id = "duo-launcher";
    document.body.appendChild(launcher);

    const container = document.createElement('div');
    container.id = "duo-master-container";
    document.body.appendChild(container);

    function triggerPulse() {
    launcher.classList.remove('pulse-anim');
    void launcher.offsetWidth;
    launcher.classList.add('pulse-anim');
    }

    function updateUI() {
    const data = getData();
    const totalWords = Object.keys(data.words).length;

    launcher.innerHTML = `<span class="count-num">${totalWords}</span><span class="count-label">Words</span>`;
    container.classList.toggle('open', data.isMainOpen);

    const words = Object.keys(data.words).reverse().slice(0, 50);

    container.innerHTML = `
    <div class="duo-header">
    <span>LIBRARY (${totalWords})</span>
    <div style="display:flex; gap:10px; align-items:center;">
    <button id="export-csv" class="btn-duo btn-blue">CSV</button>
    <span id="close-ui" style="cursor:pointer; font-size:20px; line-height:1;">✕</span>
    </div>
    </div>
    <div class="duo-content">
    ${words.map(w => `
    <div class="word-item">
    <span><span class="word-text">${w}</span> <small style="color:#aaa; font-size:10px; margin-left:4px;">${data.words[w].count}x</small></span>
    <button class="btn-duo btn-red action-block" data-word="${w}">Block</button>
    </div>
    `).join('') || '<p style="text-align:center; color:#ccc; padding:20px;">No words found yet...</p>'}
    </div>
    <div class="blacklist-toggle" id="toggle-bl">
    <span>BLACKLISTED (${data.blacklist.length})</span>
    <span>${data.isBlacklistOpen ? '▼' : '▲'}</span>
    </div>
    <div class="blacklist-content ${data.isBlacklistOpen ? 'open' : ''}">
    ${data.blacklist.map(w => `
    <span class="chip">
    ${w}
    <span class="action-unblock chip-remove" data-word="${w}">×</span>
    </span>
    `).join('') || '<p style="font-size:11px; color:#aaa; text-align:center;">No words blocked.</p>'}
    </div>
    `;

    // --- ATTACH EVENTS ---
    document.getElementById('export-csv').onclick = exportCSV;
    document.getElementById('close-ui').onclick = toggleMainUI;
    document.getElementById('toggle-bl').onclick = toggleBlacklistUI;

    container.querySelectorAll('.action-block').forEach(btn => {
    btn.onclick = () => blockWord(btn.dataset.word);
    });
    container.querySelectorAll('.action-unblock').forEach(btn => {
    btn.onclick = () => unblockWord(btn.dataset.word);
    });
    }

    function toggleMainUI() {
    let d = getData(); d.isMainOpen = !d.isMainOpen; saveData(d); updateUI();
    }

    function toggleBlacklistUI() {
    let d = getData(); d.isBlacklistOpen = !d.isBlacklistOpen; saveData(d); updateUI();
    }

    function blockWord(word) {
    let data = getData();
    delete data.words[word];
    if (!data.blacklist.includes(word)) data.blacklist.push(word);
    saveData(data);
    updateUI();
    }

    function unblockWord(word) {
    let data = getData();
    data.blacklist = data.blacklist.filter(w => w !== word);
    saveData(data);
    updateUI();
    }

    launcher.onclick = toggleMainUI;

    function exportCSV() {
    const data = getData();
    let csv = "Word,Frequency,Date Added\n" + Object.entries(data.words).map(([w, i]) => `${w},${i.count},${i.date}`).join("\n");
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([csv], {type: 'text/csv'}));
    a.download = 'duo_vocab_export.csv'; a.click();
    }

    const observer = new MutationObserver(() => {
    const elements = document.querySelectorAll('[data-test="hint-token"], [data-test="challenge-token-text"], [style*="dashed"]');
    elements.forEach(el => {
    const val = el.getAttribute('aria-label') || el.innerText;
    if (val) val.split(/\s+/).forEach(p => addWordToLibrary(p));
    });
    });

    observer.observe(document.body, { childList: true, subtree: true, characterData: true });
    updateUI();
    })();
    0