Serveur Rust : module qlik (NTLM, Chromium/CDP, extraction réseau, recherche, cache, /api/qlik/sync)

Port fidèle de l'intégration Qlik Sense :
- client.rs : configuration (réglages > env > défauts), types NetworkMetric /
  QlikMonthMetrics / NetworkProduct (JSON identique au TS), xrfkey et flux
  NTLM -> ticket -> cookie X-Qlik-Session, l'échange type 1 / type 3 sur une
  seule connexion HTTP/1.1 dédiée (TLS permissif si tlsInsecure).
- ntlm.rs : messages NTLM identiques octet pour octet à httpntlm/ntlm.js
  (NTLMv2, NTLM2 Session Response, v1 LM/NT), vérifiés contre Node.
- browser.rs : Chromium partagé relancé après déconnexion, file unique des
  extractions, page en contexte isolé pilotée en CDP (cookies, identifiants
  HTTP, console, erreurs, websocket -> qlik-csrf-token, liaison cfCheckpoint,
  navigation domcontentloaded, évaluation JSON).
- playwright.rs + js/extraction.js : extraction 12 mois (script in-page repris
  tel quel), reprises globales, agrégation, contrôles avant écriture.
- search.rs + js/recherche.js : recherche produit dans Qlik.
- date_range.rs, validation.rs : logique pure, tests portés.
- network_cache.rs : lecture / upsert de qlik_network_metrics.
- sync.rs : route /api/qlik/sync (jobs asynchrones, journaux capturés).

