aede_core/
wikipedia.rs

1//! Wikipedia, reached through Wikidata: the prose MusicBrainz does not hold.
2//!
3//! MusicBrainz answers with identifiers, dates and relationships. It never
4//! answers with a sentence about the artist, because that is not what it is
5//! for. The sentence exists on Wikipedia — and the way from one to the other
6//! is the `wikidata` relationship MusicBrainz already returns.
7//!
8//! **Two requests, not one.** A Wikidata entity is the hub: it holds a
9//! *sitelink* per language, and a sitelink is a page title rather than a
10//! summary. So reaching a paragraph is
11//!
12//! ```text
13//! MusicBrainz artist  →  wikidata: .../wiki/Q11649
14//!                        Special:EntityData/Q11649.json   →  sitelinks.enwiki.title
15//!                        en.wikipedia.org/…/summary/<title>  →  extract
16//! ```
17//!
18//! That is why this is opt-in rather than part of every fetch: it doubles a
19//! run that already takes ten minutes over a large library, and the summary
20//! is the one fact here nobody needs in order to file their music.
21//!
22//! **Everything in this module is a decision, and none of it is a request.**
23//! The two URL builders and the two extractors below are pure functions over
24//! text and JSON, so what the program concludes from an answer is proven by
25//! the tests beside them, and only the handing of a URL to a client library is
26//! left unproven — the same split [`crate::musicbrainz`] follows.
27//!
28//! # The licence is not optional
29//!
30//! Wikipedia text is CC BY-SA. Reusing it obliges attribution, so the extract
31//! is only ever produced as a [`Prose`], which cannot hold the words without
32//! the page they came from and the terms they came under. There is deliberately
33//! no function here that returns a bare `String` of article text.
34
35use crate::json::Json;
36use crate::sources::Prose;
37
38/// The name this source is stored under in `sources.json`.
39///
40/// "wikipedia" rather than "wikidata": Wikidata is the road, the article is
41/// what a reader is being shown, and the credit has to name the article.
42pub const SOURCE: &str = "wikipedia";
43
44/// The licence Wikipedia article text is under.
45///
46/// Named as Wikimedia names it, because a credit that renames a licence is not
47/// a credit. Stored per record rather than assumed at display time: if this
48/// ever changes, records fetched before the change still say what they were
49/// taken under.
50pub const LICENCE: &str = "CC BY-SA 4.0";
51
52/// The name a portrait reached through Wikidata is stored under.
53///
54/// Not `wikipedia`, even though the same entity document is the source of
55/// both: an article's prose and an entity's `P18` image are two different
56/// claims, fetched to answer two different questions, and a record that
57/// carried both could not be re-fetched for one without silently dropping the
58/// other — `Sources::set` replaces a record whole, it does not merge fields
59/// into it. Keeping them as two records under two names is what lets
60/// `fetch --summaries` and `fetch --portraits` each redo their own work
61/// without undoing the other's.
62pub const PORTRAIT_SOURCE: &str = "wikidata";
63
64/// How long to leave between requests.
65///
66/// Wikimedia's limits are far looser than MusicBrainz's, but the polite rate
67/// is the one that never needs revisiting, and a run that is already paced at
68/// one per second gains nothing from going faster in one leg of it.
69pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1100);
70
71/// Languages to look for an article in, best first.
72///
73/// English last rather than first would be the friendlier default and the
74/// wrong one: this list is a fallback chain, and the caller puts the reader's
75/// own language at its head. English is here as the floor, because for a great
76/// many artists it is the only article that exists.
77pub const FALLBACK_LANGS: [&str; 1] = ["en"];
78
79/// The Wikidata entity id inside a URL MusicBrainz returned.
80///
81/// MusicBrainz gives the relationship as a page URL rather than a bare id, and
82/// it does not always give the same one: `www.wikidata.org/wiki/Q11649` is the
83/// usual shape, but the entity URI `.../entity/Q11649` appears too. Both end
84/// in the id, so the id is read from the end rather than the middle.
85///
86/// Returns `None` for anything that does not end in an entity id, which is the
87/// honest answer for a link that leads somewhere else — a lexeme, a property,
88/// or a URL that was never Wikidata at all.
89pub fn entity_id(url: &str) -> Option<String> {
90    let address = url
91        .strip_prefix("https://")
92        .or_else(|| url.strip_prefix("http://"))?;
93    let (host, path) = address.split_once('/')?;
94    if !matches!(host, "www.wikidata.org" | "wikidata.org") {
95        return None;
96    }
97    let last = path
98        .strip_prefix("wiki/")
99        .or_else(|| path.strip_prefix("entity/"))?
100        .trim_end_matches('/');
101    // An entity id is `Q` and at least one digit, all digits. Checking the
102    // shape is what stops `.../wiki/Special:EntityData` or a property `P31`
103    // from being sent out as a lookup that cannot succeed.
104    let digits = last.strip_prefix('Q')?;
105    match !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
106        true => Some(last.to_string()),
107        false => None,
108    }
109}
110
111/// Where to ask Wikidata about an entity.
112///
113/// `Special:EntityData` rather than the `wbgetentities` API: it is a plain
114/// document at a stable address, it needs no parameters to get wrong, and it
115/// is cached, which is the difference between being a polite client and being
116/// a load.
117pub fn entity_data_url(id: &str) -> String {
118    format!("https://www.wikidata.org/wiki/Special:EntityData/{id}.json")
119}
120
121/// The Commons filename in a Wikidata entity's `image` claim (`P18`), if it
122/// states one.
123///
124/// Mirrors the redirect handling [`article`] already does: the document may
125/// answer about a different id than the one asked for, and one entity in the
126/// document is no ambiguity about which that is — the same reasoning, applied
127/// to a claim instead of a sitelink.
128///
129/// Preferred usable statements precede normal ones; deprecated statements,
130/// explicit non-values and blank filenames are skipped. Ties retain source
131/// order. `None` means the entity supplied no usable image statement.
132pub fn portrait_file(response: &Json, id: &str) -> Option<String> {
133    let entities = response.get("entities")?;
134    let entity = match entities.get(id) {
135        Some(entity) => entity,
136        None => only_value(entities)?,
137    };
138    let statements = entity.get("claims")?.get("P18")?.as_arr()?;
139    for rank in ["preferred", "normal"] {
140        for statement in statements {
141            if statement
142                .get("rank")
143                .and_then(Json::as_str)
144                .unwrap_or("normal")
145                != rank
146            {
147                continue;
148            }
149            let Some(snak) = statement.get("mainsnak") else {
150                continue;
151            };
152            if snak
153                .get("snaktype")
154                .and_then(Json::as_str)
155                .is_some_and(|kind| kind != "value")
156            {
157                continue;
158            }
159            if let Some(filename) = snak
160                .get("datavalue")
161                .and_then(|value| value.get("value"))
162                .and_then(Json::as_str)
163                .map(str::trim)
164                .filter(|name| !name.is_empty())
165            {
166                return Some(filename.to_string());
167            }
168        }
169    }
170    None
171}
172
173/// Checks portrait tables before a fetch records a completed absence.
174///
175/// Claims and P18 are optional, but a present table must keep its documented
176/// shape. Unreadable tables do not establish that an artist has no portrait.
177pub fn validate_portrait(response: &Json, id: &str) -> Result<(), &'static str> {
178    let entity = response
179        .get("entities")
180        .and_then(|entities| entities.get(id).or_else(|| only_value(entities)))
181        .filter(|entity| matches!(entity, Json::Obj(_)))
182        .ok_or("Wikidata returned no unambiguous portrait entity")?;
183    let Some(claims) = entity.get("claims") else {
184        return Ok(());
185    };
186    let Json::Obj(claims) = claims else {
187        return Err("Wikidata returned no valid claims table");
188    };
189    if claims
190        .get("P18")
191        .is_some_and(|value| !matches!(value, Json::Arr(_)))
192    {
193        return Err("Wikidata returned no valid portrait statements array");
194    }
195    Ok(())
196}
197
198/// Where to download a Commons file, at a given width.
199///
200/// `Special:FilePath` rather than resolving the MD5-sharded path Commons
201/// actually stores the file under: it is a stable redirect Commons maintains
202/// for exactly this, and following it costs one request, not a second API call
203/// to a different service to compute a hash Aède has no business working out
204/// itself.
205pub fn commons_file_url(filename: &str, width: u32) -> String {
206    format!(
207        "https://commons.wikimedia.org/wiki/Special:FilePath/{}?width={width}",
208        encode_title(filename)
209    )
210}
211
212/// An article on one language's Wikipedia.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct Article {
215    /// The language code — `en`, `fr` — which is also part of the address.
216    pub lang: String,
217    /// The page title, as Wikidata spells it, spaces and all.
218    pub title: String,
219}
220
221/// The article to read, from a `Special:EntityData` document.
222///
223/// `preferred` is tried in order, so a caller can ask for the reader's own
224/// language and fall back rather than choosing between "always English" and
225/// "nothing".
226///
227/// The document is keyed by entity id at the top, and an `EntityData` response
228/// may carry redirects — asking for `Q1` and being answered about `Q2` — so the
229/// id asked for is looked up first and, failing that, the single entity the
230/// document does carry is used. Guessing between several would be a coin toss,
231/// so several is `None`.
232pub fn article(response: &Json, id: &str, preferred: &[&str]) -> Option<Article> {
233    let entities = response.get("entities")?;
234    let entity = match entities.get(id) {
235        Some(entity) => entity,
236        // A redirect answers under the target's id. One entity means there is
237        // no ambiguity about which that is.
238        None => only_value(entities)?,
239    };
240    let sitelinks = entity.get("sitelinks")?;
241    for lang in preferred {
242        // Wikidata names the site, not the language: the English Wikipedia is
243        // `enwiki`, and `enwikiquote` or `enwikisource` are different projects
244        // that would answer with something other than an encyclopaedia entry.
245        let title = sitelinks
246            .get(&format!("{lang}wiki"))
247            .and_then(|s| s.field_str("title"))
248            .filter(|t| !t.trim().is_empty());
249        if let Some(title) = title {
250            return Some(Article {
251                lang: (*lang).to_string(),
252                title,
253            });
254        }
255    }
256    None
257}
258
259/// Checks the entity response before a fetch records a completed absence.
260///
261/// Empty sitelinks are a valid answer. A missing entity, ambiguous redirect or
262/// malformed table is a failed response, not evidence that no article exists.
263/// As in [`article`], a single redirected entity is accepted.
264pub fn validate_entity(response: &Json, id: &str) -> Result<(), &'static str> {
265    let entity = response
266        .get("entities")
267        .and_then(|entities| entities.get(id).or_else(|| only_value(entities)))
268        .ok_or("Wikidata returned no unambiguous entity")?;
269    let Some(Json::Obj(sitelinks)) = entity.get("sitelinks") else {
270        return Err("Wikidata returned no valid sitelinks table");
271    };
272    if sitelinks.values().any(|link| {
273        link.get("title")
274            .and_then(Json::as_str)
275            .is_none_or(|title| title.trim().is_empty())
276    }) {
277        return Err("Wikidata returned an unreadable sitelink");
278    }
279    Ok(())
280}
281
282/// Distinguishes a readable summary or genuine absence from an invalid body.
283///
284/// A typed standard/stub, disambiguation or main page can have no extract.
285/// Older responses with a string extract and no type remain readable. An
286/// unknown response shape must not erase a biography or prevent a later retry.
287pub fn validate_summary(response: &Json) -> Result<(), &'static str> {
288    let kind = response.get("type");
289    if kind.is_some_and(|value| {
290        !matches!(
291            value.as_str(),
292            Some("standard" | "disambiguation" | "no-extract" | "mainpage")
293        )
294    }) {
295        return Err("Wikipedia returned no recognized summary type");
296    }
297    match response.get("extract") {
298        Some(Json::Str(_)) => Ok(()),
299        None if kind.is_some() => Ok(()),
300        _ => Err("Wikipedia returned no readable summary"),
301    }
302}
303
304/// Where to ask for an article's opening paragraph.
305///
306/// The REST summary endpoint rather than the `action=query` API: it returns the
307/// lead paragraph already assembled and already plain text, where the older API
308/// returns wikitext or HTML that this program would then have to strip — and
309/// stripping markup to display it as fact is how a citation template ends up
310/// quoted at a user.
311pub fn summary_url(article: &Article) -> String {
312    format!(
313        "https://{}.wikipedia.org/api/rest_v1/page/summary/{}",
314        article.lang,
315        encode_title(&article.title)
316    )
317}
318
319/// A title as it goes into a REST path.
320///
321/// Spaces become underscores, as every Wikipedia address does, and everything
322/// outside the unreserved set is percent-encoded. Parentheses are encoded too:
323/// they are legal in a path, but article titles are full of them — `Marilyn
324/// Manson (band)` — and encoding them costs nothing while leaving them raw
325/// depends on every intermediary agreeing they are safe.
326fn encode_title(title: &str) -> String {
327    let mut out = String::with_capacity(title.len());
328    for byte in title.replace(' ', "_").bytes() {
329        match byte {
330            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
331                out.push(byte as char)
332            }
333            other => out.push_str(&format!("%{other:02X}")),
334        }
335    }
336    out
337}
338
339/// The paragraph, credited, from a REST summary document.
340///
341/// `None` when the page holds no extract — a disambiguation page, a redirect
342/// to one, a stub that is only an infobox — which is not an error: it is the
343/// same "asked, and there is nothing" the rest of the layer records.
344///
345/// The URL stored is the article's own canonical address when the response
346/// gives one, and the address that was asked otherwise. It is never left out:
347/// a `Prose` cannot be built without it, which is the point of the type.
348pub fn prose(response: &Json, asked: &Article) -> Option<Prose> {
349    // Disambiguation and other non-article pages can still carry an extract;
350    // that text is not the biography reached through the entity's sitelink.
351    if response
352        .get("type")
353        .and_then(Json::as_str)
354        .is_some_and(|kind| kind != "standard")
355    {
356        return None;
357    }
358    let text = response
359        .field_str("extract")
360        .map(|t| t.trim().to_string())
361        .filter(|t| !t.is_empty())?;
362    // The canonical page URL sits under `content_urls.desktop.page`. It is
363    // preferred over the address that was asked because a title may have been
364    // followed through a redirect, and a credit has to point at the article a
365    // reader will actually find.
366    let canonical = response
367        .get("content_urls")
368        .and_then(|c| c.get("desktop"))
369        .and_then(|d| d.field_str("page"))
370        .filter(|u| !u.is_empty());
371    let lang = response
372        .field_str("lang")
373        .filter(|l| !l.is_empty())
374        .unwrap_or_else(|| asked.lang.clone());
375    Some(Prose {
376        text,
377        url: canonical.unwrap_or_else(|| article_url(asked)),
378        lang,
379        licence: LICENCE.to_string(),
380    })
381}
382
383/// A readable address for an article, for when the response gives none.
384fn article_url(article: &Article) -> String {
385    format!(
386        "https://{}.wikipedia.org/wiki/{}",
387        article.lang,
388        encode_title(&article.title)
389    )
390}
391
392/// The only value in an object, when there is exactly one.
393fn only_value(value: &Json) -> Option<&Json> {
394    match value {
395        Json::Obj(fields) if fields.len() == 1 => fields.values().next(),
396        _ => None,
397    }
398}
399
400#[cfg(test)]
401#[path = "wikipedia_tests.rs"]
402mod tests;