# SerpApi — Algo dédup + eBay (pipeline multi-engine)

Spec de la pipeline shopping multi-engine (Google Shopping + eBay) avec
**dédoublonnage 3 niveaux** + **grouping par type de source**.

> Source canonique GPA : `gpa-core/gpa-api` (`ShoppingController::search()`,
> `SearchProvider::query()`). Ce dépôt en est un **portage adapté** au proxy
> (pas de Solr/Redis ici — cache BDD `serp_cache`). Implémentation :
> [`src/Shopping.php`](../src/Shopping.php), endpoint `GET /api/shopping`.

## TL;DR

- **Multi-engine** : `google_shopping_light` + `ebay` agrégés (1 appel chacun).
- **Dédoublonnage 3 niveaux** : (a) URL canonique, (b) marchand+SKU/product_id,
  (c) fingerprint titre (Jaccard ≥ .85) + prix ±2 %.
- **Grouping par source** : cap `maxPerSource` (déf. 3) par domaine + `maxPerType`
  (déf. 6) par type de source ; tri par score combiné.
- **Coût** : ×2 appels SerpApi, mitigé par cache BDD + **appel eBay conditionnel**
  (uniquement si Google rend < N résultats — `SHOPPING_EBAY_CONDITIONAL_THRESHOLD`).

## Engines & paramètres

| Engine | Param requête | Réponse | Params clés |
| --- | --- | --- | --- |
| `google_shopping_light` | `q` | `shopping_results` | `gl`, `hl`, `num` (≤ 60) |
| `ebay` | **`_nkw`** (pas `q` !) | `organic_results` | `ebay_domain`, `_sop=15` (prix+port ↑), `_ipg` |

Doc eBay engine : https://serpapi.com/ebay-search-api

## Pipeline (11 étapes)

1. **normalizeQuery** — trim, espaces compactés, cap 250 chars.
2. **extractKeywords** — minuscules, stopwords FR, retire les nombres < 8 chiffres (bruit).
3. *(repo GPA only)* **fetchSolrContext** — contexte structuré par `entity_id`. *(absent ici)*
4. *(repo GPA only)* **buildFallbackQueries** — 5 variantes du + spécifique au + large. Règle : jamais un OEM seul à Google Shopping (matche des SKU random).
5. **multiEngineFetch** — Google + eBay (eBay conditionnel si Google < seuil). Renvoie aussi `timings_ms`.
6. **normalize{Google,Ebay}Results** — vers un format `Offer` uniforme : `provider, source, source_type, title, price, currency, link, product_id, sku, thumbnail, condition, shipping_price, rating`.
7. **dedupOffers** — 3 passes (URL canonique → marchand+SKU → fingerprint titre/prix).
8. **groupAndCapBySource** — tri par `_score`, cap par source et par type.
9. **scoreOffer** — `prixInverseLog × trustSource × ratingBonus × condBonus × linkPenalty × 100`.
10. **filterByKeywords** — anti faux-positifs (le titre doit contenir ≥ 1 keyword).
11. **persist** — cache BDD (ici `serp_cache`, clé `mode:shopping`). *(Solr/atomic upsert : repo GPA only)*

### Dédoublonnage — détail des 3 passes

| Pass | Clé | But |
| --- | --- | --- |
| 1 | URL canonique (`canonicalUrl`, strip utm/gclid/…) | même offre publiée 2× |
| 2 | `source + sku|product_id` normalisé | même marchand, même réf, 2 URLs marketing |
| 3 | titre normalisé (tokens, Jaccard ≥ .85) **et** prix ±2 % | listings « soft » resynchronisés |

### Classification source (`source_type`)

`marketplace` (ebay, amazon, cdiscount, leboncoin, vinted…), `comparateur`
(idealo, kelkoo…), `enchere` (agorastore…), sinon `marchand_direct`.
Whitelist statique — à enrichir au fil de l'eau.

## Configuration (`.env`)

| Variable | Déf. | Rôle |
| --- | --- | --- |
| `SHOPPING_ENABLE_EBAY` | `1` | eBay agrégé à Google |
| `SHOPPING_EBAY_CONDITIONAL_THRESHOLD` | `5` | eBay seulement si Google < N (`0` = toujours) |
| `SHOPPING_MAX_PER_SOURCE` | `3` | cap par domaine marchand |
| `SHOPPING_MAX_PER_TYPE` | `6` | cap par type de source |
| `SHOPPING_NUM_GOOGLE` | `40` | max résultats Google |
| `SHOPPING_NUM_EBAY` | `50` | items eBay/page |
| `SHOPPING_GL` | `fr` | marché géo Google |

## Endpoint

`GET /api/shopping?q=...&key=...` → JSON :

```json
{
  "cache_status": "miss",
  "query": "620905715R pompe ABS Renault",
  "engines": ["google_shopping_light", "ebay"],
  "counts": { "raw": 95, "after_dedup": 94, "after_group": 9, "kept": 9 },
  "timings_ms": { "google": 2559, "ebay": 2562 },
  "price_stats": { "min": 13.88, "max": 105.41, "avg": 52.27, "count": 9 },
  "shopping_results": [
    { "provider": "google", "source": "ovoko.fr", "source_type": "marchand_direct",
      "title": "...", "price": 13.88, "currency": "EUR", "link": "...",
      "condition": "used", "score": 78.4 }
  ]
}
```

## Pitfalls (observés)

- **eBay = `_nkw`**, pas `q` (sinon 0 `organic_results`, silencieux).
- **`canonicalUrl` indispensable** : sans strip utm/gclid, la passe 1 échoue (même offre 3× avec UTM différents).
- **Currency mismatch** : un eBay.fr peut être en GBP (vendeur UK) → `guessCurrency`, à convertir au tri (conversion devise = TODO).
- **Jaccard .85** : seuil empirique (.80 si trop de doublons soft passent, .90 si trop d'offres légitimes sautent).
- **Rate SerpApi** : ~1 call/s sur les plans entry → les 2 appels sont **séquentiels** ici (pas parallèles) pour éviter les 429.

## Pour aller plus loin

3ᵉ engine `google_search` ciblé (`site:leboncoin.fr`…), conversion devise (Fixer/ECB),
embeddings titres pour un dédoublonnage plus fin, audit log des seuils (A/B test).
