# `backlink-check` — Design

- **Date** : 2026-06-08
- **Statut** : design validé, en attente de plan d'implémentation
- **Auteur** : brainstorm Bastien × Claude
- **Cible** : `rust/backlink-check/` dans le monorepo `Scripts/`

---

## 1. Goal

Outil CLI pour vérifier une commande de backlinks après livraison. À partir d'une liste d'URLs source (txt ou csv), on visite chaque page, on cherche le lien retour (`<a href="…">`), on extrait son ancre + son `rel`, puis on agrège des stats — exactement ce qu'on veut voir pour valider une commande chez un fournisseur de netlinking.

### Cas d'usage type

Bastien commande N backlinks (ex. 358) vers une URL cible (`https://elevaseo.com/X`) avec une liste d'ancres exactes commandées (variations autour de keywords SEO). Le fournisseur livre un fichier `.txt` listant les pages où les backlinks ont été placés. `backlink-check` produit :

1. **CSV ligne par ligne** : quelle page, statut HTTP, lien trouvé ou non, ancre réelle, rel, etc.
2. **Récap terminal** : taux de couverture, % dofollow, top ancres, top keywords, breakdown TLD.
3. **Rapport Markdown** : version lisible du récap, partageable / archivable.

## 2. Scope

### Dans le scope

- Input : txt (1 URL/ligne) ou csv (colonne configurable).
- Prompt interactif en TUI pour saisir cible + ancres commandées + keywords trackés.
- Mode `--non-interactive` pour CI / scripts (cible + ancres + keywords via flags).
- Fetch HTTP parallèle (rayon, 16 workers par défaut).
- Parsing HTML avec `scraper` (CSS selectors).
- Match URL multi-niveaux (exact / protocol_diff / www_diff / both).
- Match d'ancres exactes (case-insensitive, accent-insensitive, whitespace-collapsed).
- Détection de keywords dans l'ancre (word boundary, accent-insensitive).
- Détection `rel` complète (`dofollow`, `nofollow`, `sponsored`, `ugc`).
- Sélection du meilleur `<a>` si plusieurs matchent (dofollow > nofollow > doc order).
- Extraction TLD du host source pour les stats.
- 3 livrables : CSV détaillé, récap terminal, rapport Markdown.

### Hors scope (v1)

