# Duolingo Scraper - Profiles, Leaderboards, Courses & Vocabulary (`abotapi/duolingo-learner-scraper`) Actor

Scrape public Duolingo data without login. Extract learner profiles with streaks, XP and achievements, weekly league standings, course catalogs with learner counts, and vocabulary words from lessons in clean structured data.

- **URL**: https://apify.com/abotapi/duolingo-learner-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.80 / 1,000 learner records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Duolingo Scraper

Scrape Duolingo: learner profiles by username (per-course XP and crown counts, streaks, achievements, motivation, roles and more), weekly league standings, and the full course catalog with learner counts. Every surface this actor reads works with no account at all.

### Why This Scraper?

- Learner profiles by username: streak, total XP, per-course XP and crowns, join date, Super subscription flag, plus account attributes like motivation, roles and verification status. (Achievement badges are included when the source exposes them; the anonymous profile read often returns none.)
- Weekly league standings by username, with no account needed: current tier, highest tier reached, wins, first, second and top-three finishes, weeks at the current tier, and the date of the last win.
- Course catalog: all 300+ Duolingo courses with title, public course link, learning and source language, learner count, release phase and progress; filter by keyword, language pair or learner floor, sorted by learners or name. Optional Course Details mode also reads each returned course's page for its description line.
- Four modes: search by username, paste profile page links, browse the course catalog, or read a course's vocabulary.
- Incremental mode for scheduled monitoring: only NEW, UPDATED and REAPPEARED records on later runs, with EXPIRED detection on complete scans.
- Resume a large pull from a previous run or dataset ID without paying for duplicate rows.

### Data You Get

> Sample shape: values are illustrative placeholders, not from a live profile.

| Field | Example value |
|---|---|
| kind | profile |
| username | sample\_learner |
| name | Sample Learner |
| userId | 100000001 |
| profileUrl | https://www.duolingo.com/profile/sample\_learner |
| streak | 12 |
| totalXp | 50000 |
| learningLanguage | es |
| fromLanguage | en |
| hasPlus | true |
| location | US |
| achievementsDetail | \[{"name": "challenger", "tier": 3, "count": 825}] |
| coursesCount | 2 |
| courses | \[{"title": "Spanish", "xp": 42000, "crowns": 210}] |
| joinedAt | 1700000000 |
| tier (league) | 2 |
| numWins (league) | 7 |
| learningLanguageName (course) | Spanish |
| numLearners (course) | 1234567 |
| changeType | NEW |

### How to Use

Read a set of public profiles (no account needed):

```json
{
  "mode": "search",
  "searchType": "profiles",
  "usernames": ["sample_learner", "another_sample"]
}
```

Read weekly league standings for a set of learners (no account needed):

```json
{
  "mode": "search",
  "searchType": "leaderboard",
  "usernames": ["sample_learner", "another_sample"]
}
```

Read profiles from pasted links (usernames or numeric IDs):

```json
{
  "mode": "url",
  "urls": ["https://www.duolingo.com/profile/sample_learner"]
}
```

List the course catalog (Spanish courses only here, most learners first):

```json
{
  "mode": "courses",
  "courseQuery": "spanish",
  "courseSort": "learners",
  "maxItems": 50
}
```

Find only mature courses with real learners (beta and in-development pairs excluded):

```json
{
  "mode": "courses",
  "courseMinLearners": 100000,
  "maxItems": 100
}
```

### Input Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| mode | select | search | Search, or paste profile links (URL mode). |
| searchType | select | profiles | Profiles by username, or weekly league by username. Both work with no account. |
| usernames | array | duolingo | Usernames to read (Profiles and League search types). A name nobody matches is reported, not failed. |
| urls | array | one profile link | Profile page links: /profile/<username> or /u/<id> (URL mode). |
| vocabSkill | string | empty | Only lessons whose skill label contains this keyword (Vocabulary mode). |
| courseQuery | string | empty | Course keyword filter (Course-catalog mode; matches language names and codes). |
| courseLearningLanguage | string | empty | Only courses for this learning language code (Course-catalog mode). |
| courseFromLanguage | string | empty | Only courses taught FROM this language code (Course-catalog mode). |
| courseMinLearners | integer | 0 | Only courses with at least this many learners (Course-catalog mode). |
| courseSort | select | learners | Course row order: most learners first, or language name. |
| learningLanguage | select | empty | Only keep profiles learning this language (profiles and URL mode; applied to results). |
| hasPlusOnly | boolean | false | Only keep profiles with a paid Super subscription. |
| minTotalXp | integer | 0 | Only keep profiles with at least this total XP. |
| minStreak | integer | 0 | Only keep profiles with at least this active streak. |
| fetchAchievements | boolean | true | Add each profile's full achievements detail (one extra read per profile). |
| maxItems | integer | 20 | Stop after this many records in total. 0 = no limit. |
| resumeFromRunId | string | empty | Continue one interrupted run from its ID or dataset ID. |
| incrementalMode | boolean | false | Recurring monitoring: later runs return only what changed. |
| stateKey | string | empty | Name a monitoring campaign to keep its incremental state stable. |
| emitUnchanged | boolean | false | Also return (and bill) unchanged rows in incremental mode. |
| emitExpired | boolean | false | Also return (and bill) rows no longer present, after a complete scan. |
| proxyConfiguration | proxy | default pool | Apify Proxy settings; the default works on every plan. |

