Skip to main content

banished_banishedinfo/
lib.rs

1//! Optional [banishedinfo.com](https://banishedinfo.com/mods/browse) support for Un-Banished:
2//! list and search the site's Banished mods, read a mod's versions, and install the `.pkm`
3//! packages of a version. It is not part of the original game.
4//!
5//! The site has no public API. Its mod list loads from a JSON address the browse page itself
6//! uses (`/mods/ajax?action=mods&…`), its search page carries its results as JSON in the page
7//! (`var mods = […]`), and a mod's page links each version (`/mods/download/<mod>/<n>`), whose
8//! file (`/mods/file/<mod>/<n>`) redirects to the package or archive. No account is needed.
9//!
10//! Requests are made only when the player lists, searches or installs, one at a time, with a
11//! user agent that names Un-Banished ([`user_agent`]). The site sends no CORS headers, so a
12//! browser page cannot read it: the support is for native builds (`native`).
13//!
14//! This crate builds the addresses and reads the answers; [`native`] sends the requests.
15#![doc(
16    html_logo_url = "https://tanepiper.github.io/unbanished/assets/un-banished-icon.svg",
17    html_favicon_url = "https://tanepiper.github.io/unbanished/favicon.svg"
18)]
19
20use serde_json::Value;
21
22#[cfg(feature = "native")]
23pub mod native;
24
25/// The site.
26pub const SITE: &str = "https://banishedinfo.com";
27
28/// The user agent sent with every request: the application, its version and where it lives.
29pub fn user_agent(version: &str) -> String {
30    format!("Un-Banished/{version} (+https://github.com/tanepiper/unbanished)")
31}
32
33/// The order of the site's list (its `sort` values).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Sort {
36    /// The site's own overview order (`overview`).
37    Featured,
38    /// Most downloaded (`downloads`).
39    Downloads,
40    /// Most downloaded in the last day (`downloads24`).
41    Trending,
42    /// Newest first (`recent`).
43    Recent,
44}
45
46impl Sort {
47    fn value(self) -> &'static str {
48        match self {
49            Sort::Featured => "overview",
50            Sort::Downloads => "downloads",
51            Sort::Trending => "downloads24",
52            Sort::Recent => "recent",
53        }
54    }
55}
56
57/// The site's mod sections (`section` values), or all of them.
58pub const SECTIONS: [(&str, &str); 8] = [
59    ("", "All"),
60    ("audio", "Audio"),
61    ("buildings", "Buildings"),
62    ("gameplay", "Gameplay"),
63    ("maps", "Maps"),
64    ("textures", "Textures"),
65    ("translations", "Translations"),
66    ("translated-mods", "Translated mods"),
67];
68
69/// A mod as the site lists it.
70#[derive(Debug, Clone, PartialEq)]
71pub struct Mod {
72    /// The mod's id on the site.
73    pub id: u32,
74    /// The mod's part of its address: `679-CC-New-Frontier-BlackLiquid`.
75    pub url: String,
76    /// The mod's title.
77    pub title: String,
78    /// Its author.
79    pub author: String,
80    /// The start of its description.
81    pub info: String,
82    /// The version the author gave the newest upload (`1.55`).
83    pub version: String,
84    /// How many times it was downloaded.
85    pub downloads: u64,
86    /// The share of players who liked it, 0 to 100.
87    pub rating: u8,
88    /// Its thumbnail image (a PNG), as a full address.
89    pub thumbnail: Option<String>,
90    /// When it was created (`2014-10-26 05:07:55`).
91    pub created: String,
92}
93
94impl Mod {
95    /// A mod known only by its id (its page answers without the name).
96    pub fn with_id(id: u32) -> Mod {
97        Mod {
98            id,
99            url: id.to_string(),
100            title: format!("mod {id}"),
101            author: String::new(),
102            info: String::new(),
103            version: String::new(),
104            downloads: 0,
105            rating: 0,
106            thumbnail: None,
107            created: String::new(),
108        }
109    }
110
111    /// The mod's page.
112    pub fn page(&self) -> String {
113        format!("{SITE}/mods/view/{}", self.url)
114    }
115}
116
117/// One version of a mod, newest first in [`parse_versions`].
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Version {
120    /// The version as the page names it (`1.1`).
121    pub label: String,
122    /// The address of its file, which redirects to the download.
123    pub file: String,
124}
125
126/// What can go wrong.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Error {
129    /// The request failed or the answer could not be read (the message says why).
130    Network(String),
131    /// The site answered with this HTTP status.
132    Status(u16),
133    /// The page or data was not what was expected (the site may have changed).
134    Answer(String),
135    /// The download could not be installed (no `.pkm`, or a `.7z` or `.rar` archive).
136    Install(String),
137}
138
139impl std::fmt::Display for Error {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Error::Network(m) => write!(f, "could not reach banishedinfo.com: {m}"),
143            Error::Status(s) => write!(f, "banishedinfo.com answered {s}"),
144            Error::Answer(m) => write!(f, "unexpected answer from banishedinfo.com: {m}"),
145            Error::Install(m) => write!(f, "{m}"),
146        }
147    }
148}
149
150impl std::error::Error for Error {}
151
152/// Percent-encode a value for an address.
153fn encode(text: &str) -> String {
154    text.bytes()
155        .map(|b| match b {
156            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
157                (b as char).to_string()
158            }
159            b' ' => "+".to_string(),
160            _ => format!("%{b:02X}"),
161        })
162        .collect()
163}
164
165/// The address of `count` mods from `start` of a section (`""` for all), in an order.
166pub fn list_url(sort: Sort, section: &str, start: u32, count: u32) -> String {
167    format!(
168        "{SITE}/mods/ajax?action=mods&start={start}&count={count}&section={}&sort={}",
169        encode(section),
170        sort.value()
171    )
172}
173
174/// The address of the site's search for `text`.
175pub fn search_url(text: &str) -> String {
176    format!("{SITE}/search?query={}", encode(text.trim()))
177}
178
179/// A number in the site's text form (`"291,278 downloads"`, `1.55`, `98`).
180fn count(v: &Value) -> u64 {
181    if let Some(n) = v.as_u64() {
182        return n;
183    }
184    v.as_str()
185        .map(|s| s.chars().take_while(|c| c.is_ascii_digit() || *c == ','))
186        .map(|c| c.filter(char::is_ascii_digit).collect::<String>())
187        .and_then(|s| s.parse().ok())
188        .unwrap_or(0)
189}
190
191fn text(v: &Value) -> String {
192    match v {
193        Value::String(s) => s.trim().to_string(),
194        Value::Number(n) => n.to_string(),
195        _ => String::new(),
196    }
197}
198
199fn mod_of(v: &Value) -> Option<Mod> {
200    let url = text(&v["url"]);
201    let id = count(&v["mod_id"]) as u32;
202    if url.is_empty() || id == 0 {
203        return None;
204    }
205    let thumb = text(&v["thumb"]);
206    Some(Mod {
207        id,
208        url,
209        title: unescape(&text(&v["title"])),
210        author: unescape(&text(&v["author"])),
211        info: unescape(&text(&v["info"])),
212        version: text(&v["version"]),
213        downloads: count(&v["downloads"]),
214        rating: count(&v["rating"]).min(100) as u8,
215        thumbnail: (!thumb.is_empty()).then(|| {
216            if thumb.starts_with('/') {
217                format!("{SITE}{thumb}")
218            } else {
219                thumb
220            }
221        }),
222        created: text(&v["created"]),
223    })
224}
225
226/// Read the answer of [`list_url`]: a JSON list of mods (empty past the end).
227pub fn parse_list(body: &[u8]) -> Result<Vec<Mod>, Error> {
228    let v: Value = serde_json::from_slice(body).map_err(|e| Error::Answer(e.to_string()))?;
229    let list = v
230        .as_array()
231        .ok_or_else(|| Error::Answer("the mod list is not a list".into()))?;
232    Ok(list.iter().filter_map(mod_of).collect())
233}
234
235/// Read the search page of [`search_url`]: the results it carries as `var mods = […];`.
236pub fn parse_search(page: &str) -> Result<Vec<Mod>, Error> {
237    let Some(start) = page.find("var mods = ") else {
238        // A search without results has no list.
239        return Ok(Vec::new());
240    };
241    let rest = &page[start + "var mods = ".len()..];
242    let mut stream = serde_json::Deserializer::from_str(rest).into_iter::<Value>();
243    let v = stream
244        .next()
245        .ok_or_else(|| Error::Answer("no results list".into()))?
246        .map_err(|e| Error::Answer(e.to_string()))?;
247    Ok(v.as_array()
248        .map(|a| a.iter().filter_map(mod_of).collect())
249        .unwrap_or_default())
250}
251
252/// The few HTML entities mod titles use.
253fn unescape(s: &str) -> String {
254    s.replace("&amp;", "&")
255        .replace("&quot;", "\"")
256        .replace("&#039;", "'")
257        .replace("&#39;", "'")
258        .replace("&lt;", "<")
259        .replace("&gt;", ">")
260        .replace("&ntilde;", "ñ")
261        .replace("&nbsp;", " ")
262}
263
264/// Read a mod's page ([`Mod::page`]): its versions, newest first, from the links
265/// `/mods/download/<id>-<name>/<n>">Download version <label>` (the page of mod `id`).
266pub fn parse_versions(page: &str, id: u32) -> Vec<Version> {
267    let prefix = format!("/mods/download/{id}-");
268    let mut out: Vec<Version> = Vec::new();
269    let mut rest = page;
270    while let Some(i) = rest.find(&prefix) {
271        rest = &rest[i + prefix.len()..];
272        let Some((slug, after)) = rest.split_once('/') else {
273            break;
274        };
275        let n: String = after.chars().take_while(char::is_ascii_digit).collect();
276        let label = after[n.len()..]
277            .strip_prefix("\">Download version ")
278            .and_then(|t| t.split('<').next())
279            .map(|t| unescape(t.trim()));
280        if let (false, false, Some(label)) = (n.is_empty(), slug.contains(['"', '<', ' ']), label) {
281            let file = format!("{SITE}/mods/file/{id}-{slug}/{n}");
282            if !out.iter().any(|v| v.file == file) {
283                out.push(Version { label, file });
284            }
285        }
286    }
287    out
288}
289
290/// The file name at the end of a download's address (`/mods/file-download/264/XP-Schools.pkm`).
291pub fn file_name(url: &str) -> String {
292    url.split(['?', '#'])
293        .next()
294        .and_then(|u| u.rsplit('/').next())
295        .map(|n| n.replace("%20", " "))
296        .unwrap_or_default()
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn addresses() {
305        assert_eq!(
306            list_url(Sort::Downloads, "", 20, 20),
307            "https://banishedinfo.com/mods/ajax?action=mods&start=20&count=20&section=&sort=downloads"
308        );
309        assert_eq!(
310            search_url(" more terrain&x "),
311            "https://banishedinfo.com/search?query=more+terrain%26x"
312        );
313        assert!(user_agent("0.2.0").starts_with("Un-Banished/0.2.0 (+https://"));
314    }
315
316    const ITEM: &str = r#"{"mod_id":679,"title":"CC New Frontier &amp; More","info":"A complete expansion.",
317        "author":"BlackLiquid","downloads":"291,278 downloads","rating":98,"version":1.55,
318        "url":"679-CC-New-Frontier-BlackLiquid","created":"2014-10-26 05:07:55","thumb":"/images/mod-thumbs/213.png"}"#;
319
320    #[test]
321    fn reads_the_list() {
322        let body = format!("[{ITEM},{{\"mod_id\":0}}]");
323        let mods = parse_list(body.as_bytes()).unwrap();
324        assert_eq!(mods.len(), 1);
325        let m = &mods[0];
326        assert_eq!((m.id, m.downloads, m.rating), (679, 291_278, 98));
327        assert_eq!(m.title, "CC New Frontier & More");
328        assert_eq!(m.version, "1.55");
329        assert_eq!(
330            m.thumbnail.as_deref(),
331            Some("https://banishedinfo.com/images/mod-thumbs/213.png")
332        );
333        assert_eq!(
334            m.page(),
335            "https://banishedinfo.com/mods/view/679-CC-New-Frontier-BlackLiquid"
336        );
337        assert_eq!(parse_list(b"[]").unwrap(), Vec::new());
338    }
339
340    #[test]
341    fn reads_search_results_from_the_page() {
342        let page = format!("<script>var mods = [{ITEM}];\nfunction f() {{}}</script>");
343        assert_eq!(parse_search(&page).unwrap()[0].id, 679);
344        assert!(parse_search("<html>no results</html>").unwrap().is_empty());
345    }
346
347    #[test]
348    fn reads_versions_newest_first() {
349        let page = r#"<a href="/mods/download/34-Better-Schools/828" class="download">Download mod</a>
350            <a href="/mods/download/34-Better-Schools/828">Download version 1.1</a>
351            <a href="/mods/download/34-Better-Schools/99">Download version 1.0</a>"#;
352        let v = parse_versions(page, 34);
353        assert_eq!(v.len(), 2);
354        assert_eq!(v[0].label, "1.1");
355        assert_eq!(
356            v[0].file,
357            "https://banishedinfo.com/mods/file/34-Better-Schools/828"
358        );
359        assert_eq!(v[1].label, "1.0");
360        assert_eq!(
361            file_name("https://banishedinfo.com/mods/file-download/264/XP-Schools.pkm"),
362            "XP-Schools.pkm"
363        );
364    }
365}