use anyhow::{Context, Result};
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Show {
pub title: String,
pub id: u32,
}
impl fmt::Display for Show {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.title)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Subgroup {
pub name: String,
pub id: u32,
}
impl fmt::Display for Subgroup {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.name)
}
}
fn parse_shows(html: &str) -> Vec<Show> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"a[href^="/Home/Bangumi/"]"#).expect("valid selector");
let an_text = Selector::parse("div.an-text").expect("valid selector");
let mut out = Vec::new();
let mut seen = HashSet::new();
for a in doc.select(&sel) {
let href = a.value().attr("href").unwrap_or_default();
let Some(id) = href
.strip_prefix("/Home/Bangumi/")
.map(|s| s.chars().take_while(|c| c.is_ascii_digit()).collect::<String>())
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
let raw = a
.select(&an_text)
.next()
.map(|div| {
div.value()
.attr("title")
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| div.text().collect::<String>())
})
.unwrap_or_else(|| {
a.value()
.attr("title")
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| a.text().collect::<String>())
});
let title = crate::sanitize::strip_unsafe(&raw).trim().to_string();
if title.is_empty() {
continue;
}
if seen.insert(id) {
out.push(Show { title, id });
}
}
out
}
fn parse_subgroups(html: &str) -> Vec<Subgroup> {
let doc = Html::parse_document(html);
let block = Selector::parse("div.subgroup-text").expect("valid selector");
let rss = Selector::parse(r#"a[href*="/RSS/Bangumi"]"#).expect("valid selector");
let group = Selector::parse(r#"a[href^="/Home/PublishGroup/"]"#).expect("valid selector");
let mut out = Vec::new();
let mut seen = HashSet::new();
for div in doc.select(&block) {
let Some(id) = div
.select(&rss)
.next()
.and_then(|a| a.value().attr("href"))
.and_then(subgroupid_from)
else {
continue;
};
let raw = div
.select(&group)
.next()
.map(|a| a.text().collect::<String>())
.unwrap_or_else(|| leading_text(&div));
let name = crate::sanitize::strip_unsafe(&raw).trim().to_string();
if name.is_empty() {
continue;
}
if seen.insert(id) {
out.push(Subgroup { name, id });
}
}
out
}
fn leading_text(div: &scraper::ElementRef) -> String {
let mut s = String::new();
for child in div.children() {
match child.value().as_text() {
Some(text) => s.push_str(text),
None => break,
}
}
s
}
fn subgroupid_from(href: &str) -> Option<u32> {
href.split("subgroupid=")
.nth(1)
.and_then(|rest| rest.split('&').next())
.and_then(|s| s.parse::<u32>().ok())
}
const BASE: &str = "https://mikanani.me";
pub fn search_shows(client: &reqwest::blocking::Client, query: &str) -> Result<Vec<Show>> {
let html = client
.get(format!("{BASE}/Home/Search"))
.query(&[("searchstr", query)])
.send()
.context("请求蜜柑搜索")?
.error_for_status()
.context("蜜柑搜索请求被拒绝")?
.text()
.context("读取蜜柑搜索结果")?;
Ok(parse_shows(&html))
}
pub fn subgroups(client: &reqwest::blocking::Client, bangumi_id: u32) -> Result<Vec<Subgroup>> {
let html = client
.get(format!("{BASE}/Home/Bangumi/{bangumi_id}"))
.send()
.context("请求蜜柑番剧页面")?
.error_for_status()
.context("蜜柑番剧页面请求被拒绝")?
.text()
.context("读取蜜柑番剧页面")?;
Ok(parse_subgroups(&html))
}
#[cfg(test)]
mod tests {
use super::*;
const SEARCH: &str = include_str!("../tests/fixtures/mikan_search.html");
const BANGUMI: &str = include_str!("../tests/fixtures/mikan_bangumi.html");
#[test]
fn parse_shows_extracts_and_dedups() {
let shows = parse_shows(SEARCH);
assert_eq!(
shows,
vec![
Show { title: "石纪元 科学与未来 第2部分".to_string(), id: 3689 },
Show { title: "新石纪 NEW WORLD".to_string(), id: 3000 },
Show { title: "Suffixed Show".to_string(), id: 3952 },
]
);
}
#[test]
fn parse_shows_empty_page_is_empty() {
assert!(parse_shows("<html><body></body></html>").is_empty());
}
#[test]
fn parse_subgroups_extracts_name_and_id() {
let groups = parse_subgroups(BANGUMI);
assert_eq!(
groups,
vec![
Subgroup { name: "猎户发布组".to_string(), id: 597 },
Subgroup { name: "生肉/不明字幕".to_string(), id: 202 },
]
);
}
#[test]
fn parse_subgroups_empty_page_is_empty() {
assert!(parse_subgroups("<html><body></body></html>").is_empty());
}
}