const jsonHttpFetch = require('./json-http-fetch')
const login = require('./login')
function getCourseId(learningLanguageId, fromLanguageId) {
learningLanguageId = fromLegacyLanguageId(learningLanguageId)
fromLanguageId = fromLegacyLanguageId(fromLanguageId)
return `DUOLINGO_${learningLanguageId.toUpperCase()}_${fromLanguageId.toUpperCase()}`
}
function parseCourseId(courseId) {
const match = /^DUOLINGO_(\w+)(-\w+)?_(\w+)(-\w+)?$/.exec(courseId)
return {
learningLanguageId: match[1].toLowerCase() + (match[2] || ''),
fromLanguageId: match[3].toLowerCase() + (match[4] || ''),
}
}
// some languages do not use ISO language codes in some (older?) APIs
// https://www.duolingo.com/api/1/courses/list
const LEGACY_LANGUAGE_IDS = [
// norwegian
{iso: 'no-BO', legacy: 'nb'},
// dutch
{iso: 'nl-NL', legacy: 'dn'},
// klingon
{iso: 'tlh', legacy: 'kl'},
// chinese
{iso: 'zh-CN', legacy: 'zs'},
]
// zs => zh-CN
function fromLegacyLanguageId(languageId) {
return LEGACY_LANGUAGE_IDS.find(it => it.legacy == languageId) ||
languageId
}
// zh-CN => zs
function toLegacyLanguageId(languageId) {
return LEGACY_LANGUAGE_IDS.find(it => it.iso == languageId) ||
languageId
}
/**
* A high-level client for the Duolingo API.
* @memberof! module:duolingo-client
*/
class DuolingoClient {
/**
* Creates a new client. New instances are in a logged-out state.
*/
constructor() {
// initialize logged out state
this.logout()
}
/**
* Logs in as a the specified user. Future API calls requiring
* authentication will use this user's credentials. Unauthenticated
* calls will still be made without credentials.
* <p>
* If previously logged-in, those credentials will be overwritten,
* effectively logging out.
* @param {string} username The username to login as.
* @param {string} password The user's password.
* @return {Promise<void>}
*/
async login(username, password) {
const {jwt, userId} = await login(username, password)
this.auth = {
username,
userId,
headers: {
authorization: `Bearer ${jwt}`,
},
}
}
/**
* Logs out and discards credentials.
* @return {void}
*/
logout() {
this.auth = {}
}
/**
* @typedef User
* @prop {integer} id The user's id.
* @prop {string} username The user's username.
* @prop {string} displayName The user's display name.
* @prop {boolean} hasPlus Is this user a Plus subscriber?
* @prop {integer} streak The user's streak length.
* @prop {string} currentCourseId The id of the course that is currently
* active for this user.
* @prop {UserCourse[]} courses The courses started by this user.
*/
/**
* @typedef UserCourse
* @prop {string} id The course id.
* @prop {integer} points The experience earned in this course.
*/
/**
* Gets a user by username.
* @param {string} username The username to fetch.
* @return {Promise<User>} The user.
*/
async getUser(username) {
const url = `https://www.duolingo.com/2017-06-30/users?username=${username}`
const res = await jsonHttpFetch('GET', url)
const user = res.body.users[0]
const courses = user.courses
.map(it => ({
id: it.id,
xp: it.xp,
}))
// order by xp descending
.sort((a, b) => b.xp - a.xp)
return {
id: user.id,
username: user.username,
// fullname may be undefined
displayName: user.name || user.username,
hasPlus: user.hasPlus,
streak: user.streak,
currentCourseId: user.currentCourseId,
courses,
}
}
/**
* @typedef Course
* @prop {string} The id of this course.
* @prop {Language} learningLanguage The language taught in this course.
* @prop {Language} fromLanguage The native/UI language of this course.
* @prop {integer} phase The release state of the course:
* <li>1 = Hatching</li>
* <li>2 = Beta</li>
* <li>3 = Released</li>
* @prop {integer} progress How complete the course is.
* @prop {integer} usersCount The number of users taking the course.
*/
/**
* @typedef Language
* @prop {string} id The id of this language.
* @prop {string} name The display name of this language.
*/
/**
* Gets all available courses.
* @return {Promise<Course[]>} All available courses.
* @since 2.0.0
*/
async getCourses() {
const url = 'https://www.duolingo.com/api/1/courses/list'
const res = await jsonHttpFetch('GET', url)
return res.body.map(course => ({
id: getCourseId(course.learning_language_id,
course.from_language_id),
learningLanguage: {
id: course.learning_language_id,
name: course.learning_language_name,
},
fromLanguage: {
id: course.from_language_id,
name: course.from_language_name,
},
phase: course.phase,
progress: course.phase == 1 ? course.progress : 100,
usersCount: course.num_learners,
}))
}
/**
* @typedef Skill
* @prop {string} id The unique skill id.
* @prop {string} title The course-scoped display name of this skill.
* @prop {string} urlTitle The course-scoped URL path of this skill.
*/
/**
* Gets the skills taught in a course.
* <p>
* <b>Requires authentication.</b>
* <p>
* <b>Note:</b> This currently requires a user that is currently
* taking the course but the result does not include any
* user-specific data. This requirement may be dropped if a better
* Duolingo API is discovered.
* @param {string} courseId The course to get the skills from.
* @param {string} username A user who is currently studying the course.
* @retun {Promise<Skill[]>} The skills from the course.
* @since 2.0.0
*/
async getCourseSkills(courseId, username) {
if (!this.auth) {
throw new Error('Login required')
}
username = username || this.auth.username
// TODO: Find a way to discover skills without a user
const url = `https://www.duolingo.com/users/${username}`
const res = await jsonHttpFetch('GET', url, this.auth.headers)
// confirm user's current course
const currentCourseId = getCourseId(res.body.learning_language,
res.body.ui_language)
if (currentCourseId != courseId) {
throw new Error(`The current course for ${username} is ${currentCourseId}, not ${courseId}`)
}
const data = res.body.language_data[res.body.learning_language]
return data.skills
.sort((a, b) => {
if (a.coords_y != b.coords_y) {
return a.coords_y - b.coords_y
}
return a.coords_x - b.coords_x
})
.map(it => ({
id: it.id,
title: it.title,
urlTitle: it.url_title,
}))
}
/**
* Gets the words taught in a skill.
* <p>
* <b>Requires authentication.</b>
* @param {string} skillId The id of the skill to get words from.
* @return {Promise<String[]>} The words in the skill.
* @since 2.0.0
*/
async getSkillWords(skillId) {
if (!this.auth) {
throw new Error('Login required')
}
const url = `https://www.duolingo.com/api/1/skills/show?id=${skillId}`
const res = await jsonHttpFetch('GET', url, this.auth.headers)
// Accumulate words from lessons
const words = []
res.body.path
// Ignore any lessons without words (???)
.filter(it => it.words)
.forEach(it => words.push(...it.words))
return words
}
/**
* Translates a list of words from a course. Each word may have more than
* one possible translation.
* @param {string} courseId The id of the course that the words belong to.
* @param {string[]} words The list of words to translate.
* @return {string[][]} A list of translations for every word.
*/
async translate(courseId, words) {
const {learningLanguageId, fromLanguageId} = parseCourseId(courseId)
// from the language we are learning
const from = toLegacyLanguageId(learningLanguageId)
const to = toLegacyLanguageId(fromLanguageId)
const tokens = encodeURIComponent(JSON.stringify(words))
const url = `http://d2.duolingo.com/api/1/dictionary/hints/${from}/${to}?tokens=${tokens}`
const res = await jsonHttpFetch('GET', url)
return words.map(word => res.body[word])
}
/**
* @typedef Item
* @prop {string} id The item's id.
* @prop {string} type The item's category, e.g. "misc" or "outfit".
* @prop {string} name The item's display name.
* @prop {string} description The item's description.
* @prop {integer} price The cost to purchase this item.
*/
/**
* Gets items available for purchase by the logged-in user.
* <p>
* <b>Requires authentication.</b>
* @return {Promise<Item[]>} The items available.
*/
async getShopItems() {
if (!this.auth) {
throw new Error('Login required')
}
const url = 'https://www.duolingo.com/2017-06-30/store-items'
const res = await jsonHttpFetch('GET', url, this.auth.headers)
return res.body.shopItems
// hide in-app purchases that cost real money
.filter(item => item.type != 'in_app_purchase')
// hide things that don't show in the app
.filter(item => item.name && item.localizedDescription)
.map(item => ({
id: item.id,
type: item.type,
name: item.name,
description: item.localizedDescription,
price: item.price,
}))
}
/**
* Buys streak freeze for the logged-in user.
* <p>
* <b>Requires authentication.</b>
* @return {Promise<boolean>} Returns true if streak freeze was bought, or
* false if this user already has streak freeze.
*/
async buyStreakFreeze() {
return this.buyItem('streak_freeze')
}
// TODO: some items are course scoped but the API only accepts legacy ids
// for learning languages
/**
* Buy an item for the logged-in user.
* <p>
* <b>Requires authentication.</b>
* @param {string} itemId The id of the item to buy.
* @return {Promise<boolean>} Returns true if the item was bought, or
* false if this user already has the item.
*/
async buyItem(itemId) {
if (!this.auth) {
throw new Error('Login required')
}
const url = `https://www.duolingo.com/2017-06-30/users/${this.auth.userId}/purchase-store-item`
const body = {name: itemId, learningLanguage: null}
const res = await jsonHttpFetch('POST', url, this.auth.headers, body)
if (res.body.error == 'ALREADY_HAVE_STORE_ITEM') {
return false
}
return true
}
/**
* Switches the active course for the logged-in user.
* <p>
* <b>Requires authentication.</b>
* @param {string} courseId The course to switch to.
* @since 2.0.0
*/
async setCurrentCourse(courseId) {
if (!this.auth) {
throw new Error('Login required')
}
// set fields to empty to avoid getting entire user back
const url = `https://www.duolingo.com/2017-06-30/users/${this.auth.userId}?fields=`
const body = {courseId}
return jsonHttpFetch('PATCH', url, this.auth.headers, body)
}
}
module.exports = DuolingoClient
// for testing
module.exports.getCourseId = getCourseId
module.exports.parseCourseId = parseCourseId