Aller au contenu

Note d'ingénierie

Alerter en une minute sans malmener Vinted, leboncoin ni eBay

Par Alexandre Kaczor7 min de lecture
  • Architecture
  • NestJS
  • Ops
Illustration de l'article « Alerter en une minute sans malmener Vinted, leboncoin ni eBay »

Planchers d'intervalle par plateforme, déduplication confiée à Postgres, envois limités par canal, vérifications rendues à l'humain : comment Get Choppy surveille des annonces sans abuser.

Get Choppy prévient ses utilisateurs quand une annonce correspondant à leurs critères apparaît sur eBay, Vinted ou leboncoin. Le produit se vend sur deux promesses : la vitesse, puisqu'une bonne affaire part en quelques minutes, et la précision, grâce à des filtres que les plateformes ne proposent pas. Ces promesses entrent en tension avec une troisième contrainte, non négociable : ne pas mettre en danger les plateformes, ni les comptes des utilisateurs, ni les canaux sur lesquels ils reçoivent leurs alertes.

La règle qui décide de l'architecture

Le document d'architecture commence par une phrase : la session d'un client ne quitte jamais sa machine. Tout le reste en découle.

eBay dispose d'une API officielle : elle est interrogée depuis le serveur, avec une clé de développeur. Vinted et leboncoin n'ont pas d'API publique. Leur recherche passe donc par une extension Chrome installée chez l'utilisateur, qui ouvre la page de résultats dans un onglet de son propre navigateur et lit ce que la page affiche, comme il le ferait lui-même. Aucune requête vers ces deux sites ne part de mes serveurs. Le point d'aiguillage est explicite dans le code :

if (platform === 'ebay' || platform === 'amazon') {
  // API officielle, depuis le cloud
  const outcome = await scraper.scrape({ criteria, limit: SCRAPE_LIMIT, workspaceId });
  // ...
}

const agentId = this.agents.findAgentFor(search.workspaceId, platform);
if (!agentId) {
  return {
    status: 'auth_required',
    message: `Aucun agent connecté ne peut interroger ${platform}.`,
    // ...
  };
}

La liste de ce qui est interdit est écrite, définitivement : stocker les identifiants Vinted ou leboncoin d'un client, interroger ces sites depuis le serveur, exploiter un pool de comptes, écrire du code de contournement des protections anti-robots. Si une fonctionnalité exige l'une de ces choses, on change la fonctionnalité, pas la règle. L'extension ouvre une connexion WebSocket sortante vers le serveur, ce qui évite à l'utilisateur d'ouvrir un port ou de toucher à sa box.

Un plancher par plateforme, même pour l'administrateur

Le planificateur se réveille toutes les 10 secondes, prend au plus 20 recherches dues et demande 60 annonces par passage. Il applique d'office un intervalle minimal par plateforme :

export const PLATFORM_MIN_INTERVAL_SEC: Record<Platform, number> = {
  ebay: 30,
  amazon: 60,
  vinted: 60,
  leboncoin: 300,
};

Les 300 secondes de leboncoin ne sont pas arbitraires : en production, la protection du site signalait l'activité dès environ quatre passages par minute. Ce plancher est infranchissable, même avec le rôle administrateur. En dessous, c'est le compte de l'utilisateur qui risque d'être restreint.

eBay porte un second plancher, lié à l'abonnement : 300 secondes en gratuit, 30 en Pro. Le quota gratuit de l'API eBay (5 000 appels par jour) est partagé par toute la plateforme. Quand il est épuisé, seule la plateforme eBay de la recherche est gelée, pour la durée indiquée par l'API. Vinted et leboncoin continuent, puisqu'ils ne consomment rien de ce quota. Après des échecs répétés, une recherche recule de façon exponentielle : 60 secondes, puis le double à chaque échec, plafonné à une heure.

Dédupliquer avec la base, pas avec un if

Une alerte reçue deux fois est un défaut visible. La déduplication repose sur une contrainte d'unicité Postgres sur le triplet recherche, plateforme et identifiant externe. On ne fait pas un findFirst suivi d'un create, qui laisserait une fenêtre de concurrence. On insère, et la base tranche :

} catch (err) {
  // P2002 = violation de la contrainte d'unicité (searchId, platform,
  // externalId) : l'annonce a déjà été vue. C'est le cas nominal.
  if (isUniqueViolation(err)) return null;
  // Toute AUTRE erreur n'est PAS une dédup : on la journalise,
  // et le prochain passage la réessaiera.
  this.logger.error(`persist ${platform} externalId=${listing.externalId} : ...`);
  return null;
}