- Rendu JavaScript (pas de headless browser).
- Crawl récursif sur les sites source.
- Détection de la position du lien (header / contenu / footer).
- Persistance d'historique (re-comparaison J vs J+7).
- Rate limiting per-host (acceptable car l'input typique a des hosts distincts).
- API publique / serveur web — c'est un CLI one-shot.

## 3. Input

### Format txt

- 1 URL par ligne.
- Lignes vides → ignorées.
- Lignes commençant par `#` → ignorées (commentaires) — règle txt uniquement.
- Pas de header attendu.

### Format csv

- Première ligne = header.
- Colonne URL configurable via `--csv-column` (défaut : `url`).
- Si la colonne demandée n'existe pas → erreur explicite avec liste des colonnes trouvées.

### Détection du format

Par l'extension : `.txt` → txt, `.csv` → csv. Override possible avec `--format txt|csv`.

## 4. Prompt interactif (TUI)

Lancement sans `--non-interactive` :

```
$ backlink-check -i data/imports/FO2DEA5B9042.txt

✓ 358 URLs détectées (txt, 1 par ligne)

? URL cible (le lien qu'on attend) : https://elevaseo.com/station-solaire
  ↳ normalisé : https://elevaseo.com/station-solaire

? Ancres commandées (une par ligne, ligne vide pour terminer) :
  1. station de calories solaire
  2. comment fonctionne une station solaire
  3. station d'énergie solaire pour habitation écologique
  4. ⏎ (terminé — 3 ancres)

? Keywords à tracker (séparés par virgule) : solaire, calories, kjoules, kcalories, énergie

? Confirmer et lancer ?
  • 358 URLs
  • Cible : https://elevaseo.com/station-solaire
  • 3 ancres
  • 5 keywords
  • 16 workers, timeout 20s
  [Y/n] _
```

### Règles

- **URL cible** : doit parser via `url::Url` (sinon ré-itère la question). Doit être `http(s)`. On stocke la forme normalisée + on en dérive 3 variantes (protocol/www) pour la classification du match.
- **Ancres** : boucle `dialoguer::Input`, ligne vide → stop. Minimum 1 ancre requise.
- **Keywords** : un seul `Input`, split sur `,`, trim. Liste vide autorisée (stats keywords vides).
- **Confirmation** : `dialoguer::Confirm` (défaut Y). Esc/N → abort propre, code de sortie 1.

### Mode non-interactif

```bash
backlink-check -i ... \
  --non-interactive \
  --target https://elevaseo.com/station-solaire \
  --anchors "station de calories solaire|comment fonctionne une station solaire" \
  --keywords "solaire,calories,kjoules,kcalories,énergie"
```

- Séparateur ancres : `|` (les ancres peuvent contenir des virgules).
- Séparateur keywords : `,`.

## 5. CLI surface

```
backlink-check -i <FILE> [OPTIONS]

  -i, --input <FILE>          Fichier d'URLs (txt 1/ligne ou csv) — requis
      --format <FMT>          txt | csv (auto-détecté par extension)
      --csv-column <NAME>     Colonne URL si csv (défaut: "url")
      --out <DIR>             Dossier de sortie (défaut: data/exports/)
  -j, --jobs <N>              Workers parallèles (défaut: min(nb_cpu, 16))
      --timeout <SEC>         Timeout HTTP par requête (défaut: 20)
      --user-agent <UA>       User-Agent HTTP (défaut: "backlink-check/0.1")
      --non-interactive       Saute le prompt; utilise les flags ci-dessous
      --target <URL>          (non-interactif) URL cible
      --anchors <STR>         (non-interactif) ancres séparées par '|'
      --keywords <STR>        (non-interactif) keywords séparés par ','
  -h, --help
  -V, --version
```

## 6. Layout projet

```
rust/backlink-check/
├── Cargo.toml          # workspace member, déclare deps directes
├── README.md           # 1 paragraphe + usage table (FR, comme les autres tools)
├── data/
│   ├── imports/        # gitignored sauf .gitkeep
│   │   └── .gitkeep
│   └── exports/        # gitignored sauf .gitkeep
│       └── .gitkeep
├── src/
│   ├── main.rs         # CLI (clap), orchestration, prompt
│   ├── input.rs        # parse txt/csv → Vec<String>
│   ├── prompt.rs       # dialoguer flow, sidecar JSON load/save
│   ├── campaign.rs     # CampaignConfig struct + URL normalize variants
│   ├── fetch.rs        # reqwest client + retry policy
│   ├── parse.rs        # scraper: find matching <a>, pick best, extract anchor/rel
│   ├── match_.rs       # URL match_kind, anchor compare, keyword scan, TLD extract
│   ├── stats.rs        # aggregate Vec<SourceCheck> → Stats
│   └── report.rs       # write CSV + Markdown
└── tests/
    ├── fixtures/       # *.html pages mockées
    └── integration.rs  # tests bout-en-bout sans HTTP réel
```

Conventions alignées sur les outils existants du repo (`html-img-grab`, `convert-to-webp`, etc.) :
- `Cargo.toml` workspace member : `[package]` minimal, `workspace.*` inherits.
- `README.md` : 1 paragraphe descriptif + section Usage avec table de flags + commande de build.
- Le binaire `name = "backlink-check"` est le nom du dossier.

## 7. Dependencies

```toml
[dependencies]
clap = { workspace = true, features = ["derive"] }
anyhow = { workspace = true }
serde = { workspace = true, features = ["derive"] }
url = "2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "gzip", "brotli", "deflate", "rustls-tls", "cookies"] }
scraper = "0.20"
csv = "1"
rayon = "1"
dialoguer = "0.11"
indicatif = "0.17"
regex = "1"
unicode-normalization = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }

[dev-dependencies]
tempfile = "3"
```

Note : `reqwest` avec `rustls-tls` (pas `native-tls`) pour rester portable sans OpenSSL système.

## 8. Data model

```rust
struct CampaignConfig {
    target: Url,                       // parsed et validé
    target_strict: NormalizedUrl,      // host lowercase, port défaut viré, slash collapsed, query/fragment virés
    target_no_proto: NormalizedUrl,    // strict + protocole agnostique
    target_no_www: NormalizedUrl,      // strict + www agnostique
    target_loose: NormalizedUrl,       // strict + protocole + www agnostiques
    anchors: Vec<String>,              // original casing, pour le report
    anchors_normalized: Vec<String>,   // lowercase, sans accents, whitespace collapsed
    keywords: Vec<String>,             // original casing
    keywords_normalized: Vec<Regex>,   // word-boundary regex, lowercase, sans accents
}

#[derive(Serialize)]  // pour CSV
struct SourceCheck {
    source_url: String,
    final_url: Option<String>,         // si redirect
    http_status: Option<u16>,          // None si network error
    error: Option<String>,
    link_found: bool,
    match_kind: Option<MatchKind>,
    href_actual: Option<String>,
    anchor_actual: Option<String>,
    anchor_matched: Option<String>,    // l'ancre originale matchée, vide sinon
    rel_raw: Option<String>,
    is_dofollow: Option<bool>,
    keywords_found: Vec<String>,       // sérialisé `;`-separated dans le CSV
    host: Option<String>,
    tld: Option<String>,
    total_matching_links: usize,
}

enum MatchKind {
    Exact,
    ProtocolDiff,
    WwwDiff,
    Both,
}
```

## 9. Matching logic

### URL match (4-niveaux)

1. Si le href est relatif (sans schéma) → no match (la cible est cross-domain par construction, un href relatif sur une page externe ne peut pas pointer vers `elevaseo.com`).
2. Normaliser le href du `<a>` et la cible (strict : lowercase host, port défaut viré, trailing slash unifié, query+fragment virés).
3. Classifier la relation href↔cible :
   - même protocole + même host + même path → `Exact`
   - host & path identiques, protocole différent → `ProtocolDiff`
   - protocole & path identiques, host diffère uniquement par `www.` → `WwwDiff`
   - path identique, protocole **et** www diffèrent → `Both`
   - sinon → no match.

### Anchor match

- Texte visible du `<a>` = concat des nœuds texte enfants (récursif, inclut `<strong>`, `<em>`).
- Normalisation : `nfd().filter(is_ascii or alphanumeric).lowercase().collapse_whitespace().trim()`.
- Compare la version normalisée à chaque ancre normalisée → si égalité, retourner l'ancre originale.

### Keyword scan

- Compile chaque keyword en regex : `\b{keyword_normalized}\b` après strip diacritics + lowercase.
- Sur l'ancre actuelle normalisée, applique chaque regex.
- `keywords_found` = liste des keywords (forme originale) dont la regex matche.

### Sélection du meilleur `<a>` (si plusieurs matchent la cible)

1. Si au moins un est `dofollow` → premier dofollow en doc order.
2. Sinon → premier nofollow en doc order.
3. `total_matching_links` enregistre le nombre total trouvé (>1 = à investiguer manuellement).

### `rel` parsing

- Récupère `rel` de l'élément `<a>` choisi.
- Split par whitespace → set de tokens (lowercase).
- `is_dofollow = !tokens.contains("nofollow") && !tokens.contains("sponsored") && !tokens.contains("ugc")`.
- `rel_raw` = valeur brute (préservée pour audit).

### TLD extract

- Parse l'URL source, prend `host`, garde tout après le dernier `.` → `.com`, `.fr`, `.net`, etc.
- Pour `localhost` ou IP → TLD = `""` (cas marginal, sans crash).

## 10. Pipeline

```
parse_input(path)
   ↓
run_prompt() → CampaignConfig          // OR build_from_flags()
   ↓
build_http_client(timeout, ua)         // shared Arc<Client>
   ↓
rayon::par_iter(urls, jobs)
   ↓ check_one(url, &client, &campaign)
       ├─ http_get(url) → (status, body, final_url)
       ├─ parse_html(body)
       ├─ find_matching_links(doc, &campaign)
       ├─ pick_best(links)
       └─ build_source_check(...)
   ↓
collect → Vec<SourceCheck>
   ↓
aggregate → Stats
   ↓
write_csv + write_markdown + print_terminal_summary
```

## 11. HTTP client (config)

```rust
reqwest::blocking::Client::builder()
    .user_agent(ua)
    .timeout(Duration::from_secs(timeout_secs))
    .connect_timeout(Duration::from_secs(10))
    .redirect(redirect::Policy::limited(5))
    .cookie_store(true)
    .gzip(true).brotli(true).deflate(true)
    .build()?
```

### Retries

- 1 retry après 500 ms sur : connect timeout, DNS error, 502, 503, 504.
- 4xx, 200, 3xx finaux : pas de retry.
- Toute erreur finale → `SourceCheck { error: Some(...), link_found: false, .. }`.

### Politesse réseau

- UA explicite (pas le défaut `reqwest/0.x`).
- Pas de rate limiting per-host en v1.
- Documenter dans le README : si l'input cible un seul host, baisser `-j`.

## 12. Output

### CSV schema

Fichier : `data/exports/<input_basename>-YYYY-MM-DD.csv`.

```
source_url, final_url, http_status, error,
link_found, match_kind, href_actual,
anchor_actual, anchor_matched,
rel_raw, is_dofollow,
keywords_found,
host, tld,
total_matching_links
```

- `keywords_found` : `;`-séparés (ex. `solaire;énergie`).
- Booleans : `true` / `false` (pas `Y`/`N` — les tableurs aiment mieux).
- `error` : message court (`timeout`, `dns_error`, `http_404`, etc.) ; détail dans le terminal en récap d'erreurs.

### Markdown report

Fichier : `data/exports/<input_basename>-YYYY-MM-DD.md`.

Sections :
1. En-tête (input, cible, ancres, keywords, timestamp).
2. Couverture HTTP (OK / erreurs).
3. Backlink trouvé (lien matchant / absent / page inaccessible).
4. Match URL par `match_kind`.
5. `dofollow` vs `nofollow` (sur les liens trouvés uniquement).
6. Ancres exactes (par ancre commandée + bucket "hors liste").
7. Keywords présents (par keyword).
8. Répartition TLD (toutes sources).
9. Top 10 erreurs réseau.

### Terminal summary (compact)

Format texte ~12 lignes, voir spec Section 5 du brainstorm pour le mockup exact.

## 13. Naming convention output

- Input `data/imports/FO2DEA5B9042.txt` → output `data/exports/FO2DEA5B9042-2026-06-08.csv` + `.md`.
- Date du run (locale Paris) pour permettre re-runs sans écraser.
- Override via `--out <DIR>` : on garde le basename + date, on change juste le dossier.

## 14. Erreurs et codes de sortie

| Cas | Code |
|---|---|
| Run terminé (même si certains liens en erreur) | 0 |
| Argument CLI invalide / input file inexistant | 2 |
| Prompt annulé (Esc / N) | 1 |
| Erreur I/O sur output (disque plein, etc.) | 3 |

Pas d'exit 1 si "des liens manquent" — c'est de l'information, pas une erreur du tool.

## 15. Testing strategy

### Unit tests (par module)

- `input.rs` : txt avec lignes vides + `#`, csv valide, csv sans colonne demandée.
- `match_.rs` : normalize_url (slash, port, query), compare_anchor (accents, casse, whitespace), scan_keywords (word boundary `calories` vs `kcalories`), extract_tld.
- `parse.rs` (fixtures HTML) :
  - `simple-dofollow.html`
  - `nofollow.html`
  - `multiple-rel.html` (sponsored, ugc)
  - `multiple-matching-links.html` (footer + contenu)
  - `link-with-strong.html`
  - `no-link.html`
  - `protocol-diff.html`
  - `www-diff.html`
- `stats.rs` : agrégation depuis Vec<SourceCheck> mocké ; gestion 0 link_found (pas de panic % div par zéro).

### Pas de test live HTTP automatisé

Validation e2e manuelle :
```bash
echo "https://elevaseo.com" | backlink-check -i /dev/stdin --non-interactive ...
```

### Lint

`mise exec -- cargo clippy --all-targets -p backlink-check -- -D warnings` doit passer.

## 16. README de l'outil (à livrer)

À écrire en français, structure identique aux autres `rust/<tool>/README.md` :
- 1 paragraphe explicatif (objectif + flow rapide).
- Section Usage avec exemples (`pbpaste | …`, `-i file`, `--non-interactive`, etc.).
- Tableau des flags.
- Section "Détails" (matching URL, ancre, keyword, TLD).
- Section Build (`mise exec -- cargo build --release -p backlink-check`).

## 17. Mise à jour racine `README.md`

Ajouter une ligne dans la section D (Conversion / migration de données) ou nouvelle section "Vérification SEO" :

```
| backlink-check | net-new (demande Bastien 2026-06-08) → `rust/backlink-check/` | **Rust (reqwest+scraper+rayon)** | Rust | 🟢 |
```

Et incrémenter le compteur Rust (10 outils Rust au lieu de 9).

## 18. Workflow git

Per `global-rules.md` et `CLAUDE.md` du projet :

1. (Pré-requis) Avoir `dev` initialisé — actuellement bloqué par le "commit et push" du monorepo encore en suspens. Le spec lui-même ne peut donc pas être commité tant que ce point n'est pas tranché. À résoudre AVANT l'implémentation.
2. `git checkout -b feature/backlink-check` depuis `dev`.
3. Commits Conventional Commits (`feat:`, `chore:`, etc.).
4. Merge fast-forward dans `dev` après validation.
5. Suppression de la branche feature.
6. Pas de merge `main` automatique (validation Bastien explicite requise).

## 19. Hors v1 (idées notées)

- Détection JS-rendered (chromium headless) si certains sites masquent les liens en JS.
- Position du lien (contenu vs footer) via heuristique de profondeur DOM.
- Diff entre 2 runs (J vs J+7) pour suivre l'indexation des nouveaux backlinks.
- Export JSON pour intégrations downstream (Twenty, Google Sheets).
- Mode `--watch` qui re-scanne automatiquement après N heures.