### Output Example

> Sample shape: values are illustrative placeholders, not from a live profile.

```json
{
  "kind": "profile",
  "recordId": "100000001",
  "username": "sample_learner",
  "name": "Sample Learner",
  "userId": 100000001,
  "profileUrl": "https://www.duolingo.com/profile/sample_learner",
  "bio": "Sample bio text.",
  "joinedAt": 1700000000,
  "streak": 12,
  "streakData": {"currentStreak": {"length": 412, "startDate": "2025-07-28", "endDate": "2026-09-19"}, "longestStreak": null},
  "totalXp": 50000,
  "learningLanguage": "es",
  "fromLanguage": "en",
  "currentCourseId": "DUOLINGO_ES_EN",
  "hasPlus": true,
  "profileCountry": null,
  "location": "Sample City",
  "courses": [
    {"id": "DUOLINGO_ES_EN", "title": "Spanish", "learningLanguage": "es", "fromLanguage": "en", "xp": 42000, "crowns": 210}
  ],
  "coursesCount": 1,
  "achievements": [],
  "achievementsCount": 0,
  "achievementsDetail": [{"name": "sample_badge", "tier": 1, "count": 5}],
  "changeType": "NEW"
}
```

League runs emit one `kind: "league_summary"` row per requested username, carrying that learner's league standing: `tier`, `highestLeague`, `numWins`, `numberOneFinishes`, `numberTwoFinishes`, `topThreeFinishes`, `streakInTier` and `lastWinContestEnd`. Course-catalog runs emit `kind: "course"` rows: composed `title` ("Spanish (from English)"), the public `courseUrl` link, language pair (codes and names), `numLearners`, release maturity (`releaseStatus`: released / beta / in development, mapped from the source's own `phase`), and `fromLanguageCount`: how many source languages teach that course. The legacy per-course page carries no richer body, and its meta description is a site-wide template line that is not even served consistently, so no page-description fetch exists by design.

### Viewer session: self-established, no login

Profiles, /u/<id> links and the achievements service answer only to a signed-in viewer. The actor mints its own viewer session transparently: two onboarding clicks in a short headless-browser run (no captcha, no email, no signup form), then every data read replays over plain HTTP. The sealed session is reused across runs and refreshed when stale; a failed mint fails the run loudly instead of shipping empty rows. You never paste a credential. Course catalog and league-standing lanes never touch the browser at all.

### Scope

Profiles (with achievements detail), the per-user weekly league standing, profile links of the form /profile/<username> and numeric /u/<id> links, and the public course catalog. There is no anonymous user search on the source: profiles are read by exact username. The source has no user-review surface anywhere; per-course popularity is the learner count carried by the catalog rows. The follow graph (following/followers lists) and the signed-in account's own cohort ranking are out of scope: the endpoints answer correctly, but every reachable account reads zero on both lists, so no live data exists to build against. Course progress is covered at the granularity the public profile exposes: XP and crowns per course. Skill-by-skill tree detail is not part of the public profile data.

Keep usage respectful of the source's terms.

### Send results into your apps (MCP connectors)

Optionally pipe results into the apps you already use through Model Context Protocol (MCP) connectors. Authorize a connector under Apify, Settings, API & Integrations, then select it in the input:

- `mcpConnectors`: pick one or more connectors (Notion, Linear, Airtable, Apify).
- `notionParentPageUrl`: for the Notion connector, the parent page under which one page per record is created.
- `maxNotifyListings`: cap on records written per connector per run (default 50).

The connector receives a condensed, human-readable summary per record: a title plus the key fields flattened to plain text. It does not mirror the full dataset record; the complete data is always in the Apify dataset. Leaving every connector empty skips the export entirely and never changes the dataset output.

# Actor input Schema

## `mode` (type: `string`):

How to find data: pick a search type below, paste profile links, list the whole course catalog, or read a course's vocabulary.

## `searchType` (type: `string`):

What Search mode reads (Search mode only). Both types work with no account.

## `usernames` (type: `array`):

Duolingo usernames to read, one record each (Profiles and League search types). A username that matches nobody is reported, not failed. Public profile data only: whatever the profile owner's privacy settings expose.

## `urls` (type: `array`):

Profile page links (URL mode), like https://www.duolingo.com/profile/duolingo. Each link returns one learner record.

## `courseQuery` (type: `string`):

Only keep courses whose language names or codes contain this keyword, like spanish or ja (Course-catalog mode only; applied to results).

## `courseLearningLanguage` (type: `string`):

Course pair selector (Course-catalog and Vocabulary modes): the learning language code, like es or ja.

## `courseFromLanguage` (type: `string`):

