get-wikipedia-article
Version:
Fetch Wikipedia article resources (e.g. title, body, links...).
656 lines (641 loc) • 18.2 kB
JavaScript
class CustomError extends Error {
/**
* Create a `CustomError` (same as `Error` but with an extra property called `library`).
* @param message Argument to be passed to base constructor (`Error()`).
* @param library Name of the library in which error will be thrown.
*/
constructor(message, library) {
super(message);
this.library = library;
}
toString() {
return `Error in \`${this.library}\` library:\n\t${this.message}`;
}
}
/*
max must be at least `min * 2 - 1`
otherwise, min & max couldn't be used to break down any number inside range `max to (min * 2)`
e.g. if min is 7, max can't be anything less than 13
- range 7 - 12 = couldn't break down 13
- range 7 - 11 = couldn't break down 12, 13
- range 7 - 10 = couldn't break down 11, 12, 13
- so on...
*/
// all languages in which Wikipedia is available
// script used to fetch languages object is at `./languages.js`
/* cSpell:disable */
const wikipediaLanguages = {
aa: 'Afar',
ab: 'Abkhazian',
ace: 'Achinese',
ady: 'Adyghe',
af: 'Afrikaans',
ak: 'Akan',
als: 'Alemannisch',
alt: 'Southern Altai',
am: 'Amharic',
ami: 'Amis',
an: 'Aragonese',
ang: 'Old English',
anp: 'Angika',
ar: 'Arabic',
arc: 'Aramaic',
ary: 'Moroccan Arabic',
arz: 'Egyptian Arabic',
as: 'Assamese',
ast: 'Asturian',
atj: 'Atikamekw',
av: 'Avaric',
avk: 'Kotava',
awa: 'Awadhi',
ay: 'Aymara',
az: 'Azerbaijani',
azb: 'South Azerbaijani',
ba: 'Bashkir',
ban: 'Balinese',
bar: 'Bavarian',
'bat-smg': 'Samogitian',
bcl: 'Central Bikol',
be: 'Belarusian',
'be-x-old': 'Belarusian (Taraškievica orthography)',
bg: 'Bulgarian',
bh: 'Bhojpuri',
bi: 'Bislama',
bjn: 'Banjar',
blk: "Pa'O",
bm: 'Bambara',
bn: 'Bangla',
bo: 'Tibetan',
bpy: 'Bishnupriya',
br: 'Breton',
bs: 'Bosnian',
bug: 'Buginese',
bxr: 'Russia Buriat',
ca: 'Catalan',
'cbk-zam': 'Chavacano',
cdo: 'Min Dong Chinese',
ce: 'Chechen',
ceb: 'Cebuano',
ch: 'Chamorro',
cho: 'Choctaw',
chr: 'Cherokee',
chy: 'Cheyenne',
ckb: 'Central Kurdish',
co: 'Corsican',
cr: 'Cree',
crh: 'Crimean Tatar',
cs: 'Czech',
csb: 'Kashubian',
cu: 'Church Slavic',
cv: 'Chuvash',
cy: 'Welsh',
da: 'Danish',
dag: 'Dagbani',
de: 'German',
din: 'Dinka',
diq: 'Zazaki',
dsb: 'Lower Sorbian',
dty: 'Doteli',
dv: 'Divehi',
dz: 'Dzongkha',
ee: 'Ewe',
el: 'Greek',
eml: 'Emiliano-Romagnolo',
en: 'English',
eo: 'Esperanto',
es: 'Spanish',
et: 'Estonian',
eu: 'Basque',
ext: 'Extremaduran',
fa: 'Persian',
ff: 'Fula',
fi: 'Finnish',
'fiu-vro': 'võro',
fj: 'Fijian',
fo: 'Faroese',
fr: 'French',
frp: 'Arpitan',
frr: 'Northern Frisian',
fur: 'Friulian',
fy: 'Western Frisian',
ga: 'Irish',
gag: 'Gagauz',
gan: 'Gan Chinese',
gcr: 'Guianan Creole',
gd: 'Scottish Gaelic',
gl: 'Galician',
glk: 'Gilaki',
gn: 'Guarani',
gom: 'Goan Konkani',
gor: 'Gorontalo',
got: 'Gothic',
gu: 'Gujarati',
guc: 'Wayuu',
gur: 'Frafra',
guw: 'Gun',
gv: 'Manx',
ha: 'Hausa',
hak: 'Hakka Chinese',
haw: 'Hawaiian',
he: 'Hebrew',
hi: 'Hindi',
hif: 'Fiji Hindi',
ho: 'Hiri Motu',
hr: 'Croatian',
hsb: 'Upper Sorbian',
ht: 'Haitian Creole',
hu: 'Hungarian',
hy: 'Armenian',
hyw: 'Western Armenian',
hz: 'Herero',
ia: 'Interlingua',
id: 'Indonesian',
ie: 'Interlingue',
ig: 'Igbo',
ii: 'Sichuan Yi',
ik: 'Inupiaq',
ilo: 'Iloko',
inh: 'Ingush',
io: 'Ido',
is: 'Icelandic',
it: 'Italian',
iu: 'Inuktitut',
ja: 'Japanese',
jam: 'Jamaican Creole English',
jbo: 'Lojban',
jv: 'Javanese',
ka: 'Georgian',
kaa: 'Kara-Kalpak',
kab: 'Kabyle',
kbd: 'Kabardian',
kbp: 'Kabiye',
kcg: 'Tyap',
kg: 'Kongo',
ki: 'Kikuyu',
kj: 'Kuanyama',
kk: 'Kazakh',
kl: 'Kalaallisut',
km: 'Khmer',
kn: 'Kannada',
ko: 'Korean',
koi: 'Komi-Permyak',
kr: 'Kanuri',
krc: 'Karachay-Balkar',
ks: 'Kashmiri',
ksh: 'Colognian',
ku: 'Kurdish',
kv: 'Komi',
kw: 'Cornish',
ky: 'Kyrgyz',
la: 'Latin',
lad: 'Ladino',
lb: 'Luxembourgish',
lbe: 'Lak',
lez: 'Lezghian',
lfn: 'Lingua Franca Nova',
lg: 'Ganda',
li: 'Limburgish',
lij: 'Ligurian',
lld: 'Ladin',
lmo: 'Lombard',
ln: 'Lingala',
lo: 'Lao',
lrc: 'Northern Luri',
lt: 'Lithuanian',
ltg: 'Latgalian',
lv: 'Latvian',
mad: 'Madurese',
mai: 'Maithili',
'map-bms': 'Basa Banyumasan',
mdf: 'Moksha',
mg: 'Malagasy',
mh: 'Marshallese',
mhr: 'Eastern Mari',
mi: 'Māori',
min: 'Minangkabau',
mk: 'Macedonian',
ml: 'Malayalam',
mn: 'Mongolian',
mni: 'Manipuri',
mnw: 'Mon',
mr: 'Marathi',
mrj: 'Western Mari',
ms: 'Malay',
mt: 'Maltese',
mus: 'Muscogee',
mwl: 'Mirandese',
my: 'Burmese',
myv: 'Erzya',
mzn: 'Mazanderani',
na: 'Nauru',
nah: 'Nāhuatl',
nap: 'Neapolitan',
nds: 'Low German',
'nds-nl': 'Low Saxon',
ne: 'Nepali',
new: 'Newari',
ng: 'Ndonga',
nia: 'Nias',
nl: 'Dutch',
nn: 'Norwegian Nynorsk',
no: 'Norwegian',
nov: 'Novial',
nqo: 'N’Ko',
nrm: 'Norman',
nso: 'Northern Sotho',
nv: 'Navajo',
ny: 'Nyanja',
oc: 'Occitan',
olo: 'Livvi-Karelian',
om: 'Oromo',
or: 'Odia',
os: 'Ossetic',
pa: 'Punjabi',
pag: 'Pangasinan',
pam: 'Pampanga',
pap: 'Papiamento',
pcd: 'Picard',
pcm: 'Nigerian Pidgin',
pdc: 'Pennsylvania German',
pfl: 'Palatine German',
pi: 'Pali',
pih: 'Norfuk / Pitkern',
pl: 'Polish',
pms: 'Piedmontese',
pnb: 'Western Punjabi',
pnt: 'Pontic',
ps: 'Pashto',
pt: 'Portuguese',
pwn: 'Paiwan',
qu: 'Quechua',
rm: 'Romansh',
rmy: 'Vlax Romani',
rn: 'Rundi',
ro: 'Romanian',
'roa-rup': 'Aromanian',
'roa-tara': 'Tarantino',
ru: 'Russian',
rue: 'Rusyn',
rw: 'Kinyarwanda',
sa: 'Sanskrit',
sah: 'Yakut',
sat: 'Santali',
sc: 'Sardinian',
scn: 'Sicilian',
sco: 'Scots',
sd: 'Sindhi',
se: 'Northern Sami',
sg: 'Sango',
sh: 'Serbo-Croatian',
shi: 'Tachelhit',
shn: 'Shan',
si: 'Sinhala',
simple: 'Simple English',
sk: 'Slovak',
skr: 'Saraiki',
sl: 'Slovenian',
sm: 'Samoan',
smn: 'Inari Sami',
sn: 'Shona',
so: 'Somali',
sq: 'Albanian',
sr: 'Serbian',
srn: 'Sranan Tongo',
ss: 'Swati',
st: 'Southern Sotho',
stq: 'Saterland Frisian',
su: 'Sundanese',
sv: 'Swedish',
sw: 'Swahili',
szl: 'Silesian',
szy: 'Sakizaya',
ta: 'Tamil',
tay: 'Tayal',
tcy: 'Tulu',
te: 'Telugu',
tet: 'Tetum',
tg: 'Tajik',
th: 'Thai',
ti: 'Tigrinya',
tk: 'Turkmen',
tl: 'Tagalog',
tn: 'Tswana',
to: 'Tongan',
tpi: 'Tok Pisin',
tr: 'Turkish',
trv: 'Taroko',
ts: 'Tsonga',
tt: 'Tatar',
tum: 'Tumbuka',
tw: 'Twi',
ty: 'Tahitian',
tyv: 'Tuvinian',
udm: 'Udmurt',
ug: 'Uyghur',
uk: 'Ukrainian',
ur: 'Urdu',
uz: 'Uzbek',
ve: 'Venda',
vec: 'Venetian',
vep: 'Veps',
vi: 'Vietnamese',
vls: 'West Flemish',
vo: 'Volapük',
wa: 'Walloon',
war: 'Waray',
wo: 'Wolof',
wuu: 'Wu Chinese',
xal: 'Kalmyk',
xh: 'Xhosa',
xmf: 'Mingrelian',
yi: 'Yiddish',
yo: 'Yoruba',
za: 'Zhuang',
zea: 'Zeelandic',
zh: 'Chinese',
'zh-classical': 'Classical Chinese',
'zh-min-nan': 'Chinese (Min Nan)',
'zh-yue': 'Cantonese',
zu: 'Zulu'
};
/* cSpell:enable */
// messages aren't inside an object because
// tree-shaking isn't possible when exporting an object
// https://medium.com/@rauschma/note-that-default-exporting-objects-is-usually-an-anti-pattern-if-you-want-to-export-the-cf674423ac38#.nibatprx3
// used at `fullfiller/src/validate`
//
const invalidLanguage = (language, languages) => `Expected \`language\` argument to be one of the supported languages (${languages.join(', ')}).`;
const articleNotFound = 'Wikipedia does not have an article with this exact title. Try again using a different query.';
const articleIsDisambiguation = suggestions => `This query points to a Wikipedia disambiguation page. You've got to be more specific.${suggestions.length > 0 ? ` Query suggestions:\n- ${suggestions.splice(0, 10).join('\n- ')}.` : ` No query suggestions were found.`}`;
/**
* Summary is the initial chunk of text, everything before the first subtitle.
* It's possible to get it with API `&action=query&prop=extracts&exintro`.
* But the entire article was fetched already, so there's no need to make another API call.
*
* @summary Extract summary from article body.
* @param body Wikipedia article body.
* @param format Article format.
* @returns Wikipedia article summary.
*/
function extractSummaryFromBody(body, format) {
const plaintextRE = /^[\s\S]*?(?=\n\n\n==)/;
const htmlRE = /^[\s\S]*?(?=\n\n<h2>)/;
const RE = format === 'plain' ? plaintextRE : htmlRE;
const summary = RE.exec(body)?.[0];
return summary;
}
/**
* Join Wikipedia API base URL and `queries`.
* @param queries Object to be converted to query string.
* @returns URL to make API call.
*/
function generateRequestURL(language, queries) {
const baseAPI = `https://${language}.wikipedia.org/w/api.php?&format=json&origin=*&`;
const queryString = Object.entries(queries).map(([key, value]) => value === undefined ? key : `${key}=${value.toString()}`).join('&');
const requestURL = baseAPI + queryString;
return requestURL;
}
/**
* @summary Fetch response from Wikipedia API.
* @param queries Object containing query string parameters.
* @returns Object containing json.query.pages[pageID] and json.continue (if it exists) contents.
*/
async function fetchResource(language, queries) {
const url = generateRequestURL(language, queries);
const json = await (await fetch(url)).json();
// API's response isn't returned in its entirety by this function.
// That's because the main part of the response is nested 3 layers deep.
// As shown in the `response` interface.
const {
pages
} = json.query;
const pagesID = Object.keys(pages)[0];
const resp = {
...pages[pagesID],
// some resources responses (e.g. links) include `continue` when there's more to fetch
...(json.continue && json.continue)
};
return resp;
}
/**
* Fetch Wikipedia article body.
* @param title Wikipedia article title.
* @param format Which one of the 2 formats available in the Wikipedia API.
* @param related Wikipedia related articles titles (to recommend in case of error).
* @returns Wikipedia article body.
*/
async function getArticleBody(language, title, format) {
const queries = {
action: 'query',
prop: 'extracts',
...(format === 'plain' && {
explaintext: undefined
}),
redirects: undefined,
titles: encodeURIComponent(title)
};
const resp = await fetchResource(language, queries);
const body = resp.extract;
return body;
}
/**
* Fetch all Wikipedia categories the given article belongs to.
* @param title Wikipedia article title.
* @returns Array of Wikipedia categories names.
*/
async function getArticleCategories(language, title) {
const queries = {
action: 'query',
prop: 'categories',
redirects: undefined,
cllimit: 'max',
clshow: '!hidden',
titles: encodeURIComponent(title)
};
// There's no reason to use a recursive function like in getArticleLinks,
// even the articles containing the most categories don't have more than 500 categories
// (https://en.wikipedia.org/wiki/Special:MostCategories);
// request below will fetch at most 500 normal (not hidden) categories
const resp = await fetchResource(language, queries);
const categories = resp.categories.map(obj => obj.title.replace(/^Category:/, ''));
return categories;
}
async function getLinksRecursively(language, queries) {
const resp = await fetchResource(language, queries);
const links = resp.links.map(obj => obj.title);
return !('plcontinue' in resp) ? links : links.concat(await getLinksRecursively(language, {
...queries,
plcontinue: encodeURIComponent(resp.plcontinue)
}));
}
/**
* Fetch all Wikipedia articles that are linked in the given article.
* @param title Wikipedia article title.
* @returns Array of Wikipedia articles titles.
*/
async function getArticleLinks(language, title) {
const queries = {
action: 'query',
prop: 'links',
redirects: undefined,
pllimit: 'max',
plnamespace: '0',
titles: encodeURIComponent(title)
};
const links = await getLinksRecursively(language, queries);
return links;
}
/**
* Fetch Wikipedia article summary.
* @param title Wikipedia article title.
* @param format Which one of the 2 formats available in the Wikipedia API.
* @param related Wikipedia related articles titles (to recommend in case of error).
* @returns Wikipedia article body.
*/
async function getArticleSummary(language, title, format) {
const queries = {
action: 'query',
prop: 'extracts',
exintro: undefined,
redirects: undefined,
...(format === 'plain' && {
explaintext: undefined
}),
titles: encodeURIComponent(title)
};
const resp = await fetchResource(language, queries);
const summary = resp.extract;
return summary;
}
/**
* Fetch Wikipedia article terms.
* @param title Wikipedia article title.
* @param include Which terms to fetch (alias, description and/or label).
* @returns Array of Wikipedia article terms.
*/
async function getArticleTerms(language, title, include) {
const queries = {
action: 'query',
prop: 'pageterms',
redirects: undefined,
wbptlanguage: 'en',
wbptterms: include.join('|'),
titles: encodeURIComponent(title)
};
const resp = await fetchResource(language, queries);
const {
terms
} = resp;
// if (include.length !== Object.keys(terms).length) {
// const print = (array: string[]) => array.sort().join(', ');
// console.warn(
// `Article lacks some of the terms requested. Requested: ${print(
// include
// )}. Fetched: ${print(Object.keys(terms))}.`
// );
// }
return terms;
}
/**
* Fetch Wikipedia article(s) title(s) that match query.
* @param query Search string.
* @param single Fetch only a single result.
* @throws Error if no results were found.
* @returns Array of title(s).
*/
async function getMatchingArticlesTitles(language, query, single = false) {
const requestURL = generateRequestURL(language, {
action: 'opensearch',
limit: single ? 1 : 'max',
// `redirects=resolve` is required otherwise response may contain the name of a redirect
// instead of the target page; page and redirect have slight different titles
// e.g. query `lord of the rings` would return `Lord of the rings`
// which is the redirect for `The Lord of the Rings`
redirects: 'resolve',
search: encodeURIComponent(query)
});
const resp = await fetch(requestURL);
const json = await resp.json();
const titles = json[1];
if (titles.length === 0) throw new CustomError(articleNotFound, 'get-wikipedia-article');
return titles;
}
async function queryPointsToADisambiguationPage(language, title) {
const queries = {
action: 'query',
prop: 'pageprops',
ppprop: 'disambiguation',
redirects: undefined,
titles: encodeURIComponent(title)
};
const resp = await fetchResource(language, queries);
const pointsToDisambiguation = resp.pageprops?.disambiguation !== undefined;
return pointsToDisambiguation;
}
const includeDefault = ['title', 'body'];
/**
* Fetch Wikipedia article's resources (e.g. title, body, links...).
* @param query Search string.
* @param options Miscellaneous options.
* @throws Error if `query` doesn't return any results.
* @throws Error if `article.title` points to a disambiguation page.
* @returns Object containing requested resources.
*/
async function getWikipediaArticle(query, {
language = 'en',
include = includeDefault,
format = 'plain'
} = {}) {
if (!Object.keys(wikipediaLanguages).includes(language)) {
throw new CustomError(invalidLanguage(language, Object.keys(wikipediaLanguages)), 'get-wikipedia-article');
}
if (include.length === 0) include.push(...includeDefault);
const article = {};
// fetch title, related
if (include.includes('title') && include.includes('related')) {
// first result will be selected as the article to be fetched
const [title, ...related] = await getMatchingArticlesTitles(language, query);
article.title = title;
article.related = related;
} else if (include.includes('title')) {
const [title] = await getMatchingArticlesTitles(language, query);
article.title = title;
} else if (include.includes('related')) {
const [, ...related] = await getMatchingArticlesTitles(language, query);
article.related = related;
}
// API calls to `action=query` using `query` instead of `article.title`
// are allowed because `redirects` parameter is being used
const titleQuery = article.title || query;
// the only option other than to make a separate request at main function checking if page is
// disambiguation, would be to check if page is disambiguation at every resource request
if (await queryPointsToADisambiguationPage(language, titleQuery)) {
throw new CustomError(articleIsDisambiguation(article.related || [] /* suggestions */), 'get-wikipedia-article');
}
// fetch body
if (include.includes('body')) {
article.body = await getArticleBody(language, titleQuery, format);
}
// fetch summary
if (include.includes('summary')) {
article.summary = article.body ? extractSummaryFromBody(article.body, format) : await getArticleSummary(language, titleQuery, format);
}
// fetch categories
if (include.includes('categories')) {
article.categories = await getArticleCategories(language, titleQuery);
}
// fetch links
if (include.includes('links')) {
article.links = await getArticleLinks(language, titleQuery);
}
// fetch terms
const termsToInclude = ['alias', 'label', 'description'].filter(term => include.includes(term));
if (termsToInclude.length > 0) {
const terms = await getArticleTerms(language, titleQuery, termsToInclude);
Object.keys(terms).forEach(term => {
article[term] = terms[term];
});
}
return article;
}
export { getWikipediaArticle as default };