mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
Qlik : extraction réseau coupée à 30 s par chromiumoxide, corrigée
Depuis le passage au serveur Rust, la mise à jour réseau d'un fournisseur (ex. Sovacom) échouait sur « Request timed out » : chromiumoxide coupe toute commande de page au bout de 30 s, en dur (REQUEST_TIMEOUT de CommandFuture), sans tenir compte du request_timeout du navigateur. Or toute l'extraction Qlik tient dans un seul Runtime.evaluate de plusieurs minutes. Playwright, côté TS, n'avait pas cette limite. PageQlik::evaluer lance désormais le script sans l'attendre, puis relève son résultat par des évaluations courtes (20 ms → 1 s). Aucune commande CDP n'approche plus les 30 s, quelle que soit la durée de l'extraction ; la recherche produit, qui passe par la même fonction, en profite aussi. Tests : - tests/faux-qlik : faux Qlik Sense (HTTPS, NTLM → ticket → cookie, page avec jeton CSRF, mini-moteur Engine) ; - extraction_faux_qlik (ignoré par défaut) : extraction complète de bout en bout avec un vrai Chromium, y compris contre celui de l'image de prod ; - fenetre_identique_au_ts : fenêtre 12 mois identique à l'ancien TS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTseDg4hw2m9tGUJsqPt57
This commit is contained in:
7 files changed
+493
-11
No files matched your search
+66
-11
@@ -67,9 +67,10 @@ async fn lancer() -> anyhow::Result<Navigateur> {
|
||||
.arg("ignore-certificate-errors")
|
||||
.user_data_dir(&profil)
|
||||
.launch_timeout(Duration::from_secs(60))
|
||||
// Une extraction complète tient dans UN `page.evaluate` (plusieurs
|
||||
// minutes, parfois plus de 15) : pas de délai par commande CDP, comme
|
||||
// Playwright. Les étapes bornées le sont explicitement (tokio::time::timeout).
|
||||
// Délai des commandes passées par le gestionnaire du navigateur. Il ne
|
||||
// couvre PAS les commandes de page, coupées à 30 s par chromiumoxide :
|
||||
// les scripts longs passent donc par `PageQlik::evaluer`, qui n'attend
|
||||
// jamais une commande plus de quelques instants.
|
||||
.request_timeout(Duration::from_secs(24 * 3600));
|
||||
if let Some(chemin) = chemin_chromium() {
|
||||
builder = builder.chrome_executable(chemin);
|
||||
@@ -116,6 +117,21 @@ pub async fn get_qlik_browser() -> anyhow::Result<Arc<Browser>> {
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Tests : pilote un Chromium déjà lancé (`--remote-debugging-port`), par
|
||||
/// exemple celui de l'image Docker de production, au lieu d'en lancer un.
|
||||
#[cfg(test)]
|
||||
pub async fn utiliser_chromium_distant(url: &str) -> anyhow::Result<()> {
|
||||
let (browser, mut handler) = Browser::connect(url).await.context("[qlik] connexion au Chromium distant")?;
|
||||
let connecte = Arc::new(AtomicBool::new(true));
|
||||
let drapeau = connecte.clone();
|
||||
tokio::spawn(async move {
|
||||
while handler.next().await.is_some() {}
|
||||
drapeau.store(false, Ordering::SeqCst);
|
||||
});
|
||||
*NAVIGATEUR.lock().await = Some(Navigateur { browser: Arc::new(browser), connecte });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File unique des extractions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -312,14 +328,57 @@ impl PageQlik {
|
||||
/// `page.evaluate(fn, args)` : `source` est le texte d'une fonction JS
|
||||
/// (éventuellement async), appelée avec `args` ; la promesse est attendue et
|
||||
/// le résultat rendu en JSON (`JSON.stringify` côté page, donc NaN → null).
|
||||
///
|
||||
/// ⚠️ chromiumoxide coupe toute commande de page au bout de 30 s, en dur
|
||||
/// (`REQUEST_TIMEOUT` de `CommandFuture`, insensible à `request_timeout`).
|
||||
/// Une extraction Qlik dure plusieurs minutes : l'attendre dans un seul
|
||||
/// `Runtime.evaluate` la faisait échouer sur « Request timed out ». Le
|
||||
/// script est donc lancé sans être attendu, et son résultat relevé par des
|
||||
/// évaluations courtes jusqu'à ce qu'il soit prêt.
|
||||
pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result<Value> {
|
||||
let expression = format!(
|
||||
"(async () => {{ const __f = (\n{source}\n); const __r = await __f({}); return JSON.stringify(__r === undefined ? null : __r); }})()",
|
||||
static TRAVAUX: AtomicUsize = AtomicUsize::new(0);
|
||||
let cle = serde_json::to_string(&format!("__cf_eval_{}", TRAVAUX.fetch_add(1, Ordering::SeqCst)))?;
|
||||
let lancement = format!(
|
||||
"(() => {{ const __e = window[{cle}] = {{ fini: false }}; \
|
||||
Promise.resolve().then(() => (\n{source}\n)({})).then( \
|
||||
(r) => {{ __e.json = JSON.stringify(r === undefined ? null : r); __e.fini = true; }}, \
|
||||
(e) => {{ __e.erreur = String((e && e.stack) || e); __e.fini = true; }}); \
|
||||
return true; }})()",
|
||||
serde_json::to_string(args)?
|
||||
);
|
||||
self.evaluer_court(lancement).await?;
|
||||
|
||||
let releve = format!(
|
||||
"(() => {{ const __e = window[{cle}]; if (!__e) return {{ absent: true }}; if (!__e.fini) return null; \
|
||||
delete window[{cle}]; return __e.erreur !== undefined ? {{ erreur: __e.erreur }} : {{ json: __e.json }}; }})()"
|
||||
);
|
||||
let mut pause = Duration::from_millis(20);
|
||||
loop {
|
||||
tokio::time::sleep(pause).await;
|
||||
pause = (pause * 2).min(Duration::from_secs(1));
|
||||
let etat = self.evaluer_court(releve.clone()).await?;
|
||||
if etat.is_null() {
|
||||
continue;
|
||||
}
|
||||
if etat.get("absent").is_some() {
|
||||
// La page a été rechargée : le script et son état ont disparu.
|
||||
return Err(anyhow!("Execution context was destroyed (page rechargée pendant l'évaluation)"));
|
||||
}
|
||||
if let Some(erreur) = etat.get("erreur").and_then(|e| e.as_str()) {
|
||||
return Err(anyhow!("{erreur}"));
|
||||
}
|
||||
return match etat.get("json").and_then(|j| j.as_str()) {
|
||||
Some(s) => Ok(serde_json::from_str(s)?),
|
||||
None => Ok(Value::Null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `Runtime.evaluate` d'une expression synchrone, résultat par valeur.
|
||||
/// Doit rester bien en deçà des 30 s de chromiumoxide (cf. [`Self::evaluer`]).
|
||||
async fn evaluer_court(&self, expression: String) -> anyhow::Result<Value> {
|
||||
let params = EvaluateParams::builder()
|
||||
.expression(expression)
|
||||
.await_promise(true)
|
||||
.return_by_value(true)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("[qlik] paramètres d'évaluation : {e}"))?;
|
||||
@@ -328,11 +387,7 @@ impl PageQlik {
|
||||
let message = ex.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| ex.text.clone());
|
||||
return Err(anyhow!("{message}"));
|
||||
}
|
||||
match &res.result.result.value {
|
||||
Some(Value::String(s)) => Ok(serde_json::from_str(s)?),
|
||||
Some(v) => Ok(v.clone()),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
Ok(res.result.result.value.clone().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// Ferme la page puis le contexte (`ctx.close()`).
|
||||
|
||||
@@ -298,6 +298,26 @@ mod tests {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
/// Valeurs de référence calculées par l'ancien `qlik-date-range.ts`
|
||||
/// (`buildGridNetworkQlikDateFilter` + `getMonthRanges`).
|
||||
#[test]
|
||||
fn fenetre_identique_au_ts() {
|
||||
for (jour, label, q_start, q_end, premier_mois) in [
|
||||
("2026-10-10", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
|
||||
("2026-10-14", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
|
||||
("2026-01-05", "2025-01_2025-12", 45658, 46022, (45658, 45688)),
|
||||
("2026-03-31", "2025-03_2026-02", 45717, 46081, (45717, 45747)),
|
||||
] {
|
||||
let f = build_grid_network_qlik_date_filter(d(jour), 12);
|
||||
assert_eq!((f.label.as_str(), f.q_start, f.q_end, f.daily_serials.len()), (label, q_start, q_end, 365), "{jour}");
|
||||
let mois = get_month_ranges(&f);
|
||||
assert_eq!(mois.len(), 12, "{jour}");
|
||||
assert_eq!((mois[0].q_start, mois[0].q_end), premier_mois, "{jour}");
|
||||
assert_eq!(mois[11].q_end, q_end, "{jour}");
|
||||
assert_eq!(mois.iter().map(|m| m.daily_serials.len()).sum::<usize>(), 365, "{jour}");
|
||||
}
|
||||
}
|
||||
|
||||
// [1] + [2] Repères Qlik et aller-retour.
|
||||
#[test]
|
||||
fn reperes_qlik() {
|
||||
|
||||
@@ -159,3 +159,43 @@ async fn file_qlik_sequentielle() {
|
||||
let attendu: Vec<String> = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect();
|
||||
assert_eq!(o, attendu);
|
||||
}
|
||||
|
||||
/// Extraction complète (NTLM → ticket → cookie → page → jeton CSRF → websocket
|
||||
/// Engine → 12 mois → contrôles) contre le faux Qlik de `tests/faux-qlik` :
|
||||
///
|
||||
/// ```text
|
||||
/// tests/faux-qlik/lancer.sh NB_CODES=2000 DELAI_MS=40000 # extraction de plusieurs minutes
|
||||
/// NB_CODES=2000 cargo test extraction_faux_qlik -- --ignored --nocapture
|
||||
/// ```
|
||||
///
|
||||
/// `FOURNISSEUR_TEST` (défaut `SOVA`, connu du faux Qlik) : un autre code force
|
||||
/// la sélection par codes articles. `CHROMIUM_DISTANT=http://127.0.0.1:9333` :
|
||||
/// pilote un Chromium déjà lancé, par exemple celui de l'image de production.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn extraction_faux_qlik() {
|
||||
let _ = tracing_subscriber::fmt().with_env_filter("info").with_test_writer().try_init();
|
||||
if std::env::var("PLAYWRIGHT_CHROMIUM_PATH").is_err() {
|
||||
std::env::set_var("PLAYWRIGHT_CHROMIUM_PATH", "/opt/pw-browsers/chromium-1194/chrome-linux/chrome");
|
||||
}
|
||||
if let Ok(url) = std::env::var("CHROMIUM_DISTANT") {
|
||||
super::browser::utiliser_chromium_distant(&url).await.expect("Chromium distant");
|
||||
}
|
||||
let nb: usize = std::env::var("NB_CODES").ok().and_then(|v| v.parse().ok()).unwrap_or(400);
|
||||
let codes: Vec<String> = (0..nb).map(|i| (10000100000u64 + i as u64).to_string()).collect();
|
||||
let mut cfg = super::client::get_qlik_config();
|
||||
cfg.host = "localhost".into();
|
||||
cfg.user = "FFSCH".into();
|
||||
cfg.password = "Mot2Passe!".into();
|
||||
let df = super::date_range::build_grid_network_qlik_date_filter_now(12);
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = super::playwright::fetch_network_metrics_playwright(Some(codes.clone()), Some(cfg), Some(df.clone()), Some(std::env::var("FOURNISSEUR_TEST").unwrap_or_else(|_| "SOVA".into()))).await;
|
||||
let m = r.expect("extraction");
|
||||
eprintln!("EXTRACTION OK : {} métriques en {:?}, fenêtre {}", m.len(), t0.elapsed(), df.label);
|
||||
let premier = m.values().next().unwrap();
|
||||
eprintln!("exemple {} : {:?}", premier.code_centrale, premier.qte_by_month);
|
||||
assert!(m.len() > nb * 9 / 10);
|
||||
for v in m.values() {
|
||||
assert_eq!(v.qte_by_month.as_ref().unwrap().len(), 12, "{}", v.code_centrale);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user