Skip to main content
Glama
README.md
# chineur

Chiffrage d'un **projet entier** au meilleur prix **livraison comprise**, neuf et occasion
séparés, avec un **plan de commande groupé** qui minimise le total réellement payé.

Le moins cher article par article n'est pas la bonne réponse : 10 articles chez 10
marchands, c'est 10 frais de port. Le moteur de panier groupe les achats chez 2 ou 3
marchands, seuils de franco compris, et affiche l'écart avec ce total naïf.

## Installation

```bash
python -m venv .venv && ./.venv/bin/pip install -e .
./.venv/bin/pytest          # 74 tests hors ligne, aucun réseau
```

Python 3.11 ou plus. Le projet tourne sans aucune clé : LeDénicheur, AliExpress et
Leboncoin ne demandent rien. Les sources à clé (eBay, Mouser) restent simplement éteintes,
et `list_sources()` le dit.

## Clés

Aucun secret n'est lu dans un fichier versionné. `bin/chineur-mcp.sh` charge un `.env`
local s'il existe (ignoré par git), sinon il prend l'environnement tel quel, ce qui permet
de les injecter depuis un gestionnaire de secrets par simple substitution, la valeur ne
transitant alors que par l'environnement du processus :

```bash
export EBAY_CERT_ID=$(votre-cli-de-coffre get password 'eBay - API Browse')
```

| Variable | Rôle | Sans elle |
|---|---|---|
| `EBAY_APP_ID` / `EBAY_CERT_ID` | keyset eBay Browse | source eBay éteinte |
| `MOUSER_API_KEY` | clé Mouser Search API | source Mouser éteinte |
| `FLARESOLVERR_URL` | recours navigateur sur mur anti-bot | recours éteint, le reste fonctionne |
| `CHINEUR_VAR` | répertoire d'état (cache, quotas, cookies, journal) | `<dépôt>/var` |

## Utilisation

Exposé en **MCP stdio** (`bin/chineur-mcp.sh`), trois outils :

| Outil | Rôle |
|---|---|
| `price_project(articles, zipcode?, used_threshold?, detail?)` | chiffre une liste complète en **un seul appel** |
| `best_price(article, …)` | le cas à un seul article, en `detail="full"` |
| `list_sources()` | état, quota horaire restant et coupure en cours de chaque source |

Chaque article accepte une quantité ou un plafond : `"LD2410C x3"`, `"ESP32-S3 <=12€"`.

En ligne de commande :

```bash
python -c "
import asyncio; from chineur.models import Project
from chineur.search import Orchestrator; from chineur.report import render
async def m():
    o = Orchestrator(); print(render(await o.run(Project.build(['LD2410C x2','BME280'])), failures=o.failures))
asyncio.run(m())"
```

## Sources et ce qu'elles valent

| Source | Volet | Port | Remarque |
|---|---|---|---|
| LeDénicheur | neuf + occasion | **exact** | agrégateur : apporte Amazon, Cdiscount, Fnac, Rue du Commerce, Back Market, Rakuten en une requête |
| AliExpress | neuf | estimé | seule source réaliste pour les modules chinois de niche |
| Leboncoin | occasion | estimé | 10 recherches/heure maximum, non négociable |
| eBay | neuf + occasion | exact | API Browse officielle, **active depuis le 2026-08-06** |
| Mouser | neuf | estimé | distributeur pro, API officielle. Couvre ce qu'AliExpress rate (étain, tresse, résistances, transistors, connectique). **Inutile sur les modules chinois** : absent, ou 3 à 5x le prix |

Limites connues, mesurées le 2026-08-05 et non contournables sans Playwright (V2) :

- **AliExpress ne donne ni le vendeur ni le port** sur la page de recherche, et la fiche
  produit en HTTP simple est vide. Le groupage se fait donc au niveau de la plateforme,
  avec son seuil de franco. Après plusieurs requêtes rapprochées, AliExpress répond 200
  avec une page de 2 Ko : c'est détecté et traité comme un blocage, pas comme une absence
  d'offre.
- **Le budget AliExpress est d'environ 6 requêtes par IP fraîche**, mesuré le 2026-08-10
  après 46 h sans aucune sollicitation : 6 articles servis (133 offres), blocage au 7e,
  FlareSolverr mis au mur lui aussi. Une liste de 17 articles ne peut donc **pas** être
  chiffrée en une fois par scraping, quelle que soit l'attente respectée. C'est la limite
  structurelle qui justifie l'API affiliée officielle (portals.aliexpress.com), et non un
  réglage de patience à trouver.
- **En occasion, le prix est indicatif.** Les annonces Leboncoin sont du texte libre : un
  résultat peut être un lot, une pièce cassée ou un accessoire. Neuf et occasion ne sont
  jamais fusionnés en un prix unique.