Dépendances ajoutées : des, md4, md-5, hyper, hyper-util, http-body-util,
rustls, tokio-rustls, webpki-roots, indexmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:25:54 +00:00
1 parent 559e6ed714
commit dc53bb000d
16 files changed
+6601 -2

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
//! Helper de fenêtre temporelle pour Qlik réseau (port de `src/lib/qlik-date-range.ts`).
//!
//! Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille :
//! - `months_back` mois complets glissants (défaut : 12)
//! - exclut systématiquement le mois courant
//!
//! Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts`
//! (même source que la grille). Si la source officielle change, mettre à jour
//! ici aussi.
//!
//! Qlik référence les dates par un sérial numérique (jours écoulés depuis
//! l'epoch Qlik 1899-12-30). Repères validés :
//! 2025-01-01 → 45658
//! 2025-06-19 → 45827
//! 2026-01-01 → 46023
//! 2026-06-19 → 46192
//!
//! Le filtre côté Qlik s'applique via une sélection de champ `Date` :
//! - valeurs numériques = serials Qlik
//! - la sélection par défaut (`{<Date={...}>}`) est héritée par toutes
//! les master measures qui référencent le champ `Date`.
//!
//! Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ?
//! - les master items sont référencés par `qLibraryId` (réutilisation sans
//! devoir dupliquer l'expression)
//! - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX
//! - sélectionner toutes les dates de la fenêtre via `SelectValues` revient
//! fonctionnellement à `{<Date={">=…<=…"}>}` pour les mesures qui touchent
//! ce champ, sans dépendre de l'expression interne des master items
//!
//! Pas de secrets ici. Fonctions pures, testées par `cargo test`.
use chrono::{Datelike, Duration, NaiveDate};
use serde::{Deserialize, Serialize};
/// Bornes acceptées pour `months_back`. On reste sur 1..12 pour ne pas casser
/// la sémantique "12 mois glissants alignés sur la grille" : une valeur plus
/// grande n'apporterait pas de signal réseau supplémentaire (la grille
/// n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait
/// vide (mois courant exclu) ou incohérente.
pub const QLIK_MONTHS_BACK_MIN: i64 = 1;
pub const QLIK_MONTHS_BACK_MAX: i64 = 12;
pub const QLIK_MONTHS_BACK_DEFAULT: i64 = 12;
/// Conversion d'une chaîne en nombre avec la sémantique de `Number(s)` en JS :
/// espaces ignorés, chaîne vide → 0, préfixes `0x` / `0o` / `0b`, sinon NaN.
pub fn js_number(raw: &str) -> f64 {
let s = raw.trim();
if s.is_empty() {
return 0.0;
}
let lower = s.to_ascii_lowercase();
for (prefixe, base) in [("0x", 16), ("0o", 8), ("0b", 2)] {
if let Some(reste) = lower.strip_prefix(prefixe) {
return u64::from_str_radix(reste, base).map(|v| v as f64).unwrap_or(f64::NAN);
}
}
match s {
"Infinity" | "+Infinity" => return f64::INFINITY,
"-Infinity" => return f64::NEG_INFINITY,
_ => {}
}
// Rust accepte « inf », « nan »… que JS refuse : on les écarte.
if lower.contains("inf") || lower.contains("nan") {
return f64::NAN;
}
s.parse::<f64>().unwrap_or(f64::NAN)
}
/// Lit la variable d'environnement `name` et renvoie un entier borné entre
/// [`QLIK_MONTHS_BACK_MIN`] et [`QLIK_MONTHS_BACK_MAX`]. Renvoie `default_value`
/// si la variable est absente / vide / non numérique.
pub fn env_months_back(name: &str, default_value: i64) -> i64 {
months_back_from(std::env::var(name).ok().as_deref(), default_value)
}
/// Cœur testable de [`env_months_back`].
pub fn months_back_from(raw: Option<&str>, default_value: i64) -> i64 {
let Some(raw) = raw else { return default_value };
if raw.trim().is_empty() {
return default_value;
}
let parsed = js_number(raw);
if !parsed.is_finite() {
return default_value;
}
(parsed.trunc() as i64).clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX)
}
/// Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3).
fn qlik_epoch() -> NaiveDate {
NaiveDate::from_ymd_opt(1899, 12, 30).expect("epoch Qlik")
}
/// Repères de validation.
pub const QLIK_DATE_ANCHORS: [(&str, i64); 4] = [("2025-01-01", 45658), ("2025-06-19", 45827), ("2026-01-01", 46023), ("2026-06-19", 46192)];
fn parse_iso(iso_date: &str) -> Option<(i32, u32, u32)> {
let b = iso_date.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let chiffres = |r: std::ops::Range<usize>| -> Option<u32> {
let part = &iso_date[r];
if part.bytes().all(|c| c.is_ascii_digit()) {
part.parse().ok()
} else {
None
}
};
Some((chiffres(0..4)? as i32, chiffres(5..7)?, chiffres(8..10)?))
}
/// Jours depuis l'epoch Qlik pour une date (y, m, d), avec le débordement de
/// `Date.UTC` (mois 13 → janvier suivant, jour 32 → mois suivant…).
fn serial_ymd(y: i32, mo: u32, d: u32) -> i64 {
let base = NaiveDate::from_ymd_opt(y, 1, 1).expect("année valide");
let premier_du_mois = base.checked_add_months(chrono::Months::new(mo.saturating_sub(1))).unwrap_or(base);
let date = premier_du_mois + Duration::days(d as i64 - 1);
// Date.UTC(y, -1, …) n'arrive pas ici : le format impose deux chiffres ≥ 00.
let date = if mo == 0 { date - chrono::Months::new(1) } else { date };
(date - qlik_epoch()).num_days()
}
/// Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique.
/// Inverse documenté : Qlik epoch = 1899-12-30 (jour 0).
///
/// Erreur si l'entrée n'est pas au format `YYYY-MM-DD`.
pub fn iso_date_to_qlik_serial(iso_date: &str) -> anyhow::Result<i64> {
let Some((y, mo, d)) = parse_iso(iso_date) else {
anyhow::bail!("isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu \"{iso_date}\"");
};
Ok(serial_ymd(y, mo, d))
}
/// Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics).
pub fn qlik_serial_to_iso_date(serial: i64) -> String {
(qlik_epoch() + Duration::days(serial)).format("%Y-%m-%d").to_string()
}
/// Sérial Qlik → entier `AAAAMMJJ` (format de `Date_Key` sur certains modèles).
pub fn qlik_serial_to_ymd(serial: i64) -> i64 {
let d = qlik_epoch() + Duration::days(serial);
d.year() as i64 * 10000 + d.month() as i64 * 100 + d.day() as i64
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QlikDateFilter {
/// Date ISO du 1er jour de la fenêtre (inclus).
pub date_debut: String,
/// Date ISO du dernier jour de la fenêtre (inclus).
pub date_fin: String,
/// Sérial Qlik du 1er jour (= `Date >= …`).
pub q_start: i64,
/// Sérial Qlik du dernier jour (= `Date <= …`).
pub q_end: i64,
/// Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache.
pub label: String,
/// Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues.
pub daily_serials: Vec<i64>,
/// Expression set analysis prête à intégrer dans un `qDef` de mesure.
pub set_analysis: String,
}
/// Dernier jour du mois (m: 1..12).
fn last_day_of_month(y: i32, m: u32) -> u32 {
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
NaiveDate::from_ymd_opt(ny, nm, 1).expect("mois valide").pred_opt().expect("veille").day()
}
/// Calcule la fenêtre `months_back` mois complets (excluant le mois courant).
///
/// `now` est la date LOCALE du jour (`chrono::Local::now().date_naive()`) : les
/// bornes sont des dates calendaires Qlik, jamais converties en UTC — minuit
/// local en Europe/Paris devenait la veille en UTC et transformait par exemple
/// 2025-07-01 en 2025-06-30, soit une extraction de 13 mois.
///
/// Bornes : `months_back` est ramené dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]`
/// (1..12) sans erreur — on veut que la sync reste possible même si l'env est
/// mal configuré.
pub fn build_grid_network_qlik_date_filter(now: NaiveDate, months_back: i64) -> QlikDateFilter {
let months = months_back.clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX) as u32;
let premier_du_mois = NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("date valide");
// Fin : dernier jour du mois précédent le mois courant
let end_month = premier_du_mois.pred_opt().expect("veille");
// Début : `months` mois avant le mois courant (1er du mois)
let start_month = premier_du_mois - chrono::Months::new(months);
let date_debut = start_month.format("%Y-%m-%d").to_string();
let date_fin = end_month.format("%Y-%m-%d").to_string();
let q_start = (start_month - qlik_epoch()).num_days();
let q_end = (end_month - qlik_epoch()).num_days();
// qEnd < qStart est impossible ici (au moins un mois complet) : le TS levait
// une exception pour ce cas, inatteignable avec des bornes ramenées à 1..12.
let daily_serials: Vec<i64> = (q_start..=q_end).collect();
let label = format!("{}_{}", &date_debut[..7], &date_fin[..7]);
let set_analysis = format!("Date={{\">={q_start}<={q_end}\"}}");
QlikDateFilter { date_debut, date_fin, q_start, q_end, label, daily_serials, set_analysis }
}
/// Fenêtre du jour (date locale) — `buildGridNetworkQlikDateFilter()` sans argument.
pub fn build_grid_network_qlik_date_filter_now(months_back: i64) -> QlikDateFilter {
build_grid_network_qlik_date_filter(chrono::Local::now().date_naive(), months_back)
}
/// Découpe les serials en lots pour appels `SelectValues` paginés.
/// Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête).
pub fn chunk_serials(serials: &[i64], batch_size: usize) -> Vec<Vec<i64>> {
serials.chunks(batch_size.max(1)).map(|c| c.to_vec()).collect()
}
/// Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
/// **inclusives et continues**.
///
/// But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
/// codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
/// chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
///
/// Règles :
/// - Le 1er mois commence à `filter.date_debut` (peut être un jour quelconque).
/// - Chaque mois intermédiaire est un mois calendaire **complet**.
/// - Le dernier mois se termine à `filter.date_fin` (tronqué si pas fin de mois).
/// - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
/// - Si début et fin tombent dans le même mois, on renvoie un seul mois.
///
/// Chaque mois retourné est un `QlikDateFilter` autonome (`label` au format `YYYY-MM`).
pub fn get_month_ranges(filter: &QlikDateFilter) -> Vec<QlikDateFilter> {
let start = filter.date_debut.as_str();
let end = filter.date_fin.as_str();
let num = |s: &str, r: std::ops::Range<usize>| -> i64 { s.get(r).and_then(|x| x.parse().ok()).unwrap_or(0) };
let sy = num(start, 0..4) as i32;
let sm = num(start, 5..7) as u32;
let ey = num(end, 0..4) as i32;
let em = num(end, 5..7) as u32;
let ed = num(end, 8..10) as u32;
let mut out = Vec::new();
let (mut cur_y, mut cur_m) = (sy, sm);
// Sécurité anti-boucle infinie (au cas où) : 100 ans, large.
const MAX_MONTHS: usize = 1200;
let mut safety = 0;
while safety < MAX_MONTHS && (1..=12).contains(&cur_m) {
safety += 1;
let is_first = cur_y == sy && cur_m == sm;
let is_last = cur_y == ey && cur_m == em;
// Début : si c'est le premier mois, on respecte `date_debut` ; sinon le 1er.
let start_day = if is_first { num(start, 8..10) as u32 } else { 1 };
let month_start = format!("{cur_y}-{cur_m:02}-{start_day:02}");
// Fin : si c'est le dernier mois, on respecte `date_fin` ; sinon le dernier jour.
let end_day = if is_last { ed } else { last_day_of_month(cur_y, cur_m) };
let month_end = format!("{cur_y}-{cur_m:02}-{end_day:02}");
let q_start = serial_ymd(cur_y, cur_m, start_day);
let q_end = serial_ymd(cur_y, cur_m, end_day);
let daily_serials: Vec<i64> = (q_start..=q_end).collect();
out.push(QlikDateFilter {
date_debut: month_start,
date_fin: month_end,
q_start,
q_end,
label: format!("{cur_y}-{cur_m:02}"),
daily_serials,
set_analysis: format!("Date={{\">={q_start}<={q_end}\"}}"),
});
if is_last {
break;
}
// Avance d'un mois calendaire (gère le passage décembre → janvier).
cur_m += 1;
if cur_m > 12 {
cur_m = 1;
cur_y += 1;
}
}
if out.is_empty() {
// Fenêtre vide : on retourne un seul mois vide aligné sur date_debut.
out.push(QlikDateFilter {
date_debut: filter.date_debut.clone(),
date_fin: filter.date_fin.clone(),
q_start: filter.q_start,
q_end: filter.q_end,
label: start.get(0..7).unwrap_or(start).to_string(),
daily_serials: Vec::new(),
set_analysis: format!("Date={{\">={}<={}\"}}", filter.q_start, filter.q_end),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
// [1] + [2] Repères Qlik et aller-retour.
#[test]
fn reperes_qlik() {
for (iso, serial) in QLIK_DATE_ANCHORS {
assert_eq!(iso_date_to_qlik_serial(iso).unwrap(), serial, "{iso}");
assert_eq!(qlik_serial_to_iso_date(serial), iso);
}
}
// [3] Fenêtre grille (now=2026-06-21) → 2025-06-01 .. 2026-05-31
#[test]
fn fenetre_juin_2026() {
let f = build_grid_network_qlik_date_filter(d("2026-06-21"), QLIK_MONTHS_BACK_DEFAULT);
assert_eq!(f.date_debut, "2025-06-01");
assert_eq!(f.date_fin, "2026-05-31");
assert_eq!(f.q_start, 45809);
assert_eq!(f.q_end, 46173);
assert_eq!(f.label, "2025-06_2026-05");
assert_eq!(f.set_analysis, r#"Date={">=45809<=46173"}"#);
assert_eq!(f.daily_serials.len(), 365);
assert_eq!(f.daily_serials[0], 45809);
assert_eq!(f.daily_serials[364], 46173);
}
// [4] et [5]
#[test]
fn fenetres_mars_et_bissextile() {
let f2 = build_grid_network_qlik_date_filter(d("2026-03-15"), 12);
assert_eq!((f2.date_debut.as_str(), f2.date_fin.as_str()), ("2025-03-01", "2026-02-28"));
assert_eq!((f2.q_start, f2.q_end), (45717, 46081));
let f3 = build_grid_network_qlik_date_filter(d("2025-01-05"), 12);
assert_eq!((f3.date_debut.as_str(), f3.date_fin.as_str()), ("2024-01-01", "2024-12-31"));
assert_eq!(f3.daily_serials.len(), 366);
}
// [6] chunkSerials
#[test]
fn decoupe_serials() {
let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12);
let chunks = chunk_serials(&f.daily_serials, 100);
assert_eq!(chunks.len(), 4);
assert_eq!(chunks[3].len(), 65);
assert_eq!(chunks[0][0], 45809);
assert_eq!(chunks[3][64], 46173);
}
// [7] Robustesse
#[test]
fn formats_invalides() {
for bad in ["2025/01/01", ""] {
let e = iso_date_to_qlik_serial(bad).unwrap_err().to_string();
assert!(e.contains("YYYY-MM-DD"), "{e}");
}
let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12);
assert!(!f.label.is_empty());
assert!(f.set_analysis.starts_with("Date="));
}
// [8] getMonthRanges — fenêtre 12 mois
#[test]
fn mois_de_la_fenetre() {
let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12);
let months = get_month_ranges(&f);
assert_eq!(months.len(), 12);
assert_eq!(months[0].label, "2025-06");
assert_eq!(months[0].date_debut, "2025-06-01");
assert_eq!(months[0].date_fin, "2025-06-30");
assert_eq!(months[0].daily_serials.len(), 30);
assert_eq!(months[0].q_start, 45809);
assert_eq!(months[0].q_end, 45838);
assert_eq!(months[11].label, "2026-05");
assert_eq!(months[11].date_debut, "2026-05-01");
assert_eq!(months[11].date_fin, "2026-05-31");
assert_eq!(months[11].daily_serials.len(), 31);
let feb25 = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-06-15"), 12));
assert!(feb25.iter().any(|m| m.label == "2025-02" && m.daily_serials.len() == 28));
let biss = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-01-05"), 12));
assert!(biss.iter().any(|m| m.label == "2024-02" && m.daily_serials.len() == 29));
assert_eq!(months.iter().map(|m| m.daily_serials.len()).sum::<usize>(), 365);
}
// [9] fenêtre courte partielle
#[test]
fn fenetre_partielle() {
let mut f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12);
f.date_debut = "2025-06-01".into();
f.date_fin = "2025-08-15".into();
f.q_start = 45809;
f.q_end = 45884;
f.daily_serials.clear();
f.set_analysis.clear();
let m = get_month_ranges(&f);
assert_eq!(m.len(), 3);
assert_eq!(m[2].daily_serials.len(), 15);
assert_eq!(m[2].date_fin, "2025-08-15");
}
// [10] monthsBack=6
#[test]
fn six_mois() {
let f6 = build_grid_network_qlik_date_filter(d("2026-06-21"), 6);
assert_eq!(f6.date_debut, "2025-12-01");
assert_eq!(f6.date_fin, "2026-05-31");
assert_eq!((f6.q_start, f6.q_end), (45992, 46173));
assert_eq!(f6.label, "2025-12_2026-05");
assert_eq!(f6.daily_serials.len(), 182);
let m6 = get_month_ranges(&f6);
assert_eq!(m6.len(), 6);
assert_eq!(m6[0].label, "2025-12");
assert_eq!(m6[5].label, "2026-05");
assert_eq!(m6.iter().map(|m| m.daily_serials.len()).sum::<usize>(), 182);
}
// [11] bornes et clamp
#[test]
fn bornes_months_back() {
let now = d("2026-06-21");
let x = build_grid_network_qlik_date_filter(now, 1);
assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2026-05-01", "2026-05-31"));
assert_eq!(build_grid_network_qlik_date_filter(now, 0).date_debut, "2026-05-01");
let x = build_grid_network_qlik_date_filter(now, 24);
assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2025-06-01", "2026-05-31"));
assert_eq!(build_grid_network_qlik_date_filter(now, -5).date_debut, "2026-05-01");
assert_eq!((QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX, QLIK_MONTHS_BACK_DEFAULT), (1, 12, 12));
}
// [12] envMonthsBack — lecture env (cœur pur, sans toucher l'environnement du processus)
#[test]
fn lecture_env_months_back() {
let def = QLIK_MONTHS_BACK_DEFAULT;
assert_eq!(months_back_from(None, def), def);
assert_eq!(months_back_from(Some(""), def), def);
assert_eq!(months_back_from(Some("6"), def), 6);
assert_eq!(months_back_from(Some("abc"), def), def);
assert_eq!(months_back_from(Some("24"), def), QLIK_MONTHS_BACK_MAX);
assert_eq!(months_back_from(Some("-3"), def), QLIK_MONTHS_BACK_MIN);
assert_eq!(months_back_from(Some("0"), def), QLIK_MONTHS_BACK_MIN);
assert_eq!(months_back_from(Some("3.7"), def), 3);
assert_eq!(months_back_from(Some(" 8 "), def), 8);
assert_eq!(months_back_from(Some("NaN"), def), def);
assert_eq!(months_back_from(Some("Infinity"), def), def);
// Sur le vrai environnement : variable absente → défaut.
assert_eq!(env_months_back("QLIK_TEST_MONTHS_BACK_ABSENTE_XYZ", def), def);
}
#[test]
fn nombres_facon_js() {
assert_eq!(js_number(""), 0.0);
assert_eq!(js_number(" 12 "), 12.0);
assert_eq!(js_number("0x10"), 16.0);
assert_eq!(js_number("1e2"), 100.0);
assert!(js_number("inf").is_nan());
assert!(js_number("12abc").is_nan());
}
#[test]
fn serial_vers_aaaammjj() {
assert_eq!(qlik_serial_to_ymd(46192), 20260619);
}
}