
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
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();
})();