use anyhow::{Result, anyhow};
pub fn attr_json(html: &str, attr: &str) -> Result<serde_json::Value> {
let raw = attr_value(html, attr).ok_or_else(|| anyhow!("no {attr} attribute in the page"))?;
let text = unescape(&raw);
serde_json::from_str(&text).map_err(|e| anyhow!("{attr} was not valid JSON: {e}"))
}
pub fn id_attr_json(html: &str, id: &str, attr: &str) -> Result<serde_json::Value> {
let needle = format!("id=\"{id}\"");
let at = html
.find(&needle)
.ok_or_else(|| anyhow!("no element with id=\"{id}\" in the page"))?;
let tag_end = html[at..].find('>').map(|e| at + e).unwrap_or(html.len());
let raw = attr_value(&html[at..tag_end], attr)
.ok_or_else(|| anyhow!("element id=\"{id}\" has no {attr} attribute"))?;
serde_json::from_str(&unescape(&raw))
.map_err(|e| anyhow!("{id}/{attr} was not valid JSON: {e}"))
}
pub fn attr_value(html: &str, attr: &str) -> Option<String> {
let needle = format!("{attr}=\"");
let start = html.find(&needle)? + needle.len();
let end = start + html[start..].find('"')?;
Some(html[start..end].to_string())
}
pub fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let (decoded, len) = decode_entity(tail);
match decoded {
Some(c) => {
out.push_str(&c);
rest = &tail[len..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
fn decode_entity(tail: &str) -> (Option<String>, usize) {
const NAMED: &[(&str, char)] = &[
(""", '"'),
("'", '\''),
("'", '\''),
("<", '<'),
(">", '>'),
(" ", '\u{a0}'),
("&", '&'),
];
for (pat, ch) in NAMED {
if tail.starts_with(pat) {
return (Some(ch.to_string()), pat.len());
}
}
if let Some(body) = tail.strip_prefix("&#")
&& let Some(semi) = body.find(';')
{
let digits = &body[..semi];
let parsed = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => digits.parse::<u32>().ok(),
};
if let Some(c) = parsed.and_then(char::from_u32) {
return (Some(c.to_string()), 2 + semi + 1);
}
}
(None, 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unescapes_the_entities_bandcamp_emits() {
assert_eq!(unescape(""id""), "\"id\"");
assert_eq!(unescape("a<b>c"), "a<b>c");
assert_eq!(unescape("Bob's"), "Bob's");
assert_eq!(unescape("R&B"), "R&B");
assert_eq!(unescape("’"), "\u{2019}");
assert_eq!(unescape("☺"), "\u{263A}");
}
#[test]
fn ampersand_is_decoded_last_so_double_escaping_survives() {
assert_eq!(unescape("&quot;"), """);
}
#[test]
fn unknown_entities_are_left_alone_rather_than_dropped() {
assert_eq!(unescape("100 &fakeentity; x"), "100 &fakeentity; x");
assert_eq!(unescape("bare & ampersand"), "bare & ampersand");
assert_eq!(unescape("&#notanumber;"), "&#notanumber;");
}
#[test]
fn unescape_leaves_plain_text_untouched() {
assert_eq!(unescape("nothing to do here"), "nothing to do here");
assert_eq!(unescape(""), "");
}
#[test]
fn extracts_and_parses_an_attribute_blob() {
let html = r#"<script data-tralbum="{"id":856850876,"t":"R&amp;B"}"></script>"#;
let v = attr_json(html, "data-tralbum").unwrap();
assert_eq!(v["id"], 856850876_i64);
assert_eq!(v["t"], "R&B");
}
#[test]
fn id_lookup_does_not_drift_into_a_later_element() {
let html = r#"<div id="other" data-blob="{"x":1}"></div>
<div id="pagedata" data-blob="{"x":2}"></div>"#;
assert_eq!(id_attr_json(html, "pagedata", "data-blob").unwrap()["x"], 2);
}
#[test]
fn id_lookup_stops_at_the_end_of_its_own_tag() {
let html =
r#"<div id="pagedata" class="x"></div><div data-blob="{"x":9}"></div>"#;
let err = id_attr_json(html, "pagedata", "data-blob")
.unwrap_err()
.to_string();
assert!(err.contains("has no data-blob"), "got: {err}");
}
#[test]
fn missing_attribute_says_which_one() {
let err = attr_json("<html></html>", "data-tralbum")
.unwrap_err()
.to_string();
assert!(err.contains("data-tralbum"), "got: {err}");
}
#[test]
fn invalid_json_is_reported_as_such_not_as_absent() {
let html = r#"<script data-tralbum="{not json"></script>"#;
let err = attr_json(html, "data-tralbum").unwrap_err().to_string();
assert!(err.contains("not valid JSON"), "got: {err}");
}
#[test]
fn missing_id_says_which_id() {
let err = id_attr_json("<html></html>", "pagedata", "data-blob")
.unwrap_err()
.to_string();
assert!(err.contains("pagedata"), "got: {err}");
}
#[test]
fn attr_value_returns_the_first_match_verbatim() {
assert_eq!(
attr_value(r#"<a x="1"><b x="2">"#, "x").as_deref(),
Some("1")
);
assert_eq!(attr_value("<a>", "x"), None);
assert_eq!(attr_value(r#"<a x="unterminated"#, "x"), None);
}
}