Course pair selector (Course-catalog and Vocabulary modes): the source language code the course is taught from, like en.

## `courseMinLearners` (type: `integer`):

Only keep courses with at least this many learners (0 = no floor; Course-catalog mode only; applied to results).

## `courseSort` (type: `string`):

Order of the course rows (Course-catalog mode only): learners = most learners first, name = course language name A to Z.

## `vocabSkill` (type: `string`):

Only read lessons whose skill label contains this keyword, like cafe (Vocabulary mode only; applied per lesson).

## `learningLanguage` (type: `string`):

Only return profiles learning this language, as a course code like es, fr, de, it, ja (empty = all). The list is the source's own live course catalog codes. Applies to returned profiles in Profiles search type and URL mode; the filter is applied to results, not server-side.

## `hasPlusOnly` (type: `boolean`):

Only return profiles with a paid Super subscription. Applied to results in Profiles search type and URL mode.

## `minTotalXp` (type: `integer`):

Only return profiles with at least this much total XP (0 = no floor). Applied to results in Profiles search type and URL mode.

## `minStreak` (type: `integer`):

Only return profiles with an active streak of at least this many days (0 = no floor). Applied to results in Profiles search type and URL mode.

## `fetchAchievements` (type: `boolean`):

On by default. Adds each profile's full achievements detail (badge name, tier, count) from the achievements service: one extra read per profile, with per-profile progress in the log. Applies to Profiles search type and URL mode.

## `maxItems` (type: `integer`):

Stop after this many records in total. Set 0 for no limit. This is the only limit that stops a run early by default.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue a large pull without returning records already collected there.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or recurring monitoring. The first run returns every matching record as NEW. Later runs normally return only NEW, UPDATED and REAPPEARED records. Turn on Emit unchanged or Emit expired only when you also want those rows returned (and billed). State is kept separately for each search, filter and URL setup; use State key to name or deliberately share a monitoring campaign. Live activity flags are excluded from change detection: the source recomputes them constantly, so an activity flip alone never marks a record UPDATED.

## `stateKey` (type: `string`):

Optional. Name this monitoring campaign to keep its state stable, or deliberately share state across differently configured runs. Leave empty to let the actor derive a key automatically from the search and filter settings.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return records that have not changed since the last run, marked UNCHANGED. This returns, and bills, extra rows you already have.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return records that were present in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked scope; a run that could not read every requested item skips EXPIRED detection rather than guessing.

## `mcpConnectors` (type: `array`):

Optionally send results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize one under Apify, Settings, API & Integrations, then select it here. Notion gets a rich page-per-item export; other connectors get a best-effort write or digest. Leave empty to skip; never changes the dataset output.

## `notionParentPageUrl` (type: `string`):

URL or id of the Notion page under which item pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on items written to each connector per run. Does not affect the dataset.

## `proxyConfiguration` (type: `object`):

Apify Proxy settings. The prefilled default uses the standard shared pool and works on every proxy plan. If your plan includes residential or other dedicated groups, name them here.

## Actor input object example

```json
{
  "mode": "search",
  "searchType": "profiles",
  "usernames": [
    "duolingo"
  ],
  "urls": [
    "https://www.duolingo.com/profile/duolingo"
  ],
  "courseMinLearners": 0,
  "courseSort": "learners",
  "learningLanguage": "",
  "hasPlusOnly": false,
  "minTotalXp": 0,
  "minStreak": 0,
  "fetchAchievements": true,
  "maxItems": 20,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "maxNotifyListings": 50,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "search",
    "searchType": "profiles",
    "usernames": [
        "duolingo"
    ],
    "urls": [
        "https://www.duolingo.com/profile/duolingo"
    ],
    "courseMinLearners": 0,
    "courseSort": "learners",
    "hasPlusOnly": false,
    "minTotalXp": 0,
    "minStreak": 0,
    "fetchAchievements": true,
    "maxItems": 20,
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "maxNotifyListings": 50,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/duolingo-learner-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "search",
    "searchType": "profiles",
    "usernames": ["duolingo"],
    "urls": ["https://www.duolingo.com/profile/duolingo"],
    "courseMinLearners": 0,
    "courseSort": "learners",
    "hasPlusOnly": False,
    "minTotalXp": 0,
    "minStreak": 0,
    "fetchAchievements": True,
    "maxItems": 20,
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "maxNotifyListings": 50,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/duolingo-learner-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "search",
  "searchType": "profiles",
  "usernames": [
    "duolingo"
  ],
  "urls": [
    "https://www.duolingo.com/profile/duolingo"
  ],
  "courseMinLearners": 0,
  "courseSort": "learners",
  "hasPlusOnly": false,
  "minTotalXp": 0,
  "minStreak": 0,
  "fetchAchievements": true,
  "maxItems": 20,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "maxNotifyListings": 50,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call abotapi/duolingo-learner-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/duolingo-learner-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/mKOIxaiCio4qdDjR3/builds/KacK8XRJsmRrCzVWV/openapi.json
