use scraper::{Html, Selector};
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
pub struct BedethequeProvider;
impl MetadataProvider for BedethequeProvider {
fn name(&self) -> &str {
"bedetheque"
}
fn search_series(
&self,
query: &str,
config: &ProviderConfig,
) -> std::pin::Pin<
Box, String>> + Send + '_>,
> {
let query = query.to_string();
let config = config.clone();
Box::pin(async move { search_series_impl(&query, &config).await })
}
fn get_series_books(
&self,
external_id: &str,
config: &ProviderConfig,
) -> std::pin::Pin<
Box, String>> + Send + '_>,
> {
let external_id = external_id.to_string();
let config = config.clone();
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
}
}
fn build_client() -> Result {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.user_agent("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0")
.default_headers({
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::ACCEPT,
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
.parse()
.unwrap(),
);
h.insert(
reqwest::header::ACCEPT_LANGUAGE,
"fr-FR,fr;q=0.9,en;q=0.5".parse().unwrap(),
);
h.insert(reqwest::header::REFERER, "https://www.bedetheque.com/".parse().unwrap());
h
})
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}
/// Remove diacritics for URL construction (bedetheque uses ASCII slugs)
fn normalize_for_url(s: &str) -> String {
s.chars()
.map(|c| match c {
'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => 'e',
'à' | 'â' | 'ä' | 'À' | 'Â' | 'Ä' => 'a',
'ù' | 'û' | 'ü' | 'Ù' | 'Û' | 'Ü' => 'u',
'ô' | 'ö' | 'Ô' | 'Ö' => 'o',
'î' | 'ï' | 'Î' | 'Ï' => 'i',
'ç' | 'Ç' => 'c',
'ñ' | 'Ñ' => 'n',
_ => c,
})
.collect()
}
fn urlencoded(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
b' ' => result.push('+'),
_ => result.push_str(&format!("%{:02X}", byte)),
}
}
result
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
async fn search_series_impl(
query: &str,
_config: &ProviderConfig,
) -> Result, String> {
let client = build_client()?;
// Use the full-text search page
let url = format!(
"https://www.bedetheque.com/search/tout?RechTexte={}&RechWhere=0",
urlencoded(&normalize_for_url(query))
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Bedetheque request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
return Err(format!("Bedetheque returned {status}"));
}
let html = resp
.text()
.await
.map_err(|e| format!("Failed to read Bedetheque response: {e}"))?;
// Detect IP blacklist
if html.contains("") || html.contains("") {
return Err("Bedetheque: IP may be rate-limited, please retry later".to_string());
}
// Parse HTML in a block so the non-Send Html type is dropped before any .await
let candidates = {
let document = Html::parse_document(&html);
let link_sel =
Selector::parse("a[href*='/serie-']").map_err(|e| format!("selector error: {e}"))?;
let query_lower = query.to_lowercase();
let mut seen = std::collections::HashSet::new();
let mut candidates = Vec::new();
for el in document.select(&link_sel) {
let href = match el.value().attr("href") {
Some(h) => h.to_string(),
None => continue,
};
let (series_id, _slug) = match parse_serie_href(&href) {
Some(v) => v,
None => continue,
};
if !seen.insert(series_id.clone()) {
continue;
}
let title = el.text().collect::().trim().to_string();
if title.is_empty() {
continue;
}
let confidence = compute_confidence(&title, &query_lower);
let cover_url = format!(
"https://www.bedetheque.com/cache/thb_series/PlancheS_{}.jpg",
series_id
);
candidates.push(SeriesCandidate {
external_id: series_id.clone(),
title: title.clone(),
authors: vec![],
description: None,
publishers: vec![],
start_year: None,
total_volumes: None,
cover_url: Some(cover_url),
external_url: Some(href),
confidence,
metadata_json: serde_json::json!({}),
});
}
candidates.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates.truncate(10);
candidates
}; // document is dropped here — safe to .await below
// For the top candidates, fetch series details to enrich metadata
// (limit to top 3 to avoid hammering the site)
let mut enriched = Vec::new();
for mut c in candidates {
if enriched.len() < 3 {
if let Ok(details) = fetch_series_details(&client, &c.external_id, c.external_url.as_deref()).await {
if let Some(desc) = details.description {
c.description = Some(desc);
}
if !details.authors.is_empty() {
c.authors = details.authors;
}
if !details.publishers.is_empty() {
c.publishers = details.publishers;
}
if let Some(year) = details.start_year {
c.start_year = Some(year);
}
if let Some(count) = details.album_count {
c.total_volumes = Some(count);
}
c.metadata_json = serde_json::json!({
"description": c.description,
"authors": c.authors,
"publishers": c.publishers,
"start_year": c.start_year,
"genres": details.genres,
"status": details.status,
"origin": details.origin,
"language": details.language,
});
}
}
enriched.push(c);
}
Ok(enriched)
}
/// Parse serie URL to extract (id, slug)
fn parse_serie_href(href: &str) -> Option<(String, String)> {
// Patterns:
// https://www.bedetheque.com/serie-3-BD-Blacksad.html
// /serie-3-BD-Blacksad.html
let re = regex::Regex::new(r"/serie-(\d+)-[A-Za-z]+-(.+?)(?:__\d+)?\.html").ok()?;
let caps = re.captures(href)?;
Some((caps[1].to_string(), caps[2].to_string()))
}
struct SeriesDetails {
description: Option,
authors: Vec,
publishers: Vec,
start_year: Option,
album_count: Option,
genres: Vec,
status: Option,
origin: Option,
language: Option,
}
async fn fetch_series_details(
client: &reqwest::Client,
series_id: &str,
series_url: Option<&str>,
) -> Result {
// Build URL — append __10000 to get all albums on one page
let url = match series_url {
Some(u) => {
// Replace .html with __10000.html
u.replace(".html", "__10000.html")
}
None => format!(
"https://www.bedetheque.com/serie-{}-BD-Serie__10000.html",
series_id
),
};
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch series page: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Series page returned {}", resp.status()));
}
let html = resp
.text()
.await
.map_err(|e| format!("Failed to read series page: {e}"))?;
let doc = Html::parse_document(&html);
let mut details = SeriesDetails {
description: None,
authors: vec![],
publishers: vec![],
start_year: None,
album_count: None,
genres: vec![],
status: None,
origin: None,
language: None,
};
// Description from — format: "Tout sur la série {name} : {description}"
if let Ok(sel) = Selector::parse(r#"meta[name="description"]"#) {
if let Some(el) = doc.select(&sel).next() {
if let Some(content) = el.value().attr("content") {
let desc = content.trim().to_string();
// Strip the "Tout sur la série ... : " prefix
let cleaned = if let Some(pos) = desc.find(" : ") {
desc[pos + 3..].trim().to_string()
} else {
desc
};
if !cleaned.is_empty() {
details.description = Some(cleaned);
}
}
}
}
// Extract authors from itemprop="author" and itemprop="illustrator" (deduplicated)
{
let mut authors_set = std::collections::HashSet::new();
for attr in ["author", "illustrator"] {
if let Ok(sel) = Selector::parse(&format!(r#"[itemprop="{attr}"]"#)) {
for el in doc.select(&sel) {
let name = el.text().collect::().trim().to_string();
// Names are "Last, First" — normalize to "First Last"
let normalized = if let Some((last, first)) = name.split_once(',') {
format!("{} {}", first.trim(), last.trim())
} else {
name
};
if !normalized.is_empty() && is_real_author(&normalized) {
authors_set.insert(normalized);
}
}
}
}
details.authors = authors_set.into_iter().collect();
details.authors.sort();
}
// Extract publishers from itemprop="publisher" (deduplicated)
{
let mut publishers_set = std::collections::HashSet::new();
if let Ok(sel) = Selector::parse(r#"[itemprop="publisher"]"#) {
for el in doc.select(&sel) {
let name = el.text().collect::().trim().to_string();
if !name.is_empty() {
publishers_set.insert(name);
}
}
}
details.publishers = publishers_set.into_iter().collect();
details.publishers.sort();
}
// Extract series-level info from
value
blocks
// Genre:
Animalier, Aventure, Humour
if let Ok(sel) = Selector::parse("span.style-serie") {
if let Some(el) = doc.select(&sel).next() {
let text = el.text().collect::();
details.genres = text
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
// Parution: