DatafetchPro
    export-duolingo-vocabulary-csv-output
    Apr 8, 20265 min read47 views

    Export Duolingo Vocabulary With a Userscript

    I built a Tampermonkey userscript that lets you export Duolingo vocabulary to CSV automatically, with word frequency tracking and a cleaning filter.

    Most people use Duolingo passively. You go through the lessons, you forget half the words, and it goes on. I wanted to see what would happen if every word I came across got captured, counted, and written to a file β€” so I built a userscript that does it. It lets you export Duolingo vocabulary from the lessons you're already doing, with no API, no backend, and no change to how you study.

    The whole thing runs in the browser. It watches the page, pulls out the words as they appear, filters the junk, and stores the result on your own machine. The full script is in a code block further down, along with a video of it running.

    How the script exports Duolingo vocabulary

    When Duolingo puts new content on screen β€” a challenge sentence, a hint bubble, a word bank tile β€” the script notices and reacts. It identifies words it hasn't seen before, cleans and filters them, saves them locally with a running count, shows them in a small on-page panel, and hands you a CSV whenever you ask for one.

    That means you aren't only learning. You're building a vocabulary database as a side effect of practice.


    What the script actually does

    Live word capture

    The script watches the page the way a bot would. As soon as new elements appear in the lesson, it reads the text out of them and extracts anything meaningful.

    This is client-side scraping. Nothing is requested from Duolingo's servers that your browser wasn't already going to request β€” the script only reads what has already been rendered on screen, which is why there's no request volume to manage.

    Filtering out the noise

    Not every word on screen is worth keeping. The script strips out:

    • Common filler words β€” "the", "is", "at" and the rest
    • Single stray characters
    • Punctuation and invisible unicode

    Without this, you end up with a file full of articles and commas. With it, the dataset stays usable.

    Frequency tracking

    Every word is stored with a count, not just a checkmark. You end up knowing how many times you've encountered a word and when it first showed up.

    That's what makes the export worth something later. You can sort by the words you've seen most, target the ones you've seen once and never again, or feed the counts into a spaced repetition system β€” an SRS, meaning a review schedule that shows you a word right before you'd forget it.

    A note on what the data can and can't support

    The file is a word list with counts. That's enough for study tools, sorting, and personal analysis. It is not a training corpus β€” a few hundred vocabulary rows won't build a language model, and I'd rather say that plainly than imply otherwise.

    A built-in panel, not a separate dashboard

    A small floating widget sits on the page showing the total words collected, the newest additions, and quick actions like blocking a word. It's deliberately minimal so it doesn't interrupt the lesson.

    Blacklisting

    Sometimes a word gets in that you don't want. The widget lets you block unwanted words, keep the list clean, and unblock them again later. It's manual curation sitting on top of automatic collection, which is usually the right split β€” let the machine gather, let the human decide.

    One-click CSV export

    One button writes everything to a CSV. From there you can open it in Excel or Sheets, import it into Anki, using its documented text-import format, feed it to a Python script, or keep it as your own language learning dataset.




    Watch on YouTube


    Installing it

    You need a userscript manager β€” a browser extension that runs small scripts on pages you choose. Tampermonkey, which publishes builds for Chrome, Firefox and Edge is the one I use.

    Install the extension, open its dashboard, create a new script, paste the code from the block below over whatever template it gives you, and save. Open a Duolingo lesson and the widget appears in the corner. Words start accumulating on the first exercise.


    Watch it run

    Watch the script capture words during a live Duolingo lesson and export them to CSV β€” the video shows the widget filling up in real time and the export firing at the end.

    Technical notes

    If you write code, these are the parts worth looking at.

    MutationObserver instead of polling

    The script doesn't check the page on a timer. It uses the MutationObserver API documented by MDN, which fires a callback only when the DOM β€” the live structure of the page in the browser's memory β€” actually changes. Nothing runs between lessons. That's a design choice about efficiency rather than a benchmark I've measured.

    Session cache

    A short-lived cache stops the same element being processed twice when Duolingo re-renders a component without changing its text. Without it, counts inflate.

    Local storage via GM_setValue

    GM_setValue is Tampermonkey's own storage function. It keeps data across page loads and browser restarts without any server involved. Your word list never leaves your machine.

    Regex cleaning pipeline

    A chain of regular expressions handles the punctuation stripping, unicode removal, and stop-word filtering before anything gets stored. Cleaning at write time rather than export time means the stored data is already good.

    Dynamic UI rendering

    The widget re-renders after every change, so the counter you're looking at is always current.

    What breaks, and what to expect

    This is DOM scraping, so it depends on Duolingo's markup staying roughly as it is. If they rename their CSS classes or restructure the lesson components, the selectors will need updating. That's normal for this approach and easy to fix once you know where to look.

    Storage is per-browser. Reinstall your browser or clear extension data and the list goes with it. Export regularly.

    One more thing: whether running a script against Duolingo fits their terms of service is your call, not mine. Duolingo publishes its terms publicly and I'd read them before running anything. I can describe how the technical approach works; I don't make the compliance decision for you.

    Where to take it next

    Each of these is a real project rather than a toggle:

    • Sync to a backend. Push the word list to Supabase or PostgreSQL so it survives a browser wipe and you can query it properly.
    • Add translations. Call a translation API for each new word and store the result alongside it, so the CSV arrives already glossed.
    • Build a spaced repetition system. The frequency and first-seen columns are the two inputs a review scheduler needs.
    • Auto-sync with Anki. Skip the manual import and push cards straight into a deck.
    • Multi-user dashboard. Once several people are collecting, the data is worth looking at in aggregate.

    At that point you aren't studying with a helper script. You're running a product.

    The same approach outside Duolingo

    Duolingo is the example, not the point. What's underneath it is a pattern I use constantly: a site with no usable API, data on the screen, and a browser that can read it.

    The same four pieces β€” DOM scraping when no API exists, event-driven automation, lightweight persistence, and a micro-tool embedded in a platform you don't control β€” turn into lead extraction tools, content monitoring bots, data pipelines, and browser RPA workflows. RPA means robotic process automation: software clicking through a site the way a person would, on a schedule.

    That's the browser automation work I take on. Some of it is a Tampermonkey userscript like this one. Some of it is a Python and Playwright bot running on a timer, cleaning its output into PostgreSQL or Google Sheets. If you've got a site holding data you can't get out of it, tell me what data you need and I'll scope the build β€” I sell through Fiverr and I'll tell you upfront if a job isn't worth doing.

    If you want to see the range first, there's my other write-ups on scraping and automation, and how I work and what I've built before.

    The script is below.

    JavaScript
    // ==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

    Rather not build it yourself?

    I build this kind of thing for a living.

    Send me the site and what you need out of it β€” you'll get an approach, a timeline, and a fixed quote back. Scoping is free.