feat: upgrade needed ressources and fight for each quest
This commit is contained in:
4
.claude/agent-memory/dofus-scraper-architect/MEMORY.md
Normal file
4
.claude/agent-memory/dofus-scraper-architect/MEMORY.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Memory Index
|
||||||
|
|
||||||
|
- [HTML "À prévoir" section](html_a_prevoir_section.md) — Patterns, sélecteurs et mapping texte→combat_type pour la section À prévoir
|
||||||
|
- [Weebly rendering quirks](site_rendering_weebly.md) — Le site est un Weebly : cascades de span, libellés par texte, slugs avec entités HTML
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: HTML structure of "À prévoir" section
|
||||||
|
description: Pattern HTML, sélecteurs et règles de mapping pour la section "À prévoir" des pages de quête dofuspourlesnoobs.com
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Le site est rendu via Weebly. L'en-tête de quête (Prérequis, Position de lancement, Récompenses, À prévoir) tient dans un seul `<div class="paragraph">`, en HTML plat, avec des cascades de `<span>` parasites.
|
||||||
|
|
||||||
|
**Identification de la section** : il n'existe pas de classe CSS dédiée. La seule clé fiable est le libellé textuel `<strong>À prévoir</strong>` (encodé `À prévoir`, parfois suivi d'un espace, parfois `:` à l'intérieur ou à l'extérieur du `<strong>`). Les couleurs `<font color="...">` du header (`#f00` Prérequis, `#3a96b8` Position de lancement, `#5fa233` Récompenses) **ne s'appliquent pas** à À prévoir — ne pas s'en servir comme discriminateur.
|
||||||
|
|
||||||
|
**Structure typique** :
|
||||||
|
```html
|
||||||
|
<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>2 x combats (réalisable en groupe).</li>
|
||||||
|
<li>1 x Donjon Antre du Dragon Cochon.</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stratégie de parsing robuste** : sur le `inner_html()` du `<div class="paragraph">`, splitter via regex `(?i)<strong[^>]*>\s*À\s*prévoir\s*</strong>\s*:?` puis couper au prochain `<strong` rencontré. Parser ce fragment avec `Html::parse_fragment` et itérer les `<li>`. Cette approche tolère la cascade de `<span>` Weebly mieux que la navigation par siblings.
|
||||||
|
|
||||||
|
**Cas "À savoir"** : sur quêtes simples (cryptologie, mise-à-l'épreuve), un `<strong>À savoir :</strong>` apparaît à la place — texte libre sans `<ul>`. Section distincte, ne pas la confondre.
|
||||||
|
|
||||||
|
**Cas "absence"** : certaines quêtes (la-colere-des-dieux) n'ont ni À prévoir ni À savoir → retourner Vec::new(), c'est valide.
|
||||||
|
|
||||||
|
**Mapping texte → combat_type observé** :
|
||||||
|
- `donjon` dans le texte → `"donjon"` (label = nom du donjon)
|
||||||
|
- `combat` + `seul` → `"solo"`
|
||||||
|
- `combat` + `groupe`/`réalisable en groupe` → `"groupe"`
|
||||||
|
- `combat à vagues` → `"combat_vagues"`
|
||||||
|
- `combat "tactique"` → `"combat_tactique"`
|
||||||
|
- `combats aléatoires` → `"combat_aleatoire"`
|
||||||
|
- `combats contre des monstres` → `"combat_zone"`
|
||||||
|
- `Aller à <lieu>` → `"deplacement"`
|
||||||
|
- `n x <Nom>` sans "combat"/"donjon" → `"item"` (matériaux à fournir)
|
||||||
|
- modifier observé : `évitable` → flag boolean
|
||||||
|
|
||||||
|
**Count** : extraire via regex `^(\d+)\s*x\s*`. Si commence par `Des ` → quantité indéterminée (`"x?"`).
|
||||||
|
|
||||||
|
**Why** : ces patterns ont été validés sur 10 pages réelles (espoirs-et-trageacutedies, dans-la-gueule-du-milimilou, voir-le-dark-vlad-et-mourir-ou-pas, mise-agrave-leacutepreuve, cryptologie, plongeon-et-dragon, le-dragon-noir, une-acircme-en-colegravere, a-la-recherche-de-crocoburio, l-oeuf-de-crocabulia) en avril 2026.
|
||||||
|
|
||||||
|
**How to apply** : utiliser ce mapping dans `src-tauri/src/commands.rs` pour peupler `CombatIndicator`. Si la struct actuelle (combat_type + count) est conservée, le label du donjon/item est perdu — recommander d'ajouter `label: Option<String>` et `evitable: bool` pour préserver l'info riche.
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: dofuspourlesnoobs.com is a Weebly site
|
||||||
|
description: Caractéristiques du rendu Weebly du site et conséquences pour le scraping
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
dofuspourlesnoobs.com est hébergé sur Weebly. Conséquences pratiques pour le scraping :
|
||||||
|
|
||||||
|
- Le contenu est servi en HTML statique côté serveur — pas de SPA, pas besoin de headless browser.
|
||||||
|
- L'éditeur WYSIWYG produit des **cascades énormes de `<span>` vides** (parfois 100+ niveaux) autour du moindre fragment de texte. Tous les sélecteurs doivent ignorer cette pollution (utiliser `.text()` qui aplatit, ou splitter directement sur `inner_html()`).
|
||||||
|
- Les libellés de sections (Prérequis, Récompenses, À prévoir, etc.) sont identifiés **par texte**, pas par classe CSS. Aucune classe sémantique n'est ajoutée par Weebly.
|
||||||
|
- Les couleurs sont en `<font color="#xxx">` inline (legacy), pas en CSS. Couleurs vues : `#f00`/`#f70000` Prérequis, `#3a96b8` Position de lancement, `#5fa233` Récompenses. À prévoir n'a pas de couleur propre.
|
||||||
|
- Les caractères accentués sont presque toujours encodés en entités HTML (`À`, `é`, etc.) dans la source — penser à `html.unescape` côté Rust ou utiliser `.text()` de scraper qui décode.
|
||||||
|
- Le `<div class="paragraph">` est le container Weebly de base pour un bloc de texte. Une page de quête type contient : 1 paragraph d'en-tête (méta-données) + N paragraphs d'étapes.
|
||||||
|
- Les slugs d'URL utilisent les entités HTML décodées : `é` → `eacute`, `à` → `agrave`, `ê` → `ecirc`, `ô` → `ocirc`, etc. Exemples : `quecirctes.html`, `mise-agrave-leacutepreuve.html`, `chemin-vers-meacuteriana.html`.
|
||||||
|
|
||||||
|
**How to apply** : quand on conçoit un sélecteur, ne jamais s'appuyer sur la profondeur DOM ni sur des classes CSS sémantiques (elles n'existent pas). S'appuyer sur le texte des `<strong>` et la position relative dans `inner_html()` du paragraph parent.
|
||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -5701,6 +5701,7 @@ dependencies = [
|
|||||||
"csv",
|
"csv",
|
||||||
"dirs-next",
|
"dirs-next",
|
||||||
"gtk",
|
"gtk",
|
||||||
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"scraper",
|
"scraper",
|
||||||
|
|||||||
@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
dirs-next = "2"
|
dirs-next = "2"
|
||||||
scraper = "0.20"
|
scraper = "0.20"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
webkit2gtk = { version = "2.0", features = ["v2_38"] }
|
webkit2gtk = { version = "2.0", features = ["v2_38"] }
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use tauri::{AppHandle, Emitter, Manager, State};
|
|||||||
use tauri::window::Color;
|
use tauri::window::Color;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::collections::HashMap;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::{db, parser};
|
use crate::{db, parser};
|
||||||
@ -556,6 +557,112 @@ pub async fn open_image_viewer(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lit le cache SQLite pour une liste d'URLs et retourne les indicateurs déjà stockés.
|
||||||
|
/// Synchrone et instantané — utilisé au chargement de la vue pour afficher les données en cache.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_cached_previews(
|
||||||
|
state: State<DbState>,
|
||||||
|
quest_urls: Vec<String>,
|
||||||
|
) -> Result<HashMap<String, Vec<parser::CombatIndicator>>, String> {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(db::get_cached_previews(&conn, &quest_urls))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrape toutes les quêtes d'un guide qui ne sont pas encore en cache, stocke les résultats
|
||||||
|
/// en DB et retourne l'ensemble `url → indicateurs` pour le guide demandé.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_guide_previews(
|
||||||
|
state: State<'_, DbState>,
|
||||||
|
gid: String,
|
||||||
|
) -> Result<HashMap<String, Vec<parser::CombatIndicator>>, String> {
|
||||||
|
// 1. Charge le guide depuis la DB (section critique minimale)
|
||||||
|
let guide = {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::get_guide(&conn, &gid)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("Guide {} introuvable", gid))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Collecte toutes les URLs des quêtes du guide
|
||||||
|
let all_urls: Vec<String> = collect_quest_urls(&guide);
|
||||||
|
|
||||||
|
// 3. Détermine quelles URLs ne sont pas encore en cache
|
||||||
|
let cached_urls = {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::get_cached_urls(&conn, &all_urls)
|
||||||
|
};
|
||||||
|
|
||||||
|
let urls_to_fetch: Vec<String> = all_urls
|
||||||
|
.iter()
|
||||||
|
.filter(|u| !cached_urls.contains(*u))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 4. Scrape les pages manquantes (bloquant → spawn_blocking)
|
||||||
|
for url in urls_to_fetch {
|
||||||
|
let url_clone = url.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> Result<Vec<parser::CombatIndicator>, String> {
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(20))
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let html = client
|
||||||
|
.get(&url_clone)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Erreur réseau {} : {}", url_clone, e))?
|
||||||
|
.text()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(parser::extract_a_prevoir(&html))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// On persiste même si le résultat est vide (évite de re-scraper une page sans section)
|
||||||
|
match result {
|
||||||
|
Ok(indicators) => {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::upsert_preview(&conn, &url, &indicators).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Erreur réseau non fatale : on log et on continue
|
||||||
|
eprintln!("[fetch_guide_previews] Erreur pour {} : {}", url, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Retourne l'ensemble du cache pour toutes les URLs du guide
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(db::get_cached_previews(&conn, &all_urls))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait toutes les URLs de quêtes depuis un `GuideData` (Quest + Group.quests).
|
||||||
|
fn collect_quest_urls(data: &parser::GuideData) -> Vec<String> {
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
for section in &data.sections {
|
||||||
|
for item in §ion.items {
|
||||||
|
match item {
|
||||||
|
parser::SectionItem::Quest(q) => {
|
||||||
|
if let Some(url) = &q.url {
|
||||||
|
urls.push(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser::SectionItem::Group(g) => {
|
||||||
|
for q in &g.quests {
|
||||||
|
if let Some(url) = &q.url {
|
||||||
|
urls.push(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser::SectionItem::Instruction(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urls
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
for section in &data.sections {
|
for section in &data.sections {
|
||||||
|
|||||||
@ -2,6 +2,9 @@ use rusqlite::{Connection, Result, params};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::parser::CombatIndicator;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
@ -76,10 +79,125 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
PRIMARY KEY (profile_id, resource_name),
|
PRIMARY KEY (profile_id, resource_name),
|
||||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quest_previews (
|
||||||
|
quest_url TEXT PRIMARY KEY,
|
||||||
|
indicators_json TEXT NOT NULL,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
")?;
|
")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_cached_previews(conn: &Connection, urls: &[String]) -> HashMap<String, Vec<CombatIndicator>> {
|
||||||
|
if urls.is_empty() {
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construit les placeholders : (?1, ?2, …)
|
||||||
|
let placeholders: String = (1..=urls.len())
|
||||||
|
.map(|i| format!("?{}", i))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT quest_url, indicators_json FROM quest_previews WHERE quest_url IN ({})",
|
||||||
|
placeholders
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = match conn.prepare(&sql) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// rusqlite attend &dyn ToSql — on construit un vecteur de références
|
||||||
|
let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls
|
||||||
|
.iter()
|
||||||
|
.map(|u| u as &dyn rusqlite::types::ToSql)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = match stmt.query_map(params_vec.as_slice(), |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
}) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for row in rows.flatten() {
|
||||||
|
let (url, json) = row;
|
||||||
|
if let Ok(indicators) = serde_json::from_str::<Vec<CombatIndicator>>(&json) {
|
||||||
|
map.insert(url, indicators);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_preview(conn: &Connection, url: &str, indicators: &[CombatIndicator]) -> Result<()> {
|
||||||
|
let json = serde_json::to_string(indicators).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO quest_previews (quest_url, indicators_json, cached_at) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(quest_url) DO UPDATE SET indicators_json=excluded.indicators_json, cached_at=excluded.cached_at",
|
||||||
|
params![url, json, now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'ensemble des URLs déjà présentes dans quest_previews parmi celles fournies.
|
||||||
|
pub fn get_cached_urls(conn: &Connection, urls: &[String]) -> std::collections::HashSet<String> {
|
||||||
|
if urls.is_empty() {
|
||||||
|
return std::collections::HashSet::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let placeholders: String = (1..=urls.len())
|
||||||
|
.map(|i| format!("?{}", i))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT quest_url FROM quest_previews WHERE quest_url IN ({})",
|
||||||
|
placeholders
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = match conn.prepare(&sql) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return std::collections::HashSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls
|
||||||
|
.iter()
|
||||||
|
.map(|u| u as &dyn rusqlite::types::ToSql)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = match stmt.query_map(params_vec.as_slice(), |row| row.get::<_, String>(0)) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return std::collections::HashSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.flatten().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge un guide depuis la DB à partir de son gid.
|
||||||
|
pub fn get_guide(conn: &Connection, gid: &str) -> Result<Option<crate::parser::GuideData>> {
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT data FROM guides WHERE gid = ?1",
|
||||||
|
params![gid],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(json) => {
|
||||||
|
let data = serde_json::from_str(&json)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
0,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(e),
|
||||||
|
))?;
|
||||||
|
Ok(Some(data))
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_profiles(conn: &Connection) -> Result<Vec<Profile>> {
|
pub fn get_profiles(conn: &Connection) -> Result<Vec<Profile>> {
|
||||||
let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?;
|
let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
|
|||||||
@ -60,6 +60,8 @@ pub fn run() {
|
|||||||
commands::get_resource_inventory,
|
commands::get_resource_inventory,
|
||||||
commands::set_resource_quantity,
|
commands::set_resource_quantity,
|
||||||
commands::open_image_viewer,
|
commands::open_image_viewer,
|
||||||
|
commands::get_cached_previews,
|
||||||
|
commands::fetch_guide_previews,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -50,6 +50,136 @@ pub struct QuestItem {
|
|||||||
pub struct CombatIndicator {
|
pub struct CombatIndicator {
|
||||||
pub combat_type: String,
|
pub combat_type: String,
|
||||||
pub count: String,
|
pub count: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub evitable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait les indicateurs de combat depuis la section "À prévoir" d'une page de quête DPLN.
|
||||||
|
///
|
||||||
|
/// La section est identifiée par un `<strong>` contenant "À prévoir" dans un `<div class="paragraph">`.
|
||||||
|
/// Le fragment HTML entre ce marqueur et le prochain `<strong>` est re-parsé pour extraire les `<li>`.
|
||||||
|
pub fn extract_a_prevoir(html: &str) -> Vec<CombatIndicator> {
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
// Sélecteur sur tous les paragraphes potentiels du header de quête
|
||||||
|
let para_sel = Selector::parse("div.paragraph").unwrap();
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
|
||||||
|
// Regex pour détecter "À prévoir" dans le inner_html (gère les entités HTML)
|
||||||
|
let re_header = Regex::new(
|
||||||
|
r"(?i)<strong[^>]*>\s*(?:À|À)\s*pr(?:é|é)voir\s*:?\s*</strong>"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Regex pour extraire le count en début de texte : "2 x ..." ou "Des ..."
|
||||||
|
let re_count = Regex::new(r"(?i)^(\d+)\s*[xX]\s*").unwrap();
|
||||||
|
|
||||||
|
for para in document.select(¶_sel) {
|
||||||
|
let inner = para.inner_html();
|
||||||
|
|
||||||
|
if !re_header.is_match(&inner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coupe le fragment après le marqueur "À prévoir" jusqu'au prochain <strong> (champ suivant)
|
||||||
|
let after_marker = re_header.splitn(&inner, 2).nth(1).unwrap_or("");
|
||||||
|
// Tronque au prochain <strong> pour ne pas déborder sur un autre champ
|
||||||
|
let fragment_html = match after_marker.find("<strong") {
|
||||||
|
Some(pos) => &after_marker[..pos],
|
||||||
|
None => after_marker,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fragment = Html::parse_fragment(fragment_html);
|
||||||
|
let li_sel = Selector::parse("li").unwrap();
|
||||||
|
|
||||||
|
let mut indicators = Vec::new();
|
||||||
|
|
||||||
|
for li in fragment.select(&li_sel) {
|
||||||
|
let raw_text: String = li.text().collect::<Vec<_>>().join(" ");
|
||||||
|
let text = raw_text.trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let evitable = lower.contains("évitable") || lower.contains("evitable");
|
||||||
|
|
||||||
|
// Extraction du count
|
||||||
|
let count = if lower.starts_with("des ") {
|
||||||
|
"?".to_string()
|
||||||
|
} else if let Some(cap) = re_count.captures(&text) {
|
||||||
|
cap[1].to_string()
|
||||||
|
} else {
|
||||||
|
"1".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Texte sans le préfixe "N x "
|
||||||
|
let text_without_count = re_count.replace(&text, "").to_string();
|
||||||
|
let lower_no_count = text_without_count.to_lowercase();
|
||||||
|
|
||||||
|
// Mapping vers combat_type
|
||||||
|
let (combat_type, label) = if lower_no_count.contains("donjon") {
|
||||||
|
// Extrait le nom du donjon : tout sauf le mot "donjon" et ce qui suit
|
||||||
|
let label = extract_donjon_label(&text_without_count);
|
||||||
|
("donjon".to_string(), Some(label))
|
||||||
|
} else if lower_no_count.contains("combat") {
|
||||||
|
if lower_no_count.contains("tactique") {
|
||||||
|
("combat_tactique".to_string(), None)
|
||||||
|
} else if lower_no_count.contains("vague") {
|
||||||
|
("combat_vagues".to_string(), None)
|
||||||
|
} else if lower_no_count.contains("aléatoire") || lower_no_count.contains("aleatoire") {
|
||||||
|
("combat_aleatoire".to_string(), None)
|
||||||
|
} else if lower_no_count.contains("monstre") {
|
||||||
|
("combat_zone".to_string(), None)
|
||||||
|
} else if lower_no_count.contains("seul") {
|
||||||
|
("solo".to_string(), None)
|
||||||
|
} else if lower_no_count.contains("groupe") || lower_no_count.contains("réalisable") || lower_no_count.contains("realisable") {
|
||||||
|
("groupe".to_string(), None)
|
||||||
|
} else {
|
||||||
|
// Combat sans précision supplémentaire
|
||||||
|
("groupe".to_string(), None)
|
||||||
|
}
|
||||||
|
} else if lower_no_count.contains("aller à") || lower_no_count.contains("aller a") {
|
||||||
|
("deplacement".to_string(), None)
|
||||||
|
} else {
|
||||||
|
// Fallback : item nommé
|
||||||
|
let label = text_without_count
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
("item".to_string(), Some(label))
|
||||||
|
};
|
||||||
|
|
||||||
|
indicators.push(CombatIndicator {
|
||||||
|
combat_type,
|
||||||
|
count,
|
||||||
|
label,
|
||||||
|
evitable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premier paragraphe "À prévoir" trouvé — on retourne immédiatement
|
||||||
|
return indicators;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait le nom du donjon depuis un texte de type "Donjon Antre du Dragon Cochon."
|
||||||
|
/// Supprime le mot "Donjon" en début (case-insensitive), nettoie la ponctuation finale.
|
||||||
|
fn extract_donjon_label(text: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"(?i)^donjon\s*").unwrap();
|
||||||
|
let without = re.replace(text, "");
|
||||||
|
// Retire les suffixes parenthétiques courants : "(réalisable en groupe)."
|
||||||
|
let trimmed = if let Some(paren) = without.find('(') {
|
||||||
|
without[..paren].trim().to_string()
|
||||||
|
} else {
|
||||||
|
without.trim().trim_end_matches('.').trim().to_string()
|
||||||
|
};
|
||||||
|
trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@ -284,6 +414,8 @@ fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col:
|
|||||||
indicators.push(CombatIndicator {
|
indicators.push(CombatIndicator {
|
||||||
combat_type: ct.name.clone(),
|
combat_type: ct.name.clone(),
|
||||||
count: cell.to_string(),
|
count: cell.to_string(),
|
||||||
|
label: None,
|
||||||
|
evitable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
indicators
|
indicators
|
||||||
|
|||||||
@ -16,6 +16,19 @@ function useWindowWidth() {
|
|||||||
|
|
||||||
function combatIcon(name: string): string {
|
function combatIcon(name: string): string {
|
||||||
const l = name.toLowerCase();
|
const l = name.toLowerCase();
|
||||||
|
if (l === "solo") return "🗡️";
|
||||||
|
if (l === "groupe") return "⚔️";
|
||||||
|
if (l === "donjon") return "💀";
|
||||||
|
if (
|
||||||
|
l === "combat_vagues" ||
|
||||||
|
l === "combat_tactique" ||
|
||||||
|
l === "combat_aleatoire" ||
|
||||||
|
l === "combat_zone" ||
|
||||||
|
l === "combat"
|
||||||
|
) return "🗡️";
|
||||||
|
if (l === "deplacement") return "🗺️";
|
||||||
|
if (l === "item") return "📦";
|
||||||
|
// Fallback — conserve l'ancienne logique pour les données CSV existantes
|
||||||
if (l.includes("solo") || l.includes("seul")) return "🗡️";
|
if (l.includes("solo") || l.includes("seul")) return "🗡️";
|
||||||
if (l.includes("group") || l.includes("groupe")) return "⚔️";
|
if (l.includes("group") || l.includes("groupe")) return "⚔️";
|
||||||
if (l.includes("donjon") || l.includes("boss")) return "💀";
|
if (l.includes("donjon") || l.includes("boss")) return "💀";
|
||||||
@ -324,6 +337,15 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
|||||||
onSelect: (quest: { name: string; url: string | null }) => void;
|
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||||
indent?: boolean;
|
indent?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const questPreviews = useStore(s => s.questPreviews);
|
||||||
|
const previewsLoading = useStore(s => s.previewsLoading);
|
||||||
|
const [previewsOpen, setPreviewsOpen] = useState(false);
|
||||||
|
|
||||||
|
const previews = quest.url ? questPreviews[quest.url] : undefined;
|
||||||
|
const showLoadingPlaceholder = previewsLoading && quest.url !== null && previews === undefined;
|
||||||
|
|
||||||
|
const hasPreviewSection = showLoadingPlaceholder || (previews && previews.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -364,6 +386,72 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasPreviewSection && (
|
||||||
|
<span
|
||||||
|
onClick={() => setPreviewsOpen(o => !o)}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
marginTop: "2px",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "#4a5568",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "#4a5568"; }}
|
||||||
|
>
|
||||||
|
{previewsOpen
|
||||||
|
? `▾ À prévoir`
|
||||||
|
: `▸ À prévoir${previews && previews.length > 0 ? ` (${previews.length})` : ""}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewsOpen && (
|
||||||
|
<>
|
||||||
|
{showLoadingPlaceholder && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: "3px",
|
||||||
|
width: "60px", height: "14px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
background: "linear-gradient(90deg, #1f2937 25%, #2d3748 50%, #1f2937 75%)",
|
||||||
|
backgroundSize: "200% 100%",
|
||||||
|
animation: "shimmer 1.4s infinite",
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showLoadingPlaceholder && previews && previews.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px", marginTop: "3px" }}>
|
||||||
|
{previews.map((ci, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: "3px",
|
||||||
|
fontSize: "10px", padding: "1px 6px", borderRadius: "4px",
|
||||||
|
background: "rgba(255,255,255,0.05)", border: "1px solid #2d3748",
|
||||||
|
color: "#4a5568",
|
||||||
|
}}>
|
||||||
|
<span>{combatIcon(ci.combat_type)}</span>
|
||||||
|
<span>×{ci.count}{ci.combat_type !== "item" ? ` ${ci.combat_type}` : ""}</span>
|
||||||
|
{ci.evitable && (
|
||||||
|
<span style={{ color: "#4ade80" }}>(évit.)</span>
|
||||||
|
)}
|
||||||
|
{ci.label && (
|
||||||
|
<span style={{
|
||||||
|
fontStyle: "italic",
|
||||||
|
maxWidth: "80px", overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
display: "inline-block",
|
||||||
|
}}>
|
||||||
|
{ci.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quest.note && (
|
{quest.note && (
|
||||||
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
||||||
→ <TextWithCoords text={quest.note} />
|
→ <TextWithCoords text={quest.note} />
|
||||||
|
|||||||
@ -110,6 +110,11 @@ input[type="checkbox"] {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/store.ts
43
src/store.ts
@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { Profile, GuideListItem, GuideData, SyncResult } from "./types";
|
import { Profile, GuideListItem, GuideData, SyncResult, CombatIndicator, Section } from "./types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
profiles: Profile[];
|
profiles: Profile[];
|
||||||
@ -15,6 +15,8 @@ interface AppState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
resourcesPanelCollapsed: boolean;
|
resourcesPanelCollapsed: boolean;
|
||||||
resourceInventory: Record<string, number>;
|
resourceInventory: Record<string, number>;
|
||||||
|
questPreviews: Record<string, CombatIndicator[]>;
|
||||||
|
previewsLoading: boolean;
|
||||||
|
|
||||||
setResourcesPanelCollapsed: (v: boolean) => void;
|
setResourcesPanelCollapsed: (v: boolean) => void;
|
||||||
loadResourceInventory: () => Promise<void>;
|
loadResourceInventory: () => Promise<void>;
|
||||||
@ -28,6 +30,7 @@ interface AppState {
|
|||||||
openGuide: (gid: string) => Promise<void>;
|
openGuide: (gid: string) => Promise<void>;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
closeGuide: () => void;
|
closeGuide: () => void;
|
||||||
|
loadQuestPreviews: (gid: string) => Promise<void>;
|
||||||
|
|
||||||
toggleQuest: (questName: string) => Promise<void>;
|
toggleQuest: (questName: string) => Promise<void>;
|
||||||
|
|
||||||
@ -48,6 +51,8 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
resourcesPanelCollapsed: false,
|
resourcesPanelCollapsed: false,
|
||||||
resourceInventory: {},
|
resourceInventory: {},
|
||||||
|
questPreviews: {},
|
||||||
|
previewsLoading: false,
|
||||||
|
|
||||||
setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }),
|
setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }),
|
||||||
|
|
||||||
@ -112,13 +117,32 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
const data = await invoke<GuideData>("get_guide", { gid });
|
const data = await invoke<GuideData>("get_guide", { gid });
|
||||||
await invoke("set_setting", { key: "active_guide", value: gid });
|
await invoke("set_setting", { key: "active_guide", value: gid });
|
||||||
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
|
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
|
||||||
|
await get().loadQuestPreviews(gid);
|
||||||
},
|
},
|
||||||
|
|
||||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||||
|
|
||||||
closeGuide: () => {
|
closeGuide: () => {
|
||||||
invoke("set_setting", { key: "active_guide", value: "" });
|
invoke("set_setting", { key: "active_guide", value: "" });
|
||||||
set({ activeGuideGid: null, activeGuideData: null, view: "home" });
|
set({ activeGuideGid: null, activeGuideData: null, view: "home", questPreviews: {}, previewsLoading: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadQuestPreviews: async (gid) => {
|
||||||
|
const { activeGuideData } = get();
|
||||||
|
if (!activeGuideData) return;
|
||||||
|
|
||||||
|
const urls = collectQuestUrls(activeGuideData.sections);
|
||||||
|
if (urls.length === 0) return;
|
||||||
|
|
||||||
|
// Hydratation immédiate depuis le cache DB
|
||||||
|
const cached = await invoke<Record<string, CombatIndicator[]>>("get_cached_previews", { questUrls: urls });
|
||||||
|
set({ questPreviews: cached });
|
||||||
|
|
||||||
|
// Fetch réseau en tâche de fond (fire & forget)
|
||||||
|
set({ previewsLoading: true });
|
||||||
|
invoke<Record<string, CombatIndicator[]>>("fetch_guide_previews", { gid })
|
||||||
|
.then(result => set({ questPreviews: result, previewsLoading: false }))
|
||||||
|
.catch(() => set({ previewsLoading: false }));
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleQuest: async (questName) => {
|
toggleQuest: async (questName) => {
|
||||||
@ -164,3 +188,18 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
await invoke("sync_single_guide", { gid, name });
|
await invoke("sync_single_guide", { gid, name });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function collectQuestUrls(sections: Section[]): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (item.type === "Quest" && item.url) urls.push(item.url);
|
||||||
|
else if (item.type === "Group") {
|
||||||
|
for (const q of item.quests) {
|
||||||
|
if (q.url) urls.push(q.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export interface Resource {
|
|||||||
export interface CombatIndicator {
|
export interface CombatIndicator {
|
||||||
combat_type: string;
|
combat_type: string;
|
||||||
count: string;
|
count: string;
|
||||||
|
label?: string;
|
||||||
|
evitable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestItem {
|
export interface QuestItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user