- **Le port est marqué `~` quand il est estimé** (table `shipping.py`) et `?` quand il est
  inconnu. Un total contenant une estimation est signalé comme tel.

## Pertinence : le vrai facteur limitant

Mesuré le 2026-08-08 sur la liste type réelle (17 articles, composants) : les
« introuvables » ne venaient pas d'un manque de sources mais du libellé. Deux causes
distinctes, séparées en comptant les titres bruts rendus par eBay **avant** filtrage.

**La requête est trop longue.** « etain soudure 60/40 flux 0.8mm » fait renvoyer 0 résultat
par eBay ; « etain soudure » en renvoie 11. Chaque mot descriptif ajouté rétrécit le
résultat jusqu'au vide. D'où `reduced_query` et la **seconde chance** de `guarded_search` :
quand une source ne rend rien, on retente **une fois** sur un libellé réduit aux références
et aux deux premiers mots porteurs, les qualifiants jetés. Une requête de plus uniquement en
cas d'échec, et jamais sur le dernier crédit du quota.

**Le filtre jetait du bon.** « cables dupont assortiment » contre « Câble Dupont …
Assortiment 5 à 100pcs » tombait à 0.67 pour un seuil de 0.70 : le score ne comparait que
par inclusion de chaîne, donc le pluriel cassait tout. `_apparie` rapproche désormais sur le
**préfixe commun** (60 % du mot), réservé aux mots d'au moins 4 lettres — jamais aux nombres
ni aux références courtes, pour que « 2410 » et « 2420 » restent deux capteurs différents.

Résultat sur la même liste : **eBay passe de 10 à 14 articles couverts sur 17**, articles
sans aucune offre de 5 à 3. Restent trois vrais introuvables, à toutes les sources : la
plaque pastillée, le Diese 2201M et la Sugon 8620DX.

**Le prix à payer, et il est réel.** Une recherche élargie répond à une question plus large
que celle posée : « ESP32-WROOM-32 DevKit 38 broches » élargi en « esp32 wroom 32 » remonte
un modèle 30 broches. Toute offre issue d'un élargissement porte donc la réserve
« recherche élargie à « … » », qui doit rester visible jusqu'à l'affichage.

## Recours navigateur (FlareSolverr)

Le point faible du projet : AliExpress est la seule source des modules chinois, et elle se
fait bloquer au bout de 5 à 9 recherches. Le README annonçait ce trou comme « non
contournable sans Playwright (V2) ». Il l'est, sans dépendance supplémentaire côté projet :
**une instance FlareSolverr** pilote un vrai Chrome, exécute le JavaScript et porte une
empreinte de navigateur complète. Elle se déclare par `FLARESOLVERR_URL` ; non renseignée,
le recours est simplement éteint.

Mesuré le 2026-08-08, alors que le connecteur direct était bloqué et son disjoncteur ouvert :
`fr.aliexpress.com/w/wholesale-LD2410C.html` rend HTTP 200, 832 Ko, aucun marqueur anti-bot,
et le parseur HTML existant y décode **60 articles** avec prix en EUR. Aucune modification du
parsing n'a été nécessaire.

Branchement dans `AliExpress._items` : sur mur anti-bot **ou** page tronquée, on tente le
recours avant de lever `Blocked`. Points de conception :

- **Recours, pas régime.** Chaque appel démarre un Chrome : c'est lent et lourd. On n'y
  passe que sur un blocage avéré.
- **`None` et pas `[]`** quand le recours ne donne rien. Une liste vide passerait pour
  « aucun résultat », le disjoncteur ne s'ouvrirait pas, et le connecteur retaperait la
  source bloquée à l'article suivant en aggravant le bridage.
- **Jamais bloquant.** `flaresolverr.fetch` ne lève pas : une panne du solveur masquerait la
  vraie cause derrière un incident d'infrastructure. `FLARESOLVERR_URL=` l'éteint.
- **Abandon après 2 échecs consécutifs.** Voir la limite ci-dessous : sans ça, chaque
  article suivant paierait encore des dizaines de secondes d'attente pour rien.

**Sa limite, mesurée le 2026-08-08.** Le recours n'est pas un contournement, c'est un
**second budget**. Sur huit recherches AliExpress enchaînées : six servies à 60 articles
chacune en 4 à 6 s, la septième en erreur 500 après 101 s d'attente, la huitième renvoyant
le mur anti-bot (208 Ko, marqueur `RGV587`). L'IP de sortie ne change pas, donc le même mur
finit par tomber. Deux conséquences dans le code : `maxTimeout` ramené de 90 à 45 s, et
un mur rendu en HTTP 200 compte comme un échec, sinon le compteur d'abandon ne se
déclencherait jamais.