La distinction est importante. L'ancienne version avalait toutes les erreurs dans un catch {} vide, et une panne de base faisait disparaître des alertes sans laisser de trace.

Deux règles complètent la déduplication. Au premier passage d'une recherche, tout ce qui est déjà en ligne est mémorisé sans alerte. Sinon, créer une recherche déclencherait des dizaines de notifications pour des annonces anciennes. Un seul message confirme que la surveillance est active et explique pourquoi rien n'arrive encore. Ensuite, quand une recherche sort de ses heures calmes, les annonces publiées pendant la nuit entrent dans le flux sans sonner.

Aucune des plateformes n'indique qu'une annonce est vendue. Les interroger une par une ferait une requête par annonce, exactement le martèlement à éviter. Le planificateur déduit donc la disparition de ce qu'il a déjà reçu : si la plateforme renvoie encore des annonces plus anciennes que la nôtre et que la nôtre n'y figure plus, elle a quitté le site. Sur Vinted, où la date disponible n'est pas celle du tri, il s'abstient.

Alerter vite sans faire bannir le canal du client

Jusqu'à huit nouvelles annonces, chacune donne sa propre notification, avec prix et lien. Au-delà, un seul résumé est envoyé. Chaque alerte peut porter un verdict de prix (« 22 % sous la médiane 30 j »), mais seulement à partir de trois jours d'historique et d'un écart d'au moins 15 %. En dessous, le système préfère ne rien dire plutôt que dire faux.

Les envois sont limités par canal et par client, sous les plafonds annoncés par les fournisseurs : 20 par seconde pour Telegram, 4 pour un webhook Discord, 1 pour l'email, 50 pour Web Push. Une rafale de quarante annonces ne doit pas faire couper le bot Telegram de l'utilisateur. Le limiteur attend son tour au lieu de refuser : une alerte retardée d'un dixième de seconde reste une alerte.

Un canal a été retiré pour cette raison. Le service de notification gratuit utilisé au départ comptait sa limite par adresse IP ; toutes les alertes partant du même serveur, tous les clients partageaient un seul quota. Il a été atteint avec trois clients : 701 annonces par heure pour environ 720 autorisées. Web Push l'a remplacé, avec nos propres clés VAPID et sans quota partagé. Un abonnement qui répond 404 ou 410 est purgé. Sur iPhone, une notification web ne peut pas afficher la photo de l'annonce : le texte porte donc le prix, l'écart à la médiane et la plateforme.

Quand la plateforme change ou demande une vérification

Si un site affiche une vérification au lieu des résultats, l'extension renvoie l'état challenged, avec une consigne lisible : la recherche demande une action humaine. C'est à l'utilisateur d'ouvrir le site et de reprendre la main. Rien n'est contourné automatiquement. Dans une recherche multi-sites, cet état l'emporte sur les autres : un « ok » global masquerait un problème qui demande une action humaine.

Le 17 septembre au soir, Vinted a retiré l'API interne que l'extension lisait, au profit d'un rendu serveur. Toutes les recherches Vinted sont tombées en même temps, et c'est un client qui me l'a signalé. Le taux d'erreur par plateforme existait pourtant dans un écran d'administration, mais personne n'ouvre un écran d'administration à 22 h. Une sonde pousse désormais ce constat sur Telegram. Elle ne compte que les passages qui ont réellement atteint le site, et ne retient pas le témoignage d'un client déjà en panne depuis des jours. L'extension lit maintenant les tuiles de la page, avec un analyseur isolé et testé contre des libellés relevés sur le site.

Cette page ne contient ni vendeur, ni avis, ni date de publication. Sur Vinted, les filtres correspondants laissent donc tout passer, et le produit l'affiche au lieu de prétendre le contraire.

Ce que j'en retiens

  • La contrainte de respect se décide dans l'architecture, pas dans une option : ce qui ne doit pas partir du serveur n'a pas de chemin pour en partir.
  • Les planchers d'intervalle sont une protection, pas une offre commerciale. Personne ne peut les franchir, pas même l'administrateur.
  • La base de données déduplique mieux que le code applicatif, à condition de distinguer le doublon attendu de la vraie panne.
  • Un tableau de bord n'est pas une alerte. Ce qui doit réveiller quelqu'un doit être poussé vers lui.

Un produit de veille ou de notification à concevoir proprement, sans fragiliser les services dont il dépend ? Discutons-en.