En pratique le budget AliExpress passe donc d'environ 9 recherches à environ 15, avec
60 articles par page au lieu de 20. C'est un gain net, ce n'est pas l'illimité.

## Garde-fous anti-ban

L'IP de sortie est unique et résidentielle : un ban la coupe entièrement. Tout le module
est écrit pour ne jamais en arriver là.

- Quotas horaires persistés en SQLite (`var/chineur.db`) : Leboncoin 10/h, LeDénicheur 30/h,
  AliExpress 20/h, eBay 200/h, Mouser 60/h (quotas API, aucun risque IP).
- **Espacement sérialisé par source**, pas un simple `sleep` : l'orchestrateur lançant tous
  les articles en parallèle, des délais concurrents s'écouleraient en même temps et les
  requêtes partiraient quand même en rafale. Chaque départ attend le précédent.
- Disjoncteur sur 403 / captcha / page tronquée : 1 h, **2 h pour AliExpress**, dont la
  pénalité persiste au-delà de la rafale qui l'a déclenchée.
- **Le blocage AliExpress n'est ni un quota ni une question de cadence.** Trois recettes du
  2026-08-06, libellés jamais mis en cache :

  | espacement | transport | servies avant blocage |
  |---|---|---|
  | 6-12 s | requêtes nues | 5 (blocage à 46 s) |
  | 6-12 s | session à cookies | 6 (blocage à 56 s) |
  | 45-60 s | session à cookies | **2** (blocage à 108 s) |
  | 2,5 s | session à cookies, **endpoint JSON** | **9** (blocage à 30 s) |

  Ralentir **dégrade** le rendement, donc la cadence n'est pas le levier : le meilleur
  rendement s'obtient en allant vite. Le blocage dure **~40 min**, mesuré deux fois, et se
  signale par `FAIL_SYS_USER_VALIDATE` / `RGV587_ERROR::SM`, l'anti-bot d'Alibaba qui exige
  une validation.
- **Les articles sont lus sur l'endpoint interne** `POST www.aliexpress.com/fn/search-pc/index`
  (celui que la page de recherche appelle elle-même), charge utile de forme `mods`, depuis
  une session déjà ouverte sur l'accueil. Il rend **exactement les mêmes objets article** que
  la page HTML, en `.data.result.mods.itemList.content`, pour 390 Ko au lieu de 600. Piège :
  plusieurs blocs de la réponse portent une clé `content`, celui des filtres de recherche
  arrive **avant** `itemList`. La page HTML reste en repli automatique, mais **uniquement**
  si la réponse n'a plus la forme attendue : sur un mur anti-bot, retenter en HTML depuis la
  même IP ne ferait que gaspiller une requête.
- **Session persistante** (`transport.Session`) conservée pour AliExpress : page d'accueil
  visitée une fois, cookies rejoués ensuite, pot dans `var/cookies/` pour survivre à un
  redémarrage, jeté dès qu'une page revient tronquée. Aucun gain mesuré sur le blocage,
  gardée parce qu'elle rapproche le trafic de celui d'un navigateur.
- **Piste non tranchée** : si l'endpoint interne se referme un jour, sous-traiter la source
  à un service hébergé (omkar.cloud, 5 000 requêtes/mois gratuites), au prix d'un tiers qui
  voit nos recherches.
- Cache consulté **avant** le rate limiter : rechiffrer un projet ne retape rien. TTL 1 h par
  défaut, mais **jamais plus court que la coupure du disjoncteur** (`Connector.effective_ttl`,
  6 h pour AliExpress). Sans ce plancher, un projet plus large que le budget d'une source ne
  peut pas se terminer : la relance d'après-coupure retrouve un cache vide, refait les mêmes
  premières recherches, rebrûle le budget et se fait rebloquer, indéfiniment.
  **Sauf eBay**, exclu du cache pour rester conforme à l'exemption (voir plus bas).
- Un projet dépassant le quota Leboncoin est traité par ordre d'enjeu décroissant, et les
  articles non traités sont nommés dans la restitution.

## Économie de tokens

Le calcul reste en Python, seul le verdict remonte : sortie en texte tabulaire dense, une
meilleure offre neuve et une occasion par article, titres tronqués, URL nettoyées, notes
regroupées, diagnostics dans `var/chineur.log`. Mesuré : **10 articles ≈ 650 tokens**.

## Tests

```bash
pytest              # 74 tests hors ligne, aucun réseau
pytest -m network   # 5 tests d'intégration réels (consomment du quota)
```

Le moteur de panier est entièrement testable hors ligne, y compris ses cas piégeux (seuil
de franco déclenché par le groupage, marchand unique, article sans offre, égalités). La
propriété « total optimisé ≤ total naïf » est vérifiée sur 50 jeux d'offres aléatoires.

## Mouser

Une seule variable : `MOUSER_API_KEY`. Clé gratuite en self-service sur `mouser.com/api-hub`
(onglet Search API).

**La clé doit être créée depuis `mouser.fr`.** La recherche par mot-clé n'accepte aucun
paramètre de devise (vérifié dans le swagger `api.mouser.com/api/docs/V1`) : la devise est
celle du site d'inscription. Une clé créée sur `mouser.com` renvoie des dollars, que le
connecteur écarte plutôt que de les convertir à un taux inventé — la source paraîtrait
simplement vide.

Quotas annoncés par Mouser : 50 pièces par appel, 30 appels/minute, 1 000 appels/jour.
D'où `limit_per_hour = 60` et 2 s d'espacement : ce n'est pas de l'anti-ban, c'est le
respect du quota.

Trois choix de fond :

- **`mouserPaysCustomsAndDuties: true`** — Mouser expédie du Texas. Sans ce drapeau, les
  droits de douane arrivent après coup et un prix « moins cher » sur le papier devient plus
  cher à la livraison.
- **`searchOptions: InStock`** — une référence à 12 semaines de délai n'a rien à faire dans
  une comparaison de prix pour un projet en cours.
- **Paliers de prix suivis à la quantité demandée.** Retenir le palier 1 surestime tout
  achat groupé de composants passifs.

Les deux pièges qui fausseraient une comparaison, tous deux signalés en réserve sur l'offre :
le **minimum de commande** (0,10 € l'unité par sachet de 100, soit 10 € réels à la caisse)
et le **format de prix** (« 0,096 € » est un prix à trois décimales, « $1,250 » vaut mille
deux cent cinquante — un parseur naïf se trompe d'un facteur mille dans les deux sens).

**Port : non vérifié.** Le franco de 50 € HT et les ~20 € en dessous sont recoupés sur le
web (2026-08-07), pas sur une facture. Ils vivent donc dans `shipping.py` et ressortent en
`ESTIMATED`, jamais en fait établi. À corriger dès la première commande réelle : sous le
franco, le port écrase le gain, et c'est ce seuil qui décide si la source sert à quelque chose.

## eBay

`EBAY_APP_ID` (App ID) et `EBAY_CERT_ID` (Cert ID), pour un keyset **Production** — le seul
exploitable. Un keyset **Sandbox** se branche avec `EBAY_ENV=sandbox`, qui bascule sur
`api.sandbox.ebay.com`. **En sandbox le connecteur reste éteint** : l'inventaire est factice, les prix entreraient dans le panier sans que
rien ne le signale. `EBAY_SANDBOX_OK=1` le rallume pour tester la plomberie uniquement.

**Vérifié le 2026-08-06** sur un keyset Production : jeton `client_credentials` obtenu,
recherches réelles (29 offres sur « cable hdmi 2.1 2m », port exact lu dans
`shippingOptions`).

L'état de l'article se lit sur **`conditionId`**, jamais sur `condition` : ce dernier est
traduit par eBay (« Neuf », « Occasion », « Ouvert (jamais utilisé) » sur EBAY_FR), et la
table à clés anglaises d'origine ne matchait rien, donc **tout ressortait en occasion**.
Les articles `7000` (« pour pièces ») sont écartés : à 3 €, un article cassé gagnerait la
comparaison sans que rien ne le signale.

**Conformité (« Your Keyset is currently disabled »).** eBay n'active un keyset Production
qu'après abonnement aux notifications de suppression de compte, ou exemption. Le projet
prend l'exemption « je ne conserve pas de données eBay », et le code la rend littéralement
vraie : `EBay.cacheable = False`, donc aucune réponse eBay n'est écrite dans
`var/chineur.db` (elles contiennent des pseudos vendeurs). Un test le verrouille. Si un
jour on remet eBay en cache, l'exemption devient fausse : il faudra héberger l'endpoint de
notification à la place.

## Licence

MIT, voir [LICENSE](LICENSE).

TDQS

A3.6/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: price_project handles multi-article project planning, best_price handles a single article, and list_sources provides source status. No overlap or ambiguity between them.

Naming Consistency4/5

All names use lowercase snake_case, which is consistent. However, the structure varies slightly: price_project and list_sources follow verb_noun, while best_price is adjective_noun. This minor deviation is not confusing but prevents a perfect score.

Tool Count5/5

With only 3 tools, the server is tightly scoped. The main tool price_project serves as the primary entry point, while the others handle single-item lookups and system status. This is an appropriate size for the purpose and each tool earns its place.

Completeness5/5

The tool surface covers the core workflow: comprehensive project pricing (price_project), single-article queries (best_price), and source health monitoring (list_sources). No obvious gaps exist, as the domain is price comparison and order planning, which these tools fully address.

Maintenance

ActivityMaintained
ResponsivenessNo issues